Files
featherChat/warzone/docs/PROTOCOL.md
Siavash Sameni 60a7006ed9 Add documentation: protocol spec, server admin, client guide
docs/PROTOCOL.md (520 lines):
- Identity model (seed → Ed25519 + X25519 via HKDF)
- X3DH key exchange (4 DH operations, ASCII flow diagram)
- Double Ratchet (chain/DH ratchet, skipped keys, state machine)
- KDF chains with domain separation strings
- AEAD (ChaCha20-Poly1305)
- Wire format (WireMessage enum, bincode serialization)
- Pre-key bundle format and lifecycle

docs/SERVER.md (429 lines):
- Build and run instructions
- Full API reference with request/response examples
- Database structure (sled trees)
- Deployment (nginx reverse proxy, systemd unit)
- Security considerations
- Backup and recovery

docs/CLIENT.md (507 lines):
- Quick start guide
- All CLI commands with examples
- Identity management and mnemonic backup
- Web client usage and limitations
- Session and pre-key management
- Threat model table
- Troubleshooting guide

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:59:19 +04:00

17 KiB

Warzone Protocol Specification

This document describes the cryptographic protocol used by Warzone messenger as currently implemented in the warzone-protocol crate.


1. Identity Model

Seed-Based Identity

Every identity begins with a seed: 32 cryptographically random bytes generated from OsRng.

seed (32 bytes, from OsRng)
  |
  +-- HKDF-SHA256(seed, info="warzone-ed25519") --> Ed25519 signing keypair
  |
  +-- HKDF-SHA256(seed, info="warzone-x25519")  --> X25519 encryption keypair

The seed is the single root secret. Both key derivations use HKDF with an empty salt and distinct info strings for domain separation.

Key Types

Key Algorithm Purpose
Signing keypair Ed25519 (via ed25519-dalek) Signs pre-keys, proves identity
Encryption keypair X25519 (via x25519-dalek) Diffie-Hellman key exchange

Fingerprint

The fingerprint is the primary user identifier. It is computed as:

fingerprint = SHA-256(Ed25519_public_key)[0..16]   // first 16 bytes

Displayed as four colon-separated groups of 4 hex digits (8 bytes / 64 bits of the 128-bit fingerprint):

a3f8:c912:44be:7d01

Note: the Display implementation uses only the first 8 bytes (4 groups of u16). The full 16 bytes are stored internally and used for session keying and lookups. The from_hex parser strips colons and decodes all 16 bytes.

BIP39 Mnemonic

The 32-byte seed is presented to users as a 24-word BIP39 mnemonic for human-readable backup. Recovery works by converting the mnemonic back to 32 bytes and re-deriving the same keypairs deterministically.

PublicIdentity

The shareable portion of an identity:

pub struct PublicIdentity {
    pub signing: VerifyingKey,      // Ed25519 public key (32 bytes)
    pub encryption: PublicKey,      // X25519 public key (32 bytes)
    pub fingerprint: Fingerprint,   // SHA-256(signing)[0..16]
}

Serialized with serde; the dalek types use raw-bytes serialization.


2. Pre-Key Bundles

Pre-key bundles enable asynchronous key exchange (the recipient does not need to be online when the sender initiates a session).

Signed Pre-Key

A medium-term X25519 keypair signed by the identity Ed25519 key:

pub struct SignedPreKey {
    pub id: u32,
    pub public_key: [u8; 32],   // X25519 public key
    pub signature: Vec<u8>,     // Ed25519 signature over public_key
    pub timestamp: i64,         // unix timestamp of generation
}

The signature covers public_key directly (the raw 32 bytes). Verification uses the identity's Ed25519 verifying key.

One-Time Pre-Key

A single-use X25519 keypair. Each key has a numeric id. After a key exchange consumes it, the private half is deleted.

pub struct OneTimePreKeyPublic {
    pub id: u32,
    pub public_key: [u8; 32],   // X25519 public key
}

Bundle Format

The complete bundle uploaded to the server:

pub struct PreKeyBundle {
    pub identity_key: [u8; 32],             // Ed25519 verifying key
    pub identity_encryption_key: [u8; 32],  // X25519 identity public key
    pub signed_pre_key: SignedPreKey,
    pub one_time_pre_key: Option<OneTimePreKeyPublic>,
}

Serialized with bincode for the wire and for local storage.

Lifecycle

  1. warzone init generates 1 signed pre-key (id=1) and 10 one-time pre-keys (ids 0-9).
  2. Private halves are stored in the local sled database under the pre_keys tree (keys: spk:<id>, otpk:<id>).
  3. The public bundle is saved to ~/.warzone/bundle.bin.
  4. On first send (or explicit register), the bundle is uploaded to the server.
  5. When a one-time pre-key is consumed during X3DH, it is atomically removed from local storage (take_one_time_pre_key).

TODO (Phase 2): automatic replenishment of one-time pre-keys when supply runs low; signed pre-key rotation on a schedule.


3. X3DH Key Exchange

The implementation follows Signal's Extended Triple Diffie-Hellman (X3DH) specification.

Initiator (Alice)

Alice fetches Bob's PreKeyBundle from the server, then:

1. Verify signed_pre_key.signature against identity_key
2. Generate ephemeral X25519 keypair (ek)
3. Compute four DH values:
     DH1 = X25519(Alice_identity_x25519, Bob_signed_pre_key)
     DH2 = X25519(Alice_ephemeral,       Bob_identity_x25519)
     DH3 = X25519(Alice_ephemeral,       Bob_signed_pre_key)
     DH4 = X25519(Alice_ephemeral,       Bob_one_time_pre_key)  [if present]
4. Concatenate: DH1 || DH2 || DH3 [|| DH4]
5. shared_secret = HKDF-SHA256(concat, salt="", info="warzone-x3dh", len=32)
6. Zeroize the DH concatenation

The result includes:

  • shared_secret (32 bytes) -- used to initialize the Double Ratchet
  • ephemeral_public -- sent to Bob
  • used_one_time_pre_key_id -- tells Bob which OT pre-key was consumed

Responder (Bob)

Bob receives Alice's ephemeral public key plus her identity encryption key and computes the same DH operations in the mirror order:

DH1 = X25519(Bob_signed_pre_key_secret, Alice_identity_x25519)
DH2 = X25519(Bob_identity_x25519,       Alice_ephemeral)
DH3 = X25519(Bob_signed_pre_key_secret, Alice_ephemeral)
DH4 = X25519(Bob_one_time_pre_key,      Alice_ephemeral)  [if used]

The concatenation and HKDF produce the identical shared_secret.

ASCII Diagram

  Alice                          Server                         Bob
    |                              |                              |
    |--- fetch Bob's bundle ------>|                              |
    |<-- PreKeyBundle -------------|                              |
    |                              |                              |
    |  [verify SPK signature]      |                              |
    |  [generate ephemeral key]    |                              |
    |  [DH1..DH4 -> HKDF]         |                              |
    |  [init ratchet as Alice]     |                              |
    |                              |                              |
    |--- WireMessage::KeyExchange -|---> queue for Bob            |
    |    (ephemeral_pub, otpk_id,  |                              |
    |     ratchet_message)         |                              |
    |                              |                              |
    |                              |        Bob polls ----------->|
    |                              |<-- WireMessage::KeyExchange  |
    |                              |                              |
    |                              |  [load SPK secret, OT secret]|
    |                              |  [DH1..DH4 -> HKDF]         |
    |                              |  [init ratchet as Bob]       |
    |                              |  [decrypt first message]     |

4. Double Ratchet

The Double Ratchet provides forward secrecy and break-in recovery. The implementation follows Signal's Double Ratchet specification.

State

pub struct RatchetState {
    dh_self: Vec<u8>,                              // our current X25519 secret (32 bytes)
    dh_remote: Option<[u8; 32]>,                   // their current DH public key
    root_key: [u8; 32],                            // root chain key
    chain_key_send: Option<[u8; 32]>,              // sending chain key
    chain_key_recv: Option<[u8; 32]>,              // receiving chain key
    send_count: u32,                               // messages sent in current sending chain
    recv_count: u32,                               // messages received in current receiving chain
    prev_send_count: u32,                          // messages in previous sending chain
    skipped: BTreeMap<([u8; 32], u32), [u8; 32]>,  // cached keys for out-of-order messages
}

Initialization

Alice (initiator):

  1. Receives shared_secret from X3DH and Bob's signed pre-key public as the initial remote ratchet key.
  2. Generates a fresh DH keypair.
  3. Performs kdf_rk(shared_secret, DH(new_key, bob_spk)) to produce the first root key and sending chain key.
  4. No receiving chain yet (waits for Bob's first message).

Bob (responder):

  1. Receives the same shared_secret from X3DH.
  2. Uses his signed pre-key secret as the initial DH self key.
  3. Root key = shared_secret. No chain keys yet (waits for Alice's first message to trigger the first DH ratchet step).

Sending a Message

1. If no sending chain exists, perform a DH ratchet step first
2. (new_chain_key, message_key) = kdf_ck(chain_key_send)
3. chain_key_send = new_chain_key
4. header = RatchetHeader { dh_public, prev_chain_length, message_number }
5. aad = bincode::serialize(header)
6. ciphertext = AEAD_encrypt(message_key, plaintext, aad)
7. send_count += 1
8. Return RatchetMessage { header, ciphertext }

Receiving a Message

1. Check skipped message cache: if (dh_public, message_number) is cached,
   use that message key to decrypt and return
2. If message.dh_public != dh_remote:
   a. Skip any missed messages in the current receiving chain
   b. DH ratchet step:
      - New receiving chain: kdf_rk(root_key, DH(our_secret, their_new_pub))
      - New sending chain:   kdf_rk(root_key, DH(new_secret, their_new_pub))
      - Reset counters
3. Skip messages up to message_number (cache skipped keys)
4. (new_chain_key, message_key) = kdf_ck(chain_key_recv)
5. aad = bincode::serialize(header)
6. plaintext = AEAD_decrypt(message_key, ciphertext, aad)

Skipped Messages

When messages arrive out of order, the ratchet fast-forwards the receiving chain and caches the intermediate message keys in skipped. A maximum of MAX_SKIP = 1000 messages can be skipped in one step to prevent resource exhaustion.

Cached keys are indexed by (dh_public_key, message_number) and are consumed (removed from the map) on first use.

Message Header

pub struct RatchetHeader {
    pub dh_public: [u8; 32],       // sender's current DH ratchet public key
    pub prev_chain_length: u32,    // messages in previous sending chain
    pub message_number: u32,       // index in current sending chain
}

DH Ratchet Diagram

Alice                                              Bob
  |                                                  |
  | send_chain_0 (from X3DH)                        |
  |------- msg 0 (dh_pub_A0) ---------------------->|
  |------- msg 1 (dh_pub_A0) ---------------------->|
  |                                                  |
  |                           recv: new dh_pub_A0    |
  |                           DH ratchet step        |
  |                           send_chain_1           |
  |<------ msg 0 (dh_pub_B1) -----------------------|
  |                                                  |
  | recv: new dh_pub_B1                              |
  | DH ratchet step                                  |
  | send_chain_2                                     |
  |------- msg 0 (dh_pub_A2) ---------------------->|
  |                                                  |

Each direction change triggers a DH ratchet step, producing new chain keys and providing forward secrecy and break-in recovery.


5. KDF Chains

All key derivation uses HKDF-SHA256 (via the hkdf crate with sha2).

hkdf_derive

fn hkdf_derive(ikm: &[u8], salt: &[u8], info: &[u8], len: usize) -> Vec<u8>
  • Empty salt is treated as None (HKDF uses a zero-filled salt internally).
  • info provides domain separation.

Domain Separation Strings

Context info string salt Input
Ed25519 key from seed warzone-ed25519 (empty) seed
X25519 key from seed warzone-x25519 (empty) seed
X3DH shared secret warzone-x3dh (empty) DH1||DH2||DH3[||DH4]
Root key ratchet warzone-ratchet-rk root_key DH output
Chain key -> message key warzone-ratchet-mk (empty) chain_key
Chain key -> next chain key warzone-ratchet-ck (empty) chain_key

Root Key KDF (kdf_rk)

derived = HKDF(ikm=dh_output, salt=root_key, info="warzone-ratchet-rk", len=64)
new_root_key = derived[0..32]
new_chain_key = derived[32..64]

Chain Key KDF (kdf_ck)

message_key    = HKDF(ikm=chain_key, salt="", info="warzone-ratchet-mk", len=32)
new_chain_key  = HKDF(ikm=chain_key, salt="", info="warzone-ratchet-ck", len=32)

6. AEAD Encryption

All symmetric encryption uses ChaCha20-Poly1305 (via the chacha20poly1305 crate).

Encrypt

1. Generate 12-byte random nonce from OsRng
2. ciphertext = ChaCha20-Poly1305(key, nonce, plaintext, aad)
3. Output = nonce (12 bytes) || ciphertext (includes 16-byte Poly1305 tag)

Decrypt

1. Split input: first 12 bytes = nonce, remainder = ciphertext+tag
2. plaintext = ChaCha20-Poly1305_decrypt(key, nonce, ciphertext, aad)

Associated Data

For ratchet messages, the AAD is the bincode-serialized RatchetHeader. This binds the ciphertext to the specific ratchet position and prevents header manipulation.


7. Wire Format

WireMessage Enum

The top-level wire format is a bincode-serialized enum:

pub enum WireMessage {
    KeyExchange {
        sender_fingerprint: String,
        sender_identity_encryption_key: [u8; 32],
        ephemeral_public: [u8; 32],
        used_one_time_pre_key_id: Option<u32>,
        ratchet_message: RatchetMessage,
    },
    Message {
        sender_fingerprint: String,
        ratchet_message: RatchetMessage,
    },
}

KeyExchange is sent as the first message in a new session. It carries the X3DH parameters that the recipient needs to derive the shared secret and establish the ratchet.

Message is sent for all subsequent messages in an established session.

RatchetMessage

pub struct RatchetMessage {
    pub header: RatchetHeader,   // DH public key, counters
    pub ciphertext: Vec<u8>,     // nonce || ChaCha20-Poly1305 ciphertext
}

WarzoneMessage (Defined But Not Yet Used on Wire)

The message.rs module defines a higher-level envelope:

pub struct WarzoneMessage {
    pub version: u8,
    pub id: MessageId,
    pub from: Fingerprint,
    pub to: Fingerprint,
    pub timestamp: i64,
    pub msg_type: MessageType,       // Text, File, KeyExchange, Receipt
    pub session_id: SessionId,
    pub ratchet_header: RatchetHeader,
    pub ciphertext: Vec<u8>,
    pub signature: Vec<u8>,          // Ed25519 signature
}

Status: this struct is defined but the current send/recv flow uses the simpler WireMessage enum directly. The WarzoneMessage envelope with signatures, message IDs, and session tracking will be integrated in Phase 2.

MessageContent (Plaintext, Inside Envelope)

pub enum MessageContent {
    Text { body: String },
    File { filename: String, data: Vec<u8> },
    Receipt { message_id: MessageId },
}

Status: not yet used. Currently, raw UTF-8 bytes are encrypted directly. Structured content types will be used in Phase 2.

Serialization

  • Wire (client <-> server): bincode for WireMessage and PreKeyBundle. The server stores raw bincode blobs.
  • Server API: JSON for request/response wrappers. Binary payloads are base64-encoded within JSON.
  • Local storage: bincode for RatchetState and pre-key secrets in the sled database.

8. Transport

Current Transport

HTTP POST/GET over TCP via reqwest (client) and axum (server). No TLS in the current implementation; TLS is expected to be provided by a reverse proxy.

Messages are delivered via polling: the client periodically GETs /v1/messages/poll/{fingerprint}.

Future Transports (Phase 2+)

  • WebSocket for real-time push
  • Server-to-server federation (Phase 3)
  • Bluetooth, LoRa, Wi-Fi Direct, USB sneakernet (Phase 4-5)

9. Security Properties

What Is Achieved (Phase 1)

  • Confidentiality: messages are encrypted with ChaCha20-Poly1305 using per-message keys derived from the Double Ratchet.
  • Forward secrecy: compromising the current ratchet state does not reveal past message keys (chain ratchet is one-way).
  • Break-in recovery: after a DH ratchet step, a compromised state becomes useless for future messages.
  • Asynchronous key exchange: X3DH allows session establishment without both parties being online simultaneously.
  • Out-of-order tolerance: skipped message keys are cached (up to 1000).
  • Server learns nothing: the server stores and forwards opaque bincode blobs. It never sees plaintext.

What Is NOT Yet Implemented

  • Message signing: WarzoneMessage.signature is defined but not populated. Currently, messages are not authenticated by Ed25519 signature. (Phase 2)
  • Sealed sender: the server can see sender and recipient fingerprints in the clear. (Phase 6)
  • Key transparency: no DNS-based verification of public keys. (Phase 3)
  • Seed encryption at rest: the seed file is stored as plaintext 32 bytes. Argon2 + ChaCha20-Poly1305 encryption is TODO.
  • Pre-key replenishment: one-time pre-keys are not automatically replenished after consumption.
  • Message deduplication: no dedup on the server or client.
  • Group encryption: Sender Keys not yet implemented. (Phase 2)