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

521 lines
17 KiB
Markdown

# 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<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.
```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<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
```rust
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
```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<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:
```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<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
```rust
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:
```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<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)
```rust
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)