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>
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
warzone initgenerates 1 signed pre-key (id=1) and 10 one-time pre-keys (ids 0-9).- Private halves are stored in the local sled database under the
pre_keystree (keys:spk:<id>,otpk:<id>). - The public bundle is saved to
~/.warzone/bundle.bin. - On first
send(or explicitregister), the bundle is uploaded to the server. - 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 Ratchetephemeral_public-- sent to Bobused_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):
- Receives
shared_secretfrom X3DH and Bob's signed pre-key public as the initial remote ratchet key. - Generates a fresh DH keypair.
- Performs
kdf_rk(shared_secret, DH(new_key, bob_spk))to produce the first root key and sending chain key. - No receiving chain yet (waits for Bob's first message).
Bob (responder):
- Receives the same
shared_secretfrom X3DH. - Uses his signed pre-key secret as the initial DH self key.
- 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). infoprovides 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):
bincodeforWireMessageandPreKeyBundle. The server stores raw bincode blobs. - Server API: JSON for request/response wrappers. Binary payloads are base64-encoded within JSON.
- Local storage:
bincodeforRatchetStateand 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.signatureis 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)