feat: Phase 3 Week 2 — Double Ratchet protocol for forward-secret mesh messaging

- Create mesh/ratchet.rs: full Signal-style Double Ratchet implementation
  - DH ratchet with X25519 ephemeral keypairs per step
  - Symmetric-key ratchet via HKDF-SHA256 chain derivation
  - Per-message ChaCha20-Poly1305 encryption with derived message keys
  - Out-of-order delivery via skipped message key cache (max 100)
  - Forward secrecy: old keys zeroized on ratchet step
  - Wire format: 40B header + nonce + ciphertext + tag
- Tests: full conversation, out-of-order, forward secrecy, wire format,
  long conversation (50 messages alternating), message roundtrip

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-17 01:50:22 +00:00
parent 1ca6c280e4
commit e05bb3cc85
2 changed files with 475 additions and 0 deletions

View File

@@ -15,6 +15,8 @@ pub mod serial;
#[allow(dead_code)]
pub mod types;
#[allow(dead_code)]
pub mod ratchet;
#[allow(dead_code)]
pub mod x3dh;
pub use types::*;

View File

@@ -0,0 +1,473 @@
//! Double Ratchet protocol for forward-secret mesh messaging.
//!
//! Implements the Signal protocol's Double Ratchet algorithm:
//! - DH ratchet: new X25519 ephemeral keypair per DH step
//! - Symmetric-key ratchet: HKDF-SHA256 chain for message keys
//! - Forward secrecy: compromising current key doesn't reveal past messages
//!
//! Wire format per message:
//! ```text
//! [RatchetHeader: 40 bytes] [nonce: 12] [ciphertext] [tag: 16]
//! ```
//!
//! Reference: Signal Technical Documentation — Double Ratchet Algorithm
use super::crypto;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use zeroize::{Zeroize, ZeroizeOnDrop};
/// HKDF info string for root key + chain key derivation.
const KDF_RK_INFO: &[u8] = b"ArchyRatchetRK";
/// HKDF info string for message key derivation from chain key.
const KDF_CK_INFO: &[u8] = b"ArchyRatchetCK";
/// Maximum number of skipped message keys to store (prevents DoS).
const MAX_SKIP: u32 = 100;
/// Ratchet message header sent with every encrypted message.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RatchetHeader {
/// Sender's current DH ratchet public key (32 bytes).
#[serde(with = "hex_bytes")]
pub dh_public: [u8; 32],
/// Number of messages in the previous sending chain.
pub prev_chain_n: u32,
/// Message number in the current sending chain.
pub message_n: u32,
}
impl RatchetHeader {
/// Serialize header to bytes (fixed 40 bytes).
pub fn to_bytes(&self) -> [u8; 40] {
let mut buf = [0u8; 40];
buf[..32].copy_from_slice(&self.dh_public);
buf[32..36].copy_from_slice(&self.prev_chain_n.to_le_bytes());
buf[36..40].copy_from_slice(&self.message_n.to_le_bytes());
buf
}
/// Parse header from bytes.
pub fn from_bytes(data: &[u8; 40]) -> Self {
let mut dh_public = [0u8; 32];
dh_public.copy_from_slice(&data[..32]);
let prev_chain_n = u32::from_le_bytes([data[32], data[33], data[34], data[35]]);
let message_n = u32::from_le_bytes([data[36], data[37], data[38], data[39]]);
Self { dh_public, prev_chain_n, message_n }
}
}
/// A complete ratchet-encrypted message (header + ciphertext).
#[derive(Debug, Clone)]
pub struct RatchetMessage {
pub header: RatchetHeader,
pub ciphertext: Vec<u8>, // nonce(12) + encrypted(N) + tag(16)
}
impl RatchetMessage {
/// Serialize to wire format: header(40) + ciphertext.
pub fn to_bytes(&self) -> Vec<u8> {
let header_bytes = self.header.to_bytes();
let mut buf = Vec::with_capacity(40 + self.ciphertext.len());
buf.extend_from_slice(&header_bytes);
buf.extend_from_slice(&self.ciphertext);
buf
}
/// Parse from wire format.
pub fn from_bytes(data: &[u8]) -> Result<Self> {
if data.len() < 40 + 12 + 16 + 1 {
anyhow::bail!("Ratchet message too short: {} bytes", data.len());
}
let mut header_bytes = [0u8; 40];
header_bytes.copy_from_slice(&data[..40]);
Ok(Self {
header: RatchetHeader::from_bytes(&header_bytes),
ciphertext: data[40..].to_vec(),
})
}
}
/// Per-peer Double Ratchet state.
#[derive(Serialize, Deserialize)]
pub struct RatchetState {
// DH ratchet: our current ephemeral keypair
dh_self_secret: [u8; 32],
dh_self_public: [u8; 32],
// DH ratchet: peer's last known public key
dh_remote_public: Option<[u8; 32]>,
// Root key (ratcheted on each DH step)
root_key: [u8; 32],
// Sending chain key
chain_key_send: Option<[u8; 32]>,
// Receiving chain key
chain_key_recv: Option<[u8; 32]>,
// Message counters
send_n: u32,
recv_n: u32,
prev_send_n: u32,
// Skipped message keys for out-of-order delivery
// Key: (dh_public_hex, message_number)
skipped_keys: HashMap<(String, u32), [u8; 32]>,
}
impl Drop for RatchetState {
fn drop(&mut self) {
self.dh_self_secret.zeroize();
self.root_key.zeroize();
if let Some(ref mut k) = self.chain_key_send { k.zeroize(); }
if let Some(ref mut k) = self.chain_key_recv { k.zeroize(); }
for (_, v) in self.skipped_keys.iter_mut() { v.zeroize(); }
}
}
impl RatchetState {
/// Initialize as the session initiator (the one who performed X3DH initiate).
/// The initiator sends the first message, so they start with a sending chain.
pub fn init_as_sender(
root_key: [u8; 32],
their_signed_prekey_public: &[u8; 32],
) -> Result<Self> {
let (dh_secret, dh_public) = crypto::generate_x25519_ephemeral();
// First DH ratchet step: derive sending chain key
let dh_output = crypto::x25519_shared_secret(&dh_secret, their_signed_prekey_public);
let (new_root_key, chain_key_send) =
crypto::hkdf_sha256_64(&root_key, &dh_output, KDF_RK_INFO)?;
Ok(Self {
dh_self_secret: dh_secret,
dh_self_public: dh_public,
dh_remote_public: Some(*their_signed_prekey_public),
root_key: new_root_key,
chain_key_send: Some(chain_key_send),
chain_key_recv: None,
send_n: 0,
recv_n: 0,
prev_send_n: 0,
skipped_keys: HashMap::new(),
})
}
/// Initialize as the session receiver (the one who performed X3DH respond).
/// The receiver waits for the first message before creating their sending chain.
pub fn init_as_receiver(
root_key: [u8; 32],
our_signed_prekey_secret: [u8; 32],
our_signed_prekey_public: [u8; 32],
) -> Self {
Self {
dh_self_secret: our_signed_prekey_secret,
dh_self_public: our_signed_prekey_public,
dh_remote_public: None,
root_key,
chain_key_send: None,
chain_key_recv: None,
send_n: 0,
recv_n: 0,
prev_send_n: 0,
skipped_keys: HashMap::new(),
}
}
/// Encrypt a plaintext message.
/// Ratchets the sending chain forward, derives a per-message key,
/// and encrypts with ChaCha20-Poly1305.
pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<RatchetMessage> {
let chain_key = self.chain_key_send
.ok_or_else(|| anyhow::anyhow!("No sending chain key — session not fully initialized"))?;
// Derive message key from chain key
let (new_chain_key, message_key) =
kdf_chain_key(&chain_key)?;
self.chain_key_send = Some(new_chain_key);
// Encrypt with message key
let ciphertext = crypto::encrypt(&message_key, plaintext)?;
let header = RatchetHeader {
dh_public: self.dh_self_public,
prev_chain_n: self.prev_send_n,
message_n: self.send_n,
};
self.send_n += 1;
Ok(RatchetMessage { header, ciphertext })
}
/// Decrypt a received ratchet message.
/// Handles DH ratchet steps, out-of-order messages via skipped keys.
pub fn decrypt(&mut self, message: &RatchetMessage) -> Result<Vec<u8>> {
// 1. Try skipped message keys first (out-of-order delivery)
let dh_hex = hex::encode(&message.header.dh_public);
if let Some(mk) = self.skipped_keys.remove(&(dh_hex.clone(), message.header.message_n)) {
return crypto::decrypt(&mk, &message.ciphertext);
}
// 2. Check if we need a DH ratchet step (new DH public key from peer)
let need_dh_ratchet = match self.dh_remote_public {
None => true,
Some(ref remote) => remote != &message.header.dh_public,
};
if need_dh_ratchet {
// Skip any remaining messages in the current receiving chain
if self.chain_key_recv.is_some() {
self.skip_message_keys(message.header.prev_chain_n)?;
}
// DH ratchet step: derive new receiving chain
let dh_output = crypto::x25519_shared_secret(
&self.dh_self_secret,
&message.header.dh_public,
);
let (new_root_key, chain_key_recv) =
crypto::hkdf_sha256_64(&self.root_key, &dh_output, KDF_RK_INFO)?;
self.root_key = new_root_key;
self.chain_key_recv = Some(chain_key_recv);
self.dh_remote_public = Some(message.header.dh_public);
self.prev_send_n = self.send_n;
self.send_n = 0;
self.recv_n = 0;
// Generate new DH keypair for our next sending chain
let (new_secret, new_public) = crypto::generate_x25519_ephemeral();
let dh_output2 = crypto::x25519_shared_secret(
&new_secret,
&message.header.dh_public,
);
let (new_root_key2, chain_key_send) =
crypto::hkdf_sha256_64(&self.root_key, &dh_output2, KDF_RK_INFO)?;
self.root_key = new_root_key2;
self.chain_key_send = Some(chain_key_send);
self.dh_self_secret.zeroize();
self.dh_self_secret = new_secret;
self.dh_self_public = new_public;
}
// 3. Skip any messages before this one in the current chain
self.skip_message_keys(message.header.message_n)?;
// 4. Derive message key and decrypt
let chain_key = self.chain_key_recv
.ok_or_else(|| anyhow::anyhow!("No receiving chain key"))?;
let (new_chain_key, message_key) = kdf_chain_key(&chain_key)?;
self.chain_key_recv = Some(new_chain_key);
self.recv_n += 1;
crypto::decrypt(&message_key, &message.ciphertext)
}
/// Skip message keys up to `until` (exclusive) and store them for later.
fn skip_message_keys(&mut self, until: u32) -> Result<()> {
if self.recv_n + MAX_SKIP < until {
anyhow::bail!(
"Too many skipped messages: {} (max {})",
until - self.recv_n,
MAX_SKIP
);
}
if let Some(mut chain_key) = self.chain_key_recv {
while self.recv_n < until {
let (new_chain_key, message_key) = kdf_chain_key(&chain_key)?;
let dh_hex = self.dh_remote_public
.map(|pk| hex::encode(pk))
.unwrap_or_default();
self.skipped_keys.insert((dh_hex, self.recv_n), message_key);
chain_key = new_chain_key;
self.recv_n += 1;
// Evict oldest if over limit
if self.skipped_keys.len() > MAX_SKIP as usize {
if let Some(key) = self.skipped_keys.keys().next().cloned() {
self.skipped_keys.remove(&key);
}
}
}
self.chain_key_recv = Some(chain_key);
}
Ok(())
}
/// Get the current DH ratchet generation (number of DH steps).
pub fn generation(&self) -> u32 {
self.prev_send_n + self.send_n
}
/// Total messages sent in this session.
pub fn total_sent(&self) -> u32 {
self.prev_send_n + self.send_n
}
}
/// Derive a message key from a chain key using HKDF.
/// Returns (new_chain_key, message_key).
fn kdf_chain_key(chain_key: &[u8; 32]) -> Result<([u8; 32], [u8; 32])> {
crypto::hkdf_sha256_64(chain_key, &[0x01], KDF_CK_INFO)
}
// ─── Hex serde helper ───────────────────────────────────────────────────
mod hex_bytes {
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(bytes: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&hex::encode(bytes))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> {
let s = String::deserialize(d)?;
let bytes = hex::decode(&s).map_err(serde::de::Error::custom)?;
if bytes.len() != 32 { return Err(serde::de::Error::custom("expected 32 bytes")); }
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Ok(arr)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Simulate a full conversation between Alice and Bob.
#[test]
fn test_ratchet_conversation() {
// Shared root key from X3DH (normally derived, here mocked)
let root_key = [42u8; 32];
// Bob's signed prekey (normally from X3DH bundle)
let (bob_spk_secret, bob_spk_public) = crypto::generate_x25519_ephemeral();
// Alice (sender) initializes
let mut alice = RatchetState::init_as_sender(root_key, &bob_spk_public).unwrap();
// Bob (receiver) initializes
let mut bob = RatchetState::init_as_receiver(root_key, bob_spk_secret, bob_spk_public);
// Alice sends message 1
let msg1 = alice.encrypt(b"Hello Bob, from mesh!").unwrap();
let plain1 = bob.decrypt(&msg1).unwrap();
assert_eq!(plain1, b"Hello Bob, from mesh!");
// Bob replies
let msg2 = bob.encrypt(b"Hey Alice, loud and clear").unwrap();
let plain2 = alice.decrypt(&msg2).unwrap();
assert_eq!(plain2, b"Hey Alice, loud and clear");
// Alice sends again (new DH ratchet step)
let msg3 = alice.encrypt(b"Block 890412 confirmed").unwrap();
let plain3 = bob.decrypt(&msg3).unwrap();
assert_eq!(plain3, b"Block 890412 confirmed");
// Bob sends multiple in a row
let msg4 = bob.encrypt(b"Opening channel").unwrap();
let msg5 = bob.encrypt(b"500k sats capacity").unwrap();
let plain4 = alice.decrypt(&msg4).unwrap();
let plain5 = alice.decrypt(&msg5).unwrap();
assert_eq!(plain4, b"Opening channel");
assert_eq!(plain5, b"500k sats capacity");
}
#[test]
fn test_out_of_order_delivery() {
let root_key = [99u8; 32];
let (spk_secret, spk_public) = crypto::generate_x25519_ephemeral();
let mut alice = RatchetState::init_as_sender(root_key, &spk_public).unwrap();
let mut bob = RatchetState::init_as_receiver(root_key, spk_secret, spk_public);
// Alice sends 3 messages
let msg1 = alice.encrypt(b"first").unwrap();
let msg2 = alice.encrypt(b"second").unwrap();
let msg3 = alice.encrypt(b"third").unwrap();
// Bob receives out of order: 3, 1, 2
let p3 = bob.decrypt(&msg3).unwrap();
assert_eq!(p3, b"third");
let p1 = bob.decrypt(&msg1).unwrap();
assert_eq!(p1, b"first");
let p2 = bob.decrypt(&msg2).unwrap();
assert_eq!(p2, b"second");
}
#[test]
fn test_forward_secrecy() {
// After DH ratchet steps, old keys are destroyed
let root_key = [77u8; 32];
let (spk_secret, spk_public) = crypto::generate_x25519_ephemeral();
let mut alice = RatchetState::init_as_sender(root_key, &spk_public).unwrap();
let mut bob = RatchetState::init_as_receiver(root_key, spk_secret, spk_public);
// Exchange messages to ratchet forward
let msg1 = alice.encrypt(b"msg1").unwrap();
bob.decrypt(&msg1).unwrap();
let msg2 = bob.encrypt(b"msg2").unwrap();
alice.decrypt(&msg2).unwrap();
// At this point, both have ratcheted. The original root_key
// and initial chain keys are no longer in memory.
// We can verify the state has evolved:
assert_ne!(alice.root_key, root_key);
assert_ne!(bob.root_key, root_key);
}
#[test]
fn test_message_wire_format() {
let header = RatchetHeader {
dh_public: [0xAA; 32],
prev_chain_n: 5,
message_n: 12,
};
let bytes = header.to_bytes();
assert_eq!(bytes.len(), 40);
let parsed = RatchetHeader::from_bytes(&bytes);
assert_eq!(parsed.dh_public, [0xAA; 32]);
assert_eq!(parsed.prev_chain_n, 5);
assert_eq!(parsed.message_n, 12);
}
#[test]
fn test_ratchet_message_roundtrip() {
let msg = RatchetMessage {
header: RatchetHeader {
dh_public: [0xBB; 32],
prev_chain_n: 0,
message_n: 0,
},
ciphertext: vec![0x01, 0x02, 0x03; 30].into_iter().flatten().collect(),
};
let bytes = msg.to_bytes();
let parsed = RatchetMessage::from_bytes(&bytes).unwrap();
assert_eq!(parsed.header.dh_public, [0xBB; 32]);
assert_eq!(parsed.ciphertext.len(), msg.ciphertext.len());
}
#[test]
fn test_long_conversation() {
let root_key = [11u8; 32];
let (spk_secret, spk_public) = crypto::generate_x25519_ephemeral();
let mut alice = RatchetState::init_as_sender(root_key, &spk_public).unwrap();
let mut bob = RatchetState::init_as_receiver(root_key, spk_secret, spk_public);
// 50 messages back and forth
for i in 0..50 {
let msg_text = format!("Message #{} from {}", i, if i % 2 == 0 { "Alice" } else { "Bob" });
if i % 2 == 0 {
let msg = alice.encrypt(msg_text.as_bytes()).unwrap();
let decrypted = bob.decrypt(&msg).unwrap();
assert_eq!(decrypted, msg_text.as_bytes());
} else {
let msg = bob.encrypt(msg_text.as_bytes()).unwrap();
let decrypted = alice.decrypt(&msg).unwrap();
assert_eq!(decrypted, msg_text.as_bytes());
}
}
}
}