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:
parent
1ca6c280e4
commit
e05bb3cc85
@ -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::*;
|
||||
|
||||
473
core/archipelago/src/mesh/ratchet.rs
Normal file
473
core/archipelago/src/mesh/ratchet.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user