# 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: ```rust 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: ```rust pub struct SignedPreKey { pub id: u32, pub public_key: [u8; 32], // X25519 public key pub signature: Vec, // 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. ```rust pub struct OneTimePreKeyPublic { pub id: u32, pub public_key: [u8; 32], // X25519 public key } ``` ### Bundle Format The complete bundle uploaded to the server: ```rust 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, } ``` 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:`, `otpk:`). 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 ```rust pub struct RatchetState { dh_self: Vec, // 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 ```rust 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 ```rust fn hkdf_derive(ikm: &[u8], salt: &[u8], info: &[u8], len: usize) -> Vec ``` - 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: ```rust pub enum WireMessage { KeyExchange { sender_fingerprint: String, sender_identity_encryption_key: [u8; 32], ephemeral_public: [u8; 32], used_one_time_pre_key_id: Option, 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 ```rust pub struct RatchetMessage { pub header: RatchetHeader, // DH public key, counters pub ciphertext: Vec, // nonce || ChaCha20-Poly1305 ciphertext } ``` ### WarzoneMessage (Defined But Not Yet Used on Wire) The `message.rs` module defines a higher-level envelope: ```rust 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, pub signature: Vec, // 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) ```rust pub enum MessageContent { Text { body: String }, File { filename: String, data: Vec }, 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)