Scaffold Rust workspace: warzone-protocol, server, client, mule
4 crates, all compile. 16/17 tests pass.
warzone-protocol (core crypto):
- Seed-based identity (Ed25519 + X25519 from 32-byte seed via HKDF)
- BIP39 mnemonic encode/decode (24 words)
- Fingerprint type (SHA-256 truncated, displayed as xxxx:xxxx:xxxx:xxxx)
- ChaCha20-Poly1305 AEAD encrypt/decrypt with random nonce
- HKDF-SHA256 key derivation
- Pre-key bundle generation with Ed25519 signatures
- X3DH key exchange (simplified, needs X25519 identity key in bundle)
- Double Ratchet: full implementation with DH ratchet, chain ratchet,
out-of-order message handling via skipped keys cache
- Message format (WarzoneMessage envelope + RatchetHeader)
- Session type with ratchet state
- Storage trait definitions (PreKeyStore, SessionStore, MessageQueue)
warzone-server (axum):
- sled database (keys, messages, one-time pre-keys)
- Routes: /v1/health, /v1/keys/register, /v1/keys/{fp},
/v1/messages/send, /v1/messages/poll/{fp}, /v1/messages/{id}/ack
warzone-client (CLI):
- `warzone init` — generate seed, show mnemonic, save to ~/.warzone/
- `warzone recover <words>` — restore from mnemonic
- `warzone info` — show fingerprint and keys
- Seed storage at ~/.warzone/identity.seed (600 perms)
- Stubs for send, recv, chat commands
warzone-mule: Phase 4 placeholder
Known issue: X3DH test fails (initiate/respond use different DH ops
due to missing X25519 identity key in bundle). Fix in next step.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
325
warzone/crates/warzone-protocol/src/ratchet.rs
Normal file
325
warzone/crates/warzone-protocol/src/ratchet.rs
Normal file
@@ -0,0 +1,325 @@
|
||||
//! Double Ratchet algorithm implementation.
|
||||
//! Follows Signal's Double Ratchet specification.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
use crate::crypto::{aead_decrypt, aead_encrypt, hkdf_derive};
|
||||
use crate::errors::ProtocolError;
|
||||
|
||||
const MAX_SKIP: u32 = 1000;
|
||||
|
||||
/// A message produced by the ratchet.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct RatchetMessage {
|
||||
pub header: RatchetHeader,
|
||||
pub ciphertext: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Header included with each ratchet message.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct RatchetHeader {
|
||||
/// Current DH ratchet public key.
|
||||
pub dh_public: [u8; 32],
|
||||
/// Number of messages in the previous sending chain.
|
||||
pub prev_chain_length: u32,
|
||||
/// Message number in the current sending chain.
|
||||
pub message_number: u32,
|
||||
}
|
||||
|
||||
/// The Double Ratchet state machine.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct RatchetState {
|
||||
dh_self: Vec<u8>, // StaticSecret bytes (32)
|
||||
dh_remote: Option<[u8; 32]>,
|
||||
root_key: [u8; 32],
|
||||
chain_key_send: Option<[u8; 32]>,
|
||||
chain_key_recv: Option<[u8; 32]>,
|
||||
send_count: u32,
|
||||
recv_count: u32,
|
||||
prev_send_count: u32,
|
||||
skipped: BTreeMap<([u8; 32], u32), [u8; 32]>, // (dh_pub, n) -> message_key
|
||||
}
|
||||
|
||||
impl RatchetState {
|
||||
/// Initialize as Alice (initiator). Alice knows Bob's ratchet public key.
|
||||
pub fn init_alice(shared_secret: [u8; 32], bob_ratchet_pub: PublicKey) -> Self {
|
||||
let dh_self = StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
let dh_out = dh_self.diffie_hellman(&bob_ratchet_pub);
|
||||
|
||||
let (root_key, chain_key_send) = kdf_rk(&shared_secret, dh_out.as_bytes());
|
||||
|
||||
RatchetState {
|
||||
dh_self: dh_self.to_bytes().to_vec(),
|
||||
dh_remote: Some(*bob_ratchet_pub.as_bytes()),
|
||||
root_key,
|
||||
chain_key_send: Some(chain_key_send),
|
||||
chain_key_recv: None,
|
||||
send_count: 0,
|
||||
recv_count: 0,
|
||||
prev_send_count: 0,
|
||||
skipped: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize as Bob (responder). Bob uses his signed pre-key as initial ratchet key.
|
||||
pub fn init_bob(shared_secret: [u8; 32], our_ratchet_secret: StaticSecret) -> Self {
|
||||
RatchetState {
|
||||
dh_self: our_ratchet_secret.to_bytes().to_vec(),
|
||||
dh_remote: None,
|
||||
root_key: shared_secret,
|
||||
chain_key_send: None,
|
||||
chain_key_recv: None,
|
||||
send_count: 0,
|
||||
recv_count: 0,
|
||||
prev_send_count: 0,
|
||||
skipped: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get our current DH ratchet public key.
|
||||
fn dh_public(&self) -> PublicKey {
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes.copy_from_slice(&self.dh_self);
|
||||
let secret = StaticSecret::from(bytes);
|
||||
PublicKey::from(&secret)
|
||||
}
|
||||
|
||||
fn dh_secret(&self) -> StaticSecret {
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes.copy_from_slice(&self.dh_self);
|
||||
StaticSecret::from(bytes)
|
||||
}
|
||||
|
||||
/// Encrypt a plaintext message.
|
||||
pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<RatchetMessage, ProtocolError> {
|
||||
// If we don't have a sending chain yet (Bob's first message), do a DH ratchet step
|
||||
if self.chain_key_send.is_none() {
|
||||
if self.dh_remote.is_none() {
|
||||
return Err(ProtocolError::RatchetError(
|
||||
"no remote DH key and no sending chain".into(),
|
||||
));
|
||||
}
|
||||
self.dh_ratchet_step()?;
|
||||
}
|
||||
|
||||
let ck = self
|
||||
.chain_key_send
|
||||
.as_ref()
|
||||
.ok_or_else(|| ProtocolError::RatchetError("no sending chain".into()))?;
|
||||
|
||||
let (new_ck, message_key) = kdf_ck(ck);
|
||||
self.chain_key_send = Some(new_ck);
|
||||
|
||||
let header = RatchetHeader {
|
||||
dh_public: *self.dh_public().as_bytes(),
|
||||
prev_chain_length: self.prev_send_count,
|
||||
message_number: self.send_count,
|
||||
};
|
||||
|
||||
// AAD: serialized header
|
||||
let aad = bincode::serialize(&header)
|
||||
.map_err(|e| ProtocolError::SerializationError(e.to_string()))?;
|
||||
let ciphertext = aead_encrypt(&message_key, plaintext, &aad);
|
||||
|
||||
self.send_count += 1;
|
||||
|
||||
Ok(RatchetMessage { header, ciphertext })
|
||||
}
|
||||
|
||||
/// Decrypt a received ratchet message.
|
||||
pub fn decrypt(&mut self, message: &RatchetMessage) -> Result<Vec<u8>, ProtocolError> {
|
||||
// Check skipped messages first
|
||||
let key = (message.header.dh_public, message.header.message_number);
|
||||
if let Some(mk) = self.skipped.remove(&key) {
|
||||
let aad = bincode::serialize(&message.header)
|
||||
.map_err(|e| ProtocolError::SerializationError(e.to_string()))?;
|
||||
return aead_decrypt(&mk, &message.ciphertext, &aad);
|
||||
}
|
||||
|
||||
// If the message's DH key differs from what we have, perform DH ratchet
|
||||
let need_ratchet = match self.dh_remote {
|
||||
Some(ref remote) => *remote != message.header.dh_public,
|
||||
None => true,
|
||||
};
|
||||
|
||||
if need_ratchet {
|
||||
// Skip any missed messages in the current receiving chain
|
||||
if self.chain_key_recv.is_some() {
|
||||
self.skip_messages(message.header.prev_chain_length)?;
|
||||
}
|
||||
|
||||
// DH ratchet step
|
||||
let their_pub = PublicKey::from(message.header.dh_public);
|
||||
|
||||
// New receiving chain
|
||||
let dh_recv = self.dh_secret().diffie_hellman(&their_pub);
|
||||
let (rk, ck_recv) = kdf_rk(&self.root_key, dh_recv.as_bytes());
|
||||
self.root_key = rk;
|
||||
self.chain_key_recv = Some(ck_recv);
|
||||
self.recv_count = 0;
|
||||
|
||||
// New sending chain
|
||||
self.prev_send_count = self.send_count;
|
||||
self.send_count = 0;
|
||||
let new_dh = StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
let dh_send = new_dh.diffie_hellman(&their_pub);
|
||||
let (rk2, ck_send) = kdf_rk(&self.root_key, dh_send.as_bytes());
|
||||
self.root_key = rk2;
|
||||
self.chain_key_send = Some(ck_send);
|
||||
self.dh_self = new_dh.to_bytes().to_vec();
|
||||
self.dh_remote = Some(message.header.dh_public);
|
||||
}
|
||||
|
||||
// Skip to the message number
|
||||
self.skip_messages(message.header.message_number)?;
|
||||
|
||||
// Derive message key
|
||||
let ck = self
|
||||
.chain_key_recv
|
||||
.as_ref()
|
||||
.ok_or_else(|| ProtocolError::RatchetError("no receiving chain".into()))?;
|
||||
let (new_ck, message_key) = kdf_ck(ck);
|
||||
self.chain_key_recv = Some(new_ck);
|
||||
self.recv_count += 1;
|
||||
|
||||
let aad = bincode::serialize(&message.header)
|
||||
.map_err(|e| ProtocolError::SerializationError(e.to_string()))?;
|
||||
aead_decrypt(&message_key, &message.ciphertext, &aad)
|
||||
}
|
||||
|
||||
fn skip_messages(&mut self, until: u32) -> Result<(), ProtocolError> {
|
||||
if self.recv_count + MAX_SKIP < until {
|
||||
return Err(ProtocolError::MaxSkipExceeded);
|
||||
}
|
||||
if let Some(ref ck) = self.chain_key_recv.clone() {
|
||||
let dh_pub = self.dh_remote.unwrap_or([0u8; 32]);
|
||||
let mut current_ck = *ck;
|
||||
while self.recv_count < until {
|
||||
let (new_ck, mk) = kdf_ck(¤t_ck);
|
||||
self.skipped.insert((dh_pub, self.recv_count), mk);
|
||||
current_ck = new_ck;
|
||||
self.recv_count += 1;
|
||||
}
|
||||
self.chain_key_recv = Some(current_ck);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dh_ratchet_step(&mut self) -> Result<(), ProtocolError> {
|
||||
let their_pub = self
|
||||
.dh_remote
|
||||
.map(PublicKey::from)
|
||||
.ok_or_else(|| ProtocolError::RatchetError("no remote key for ratchet".into()))?;
|
||||
|
||||
self.prev_send_count = self.send_count;
|
||||
self.send_count = 0;
|
||||
|
||||
let new_dh = StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
let dh_out = new_dh.diffie_hellman(&their_pub);
|
||||
let (rk, ck_send) = kdf_rk(&self.root_key, dh_out.as_bytes());
|
||||
self.root_key = rk;
|
||||
self.chain_key_send = Some(ck_send);
|
||||
self.dh_self = new_dh.to_bytes().to_vec();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Root key KDF: derive new root key + chain key from DH output.
|
||||
fn kdf_rk(root_key: &[u8; 32], dh_output: &[u8]) -> ([u8; 32], [u8; 32]) {
|
||||
let derived = hkdf_derive(dh_output, root_key, b"warzone-ratchet-rk", 64);
|
||||
let mut new_rk = [0u8; 32];
|
||||
let mut chain_key = [0u8; 32];
|
||||
new_rk.copy_from_slice(&derived[..32]);
|
||||
chain_key.copy_from_slice(&derived[32..]);
|
||||
(new_rk, chain_key)
|
||||
}
|
||||
|
||||
/// Chain key KDF: derive new chain key + message key.
|
||||
fn kdf_ck(chain_key: &[u8; 32]) -> ([u8; 32], [u8; 32]) {
|
||||
let mk_bytes = hkdf_derive(chain_key, b"", b"warzone-ratchet-mk", 32);
|
||||
let ck_bytes = hkdf_derive(chain_key, b"", b"warzone-ratchet-ck", 32);
|
||||
let mut new_ck = [0u8; 32];
|
||||
let mut mk = [0u8; 32];
|
||||
new_ck.copy_from_slice(&ck_bytes);
|
||||
mk.copy_from_slice(&mk_bytes);
|
||||
(new_ck, mk)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_pair() -> (RatchetState, RatchetState) {
|
||||
let shared_secret = [42u8; 32];
|
||||
let bob_ratchet = StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
let bob_ratchet_pub = PublicKey::from(&bob_ratchet);
|
||||
|
||||
let alice = RatchetState::init_alice(shared_secret, bob_ratchet_pub);
|
||||
let bob = RatchetState::init_bob(shared_secret, bob_ratchet);
|
||||
(alice, bob)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_exchange() {
|
||||
let (mut alice, mut bob) = make_pair();
|
||||
let msg = alice.encrypt(b"hello bob").unwrap();
|
||||
let plain = bob.decrypt(&msg).unwrap();
|
||||
assert_eq!(plain, b"hello bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bidirectional() {
|
||||
let (mut alice, mut bob) = make_pair();
|
||||
|
||||
let m1 = alice.encrypt(b"hello bob").unwrap();
|
||||
assert_eq!(bob.decrypt(&m1).unwrap(), b"hello bob");
|
||||
|
||||
let m2 = bob.encrypt(b"hello alice").unwrap();
|
||||
assert_eq!(alice.decrypt(&m2).unwrap(), b"hello alice");
|
||||
|
||||
let m3 = alice.encrypt(b"how are you?").unwrap();
|
||||
assert_eq!(bob.decrypt(&m3).unwrap(), b"how are you?");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_messages_same_direction() {
|
||||
let (mut alice, mut bob) = make_pair();
|
||||
|
||||
let m1 = alice.encrypt(b"one").unwrap();
|
||||
let m2 = alice.encrypt(b"two").unwrap();
|
||||
let m3 = alice.encrypt(b"three").unwrap();
|
||||
|
||||
assert_eq!(bob.decrypt(&m1).unwrap(), b"one");
|
||||
assert_eq!(bob.decrypt(&m2).unwrap(), b"two");
|
||||
assert_eq!(bob.decrypt(&m3).unwrap(), b"three");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_order() {
|
||||
let (mut alice, mut bob) = make_pair();
|
||||
|
||||
let m1 = alice.encrypt(b"one").unwrap();
|
||||
let m2 = alice.encrypt(b"two").unwrap();
|
||||
let m3 = alice.encrypt(b"three").unwrap();
|
||||
|
||||
// Deliver out of order
|
||||
assert_eq!(bob.decrypt(&m3).unwrap(), b"three");
|
||||
assert_eq!(bob.decrypt(&m1).unwrap(), b"one");
|
||||
assert_eq!(bob.decrypt(&m2).unwrap(), b"two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_messages() {
|
||||
let (mut alice, mut bob) = make_pair();
|
||||
for i in 0..100 {
|
||||
let msg = format!("message {}", i);
|
||||
let encrypted = alice.encrypt(msg.as_bytes()).unwrap();
|
||||
let decrypted = bob.decrypt(&encrypted).unwrap();
|
||||
assert_eq!(decrypted, msg.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user