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:
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