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:
Siavash Sameni
2026-03-27 12:45:07 +04:00
commit 51e893590c
47 changed files with 7097 additions and 0 deletions

View 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
}
}

View 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);
}
}

View 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};

View 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]);
}
}

View 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);
}
}

View 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);
}
}