feat: WarzonePhone lossy VoIP protocol — Phase 1 complete
Rust workspace with 7 crates implementing a custom VoIP protocol designed for extremely lossy connections (5-70% loss, 100-500kbps, 300-800ms RTT). 89 tests passing across all crates. Crates: - wzp-proto: Wire format, traits, adaptive quality controller, jitter buffer, session FSM - wzp-codec: Opus encoder/decoder (audiopus), Codec2 stubs, adaptive switching, resampling - wzp-fec: RaptorQ fountain codes, interleaving, block management (proven 30-70% loss recovery) - wzp-crypto: X25519+ChaCha20-Poly1305, Warzone identity compatible, anti-replay, rekeying - wzp-transport: QUIC via quinn with DATAGRAM frames, path monitoring, signaling streams - wzp-relay: Integration stub (Phase 2) - wzp-client: Integration stub (Phase 2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
204
crates/wzp-crypto/src/anti_replay.rs
Normal file
204
crates/wzp-crypto/src/anti_replay.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
//! Sliding window replay protection.
|
||||
//!
|
||||
//! Tracks seen sequence numbers using a bitmap. Window size is 1024 packets.
|
||||
//! Sequence numbers that are too old (more than WINDOW_SIZE behind the highest
|
||||
//! seen) are rejected.
|
||||
|
||||
use wzp_proto::CryptoError;
|
||||
|
||||
/// Window size in packets.
|
||||
const WINDOW_SIZE: u16 = 1024;
|
||||
|
||||
/// Sliding window anti-replay detector.
|
||||
///
|
||||
/// Uses a bitmap to track which sequence numbers have been seen within
|
||||
/// the current window. Handles u16 wrapping correctly.
|
||||
pub struct AntiReplayWindow {
|
||||
/// Highest sequence number seen so far.
|
||||
highest: u16,
|
||||
/// Bitmap of seen packets. Bit i corresponds to (highest - i).
|
||||
bitmap: Vec<u64>,
|
||||
/// Whether any packet has been received yet.
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
impl AntiReplayWindow {
|
||||
/// Number of u64 words needed for the bitmap.
|
||||
const BITMAP_WORDS: usize = (WINDOW_SIZE as usize + 63) / 64;
|
||||
|
||||
/// Create a new anti-replay window.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
highest: 0,
|
||||
bitmap: vec![0u64; Self::BITMAP_WORDS],
|
||||
initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a sequence number is valid (not a replay, not too old).
|
||||
/// If valid, marks it as seen.
|
||||
pub fn check_and_update(&mut self, seq: u16) -> Result<(), CryptoError> {
|
||||
if !self.initialized {
|
||||
self.initialized = true;
|
||||
self.highest = seq;
|
||||
self.set_bit(0);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let diff = seq.wrapping_sub(self.highest);
|
||||
|
||||
if diff == 0 {
|
||||
// Duplicate of highest
|
||||
return Err(CryptoError::ReplayDetected { seq });
|
||||
}
|
||||
|
||||
if diff < 0x8000 {
|
||||
// seq is ahead of highest (wrapping-aware: diff in [1, 0x7FFF])
|
||||
let shift = diff as usize;
|
||||
self.advance_window(shift);
|
||||
self.highest = seq;
|
||||
self.set_bit(0);
|
||||
Ok(())
|
||||
} else {
|
||||
// seq is behind highest (wrapping-aware: diff in [0x8000, 0xFFFF])
|
||||
let behind = self.highest.wrapping_sub(seq) as usize;
|
||||
if behind >= WINDOW_SIZE as usize {
|
||||
return Err(CryptoError::ReplayDetected { seq });
|
||||
}
|
||||
if self.get_bit(behind) {
|
||||
return Err(CryptoError::ReplayDetected { seq });
|
||||
}
|
||||
self.set_bit(behind);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the window by `shift` positions (shift left = new bits at position 0).
|
||||
fn advance_window(&mut self, shift: usize) {
|
||||
if shift >= WINDOW_SIZE as usize {
|
||||
for word in &mut self.bitmap {
|
||||
*word = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// We need to shift the entire bitmap right by `shift` bits.
|
||||
// Bit 0 of word 0 is the most recent. Shifting right means
|
||||
// old entries move to higher bit positions.
|
||||
let word_shift = shift / 64;
|
||||
let bit_shift = shift % 64;
|
||||
|
||||
// Move words
|
||||
let len = self.bitmap.len();
|
||||
for i in (0..len).rev() {
|
||||
let mut val = 0u64;
|
||||
if i >= word_shift {
|
||||
val = self.bitmap[i - word_shift] << bit_shift;
|
||||
if bit_shift > 0 && i > word_shift {
|
||||
val |= self.bitmap[i - word_shift - 1] >> (64 - bit_shift);
|
||||
}
|
||||
}
|
||||
self.bitmap[i] = val;
|
||||
}
|
||||
// Clear the lower words that shifted in
|
||||
for word in &mut self.bitmap[..word_shift.min(len)] {
|
||||
*word = 0;
|
||||
}
|
||||
// Clear the lower bits of the first non-shifted word
|
||||
if word_shift < len && bit_shift > 0 {
|
||||
self.bitmap[word_shift] &= !((1u64 << bit_shift) - 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_bit(&mut self, offset: usize) {
|
||||
let word = offset / 64;
|
||||
let bit = offset % 64;
|
||||
if word < self.bitmap.len() {
|
||||
self.bitmap[word] |= 1u64 << bit;
|
||||
}
|
||||
}
|
||||
|
||||
fn get_bit(&self, offset: usize) -> bool {
|
||||
let word = offset / 64;
|
||||
let bit = offset % 64;
|
||||
if word < self.bitmap.len() {
|
||||
(self.bitmap[word] >> bit) & 1 == 1
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AntiReplayWindow {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn first_packet_accepted() {
|
||||
let mut w = AntiReplayWindow::new();
|
||||
assert!(w.check_and_update(0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_rejected() {
|
||||
let mut w = AntiReplayWindow::new();
|
||||
assert!(w.check_and_update(100).is_ok());
|
||||
assert!(w.check_and_update(100).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_accepted() {
|
||||
let mut w = AntiReplayWindow::new();
|
||||
for i in 0..200 {
|
||||
assert!(w.check_and_update(i).is_ok(), "seq {} should be accepted", i);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_order_within_window() {
|
||||
let mut w = AntiReplayWindow::new();
|
||||
assert!(w.check_and_update(100).is_ok());
|
||||
assert!(w.check_and_update(95).is_ok());
|
||||
assert!(w.check_and_update(98).is_ok());
|
||||
assert!(w.check_and_update(102).is_ok());
|
||||
assert!(w.check_and_update(99).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_packet_rejected() {
|
||||
let mut w = AntiReplayWindow::new();
|
||||
assert!(w.check_and_update(0).is_ok());
|
||||
// Advance well past the window
|
||||
assert!(w.check_and_update(2000).is_ok());
|
||||
// seq 0 is now too old
|
||||
assert!(w.check_and_update(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_works() {
|
||||
let mut w = AntiReplayWindow::new();
|
||||
assert!(w.check_and_update(65530).is_ok());
|
||||
assert!(w.check_and_update(65535).is_ok());
|
||||
assert!(w.check_and_update(0).is_ok()); // wrapped
|
||||
assert!(w.check_and_update(1).is_ok());
|
||||
assert!(w.check_and_update(65535).is_err()); // duplicate
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn within_window_boundary() {
|
||||
let mut w = AntiReplayWindow::new();
|
||||
assert!(w.check_and_update(1023).is_ok());
|
||||
// 1023 - 0 = 1023, exactly at window boundary
|
||||
assert!(w.check_and_update(0).is_ok());
|
||||
// But 1024 behind would be out
|
||||
assert!(w.check_and_update(1024).is_ok());
|
||||
// Now 0 is 1024 behind 1024, which is at the boundary limit
|
||||
assert!(w.check_and_update(0).is_err()); // already seen or too old
|
||||
}
|
||||
}
|
||||
214
crates/wzp-crypto/src/handshake.rs
Normal file
214
crates/wzp-crypto/src/handshake.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
//! Warzone identity key exchange.
|
||||
//!
|
||||
//! Implements the `KeyExchange` trait from `wzp-proto`:
|
||||
//! - Identity: 32-byte seed -> HKDF -> Ed25519 (signing) + X25519 (encryption)
|
||||
//! - Fingerprint: SHA-256(Ed25519 pub)[:16]
|
||||
//! - Per-call: ephemeral X25519 -> ChaCha20-Poly1305 session
|
||||
|
||||
use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use hkdf::Hkdf;
|
||||
use rand::rngs::OsRng;
|
||||
use sha2::{Digest, Sha256};
|
||||
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
|
||||
use wzp_proto::{CryptoError, CryptoSession, KeyExchange};
|
||||
|
||||
use crate::session::ChaChaSession;
|
||||
|
||||
/// Warzone-compatible key exchange implementation.
|
||||
pub struct WarzoneKeyExchange {
|
||||
/// Ed25519 signing key (identity).
|
||||
signing_key: SigningKey,
|
||||
/// X25519 static secret (derived from seed, used for identity encryption).
|
||||
#[allow(dead_code)]
|
||||
x25519_static_secret: StaticSecret,
|
||||
/// X25519 static public key.
|
||||
#[allow(dead_code)]
|
||||
x25519_static_public: X25519PublicKey,
|
||||
/// Ephemeral X25519 secret for the current call (set by generate_ephemeral).
|
||||
ephemeral_secret: Option<StaticSecret>,
|
||||
}
|
||||
|
||||
impl KeyExchange for WarzoneKeyExchange {
|
||||
fn from_identity_seed(seed: &[u8; 32]) -> Self {
|
||||
// Derive Ed25519 signing key via HKDF
|
||||
let hk = Hkdf::<Sha256>::new(None, seed);
|
||||
let mut ed25519_bytes = [0u8; 32];
|
||||
hk.expand(b"warzone-ed25519-identity", &mut ed25519_bytes)
|
||||
.expect("HKDF expand for Ed25519 should not fail");
|
||||
let signing_key = SigningKey::from_bytes(&ed25519_bytes);
|
||||
|
||||
// Derive X25519 static key via HKDF
|
||||
let mut x25519_bytes = [0u8; 32];
|
||||
hk.expand(b"warzone-x25519-identity", &mut x25519_bytes)
|
||||
.expect("HKDF expand for X25519 should not fail");
|
||||
let x25519_static_secret = StaticSecret::from(x25519_bytes);
|
||||
let x25519_static_public = X25519PublicKey::from(&x25519_static_secret);
|
||||
|
||||
Self {
|
||||
signing_key,
|
||||
x25519_static_secret,
|
||||
x25519_static_public,
|
||||
ephemeral_secret: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_ephemeral(&mut self) -> [u8; 32] {
|
||||
let secret = StaticSecret::random_from_rng(OsRng);
|
||||
let public = X25519PublicKey::from(&secret);
|
||||
self.ephemeral_secret = Some(secret);
|
||||
public.to_bytes()
|
||||
}
|
||||
|
||||
fn identity_public_key(&self) -> [u8; 32] {
|
||||
self.signing_key.verifying_key().to_bytes()
|
||||
}
|
||||
|
||||
fn fingerprint(&self) -> [u8; 16] {
|
||||
let pub_bytes = self.identity_public_key();
|
||||
let hash = Sha256::digest(pub_bytes);
|
||||
let mut fp = [0u8; 16];
|
||||
fp.copy_from_slice(&hash[..16]);
|
||||
fp
|
||||
}
|
||||
|
||||
fn sign(&self, data: &[u8]) -> Vec<u8> {
|
||||
let sig = self.signing_key.sign(data);
|
||||
sig.to_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn verify(peer_identity_pub: &[u8; 32], data: &[u8], signature: &[u8]) -> bool {
|
||||
let Ok(verifying_key) = VerifyingKey::from_bytes(peer_identity_pub) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(sig_bytes) = <[u8; 64]>::try_from(signature) else {
|
||||
return false;
|
||||
};
|
||||
let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes);
|
||||
verifying_key.verify(data, &sig).is_ok()
|
||||
}
|
||||
|
||||
fn derive_session(
|
||||
&self,
|
||||
peer_ephemeral_pub: &[u8; 32],
|
||||
) -> Result<Box<dyn CryptoSession>, CryptoError> {
|
||||
let secret = self
|
||||
.ephemeral_secret
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
CryptoError::Internal("no ephemeral key generated; call generate_ephemeral first".into())
|
||||
})?;
|
||||
|
||||
let peer_public = X25519PublicKey::from(*peer_ephemeral_pub);
|
||||
// Use diffie_hellman with a clone of the StaticSecret
|
||||
let secret_bytes: [u8; 32] = secret.to_bytes();
|
||||
let secret_clone = StaticSecret::from(secret_bytes);
|
||||
let shared_secret = secret_clone.diffie_hellman(&peer_public);
|
||||
|
||||
// Expand shared secret via HKDF
|
||||
let hk = Hkdf::<Sha256>::new(None, shared_secret.as_bytes());
|
||||
let mut session_key = [0u8; 32];
|
||||
hk.expand(b"warzone-session-key", &mut session_key)
|
||||
.expect("HKDF expand for session key should not fail");
|
||||
|
||||
Ok(Box::new(ChaChaSession::new(session_key)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deterministic_identity_from_seed() {
|
||||
let seed = [0x42u8; 32];
|
||||
let kx1 = WarzoneKeyExchange::from_identity_seed(&seed);
|
||||
let kx2 = WarzoneKeyExchange::from_identity_seed(&seed);
|
||||
assert_eq!(kx1.identity_public_key(), kx2.identity_public_key());
|
||||
assert_eq!(kx1.fingerprint(), kx2.fingerprint());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_seeds_different_keys() {
|
||||
let kx1 = WarzoneKeyExchange::from_identity_seed(&[0x01; 32]);
|
||||
let kx2 = WarzoneKeyExchange::from_identity_seed(&[0x02; 32]);
|
||||
assert_ne!(kx1.identity_public_key(), kx2.identity_public_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_16_bytes_of_sha256() {
|
||||
let seed = [0x99u8; 32];
|
||||
let kx = WarzoneKeyExchange::from_identity_seed(&seed);
|
||||
let fp = kx.fingerprint();
|
||||
assert_eq!(fp.len(), 16);
|
||||
|
||||
// Verify manually
|
||||
let pub_key = kx.identity_public_key();
|
||||
let hash = Sha256::digest(pub_key);
|
||||
assert_eq!(&fp[..], &hash[..16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_and_verify() {
|
||||
let seed = [0xAA; 32];
|
||||
let kx = WarzoneKeyExchange::from_identity_seed(&seed);
|
||||
let data = b"hello warzone";
|
||||
let sig = kx.sign(data);
|
||||
assert!(WarzoneKeyExchange::verify(
|
||||
&kx.identity_public_key(),
|
||||
data,
|
||||
&sig
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_wrong_data_fails() {
|
||||
let seed = [0xAA; 32];
|
||||
let kx = WarzoneKeyExchange::from_identity_seed(&seed);
|
||||
let sig = kx.sign(b"correct data");
|
||||
assert!(!WarzoneKeyExchange::verify(
|
||||
&kx.identity_public_key(),
|
||||
b"wrong data",
|
||||
&sig
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_wrong_key_fails() {
|
||||
let kx1 = WarzoneKeyExchange::from_identity_seed(&[0x01; 32]);
|
||||
let kx2 = WarzoneKeyExchange::from_identity_seed(&[0x02; 32]);
|
||||
let sig = kx1.sign(b"data");
|
||||
assert!(!WarzoneKeyExchange::verify(
|
||||
&kx2.identity_public_key(),
|
||||
b"data",
|
||||
&sig
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_handshake_alice_bob_same_session_key() {
|
||||
let mut alice = WarzoneKeyExchange::from_identity_seed(&[0xAA; 32]);
|
||||
let mut bob = WarzoneKeyExchange::from_identity_seed(&[0xBB; 32]);
|
||||
|
||||
let alice_eph_pub = alice.generate_ephemeral();
|
||||
let bob_eph_pub = bob.generate_ephemeral();
|
||||
|
||||
let mut alice_session = alice.derive_session(&bob_eph_pub).unwrap();
|
||||
let mut bob_session = bob.derive_session(&alice_eph_pub).unwrap();
|
||||
|
||||
// Verify they can communicate: Alice encrypts, Bob decrypts
|
||||
let header = b"call-header";
|
||||
let plaintext = b"hello from alice";
|
||||
|
||||
let mut ciphertext = Vec::new();
|
||||
alice_session
|
||||
.encrypt(header, plaintext, &mut ciphertext)
|
||||
.unwrap();
|
||||
|
||||
let mut decrypted = Vec::new();
|
||||
bob_session
|
||||
.decrypt(header, &ciphertext, &mut decrypted)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(&decrypted, plaintext);
|
||||
}
|
||||
}
|
||||
23
crates/wzp-crypto/src/lib.rs
Normal file
23
crates/wzp-crypto/src/lib.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
//! WarzonePhone Crypto Layer
|
||||
//!
|
||||
//! Implements the cryptographic primitives compatible with the Warzone messenger identity model:
|
||||
//! - Identity: 32-byte seed -> HKDF -> Ed25519 (signing) + X25519 (encryption)
|
||||
//! - Fingerprint: SHA-256(Ed25519 pub)[:16]
|
||||
//! - Per-call: Ephemeral X25519 key exchange -> ChaCha20-Poly1305 session
|
||||
//! - Nonce: Derived from session_id + seq + direction (not transmitted)
|
||||
//! - Rekeying: Periodic ephemeral exchange with HKDF mixing for forward secrecy
|
||||
|
||||
pub mod anti_replay;
|
||||
pub mod handshake;
|
||||
pub mod nonce;
|
||||
pub mod rekey;
|
||||
pub mod session;
|
||||
|
||||
pub use anti_replay::AntiReplayWindow;
|
||||
pub use handshake::WarzoneKeyExchange;
|
||||
pub use nonce::{build_nonce, Direction};
|
||||
pub use rekey::RekeyManager;
|
||||
pub use session::ChaChaSession;
|
||||
|
||||
// Re-export trait types from wzp-proto for convenience.
|
||||
pub use wzp_proto::{CryptoError, CryptoSession, KeyExchange};
|
||||
64
crates/wzp-crypto/src/nonce.rs
Normal file
64
crates/wzp-crypto/src/nonce.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! Nonce construction for ChaCha20-Poly1305.
|
||||
//!
|
||||
//! 12-byte nonce layout:
|
||||
//! session_id[0..4] || sequence_number (u32 BE) || direction (1 byte) || padding (3 bytes zero)
|
||||
|
||||
/// Direction of packet flow, used in nonce construction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Direction {
|
||||
Send = 0,
|
||||
Recv = 1,
|
||||
}
|
||||
|
||||
/// Build a 12-byte nonce from session_id, sequence number, and direction.
|
||||
///
|
||||
/// This deterministic construction allows both sides to derive the same nonce
|
||||
/// without transmitting it, saving 12 bytes per packet.
|
||||
pub fn build_nonce(session_id: &[u8; 4], seq: u32, direction: Direction) -> [u8; 12] {
|
||||
let mut nonce = [0u8; 12];
|
||||
nonce[0..4].copy_from_slice(session_id);
|
||||
nonce[4..8].copy_from_slice(&seq.to_be_bytes());
|
||||
nonce[8] = direction as u8;
|
||||
// nonce[9..12] remain zero (padding)
|
||||
nonce
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nonce_is_deterministic() {
|
||||
let sid = [0xDE, 0xAD, 0xBE, 0xEF];
|
||||
let n1 = build_nonce(&sid, 42, Direction::Send);
|
||||
let n2 = build_nonce(&sid, 42, Direction::Send);
|
||||
assert_eq!(n1, n2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonce_differs_by_direction() {
|
||||
let sid = [0x01, 0x02, 0x03, 0x04];
|
||||
let send = build_nonce(&sid, 0, Direction::Send);
|
||||
let recv = build_nonce(&sid, 0, Direction::Recv);
|
||||
assert_ne!(send, recv);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonce_differs_by_seq() {
|
||||
let sid = [0x01, 0x02, 0x03, 0x04];
|
||||
let n1 = build_nonce(&sid, 0, Direction::Send);
|
||||
let n2 = build_nonce(&sid, 1, Direction::Send);
|
||||
assert_ne!(n1, n2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonce_layout_correct() {
|
||||
let sid = [0xAA, 0xBB, 0xCC, 0xDD];
|
||||
let seq: u32 = 0x00000100;
|
||||
let nonce = build_nonce(&sid, seq, Direction::Recv);
|
||||
assert_eq!(&nonce[0..4], &[0xAA, 0xBB, 0xCC, 0xDD]);
|
||||
assert_eq!(&nonce[4..8], &[0x00, 0x00, 0x01, 0x00]);
|
||||
assert_eq!(nonce[8], 1); // Recv
|
||||
assert_eq!(&nonce[9..12], &[0, 0, 0]);
|
||||
}
|
||||
}
|
||||
132
crates/wzp-crypto/src/rekey.rs
Normal file
132
crates/wzp-crypto/src/rekey.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
//! Rekeying state machine for forward secrecy.
|
||||
//!
|
||||
//! Triggers rekeying every 2^16 packets. Uses HKDF to mix the old key
|
||||
//! with the new DH result, then zeroizes the old key material.
|
||||
|
||||
use hkdf::Hkdf;
|
||||
use sha2::Sha256;
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
/// Rekeying interval: every 2^16 packets.
|
||||
const REKEY_INTERVAL: u64 = 1 << 16;
|
||||
|
||||
/// Manages rekeying decisions and key evolution.
|
||||
pub struct RekeyManager {
|
||||
/// Current symmetric key material (32 bytes).
|
||||
current_key: [u8; 32],
|
||||
/// Packet count at which last rekey occurred.
|
||||
last_rekey_at: u64,
|
||||
}
|
||||
|
||||
impl RekeyManager {
|
||||
/// Create a new `RekeyManager` with the initial session key.
|
||||
pub fn new(initial_key: [u8; 32]) -> Self {
|
||||
Self {
|
||||
current_key: initial_key,
|
||||
last_rekey_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether rekeying should occur based on packet count.
|
||||
pub fn should_rekey(&self, packet_count: u64) -> bool {
|
||||
packet_count.saturating_sub(self.last_rekey_at) >= REKEY_INTERVAL
|
||||
}
|
||||
|
||||
/// Perform rekeying: mix old key + new DH shared secret via HKDF.
|
||||
///
|
||||
/// The old key is zeroized after the new key is derived.
|
||||
/// Returns the new 32-byte symmetric key.
|
||||
pub fn perform_rekey(
|
||||
&mut self,
|
||||
new_peer_pub: &[u8; 32],
|
||||
our_new_secret: StaticSecret,
|
||||
packet_count: u64,
|
||||
) -> [u8; 32] {
|
||||
let peer_public = PublicKey::from(*new_peer_pub);
|
||||
let new_dh = our_new_secret.diffie_hellman(&peer_public);
|
||||
|
||||
// Mix old key (as salt) with new DH result (as IKM) via HKDF
|
||||
let hk = Hkdf::<Sha256>::new(Some(&self.current_key), new_dh.as_bytes());
|
||||
let mut new_key = [0u8; 32];
|
||||
hk.expand(b"warzone-rekey", &mut new_key)
|
||||
.expect("HKDF expand should not fail for 32 bytes");
|
||||
|
||||
// Zeroize old key for forward secrecy
|
||||
self.current_key.fill(0);
|
||||
|
||||
// Install new key
|
||||
self.current_key = new_key;
|
||||
self.last_rekey_at = packet_count;
|
||||
|
||||
new_key
|
||||
}
|
||||
|
||||
/// Get a reference to the current key.
|
||||
pub fn current_key(&self) -> &[u8; 32] {
|
||||
&self.current_key
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
#[test]
|
||||
fn should_rekey_at_interval() {
|
||||
let mgr = RekeyManager::new([0xAA; 32]);
|
||||
assert!(!mgr.should_rekey(0));
|
||||
assert!(!mgr.should_rekey(65535));
|
||||
assert!(mgr.should_rekey(65536));
|
||||
assert!(mgr.should_rekey(100_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_produces_different_key() {
|
||||
let initial = [0xBB; 32];
|
||||
let mut mgr = RekeyManager::new(initial);
|
||||
|
||||
let secret = StaticSecret::random_from_rng(OsRng);
|
||||
let peer_secret = StaticSecret::random_from_rng(OsRng);
|
||||
let peer_pub = PublicKey::from(&peer_secret).to_bytes();
|
||||
|
||||
let new_key = mgr.perform_rekey(&peer_pub, secret, 65536);
|
||||
assert_ne!(new_key, initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_key_zeroized_after_rekey() {
|
||||
let initial = [0xCC; 32];
|
||||
let mut mgr = RekeyManager::new(initial);
|
||||
|
||||
let secret = StaticSecret::random_from_rng(OsRng);
|
||||
let peer_secret = StaticSecret::random_from_rng(OsRng);
|
||||
let peer_pub = PublicKey::from(&peer_secret).to_bytes();
|
||||
|
||||
// Save pointer to check zeroization
|
||||
let _new_key = mgr.perform_rekey(&peer_pub, secret, 65536);
|
||||
// The old key slot should now contain the new key, not the initial
|
||||
assert_ne!(*mgr.current_key(), initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consistent_rekey_with_same_inputs() {
|
||||
// Two managers with same initial key, same DH inputs, should get same result
|
||||
let initial = [0xDD; 32];
|
||||
let mut mgr1 = RekeyManager::new(initial);
|
||||
let mut mgr2 = RekeyManager::new(initial);
|
||||
|
||||
// Use StaticSecret so we can clone the key bytes
|
||||
let secret_bytes = [0x42u8; 32];
|
||||
let secret1 = StaticSecret::from(secret_bytes);
|
||||
let secret2 = StaticSecret::from(secret_bytes);
|
||||
|
||||
let peer_bytes = [0x77u8; 32];
|
||||
let peer_secret = StaticSecret::from(peer_bytes);
|
||||
let peer_pub = PublicKey::from(&peer_secret).to_bytes();
|
||||
|
||||
let k1 = mgr1.perform_rekey(&peer_pub, secret1, 65536);
|
||||
let k2 = mgr2.perform_rekey(&peer_pub, secret2, 65536);
|
||||
assert_eq!(k1, k2);
|
||||
}
|
||||
}
|
||||
226
crates/wzp-crypto/src/session.rs
Normal file
226
crates/wzp-crypto/src/session.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
//! ChaCha20-Poly1305 encryption session.
|
||||
//!
|
||||
//! Implements the `CryptoSession` trait for per-call media encryption.
|
||||
//! Nonces are derived deterministically from session_id + sequence counter + direction.
|
||||
|
||||
use chacha20poly1305::aead::Aead;
|
||||
use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce};
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
use rand::rngs::OsRng;
|
||||
use wzp_proto::{CryptoError, CryptoSession};
|
||||
|
||||
use crate::nonce::{self, Direction};
|
||||
use crate::rekey::RekeyManager;
|
||||
|
||||
/// Per-call symmetric encryption session using ChaCha20-Poly1305.
|
||||
pub struct ChaChaSession {
|
||||
/// AEAD cipher instance.
|
||||
cipher: ChaCha20Poly1305,
|
||||
/// Session ID (first 4 bytes of the derived key hash).
|
||||
session_id: [u8; 4],
|
||||
/// Send packet counter.
|
||||
send_seq: u32,
|
||||
/// Receive packet counter.
|
||||
recv_seq: u32,
|
||||
/// Rekeying state machine.
|
||||
rekey_mgr: RekeyManager,
|
||||
/// Pending ephemeral secret for rekey (stored until peer responds).
|
||||
pending_rekey_secret: Option<StaticSecret>,
|
||||
}
|
||||
|
||||
impl ChaChaSession {
|
||||
/// Create a new session from a 32-byte shared secret.
|
||||
pub fn new(shared_secret: [u8; 32]) -> Self {
|
||||
use sha2::Digest;
|
||||
let session_id_hash = sha2::Sha256::digest(&shared_secret);
|
||||
let mut session_id = [0u8; 4];
|
||||
session_id.copy_from_slice(&session_id_hash[..4]);
|
||||
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(&shared_secret)
|
||||
.expect("32-byte key is valid for ChaCha20Poly1305");
|
||||
|
||||
Self {
|
||||
cipher,
|
||||
session_id,
|
||||
send_seq: 0,
|
||||
recv_seq: 0,
|
||||
rekey_mgr: RekeyManager::new(shared_secret),
|
||||
pending_rekey_secret: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a new key (after rekeying).
|
||||
fn install_key(&mut self, new_key: [u8; 32]) {
|
||||
use sha2::Digest;
|
||||
let session_id_hash = sha2::Sha256::digest(&new_key);
|
||||
self.session_id.copy_from_slice(&session_id_hash[..4]);
|
||||
self.cipher = ChaCha20Poly1305::new_from_slice(&new_key)
|
||||
.expect("32-byte key is valid for ChaCha20Poly1305");
|
||||
}
|
||||
}
|
||||
|
||||
impl CryptoSession for ChaChaSession {
|
||||
fn encrypt(
|
||||
&mut self,
|
||||
header_bytes: &[u8],
|
||||
plaintext: &[u8],
|
||||
out: &mut Vec<u8>,
|
||||
) -> Result<(), CryptoError> {
|
||||
let nonce_bytes = nonce::build_nonce(&self.session_id, self.send_seq, Direction::Send);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
// Encrypt with AAD
|
||||
use chacha20poly1305::aead::Payload;
|
||||
let payload = Payload {
|
||||
msg: plaintext,
|
||||
aad: header_bytes,
|
||||
};
|
||||
|
||||
let ciphertext = self
|
||||
.cipher
|
||||
.encrypt(nonce, payload)
|
||||
.map_err(|_| CryptoError::Internal("encryption failed".into()))?;
|
||||
|
||||
out.extend_from_slice(&ciphertext);
|
||||
self.send_seq = self.send_seq.wrapping_add(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decrypt(
|
||||
&mut self,
|
||||
header_bytes: &[u8],
|
||||
ciphertext: &[u8],
|
||||
out: &mut Vec<u8>,
|
||||
) -> Result<(), CryptoError> {
|
||||
// Use Direction::Send to match the sender's nonce construction.
|
||||
// The recv_seq counter tracks which packet from the peer we're decrypting.
|
||||
let nonce_bytes = nonce::build_nonce(&self.session_id, self.recv_seq, Direction::Send);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
use chacha20poly1305::aead::Payload;
|
||||
let payload = Payload {
|
||||
msg: ciphertext,
|
||||
aad: header_bytes,
|
||||
};
|
||||
|
||||
let plaintext = self
|
||||
.cipher
|
||||
.decrypt(nonce, payload)
|
||||
.map_err(|_| CryptoError::DecryptionFailed)?;
|
||||
|
||||
out.extend_from_slice(&plaintext);
|
||||
self.recv_seq = self.recv_seq.wrapping_add(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn initiate_rekey(&mut self) -> Result<[u8; 32], CryptoError> {
|
||||
let secret = StaticSecret::random_from_rng(OsRng);
|
||||
let public = PublicKey::from(&secret);
|
||||
self.pending_rekey_secret = Some(secret);
|
||||
Ok(public.to_bytes())
|
||||
}
|
||||
|
||||
fn complete_rekey(&mut self, peer_ephemeral_pub: &[u8; 32]) -> Result<(), CryptoError> {
|
||||
let secret = self
|
||||
.pending_rekey_secret
|
||||
.take()
|
||||
.ok_or_else(|| CryptoError::RekeyFailed("no pending rekey".into()))?;
|
||||
|
||||
let total_packets = self.send_seq as u64 + self.recv_seq as u64;
|
||||
let new_key = self.rekey_mgr.perform_rekey(peer_ephemeral_pub, secret, total_packets);
|
||||
self.install_key(new_key);
|
||||
|
||||
// Reset sequence counters after rekey for nonce uniqueness
|
||||
self.send_seq = 0;
|
||||
self.recv_seq = 0;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_session_pair() -> (ChaChaSession, ChaChaSession) {
|
||||
let key = [0x42u8; 32];
|
||||
(ChaChaSession::new(key), ChaChaSession::new(key))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_roundtrip() {
|
||||
let (mut alice, mut bob) = make_session_pair();
|
||||
let header = b"test-header";
|
||||
let plaintext = b"hello warzone";
|
||||
|
||||
let mut ciphertext = Vec::new();
|
||||
alice.encrypt(header, plaintext, &mut ciphertext).unwrap();
|
||||
|
||||
// Bob decrypts (his recv matches Alice's send)
|
||||
let mut decrypted = Vec::new();
|
||||
bob.decrypt(header, &ciphertext, &mut decrypted).unwrap();
|
||||
|
||||
assert_eq!(&decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_wrong_aad_fails() {
|
||||
let (mut alice, mut bob) = make_session_pair();
|
||||
let header = b"correct-header";
|
||||
let plaintext = b"secret data";
|
||||
|
||||
let mut ciphertext = Vec::new();
|
||||
alice.encrypt(header, plaintext, &mut ciphertext).unwrap();
|
||||
|
||||
let mut decrypted = Vec::new();
|
||||
let result = bob.decrypt(b"wrong-header", &ciphertext, &mut decrypted);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_wrong_key_fails() {
|
||||
let mut alice = ChaChaSession::new([0xAA; 32]);
|
||||
let mut eve = ChaChaSession::new([0xBB; 32]);
|
||||
|
||||
let header = b"hdr";
|
||||
let plaintext = b"secret";
|
||||
|
||||
let mut ciphertext = Vec::new();
|
||||
alice.encrypt(header, plaintext, &mut ciphertext).unwrap();
|
||||
|
||||
let mut decrypted = Vec::new();
|
||||
let result = eve.decrypt(header, &ciphertext, &mut decrypted);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_packets_roundtrip() {
|
||||
let (mut alice, mut bob) = make_session_pair();
|
||||
let header = b"hdr";
|
||||
|
||||
for i in 0..100 {
|
||||
let msg = format!("message {}", i);
|
||||
let mut ct = Vec::new();
|
||||
alice.encrypt(header, msg.as_bytes(), &mut ct).unwrap();
|
||||
|
||||
let mut pt = Vec::new();
|
||||
bob.decrypt(header, &ct, &mut pt).unwrap();
|
||||
assert_eq!(pt, msg.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rekey_changes_key() {
|
||||
let (mut alice, mut _bob) = make_session_pair();
|
||||
|
||||
let peer_secret = StaticSecret::random_from_rng(OsRng);
|
||||
let peer_pub = PublicKey::from(&peer_secret).to_bytes();
|
||||
|
||||
let rekey_pub = alice.initiate_rekey().unwrap();
|
||||
assert_ne!(rekey_pub, [0u8; 32]); // Should be a valid public key
|
||||
|
||||
alice.complete_rekey(&peer_pub).unwrap();
|
||||
// Session is now rekeyed - counters reset
|
||||
assert_eq!(alice.send_seq, 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user