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>
521 lines
17 KiB
Markdown
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)
|