Implement RX event loop with wire format dispatch

Wire format module (src/wire.rs):
- Discriminator-based packet framing (0x00/0x01/0x02)
- Header parsing: EncryptedHeader, Msg1Header, Msg2Header
- Serialization: build_msg1(), build_msg2(), build_encrypted()
- 11 unit tests for parsing and roundtrip

Session index tracking:
- PeerConnection: our_index, their_index, transport_id, source_addr
- ActivePeer: noise_session, indices, transport_id, current_addr
- Removed Clone from ActivePeer (NoiseSession nonce reuse risk)
- PromotionResult refactored to use NodeId instead of ActivePeer

Node RX event loop:
- run_rx_loop() with packet_rx channel consumption
- process_packet() discriminator dispatch
- handle_encrypted_frame() with O(1) index lookup
- handle_msg1() with rate limiting and inbound handshake
- handle_msg2() completing outbound handshakes
- dispatch_link_message() stub for link protocol

Infrastructure (from Session 56):
- IndexAllocator for random 32-bit session indices
- HandshakeRateLimiter with token bucket
- ReplayWindow for 2048-packet sliding window
- Bloom filter defaults updated to v1 spec

All 265 tests pass.
This commit is contained in:
Johnathan Corgan
2026-02-02 17:20:06 +00:00
parent ce6dba9225
commit 7c8a5bd5ae
11 changed files with 2502 additions and 44 deletions

View File

@@ -1,23 +1,45 @@
//! Bloom Filter Implementation
//!
//! 4KB Bloom filters for K-hop reachability in FIPS routing. Each node
//! 1KB Bloom filters for K-hop reachability in FIPS routing. Each node
//! maintains filters that summarize which destinations are reachable
//! through each peer, enabling efficient routing decisions without
//! global network knowledge.
//!
//! ## v1 Parameters
//!
//! - Size: 1 KB (8,192 bits) - sized for actual ~400-800 entry occupancy
//! - Hash functions: k=5 - optimal for 800-1,600 entries at 1KB
//! - Bandwidth: 1 KB/announce (75% reduction from original 4KB design)
//!
//! The original 4KB/k=7 parameters were oversized because the d^(2K) estimate
//! overcounted by assuming mesh connectivity vs tree structure with TTL-bounded
//! propagation. Actual filter occupancy is ~250-800 entries for typical nodes.
use crate::NodeId;
use std::collections::{HashMap, HashSet};
use std::fmt;
use thiserror::Error;
/// Default filter size in bits (4KB = 32,768 bits).
pub const DEFAULT_FILTER_SIZE_BITS: usize = 32768;
/// Default filter size in bits (1KB = 8,192 bits).
///
/// Sized for ~800-1,600 entries with <5% FPR at typical occupancy (~400 entries).
/// This is v1 protocol default (size_class=1).
pub const DEFAULT_FILTER_SIZE_BITS: usize = 8192;
/// Default filter size in bytes.
/// Default filter size in bytes (1KB).
pub const DEFAULT_FILTER_SIZE_BYTES: usize = DEFAULT_FILTER_SIZE_BITS / 8;
/// Default number of hash functions.
pub const DEFAULT_HASH_COUNT: u8 = 7;
///
/// k=5 is optimal for 800-1,600 entries at 1KB filter size.
/// At 400 entries: FPR ~0.3%. At 800 entries: FPR ~2.4%.
pub const DEFAULT_HASH_COUNT: u8 = 5;
/// Size class for v1 protocol (1 KB filters).
pub const V1_SIZE_CLASS: u8 = 1;
/// Filter sizes by size_class: bytes = 512 << size_class
pub const SIZE_CLASS_BYTES: [usize; 4] = [512, 1024, 2048, 4096];
/// Errors related to Bloom filter operations.
#[derive(Debug, Error)]

284
src/index.rs Normal file
View File

@@ -0,0 +1,284 @@
//! Session Index Allocator
//!
//! Manages allocation of 32-bit session indices for O(1) packet dispatch.
//! Each Noise session receives a unique index chosen by the receiver;
//! incoming encrypted packets include the receiver's index for fast lookup.
//!
//! ## Design
//!
//! - Indices are random (cryptographically secure) to prevent guessing
//! - Unique per transport to avoid collision between transports
//! - 32-bit space supports ~65K concurrent sessions before birthday collision
//! - Indices are rotated on rekey for anti-correlation
//!
//! ## Wire Format
//!
//! Encrypted frames include receiver_idx for session lookup:
//!
//! ```text
//! [0x00][receiver_idx:4 LE][counter:8 LE][ciphertext+tag]
//! ```
use rand::Rng;
use std::collections::HashSet;
use thiserror::Error;
/// Errors related to index allocation.
#[derive(Debug, Error)]
pub enum IndexError {
#[error("no available indices (too many active sessions)")]
Exhausted,
#[error("index {0} not found")]
NotFound(u32),
#[error("index {0} already in use")]
AlreadyInUse(u32),
}
/// A 32-bit session index.
///
/// Wrapper type for type safety and clarity in APIs.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct SessionIndex(u32);
impl SessionIndex {
/// Create from raw u32.
pub fn new(value: u32) -> Self {
Self(value)
}
/// Get the raw u32 value.
pub fn as_u32(&self) -> u32 {
self.0
}
/// Convert to little-endian bytes.
pub fn to_le_bytes(&self) -> [u8; 4] {
self.0.to_le_bytes()
}
/// Create from little-endian bytes.
pub fn from_le_bytes(bytes: [u8; 4]) -> Self {
Self(u32::from_le_bytes(bytes))
}
}
impl std::fmt::Display for SessionIndex {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:08x}", self.0)
}
}
/// Allocator for session indices within a single transport.
///
/// Manages a pool of random 32-bit indices, tracking which are in use
/// to prevent collision. Thread-safe for single-threaded async use.
#[derive(Debug)]
pub struct IndexAllocator {
/// Set of currently allocated indices.
in_use: HashSet<u32>,
/// Maximum allocation attempts before giving up.
max_attempts: usize,
}
impl IndexAllocator {
/// Create a new index allocator.
pub fn new() -> Self {
Self {
in_use: HashSet::new(),
max_attempts: 100,
}
}
/// Create with a specific max attempts limit.
pub fn with_max_attempts(max_attempts: usize) -> Self {
Self {
in_use: HashSet::new(),
max_attempts,
}
}
/// Allocate a new random index.
///
/// Returns a cryptographically random 32-bit index that is not
/// currently in use. Returns error if allocation fails after
/// max_attempts tries (indicates too many active sessions).
pub fn allocate(&mut self) -> Result<SessionIndex, IndexError> {
let mut rng = rand::thread_rng();
for _ in 0..self.max_attempts {
let candidate = rng.r#gen::<u32>();
if !self.in_use.contains(&candidate) {
self.in_use.insert(candidate);
return Ok(SessionIndex(candidate));
}
}
Err(IndexError::Exhausted)
}
/// Free an index, returning it to the available pool.
///
/// Returns error if the index was not allocated.
pub fn free(&mut self, index: SessionIndex) -> Result<(), IndexError> {
if self.in_use.remove(&index.0) {
Ok(())
} else {
Err(IndexError::NotFound(index.0))
}
}
/// Check if an index is currently allocated.
pub fn is_allocated(&self, index: SessionIndex) -> bool {
self.in_use.contains(&index.0)
}
/// Number of currently allocated indices.
pub fn count(&self) -> usize {
self.in_use.len()
}
/// Check if the allocator is empty (no indices allocated).
pub fn is_empty(&self) -> bool {
self.in_use.is_empty()
}
/// Reserve a specific index (for testing or migration).
///
/// Returns error if the index is already in use.
pub fn reserve(&mut self, index: SessionIndex) -> Result<(), IndexError> {
if self.in_use.contains(&index.0) {
Err(IndexError::AlreadyInUse(index.0))
} else {
self.in_use.insert(index.0);
Ok(())
}
}
/// Clear all allocations (use with caution).
pub fn clear(&mut self) {
self.in_use.clear();
}
}
impl Default for IndexAllocator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_index_roundtrip() {
let idx = SessionIndex::new(0x12345678);
assert_eq!(idx.as_u32(), 0x12345678);
let bytes = idx.to_le_bytes();
assert_eq!(bytes, [0x78, 0x56, 0x34, 0x12]);
let restored = SessionIndex::from_le_bytes(bytes);
assert_eq!(restored, idx);
}
#[test]
fn test_session_index_display() {
let idx = SessionIndex::new(0x000000ff);
assert_eq!(format!("{}", idx), "000000ff");
let idx = SessionIndex::new(0xdeadbeef);
assert_eq!(format!("{}", idx), "deadbeef");
}
#[test]
fn test_allocator_basic() {
let mut alloc = IndexAllocator::new();
assert!(alloc.is_empty());
assert_eq!(alloc.count(), 0);
let idx1 = alloc.allocate().unwrap();
assert!(!alloc.is_empty());
assert_eq!(alloc.count(), 1);
assert!(alloc.is_allocated(idx1));
let idx2 = alloc.allocate().unwrap();
assert_eq!(alloc.count(), 2);
assert!(alloc.is_allocated(idx2));
assert_ne!(idx1, idx2);
alloc.free(idx1).unwrap();
assert_eq!(alloc.count(), 1);
assert!(!alloc.is_allocated(idx1));
assert!(alloc.is_allocated(idx2));
}
#[test]
fn test_allocator_free_not_found() {
let mut alloc = IndexAllocator::new();
let result = alloc.free(SessionIndex::new(12345));
assert!(matches!(result, Err(IndexError::NotFound(12345))));
}
#[test]
fn test_allocator_reserve() {
let mut alloc = IndexAllocator::new();
let idx = SessionIndex::new(0xdeadbeef);
alloc.reserve(idx).unwrap();
assert!(alloc.is_allocated(idx));
// Double reserve fails
let result = alloc.reserve(idx);
assert!(matches!(result, Err(IndexError::AlreadyInUse(0xdeadbeef))));
}
#[test]
fn test_allocator_uniqueness() {
let mut alloc = IndexAllocator::new();
let mut indices = Vec::new();
// Allocate 1000 indices and verify all unique
for _ in 0..1000 {
let idx = alloc.allocate().unwrap();
assert!(!indices.contains(&idx));
indices.push(idx);
}
assert_eq!(alloc.count(), 1000);
}
#[test]
fn test_allocator_clear() {
let mut alloc = IndexAllocator::new();
for _ in 0..10 {
alloc.allocate().unwrap();
}
assert_eq!(alloc.count(), 10);
alloc.clear();
assert!(alloc.is_empty());
assert_eq!(alloc.count(), 0);
}
#[test]
fn test_allocator_reuse_after_free() {
let mut alloc = IndexAllocator::new();
let idx = alloc.allocate().unwrap();
alloc.free(idx).unwrap();
// The specific index might not be reused immediately (random),
// but the count should allow allocation
let idx2 = alloc.allocate().unwrap();
assert_eq!(alloc.count(), 1);
// Can now reserve the original index if it wasn't randomly reused
if idx != idx2 {
alloc.reserve(idx).unwrap();
}
}
}

View File

@@ -8,13 +8,16 @@ pub mod cache;
pub mod config;
pub mod icmp;
pub mod identity;
pub mod index;
pub mod noise;
pub mod node;
pub mod peer;
pub mod protocol;
pub mod rate_limit;
pub mod transport;
pub mod tree;
pub mod tun;
pub mod wire;
// Re-export identity types
pub use identity::{
@@ -66,3 +69,16 @@ pub use icmp::{build_dest_unreachable, should_send_icmp_error, DestUnreachableCo
// Re-export Noise types (HandshakeState not re-exported to avoid conflict with peer::HandshakeState)
pub use noise::{CipherState, HandshakeRole, NoiseError, NoiseSession};
// Re-export index types
pub use index::{IndexAllocator, IndexError, SessionIndex};
// Re-export rate limiting types
pub use rate_limit::{HandshakeRateLimiter, TokenBucket};
// Re-export wire format types
pub use wire::{
build_encrypted, build_msg1, build_msg2, EncryptedHeader, Msg1Header, Msg2Header,
DISCRIMINATOR_ENCRYPTED, DISCRIMINATOR_MSG1, DISCRIMINATOR_MSG2, ENCRYPTED_MIN_SIZE,
ENCRYPTED_OVERHEAD, MSG1_WIRE_SIZE, MSG2_WIRE_SIZE,
};

View File

@@ -7,16 +7,22 @@
use crate::bloom::BloomState;
use crate::cache::CoordCache;
use crate::config::PeerConfig;
use crate::index::IndexAllocator;
use crate::peer::{
cross_connection_winner, ActivePeer, PeerConnection, PromotionResult,
};
use crate::rate_limit::HandshakeRateLimiter;
use crate::transport::{
packet_channel, Link, LinkDirection, LinkId, PacketRx, PacketTx, TransportAddr,
TransportHandle, TransportId,
packet_channel, Link, LinkDirection, LinkId, PacketRx, PacketTx, ReceivedPacket,
TransportAddr, TransportHandle, TransportId,
};
use crate::transport::udp::UdpTransport;
use crate::tree::TreeState;
use crate::tun::{run_tun_reader, shutdown_tun_interface, TunDevice, TunError, TunState, TunTx};
use crate::wire::{
build_msg1, build_msg2, EncryptedHeader, Msg1Header, Msg2Header,
DISCRIMINATOR_ENCRYPTED, DISCRIMINATOR_MSG1, DISCRIMINATOR_MSG2,
};
use crate::{Config, ConfigError, Identity, IdentityError, NodeId, PeerIdentity};
use std::collections::HashMap;
use std::fmt;
@@ -217,6 +223,20 @@ pub struct Node {
tun_reader_handle: Option<JoinHandle<()>>,
/// TUN writer thread handle.
tun_writer_handle: Option<JoinHandle<()>>,
// === Index-Based Session Dispatch ===
/// Allocator for session indices.
index_allocator: IndexAllocator,
/// O(1) lookup: (transport_id, our_index) → NodeId.
/// This maps our session index to the peer that uses it.
peers_by_index: HashMap<(TransportId, u32), NodeId>,
/// Pending outbound handshakes by our sender_idx.
/// Tracks which LinkId corresponds to which session index.
pending_outbound: HashMap<(TransportId, u32), LinkId>,
// === Rate Limiting ===
/// Rate limiter for msg1 processing (DoS protection).
msg1_rate_limiter: HandshakeRateLimiter,
}
impl Node {
@@ -269,6 +289,10 @@ impl Node {
tun_tx: None,
tun_reader_handle: None,
tun_writer_handle: None,
index_allocator: IndexAllocator::new(),
peers_by_index: HashMap::new(),
pending_outbound: HashMap::new(),
msg1_rate_limiter: HandshakeRateLimiter::new(),
})
}
@@ -312,6 +336,10 @@ impl Node {
tun_tx: None,
tun_reader_handle: None,
tun_writer_handle: None,
index_allocator: IndexAllocator::new(),
peers_by_index: HashMap::new(),
pending_outbound: HashMap::new(),
msg1_rate_limiter: HandshakeRateLimiter::new(),
}
}
@@ -467,15 +495,14 @@ impl Node {
.unwrap_or(0);
let mut connection = PeerConnection::outbound(link_id, peer_identity.clone(), current_time_ms);
// Start the Noise handshake and get message 1
let our_keypair = self.identity.keypair();
let handshake_msg = match connection.start_handshake(our_keypair, current_time_ms) {
Ok(msg) => msg,
// Allocate a session index for this handshake
let our_index = match self.index_allocator.allocate() {
Ok(idx) => idx,
Err(e) => {
warn!(
npub = %peer_config.npub,
error = %e,
"Failed to start handshake"
"Failed to allocate session index"
);
// Clean up the link we just created
self.links.remove(&link_id);
@@ -484,6 +511,32 @@ impl Node {
}
};
// Start the Noise handshake and get message 1
let our_keypair = self.identity.keypair();
let noise_msg1 = match connection.start_handshake(our_keypair, current_time_ms) {
Ok(msg) => msg,
Err(e) => {
warn!(
npub = %peer_config.npub,
error = %e,
"Failed to start handshake"
);
// Clean up the index and link
let _ = self.index_allocator.free(our_index);
self.links.remove(&link_id);
self.addr_to_link.remove(&(transport_id, remote_addr));
continue;
}
};
// Set index and transport info on the connection
connection.set_our_index(our_index);
connection.set_transport_id(transport_id);
connection.set_source_addr(remote_addr.clone());
// Build wire format msg1: [0x01][sender_idx:4 LE][noise_msg1:82]
let wire_msg1 = build_msg1(our_index, &noise_msg1);
let alias_display = peer_config
.alias
.as_deref()
@@ -496,17 +549,21 @@ impl Node {
info!(" transport: {}", addr.transport);
info!(" addr: {}", addr.addr);
info!(" link_id: {}", link_id);
info!(" our_index: {}", our_index);
// Track in pending_outbound for msg2 dispatch
self.pending_outbound.insert((transport_id, our_index.as_u32()), link_id);
self.connections.insert(link_id, connection);
// Send the handshake message
// Send the wire format handshake message
if let Some(transport) = self.transports.get(&transport_id) {
match transport.send(&remote_addr, &handshake_msg).await {
match transport.send(&remote_addr, &wire_msg1).await {
Ok(bytes) => {
debug!(
link_id = %link_id,
our_index = %our_index,
bytes,
"Sent Noise handshake message 1"
"Sent Noise handshake message 1 (wire format)"
);
}
Err(e) => {
@@ -825,7 +882,7 @@ impl Node {
connection.link_stats().clone(),
);
self.peers.insert(peer_node_id, new_peer.clone());
self.peers.insert(peer_node_id, new_peer);
info!(
node_id = %peer_node_id,
@@ -836,7 +893,7 @@ impl Node {
Ok(PromotionResult::CrossConnectionWon {
loser_link_id,
peer: new_peer,
node_id: peer_node_id,
})
} else {
// This connection loses, keep existing
@@ -864,7 +921,7 @@ impl Node {
connection.link_stats().clone(),
);
self.peers.insert(peer_node_id, new_peer.clone());
self.peers.insert(peer_node_id, new_peer);
info!(
node_id = %peer_node_id,
@@ -872,7 +929,7 @@ impl Node {
"Connection promoted to active peer"
);
Ok(PromotionResult::Promoted(new_peer))
Ok(PromotionResult::Promoted(peer_node_id))
}
}
@@ -1027,6 +1084,431 @@ impl Node {
Ok(())
}
// === RX Event Loop ===
/// Run the receive event loop.
///
/// Processes packets from all transports, dispatching based on
/// the discriminator byte in the wire protocol:
/// - 0x00: Encrypted frame (session data)
/// - 0x01: Handshake message 1 (initiator -> responder)
/// - 0x02: Handshake message 2 (responder -> initiator)
///
/// This method takes ownership of the packet_rx channel and runs
/// until the channel is closed (typically when stop() is called).
pub async fn run_rx_loop(&mut self) -> Result<(), NodeError> {
let mut packet_rx = self.packet_rx.take()
.ok_or(NodeError::NotStarted)?;
info!("RX event loop started");
while let Some(packet) = packet_rx.recv().await {
self.process_packet(packet).await;
}
info!("RX event loop stopped (channel closed)");
Ok(())
}
/// Process a single received packet.
///
/// Dispatches based on the discriminator byte.
async fn process_packet(&mut self, packet: ReceivedPacket) {
if packet.data.is_empty() {
return; // Drop empty packets
}
let discriminator = packet.data[0];
match discriminator {
DISCRIMINATOR_ENCRYPTED => {
self.handle_encrypted_frame(packet).await;
}
DISCRIMINATOR_MSG1 => {
self.handle_msg1(packet).await;
}
DISCRIMINATOR_MSG2 => {
self.handle_msg2(packet).await;
}
_ => {
// Unknown discriminator, drop silently
debug!(
discriminator = discriminator,
transport_id = %packet.transport_id,
"Unknown packet discriminator, dropping"
);
}
}
}
/// Handle an encrypted frame (discriminator 0x00).
///
/// This is the hot path for established sessions. We use O(1)
/// index-based lookup to find the session, then decrypt.
async fn handle_encrypted_frame(&mut self, packet: ReceivedPacket) {
// Parse header (fail fast)
let header = match EncryptedHeader::parse(&packet.data) {
Some(h) => h,
None => return, // Malformed, drop silently
};
// O(1) session lookup by our receiver index
let key = (packet.transport_id, header.receiver_idx.as_u32());
let node_id = match self.peers_by_index.get(&key) {
Some(id) => *id,
None => {
// Unknown index - could be stale session or attack
debug!(
receiver_idx = %header.receiver_idx,
transport_id = %packet.transport_id,
"Unknown session index, dropping"
);
return;
}
};
let peer = match self.peers.get_mut(&node_id) {
Some(p) => p,
None => {
// Peer removed but index not cleaned up - fix it
self.peers_by_index.remove(&key);
return;
}
};
// Get the session (peer must have one for index-based lookup)
let session = match peer.noise_session_mut() {
Some(s) => s,
None => {
warn!(
node_id = %node_id,
"Peer in index map has no session"
);
return;
}
};
// Decrypt with replay check (this is the expensive part)
let ciphertext = &packet.data[header.ciphertext_offset..];
let plaintext = match session.decrypt_with_replay_check(ciphertext, header.counter) {
Ok(p) => p,
Err(e) => {
debug!(
node_id = %node_id,
counter = header.counter,
error = %e,
"Decryption failed"
);
return;
}
};
// === PACKET IS AUTHENTIC ===
// Update address for roaming support
peer.set_current_addr(packet.transport_id, packet.remote_addr.clone());
// Update statistics
peer.link_stats_mut().record_recv(packet.data.len(), packet.timestamp_ms);
peer.touch(packet.timestamp_ms);
// Dispatch to link message handler
self.dispatch_link_message(&node_id, &plaintext).await;
}
/// Handle handshake message 1 (discriminator 0x01).
///
/// This creates a new inbound connection. Rate limiting is applied
/// before any expensive crypto operations.
async fn handle_msg1(&mut self, packet: ReceivedPacket) {
// === RATE LIMITING (before any processing) ===
if !self.msg1_rate_limiter.start_handshake() {
debug!(
transport_id = %packet.transport_id,
remote_addr = %packet.remote_addr,
"Msg1 rate limited"
);
return;
}
// Parse header
let header = match Msg1Header::parse(&packet.data) {
Some(h) => h,
None => {
self.msg1_rate_limiter.complete_handshake();
debug!("Invalid msg1 header");
return;
}
};
// Check for existing connection from this address
let addr_key = (packet.transport_id, packet.remote_addr.clone());
if self.addr_to_link.contains_key(&addr_key) {
self.msg1_rate_limiter.complete_handshake();
debug!(
transport_id = %packet.transport_id,
remote_addr = %packet.remote_addr,
"Already have connection from this address"
);
return;
}
// === CRYPTO COST PAID HERE ===
let link_id = self.allocate_link_id();
let mut conn = PeerConnection::inbound_with_transport(
link_id,
packet.transport_id,
packet.remote_addr.clone(),
packet.timestamp_ms,
);
let our_keypair = self.identity.keypair();
let noise_msg1 = &packet.data[header.noise_msg1_offset..];
let msg2_response = match conn.receive_handshake_init(our_keypair, noise_msg1, packet.timestamp_ms) {
Ok(m) => m,
Err(e) => {
self.msg1_rate_limiter.complete_handshake();
debug!(
error = %e,
"Failed to process msg1"
);
return;
}
};
// Learn peer identity from msg1
let peer_identity = match conn.expected_identity() {
Some(id) => id.clone(),
None => {
self.msg1_rate_limiter.complete_handshake();
warn!("Identity not learned from msg1");
return;
}
};
let peer_node_id = *peer_identity.node_id();
// Check if this peer is already connected
if self.peers.contains_key(&peer_node_id) {
// TODO: Handle reconnection case (future: session replacement)
self.msg1_rate_limiter.complete_handshake();
debug!(
node_id = %peer_node_id,
"Peer already connected, ignoring msg1"
);
return;
}
// Allocate our session index
let our_index = match self.index_allocator.allocate() {
Ok(idx) => idx,
Err(e) => {
self.msg1_rate_limiter.complete_handshake();
warn!(error = %e, "Failed to allocate session index for inbound");
return;
}
};
conn.set_our_index(our_index);
conn.set_their_index(header.sender_idx);
// Create link
let link = Link::connectionless(
link_id,
packet.transport_id,
packet.remote_addr.clone(),
LinkDirection::Inbound,
Duration::from_millis(100),
);
self.links.insert(link_id, link);
self.addr_to_link.insert(addr_key, link_id);
self.connections.insert(link_id, conn);
// Build and send msg2 response
let wire_msg2 = build_msg2(our_index, header.sender_idx, &msg2_response);
if let Some(transport) = self.transports.get(&packet.transport_id) {
match transport.send(&packet.remote_addr, &wire_msg2).await {
Ok(bytes) => {
debug!(
link_id = %link_id,
our_index = %our_index,
their_index = %header.sender_idx,
bytes,
"Sent msg2 response"
);
}
Err(e) => {
warn!(
link_id = %link_id,
error = %e,
"Failed to send msg2"
);
// Clean up on failure
self.connections.remove(&link_id);
self.links.remove(&link_id);
self.addr_to_link.remove(&(packet.transport_id, packet.remote_addr));
let _ = self.index_allocator.free(our_index);
self.msg1_rate_limiter.complete_handshake();
return;
}
}
}
info!(
node_id = %peer_node_id,
link_id = %link_id,
our_index = %our_index,
"Inbound handshake initiated"
);
// Note: rate limiter completed when handshake completes or times out
}
/// Handle handshake message 2 (discriminator 0x02).
///
/// This completes an outbound handshake we initiated.
async fn handle_msg2(&mut self, packet: ReceivedPacket) {
// Parse header
let header = match Msg2Header::parse(&packet.data) {
Some(h) => h,
None => {
debug!("Invalid msg2 header");
return;
}
};
// Look up our pending handshake by our sender_idx (receiver_idx in msg2)
let key = (packet.transport_id, header.receiver_idx.as_u32());
let link_id = match self.pending_outbound.get(&key) {
Some(id) => *id,
None => {
debug!(
receiver_idx = %header.receiver_idx,
"No pending outbound handshake for index"
);
return;
}
};
let conn = match self.connections.get_mut(&link_id) {
Some(c) => c,
None => {
// Connection removed, clean up pending_outbound
self.pending_outbound.remove(&key);
return;
}
};
// Process Noise msg2
let noise_msg2 = &packet.data[header.noise_msg2_offset..];
if let Err(e) = conn.complete_handshake(noise_msg2, packet.timestamp_ms) {
warn!(
link_id = %link_id,
error = %e,
"Handshake completion failed"
);
conn.mark_failed();
return;
}
// Store their index
conn.set_their_index(header.sender_idx);
conn.set_source_addr(packet.remote_addr.clone());
// Get peer identity for promotion
let peer_identity = match conn.expected_identity() {
Some(id) => id.clone(),
None => {
warn!(link_id = %link_id, "No identity after handshake");
return;
}
};
info!(
node_id = %peer_identity.node_id(),
link_id = %link_id,
their_index = %header.sender_idx,
"Outbound handshake completed"
);
// Promote to active peer (TODO: implement with session transfer)
// For now, just use the existing promote_connection
match self.promote_connection(link_id, peer_identity.clone(), packet.timestamp_ms) {
Ok(result) => {
// Clean up pending_outbound
self.pending_outbound.remove(&key);
match result {
PromotionResult::Promoted(node_id) => {
info!(
node_id = %node_id,
"Peer promoted to active"
);
}
PromotionResult::CrossConnectionWon { loser_link_id, node_id } => {
info!(
node_id = %node_id,
loser_link_id = %loser_link_id,
"Cross-connection won"
);
}
PromotionResult::CrossConnectionLost { winner_link_id } => {
info!(
winner_link_id = %winner_link_id,
"Cross-connection lost"
);
}
}
}
Err(e) => {
warn!(
link_id = %link_id,
error = %e,
"Failed to promote connection"
);
}
}
}
/// Dispatch a decrypted link message to the appropriate handler.
///
/// Link messages are protocol messages exchanged between authenticated peers.
async fn dispatch_link_message(&mut self, _from: &NodeId, plaintext: &[u8]) {
if plaintext.is_empty() {
return;
}
let msg_type = plaintext[0];
let _payload = &plaintext[1..];
// TODO: Implement link message handlers
match msg_type {
0x10 => {
// TreeAnnounce
debug!("Received TreeAnnounce (not yet implemented)");
}
0x20 => {
// FilterAnnounce
debug!("Received FilterAnnounce (not yet implemented)");
}
0x30 => {
// LookupRequest
debug!("Received LookupRequest (not yet implemented)");
}
0x31 => {
// LookupResponse
debug!("Received LookupResponse (not yet implemented)");
}
0x40 => {
// SessionDatagram
debug!("Received SessionDatagram (not yet implemented)");
}
_ => {
debug!(msg_type = msg_type, "Unknown link message type");
}
}
}
/// Stop the node.
///
/// Shuts down TUN interface, stops I/O threads, and transitions to
@@ -1459,4 +1941,102 @@ mod tests {
assert_eq!(sendable.len(), 2);
assert!(sendable.iter().any(|p| p.node_id() == &node_id1));
}
// === RX Loop Tests ===
#[test]
fn test_node_index_allocator_initialized() {
let node = make_node();
// Index allocator should be empty on creation
assert_eq!(node.index_allocator.count(), 0);
}
#[test]
fn test_node_pending_outbound_tracking() {
let mut node = make_node();
let transport_id = TransportId::new(1);
let link_id = LinkId::new(1);
// Allocate an index
let index = node.index_allocator.allocate().unwrap();
// Track in pending_outbound
node.pending_outbound.insert((transport_id, index.as_u32()), link_id);
// Verify we can look it up
let found = node.pending_outbound.get(&(transport_id, index.as_u32()));
assert_eq!(found, Some(&link_id));
// Clean up
node.pending_outbound.remove(&(transport_id, index.as_u32()));
let _ = node.index_allocator.free(index);
assert_eq!(node.index_allocator.count(), 0);
assert!(node.pending_outbound.is_empty());
}
#[test]
fn test_node_peers_by_index_tracking() {
let mut node = make_node();
let transport_id = TransportId::new(1);
let node_id = make_node_id(42);
// Allocate an index
let index = node.index_allocator.allocate().unwrap();
// Track in peers_by_index
node.peers_by_index.insert((transport_id, index.as_u32()), node_id);
// Verify lookup
let found = node.peers_by_index.get(&(transport_id, index.as_u32()));
assert_eq!(found, Some(&node_id));
// Clean up
node.peers_by_index.remove(&(transport_id, index.as_u32()));
let _ = node.index_allocator.free(index);
assert!(node.peers_by_index.is_empty());
}
#[tokio::test]
async fn test_node_rx_loop_requires_start() {
let mut node = make_node();
// RX loop should fail if node not started (no packet_rx)
let result = node.run_rx_loop().await;
assert!(matches!(result, Err(NodeError::NotStarted)));
}
#[tokio::test]
async fn test_node_rx_loop_takes_channel() {
let mut node = make_node();
node.start().await.unwrap();
// packet_rx should be available after start
assert!(node.packet_rx.is_some());
// After run_rx_loop takes ownership, it should be None
// We can't actually run the loop (it blocks), but we can test the take
let rx = node.packet_rx.take();
assert!(rx.is_some());
assert!(node.packet_rx.is_none());
node.stop().await.unwrap();
}
#[test]
fn test_rate_limiter_initialized() {
let mut node = make_node();
// Rate limiter should allow handshakes initially
assert!(node.msg1_rate_limiter.can_start_handshake());
// Start a handshake
assert!(node.msg1_rate_limiter.start_handshake());
assert_eq!(node.msg1_rate_limiter.pending_count(), 1);
// Complete it
node.msg1_rate_limiter.complete_handshake();
assert_eq!(node.msg1_rate_limiter.pending_count(), 0);
}
}

View File

@@ -61,6 +61,149 @@ pub const HANDSHAKE_MSG1_SIZE: usize = PUBKEY_SIZE + PUBKEY_SIZE + TAG_SIZE;
/// Size of handshake message 2: ephemeral only.
pub const HANDSHAKE_MSG2_SIZE: usize = PUBKEY_SIZE;
/// Replay window size in packets (matching WireGuard).
pub const REPLAY_WINDOW_SIZE: usize = 2048;
// ============================================================================
// Replay Window
// ============================================================================
/// Sliding window for replay protection.
///
/// Tracks which packet counters have been received within a window of
/// REPLAY_WINDOW_SIZE. Packets with counters below the window or already
/// seen within the window are rejected.
///
/// Based on WireGuard's anti-replay mechanism (RFC 6479 style).
#[derive(Clone)]
pub struct ReplayWindow {
/// Highest counter value seen.
highest: u64,
/// Bitmap tracking which counters in the window have been seen.
/// Bit i corresponds to counter (highest - i).
bitmap: [u64; REPLAY_WINDOW_SIZE / 64],
}
impl ReplayWindow {
/// Create a new replay window.
pub fn new() -> Self {
Self {
highest: 0,
bitmap: [0; REPLAY_WINDOW_SIZE / 64],
}
}
/// Check if a counter is valid (not replayed, not too old).
///
/// Returns true if the counter is acceptable, false if it should be rejected.
/// Does NOT update the window - call `accept` after successful decryption.
pub fn check(&self, counter: u64) -> bool {
if counter > self.highest {
// New highest - always acceptable
return true;
}
// Counter is <= highest, check if it's within the window
let diff = self.highest - counter;
if diff as usize >= REPLAY_WINDOW_SIZE {
// Too old (outside window)
return false;
}
// Check bitmap - bit is set if counter was already seen
let word_idx = (diff as usize) / 64;
let bit_idx = (diff as usize) % 64;
(self.bitmap[word_idx] & (1u64 << bit_idx)) == 0
}
/// Accept a counter into the window.
///
/// Call this only after successful decryption to prevent
/// DoS attacks that exhaust the window.
pub fn accept(&mut self, counter: u64) {
if counter > self.highest {
// Shift the window
let shift = counter - self.highest;
if shift as usize >= REPLAY_WINDOW_SIZE {
// Complete reset
self.bitmap = [0; REPLAY_WINDOW_SIZE / 64];
} else {
// Shift bitmap
self.shift_bitmap(shift as usize);
}
self.highest = counter;
// Mark counter 0 (which is now the highest) as seen
self.bitmap[0] |= 1;
} else {
// Mark the counter as seen
let diff = self.highest - counter;
let word_idx = (diff as usize) / 64;
let bit_idx = (diff as usize) % 64;
self.bitmap[word_idx] |= 1u64 << bit_idx;
}
}
/// Shift the bitmap by the given number of positions.
///
/// This moves old counters to higher bit positions to make room for the
/// new highest counter at position 0.
fn shift_bitmap(&mut self, shift: usize) {
if shift >= REPLAY_WINDOW_SIZE {
self.bitmap = [0; REPLAY_WINDOW_SIZE / 64];
return;
}
let word_shift = shift / 64;
let bit_shift = shift % 64;
// Shift entire words first (from high to low to avoid overwriting)
if word_shift > 0 {
for i in (word_shift..self.bitmap.len()).rev() {
self.bitmap[i] = self.bitmap[i - word_shift];
}
for i in 0..word_shift {
self.bitmap[i] = 0;
}
}
// Shift bits within words (from low to high so carry propagates correctly)
if bit_shift > 0 {
let mut carry = 0u64;
for i in 0..self.bitmap.len() {
let new_carry = self.bitmap[i] >> (64 - bit_shift);
self.bitmap[i] = (self.bitmap[i] << bit_shift) | carry;
carry = new_carry;
}
}
}
/// Get the highest counter seen.
pub fn highest(&self) -> u64 {
self.highest
}
/// Reset the window (use when rekeying).
pub fn reset(&mut self) {
self.highest = 0;
self.bitmap = [0; REPLAY_WINDOW_SIZE / 64];
}
}
impl Default for ReplayWindow {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for ReplayWindow {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ReplayWindow")
.field("highest", &self.highest)
.field("window_size", &REPLAY_WINDOW_SIZE)
.finish()
}
}
/// Errors from Noise protocol operations.
#[derive(Debug, Error)]
pub enum NoiseError {
@@ -91,6 +234,9 @@ pub enum NoiseError {
#[error("nonce overflow")]
NonceOverflow,
#[error("replay detected: counter {0} already seen or too old")]
ReplayDetected(u64),
#[error("secp256k1 error: {0}")]
Secp256k1(#[from] secp256k1::Error),
}
@@ -197,6 +343,9 @@ impl CipherState {
}
/// Decrypt ciphertext (with appended tag), returning plaintext.
///
/// Uses the internal nonce counter. For transport phase with explicit
/// counters from the wire format, use `decrypt_with_counter` instead.
pub fn decrypt(&mut self, ciphertext: &[u8]) -> Result<Vec<u8>, NoiseError> {
if !self.has_key {
// No key means no encryption
@@ -221,6 +370,45 @@ impl CipherState {
Ok(plaintext)
}
/// Decrypt with an explicit counter value (for transport phase).
///
/// This is used when the counter comes from the wire format rather than
/// an internal counter. The counter must be validated by a replay window
/// before calling this method.
pub fn decrypt_with_counter(
&self,
ciphertext: &[u8],
counter: u64,
) -> Result<Vec<u8>, NoiseError> {
if !self.has_key {
return Ok(ciphertext.to_vec());
}
if ciphertext.len() < TAG_SIZE {
return Err(NoiseError::MessageTooShort {
expected: TAG_SIZE,
got: ciphertext.len(),
});
}
let cipher = ChaCha20Poly1305::new_from_slice(&self.key)
.map_err(|_| NoiseError::DecryptionFailed)?;
let nonce = Self::counter_to_nonce(counter);
let plaintext = cipher
.decrypt(&nonce, ciphertext)
.map_err(|_| NoiseError::DecryptionFailed)?;
Ok(plaintext)
}
/// Convert a counter value to a nonce.
fn counter_to_nonce(counter: u64) -> Nonce {
let mut nonce_bytes = [0u8; 12];
nonce_bytes[4..12].copy_from_slice(&counter.to_le_bytes());
*Nonce::from_slice(&nonce_bytes)
}
/// Get the next nonce, incrementing the counter.
fn next_nonce(&mut self) -> Result<Nonce, NoiseError> {
if self.nonce == u64::MAX {
@@ -675,6 +863,7 @@ impl HandshakeState {
recv_cipher,
handshake_hash,
remote_static,
replay_window: ReplayWindow::new(),
})
}
@@ -697,6 +886,10 @@ impl fmt::Debug for HandshakeState {
}
/// Completed Noise session for transport encryption.
///
/// Provides bidirectional authenticated encryption with replay protection.
/// The send counter is monotonically incremented; received counters are
/// validated against a sliding window to prevent replay attacks.
pub struct NoiseSession {
/// Our role in the original handshake.
role: HandshakeRole,
@@ -708,19 +901,84 @@ pub struct NoiseSession {
handshake_hash: [u8; 32],
/// Remote peer's static public key.
remote_static: PublicKey,
/// Replay window for received packets.
replay_window: ReplayWindow,
}
impl NoiseSession {
/// Encrypt a message for sending.
/// Encrypt a message for sending (using internal counter).
///
/// Returns the ciphertext. The current send counter should be included
/// in the wire format before calling this method.
pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, NoiseError> {
self.send_cipher.encrypt(plaintext)
}
/// Decrypt a received message.
/// Get the current send counter (before incrementing).
///
/// Use this to get the counter to include in the wire format.
/// The counter will be incremented when `encrypt` is called.
pub fn current_send_counter(&self) -> u64 {
self.send_cipher.nonce
}
/// Decrypt a received message (using internal counter).
///
/// This is for handshake-phase decryption. For transport phase with
/// explicit counters, use `decrypt_with_replay_check` instead.
pub fn decrypt(&mut self, ciphertext: &[u8]) -> Result<Vec<u8>, NoiseError> {
self.recv_cipher.decrypt(ciphertext)
}
/// Check if a counter passes the replay window.
///
/// Returns Ok(()) if the counter is acceptable, Err if it should be rejected.
/// Call this before attempting decryption to avoid wasting CPU on replay attacks.
pub fn check_replay(&self, counter: u64) -> Result<(), NoiseError> {
if self.replay_window.check(counter) {
Ok(())
} else {
Err(NoiseError::ReplayDetected(counter))
}
}
/// Decrypt with explicit counter and replay protection.
///
/// This is the primary decryption method for transport phase.
/// The counter comes from the wire format and is validated against
/// the replay window before and after decryption.
///
/// On success, the counter is accepted into the replay window.
pub fn decrypt_with_replay_check(
&mut self,
ciphertext: &[u8],
counter: u64,
) -> Result<Vec<u8>, NoiseError> {
// Check replay window first (cheap)
if !self.replay_window.check(counter) {
return Err(NoiseError::ReplayDetected(counter));
}
// Attempt decryption (expensive)
let plaintext = self.recv_cipher.decrypt_with_counter(ciphertext, counter)?;
// Only accept into window after successful decryption
// This prevents DoS attacks that exhaust the window
self.replay_window.accept(counter);
Ok(plaintext)
}
/// Get the highest received counter.
pub fn highest_received_counter(&self) -> u64 {
self.replay_window.highest()
}
/// Reset the replay window (use when rekeying).
pub fn reset_replay_window(&mut self) {
self.replay_window.reset();
}
/// Get the handshake hash for channel binding.
pub fn handshake_hash(&self) -> &[u8; 32] {
&self.handshake_hash
@@ -988,4 +1246,139 @@ mod tests {
// The discovered key can be used to look up peer config, verify against allow-list, etc.
}
// ===== ReplayWindow Tests =====
#[test]
fn test_replay_window_basic() {
let mut window = ReplayWindow::new();
// First packet is always acceptable
assert!(window.check(0));
window.accept(0);
assert_eq!(window.highest(), 0);
// Replay of 0 should fail
assert!(!window.check(0));
// New higher counter is acceptable
assert!(window.check(1));
window.accept(1);
assert_eq!(window.highest(), 1);
// Out-of-order within window is acceptable
// (after accepting 10, 2 is still in window)
window.accept(10);
assert!(window.check(5));
window.accept(5);
// Replay of 5 should now fail
assert!(!window.check(5));
}
#[test]
fn test_replay_window_large_jump() {
let mut window = ReplayWindow::new();
// Accept counter 0
window.accept(0);
// Jump to a large counter
window.accept(REPLAY_WINDOW_SIZE as u64 + 100);
// Old counter should be outside window
assert!(!window.check(0));
assert!(!window.check(50));
// Counters within window should work
assert!(window.check(REPLAY_WINDOW_SIZE as u64 + 99));
assert!(window.check(REPLAY_WINDOW_SIZE as u64 + 50));
}
#[test]
fn test_replay_window_boundary() {
let mut window = ReplayWindow::new();
// Accept at boundary
window.accept(REPLAY_WINDOW_SIZE as u64 - 1);
// Counter 0 should be exactly at the edge of the window
assert!(window.check(0));
window.accept(0);
// Move window forward by 1
window.accept(REPLAY_WINDOW_SIZE as u64);
// Counter 0 is now outside the window
assert!(!window.check(0));
// Counter 1 is still in the window
assert!(window.check(1));
}
#[test]
fn test_replay_window_sequential() {
let mut window = ReplayWindow::new();
// Accept counters 0-999 in order
for i in 0..1000 {
assert!(window.check(i), "Counter {} should be acceptable", i);
window.accept(i);
}
// All should be marked as seen
for i in 0..1000 {
assert!(!window.check(i), "Counter {} should be rejected as replay", i);
}
assert_eq!(window.highest(), 999);
}
#[test]
fn test_replay_window_reset() {
let mut window = ReplayWindow::new();
window.accept(100);
assert_eq!(window.highest(), 100);
assert!(!window.check(100));
window.reset();
assert_eq!(window.highest(), 0);
assert!(window.check(100));
}
#[test]
fn test_session_replay_protection() {
let keypair1 = generate_keypair();
let keypair2 = generate_keypair();
let mut init = HandshakeState::new_initiator(keypair1, keypair2.public_key());
let mut resp = HandshakeState::new_responder(keypair2);
let msg1 = init.write_message_1().unwrap();
resp.read_message_1(&msg1).unwrap();
let msg2 = resp.write_message_2().unwrap();
init.read_message_2(&msg2).unwrap();
let mut sender = init.into_session().unwrap();
let mut receiver = resp.into_session().unwrap();
// Encrypt a message
let counter = sender.current_send_counter();
let ciphertext = sender.encrypt(b"test message").unwrap();
// First decryption should succeed
let plaintext = receiver
.decrypt_with_replay_check(&ciphertext, counter)
.unwrap();
assert_eq!(plaintext, b"test message");
// Replay should fail
let result = receiver.decrypt_with_replay_check(&ciphertext, counter);
assert!(matches!(result, Err(NoiseError::ReplayDetected(_))));
// Check method alone also detects replay
assert!(receiver.check_replay(counter).is_err());
}
}

View File

@@ -4,7 +4,9 @@
//! ActivePeer holds tree state, Bloom filter, and routing information.
use crate::bloom::BloomFilter;
use crate::transport::{LinkId, LinkStats};
use crate::index::SessionIndex;
use crate::noise::NoiseSession;
use crate::transport::{LinkId, LinkStats, TransportAddr, TransportId};
use crate::tree::{ParentDeclaration, TreeCoordinate};
use crate::{FipsAddress, NodeId, PeerIdentity};
use secp256k1::XOnlyPublicKey;
@@ -58,7 +60,11 @@ impl fmt::Display for ConnectivityState {
///
/// Created only after successful Noise KK handshake. The identity is
/// cryptographically verified at this point.
#[derive(Clone, Debug)]
///
/// Note: ActivePeer intentionally does not implement Clone because it
/// contains NoiseSession, which cannot be safely cloned (cloning would
/// risk nonce reuse, a catastrophic security failure).
#[derive(Debug)]
pub struct ActivePeer {
// === Identity (Verified) ===
/// Cryptographic identity (verified via handshake).
@@ -70,6 +76,18 @@ pub struct ActivePeer {
/// Current connectivity state.
connectivity: ConnectivityState,
// === Session (Wire Protocol) ===
/// Noise session for encryption/decryption (None if legacy peer).
noise_session: Option<NoiseSession>,
/// Our session index (they include this when sending TO us).
our_index: Option<SessionIndex>,
/// Their session index (we include this when sending TO them).
their_index: Option<SessionIndex>,
/// Transport ID for this peer's link.
transport_id: Option<TransportId>,
/// Current transport address (for roaming support).
current_addr: Option<TransportAddr>,
// === Spanning Tree ===
/// Their latest parent declaration.
declaration: Option<ParentDeclaration>,
@@ -101,11 +119,17 @@ impl ActivePeer {
/// Create a new active peer from verified identity.
///
/// Called after successful authentication handshake.
/// For peers with Noise sessions, use `with_session` instead.
pub fn new(identity: PeerIdentity, link_id: LinkId, authenticated_at: u64) -> Self {
Self {
identity,
link_id,
connectivity: ConnectivityState::Connected,
noise_session: None,
our_index: None,
their_index: None,
transport_id: None,
current_addr: None,
declaration: None,
ancestry: None,
inbound_filter: None,
@@ -122,6 +146,7 @@ impl ActivePeer {
/// Create from verified identity with existing link stats.
///
/// Used when promoting from PeerConnection, preserving handshake stats.
/// For peers with Noise sessions, use `with_session` instead.
pub fn with_stats(
identity: PeerIdentity,
link_id: LinkId,
@@ -133,6 +158,44 @@ impl ActivePeer {
peer
}
/// Create from verified identity with Noise session and index tracking.
///
/// This is the primary constructor for the wire protocol path.
/// The NoiseSession provides encryption/decryption and replay protection.
#[allow(clippy::too_many_arguments)]
pub fn with_session(
identity: PeerIdentity,
link_id: LinkId,
authenticated_at: u64,
noise_session: NoiseSession,
our_index: SessionIndex,
their_index: SessionIndex,
transport_id: TransportId,
current_addr: TransportAddr,
link_stats: LinkStats,
) -> Self {
Self {
identity,
link_id,
connectivity: ConnectivityState::Connected,
noise_session: Some(noise_session),
our_index: Some(our_index),
their_index: Some(their_index),
transport_id: Some(transport_id),
current_addr: Some(current_addr),
declaration: None,
ancestry: None,
inbound_filter: None,
filter_sequence: 0,
filter_ttl: 0,
filter_received_at: 0,
pending_filter_update: true,
link_stats,
authenticated_at,
last_seen: authenticated_at,
}
}
// === Identity Accessors ===
/// Get the peer's verified identity.
@@ -187,6 +250,51 @@ impl ActivePeer {
self.connectivity.is_terminal()
}
// === Session Accessors ===
/// Check if this peer has a Noise session.
pub fn has_session(&self) -> bool {
self.noise_session.is_some()
}
/// Get the Noise session, if present.
pub fn noise_session(&self) -> Option<&NoiseSession> {
self.noise_session.as_ref()
}
/// Get mutable access to the Noise session.
pub fn noise_session_mut(&mut self) -> Option<&mut NoiseSession> {
self.noise_session.as_mut()
}
/// Get our session index (they use this to send TO us).
pub fn our_index(&self) -> Option<SessionIndex> {
self.our_index
}
/// Get their session index (we use this to send TO them).
pub fn their_index(&self) -> Option<SessionIndex> {
self.their_index
}
/// Get the transport ID for this peer.
pub fn transport_id(&self) -> Option<TransportId> {
self.transport_id
}
/// Get the current transport address.
pub fn current_addr(&self) -> Option<&TransportAddr> {
self.current_addr.as_ref()
}
/// Update the current address (for roaming support).
///
/// Called when we receive a valid authenticated packet from a new address.
pub fn set_current_addr(&mut self, transport_id: TransportId, addr: TransportAddr) {
self.transport_id = Some(transport_id);
self.current_addr = Some(addr);
}
// === Tree Accessors ===
/// Get the peer's tree coordinates, if known.

View File

@@ -4,8 +4,9 @@
//! PeerConnection tracks the Noise IK handshake state and transitions to
//! ActivePeer upon successful authentication.
use crate::index::SessionIndex;
use crate::noise::{self, NoiseError, NoiseSession};
use crate::transport::{LinkDirection, LinkId, LinkStats};
use crate::transport::{LinkDirection, LinkId, LinkStats, TransportAddr, TransportId};
use crate::PeerIdentity;
use secp256k1::Keypair;
use std::fmt;
@@ -101,6 +102,23 @@ pub struct PeerConnection {
// === Statistics ===
/// Link statistics during handshake.
link_stats: LinkStats,
// === Wire Protocol Index Tracking ===
/// Our sender_idx for this handshake (chosen by us).
/// For outbound: included in msg1, used as receiver_idx in msg2 echo.
/// For inbound: chosen after processing msg1, included in msg2.
our_index: Option<SessionIndex>,
/// Their sender_idx (learned from their messages).
/// For outbound: learned from msg2.
/// For inbound: learned from msg1.
their_index: Option<SessionIndex>,
/// Transport ID (for index namespace).
transport_id: Option<TransportId>,
/// Current source address (updated on packet receipt).
source_addr: Option<TransportAddr>,
}
impl PeerConnection {
@@ -124,6 +142,10 @@ impl PeerConnection {
last_activity: current_time_ms,
retry_count: 0,
link_stats: LinkStats::new(),
our_index: None,
their_index: None,
transport_id: None,
source_addr: None,
}
}
@@ -143,6 +165,37 @@ impl PeerConnection {
last_activity: current_time_ms,
retry_count: 0,
link_stats: LinkStats::new(),
our_index: None,
their_index: None,
transport_id: None,
source_addr: None,
}
}
/// Create a new inbound connection with transport information.
///
/// Used when processing msg1 where we know the transport and source address.
pub fn inbound_with_transport(
link_id: LinkId,
transport_id: TransportId,
source_addr: TransportAddr,
current_time_ms: u64,
) -> Self {
Self {
link_id,
direction: LinkDirection::Inbound,
handshake_state: HandshakeState::Initial,
expected_identity: None,
noise_handshake: None,
noise_session: None,
started_at: current_time_ms,
last_activity: current_time_ms,
retry_count: 0,
link_stats: LinkStats::new(),
our_index: None,
their_index: None,
transport_id: Some(transport_id),
source_addr: Some(source_addr),
}
}
@@ -228,6 +281,48 @@ impl PeerConnection {
&mut self.link_stats
}
// === Index Accessors ===
/// Get our session index (if set).
pub fn our_index(&self) -> Option<SessionIndex> {
self.our_index
}
/// Set our session index.
pub fn set_our_index(&mut self, index: SessionIndex) {
self.our_index = Some(index);
}
/// Get their session index (if known).
pub fn their_index(&self) -> Option<SessionIndex> {
self.their_index
}
/// Set their session index.
pub fn set_their_index(&mut self, index: SessionIndex) {
self.their_index = Some(index);
}
/// Get the transport ID (if set).
pub fn transport_id(&self) -> Option<TransportId> {
self.transport_id
}
/// Set the transport ID.
pub fn set_transport_id(&mut self, id: TransportId) {
self.transport_id = Some(id);
}
/// Get the source address (if known).
pub fn source_addr(&self) -> Option<&TransportAddr> {
self.source_addr.as_ref()
}
/// Set the source address.
pub fn set_source_addr(&mut self, addr: TransportAddr) {
self.source_addr = Some(addr);
}
// === Noise Handshake Operations ===
/// Start the handshake as initiator and generate message 1.
@@ -402,6 +497,9 @@ impl fmt::Debug for PeerConnection {
.field("expected_identity", &self.expected_identity)
.field("has_noise_handshake", &self.noise_handshake.is_some())
.field("has_noise_session", &self.noise_session.is_some())
.field("our_index", &self.our_index)
.field("their_index", &self.their_index)
.field("transport_id", &self.transport_id)
.field("started_at", &self.started_at)
.field("last_activity", &self.last_activity)
.field("retry_count", &self.retry_count)

View File

@@ -65,10 +65,14 @@ pub enum PeerError {
/// When a handshake completes, we may discover that we already have a
/// connection to this peer (cross-connection). The tie-breaker rule
/// determines which connection survives.
#[derive(Debug)]
///
/// Note: Returns NodeId instead of ActivePeer because ActivePeer cannot
/// be cloned (it contains NoiseSession which has cryptographic state).
/// Callers can look up the peer from the peers map using the NodeId.
#[derive(Debug, Clone, Copy)]
pub enum PromotionResult {
/// New peer created successfully.
Promoted(ActivePeer),
Promoted(NodeId),
/// Cross-connection detected. This connection lost the tie-breaker
/// and should be closed.
@@ -82,17 +86,17 @@ pub enum PromotionResult {
CrossConnectionWon {
/// The link that lost (previous connection, now closed).
loser_link_id: LinkId,
/// The new active peer.
peer: ActivePeer,
/// The node ID of the peer.
node_id: NodeId,
},
}
impl PromotionResult {
/// Get the active peer if promotion succeeded.
pub fn peer(&self) -> Option<&ActivePeer> {
/// Get the node ID if promotion succeeded.
pub fn node_id(&self) -> Option<NodeId> {
match self {
PromotionResult::Promoted(peer) => Some(peer),
PromotionResult::CrossConnectionWon { peer, .. } => Some(peer),
PromotionResult::Promoted(node_id) => Some(*node_id),
PromotionResult::CrossConnectionWon { node_id, .. } => Some(*node_id),
PromotionResult::CrossConnectionLost { .. } => None,
}
}
@@ -328,10 +332,11 @@ mod tests {
#[test]
fn test_promotion_result_promoted() {
let identity = make_peer_identity();
let peer = ActivePeer::new(identity, LinkId::new(1), 1000);
let result = PromotionResult::Promoted(peer);
let node_id = *identity.node_id();
let result = PromotionResult::Promoted(node_id);
assert!(result.peer().is_some());
assert!(result.node_id().is_some());
assert_eq!(result.node_id(), Some(node_id));
assert!(!result.should_close_this_connection());
assert!(result.link_to_close().is_none());
}
@@ -342,7 +347,7 @@ mod tests {
winner_link_id: LinkId::new(1),
};
assert!(result.peer().is_none());
assert!(result.node_id().is_none());
assert!(result.should_close_this_connection());
assert!(result.link_to_close().is_none()); // Caller closes their own
}
@@ -350,13 +355,14 @@ mod tests {
#[test]
fn test_promotion_result_cross_won() {
let identity = make_peer_identity();
let peer = ActivePeer::new(identity, LinkId::new(2), 2000);
let node_id = *identity.node_id();
let result = PromotionResult::CrossConnectionWon {
loser_link_id: LinkId::new(1),
peer,
node_id,
};
assert!(result.peer().is_some());
assert!(result.node_id().is_some());
assert_eq!(result.node_id(), Some(node_id));
assert!(!result.should_close_this_connection());
assert_eq!(result.link_to_close(), Some(LinkId::new(1)));
}

View File

@@ -277,6 +277,17 @@ impl TreeAnnounce {
///
/// Sent to peers to advertise which destinations are reachable.
/// The TTL controls propagation depth (decremented at each hop).
///
/// ## Wire Format (v1)
///
/// | Offset | Field | Size | Notes |
/// |--------|-------------|----------|----------------------------------|
/// | 0 | msg_type | 1 byte | 0x20 |
/// | 1 | sequence | 8 bytes | LE u64 |
/// | 9 | ttl | 1 byte | Remaining hops |
/// | 10 | hash_count | 1 byte | Number of hash functions |
/// | 11 | size_class | 1 byte | Filter size: 512 << size_class |
/// | 12 | filter_bits | variable | 512 << size_class bytes |
#[derive(Clone, Debug)]
pub struct FilterAnnounce {
/// The bloom filter contents.
@@ -285,12 +296,35 @@ pub struct FilterAnnounce {
pub ttl: u8,
/// Sequence number for freshness/dedup.
pub sequence: u64,
/// Number of hash functions used by the filter.
pub hash_count: u8,
/// Size class: filter size in bytes = 512 << size_class.
/// v1 protocol requires size_class=1 (1 KB filters).
pub size_class: u8,
}
impl FilterAnnounce {
/// Create a new FilterAnnounce message.
/// Create a new FilterAnnounce message with v1 defaults.
pub fn new(filter: BloomFilter, ttl: u8, sequence: u64) -> Self {
Self {
hash_count: filter.hash_count(),
size_class: crate::bloom::V1_SIZE_CLASS,
filter,
ttl,
sequence,
}
}
/// Create with explicit size_class (for testing or future protocol versions).
pub fn with_size_class(
filter: BloomFilter,
ttl: u8,
sequence: u64,
size_class: u8,
) -> Self {
Self {
hash_count: filter.hash_count(),
size_class,
filter,
ttl,
sequence,
@@ -311,8 +345,26 @@ impl FilterAnnounce {
filter: self.filter.clone(),
ttl: self.ttl - 1,
sequence: self.sequence,
hash_count: self.hash_count,
size_class: self.size_class,
})
}
/// Get the expected filter size in bytes for this size_class.
pub fn filter_size_bytes(&self) -> usize {
512 << self.size_class
}
/// Validate the filter matches the declared size_class.
pub fn is_valid(&self) -> bool {
self.filter.num_bytes() == self.filter_size_bytes()
&& self.filter.hash_count() == self.hash_count
}
/// Check if this is a v1-compliant filter (size_class=1).
pub fn is_v1_compliant(&self) -> bool {
self.size_class == crate::bloom::V1_SIZE_CLASS
}
}
// ============ Discovery Messages ============
@@ -605,26 +657,59 @@ impl SessionAck {
// ============ Data Messages ============
/// Data packet flags.
///
/// ## Flag Bits
///
/// | Bit | Name | Description |
/// |-----|----------------|------------------------------------------|
/// | 0 | COORDS_PRESENT | Coordinates follow the fixed header |
/// | 1-7 | reserved | Reserved for future use |
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct DataFlags {
/// Reserved bits for future use.
/// When set, source and destination coordinates follow the header.
/// Used to warm router caches after receiving CoordsRequired.
pub coords_present: bool,
/// Reserved bits (preserved for forward compatibility).
reserved: u8,
}
/// Bit 0: coordinates follow the header.
pub const DATA_FLAG_COORDS_PRESENT: u8 = 0x01;
impl DataFlags {
/// Create default flags.
/// Create default flags (no coordinates).
pub fn new() -> Self {
Self::default()
}
/// Create flags with COORDS_PRESENT set.
pub fn with_coords() -> Self {
Self {
coords_present: true,
reserved: 0,
}
}
/// Set the coords_present flag.
pub fn set_coords_present(&mut self, value: bool) {
self.coords_present = value;
}
/// Convert to a byte.
pub fn to_byte(&self) -> u8 {
self.reserved
let mut flags = self.reserved & !DATA_FLAG_COORDS_PRESENT;
if self.coords_present {
flags |= DATA_FLAG_COORDS_PRESENT;
}
flags
}
/// Convert from a byte.
pub fn from_byte(byte: u8) -> Self {
Self { reserved: byte }
Self {
coords_present: byte & DATA_FLAG_COORDS_PRESENT != 0,
reserved: byte & !DATA_FLAG_COORDS_PRESENT,
}
}
}
@@ -925,6 +1010,34 @@ mod tests {
assert_eq!(packet.flags.to_byte(), 0x80);
}
#[test]
fn test_data_flags_coords_present() {
// Default: no coords
let flags = DataFlags::new();
assert!(!flags.coords_present);
assert_eq!(flags.to_byte(), 0x00);
// With coords
let flags = DataFlags::with_coords();
assert!(flags.coords_present);
assert_eq!(flags.to_byte(), 0x01);
// Round-trip preserves flag
let flags = DataFlags::from_byte(0x01);
assert!(flags.coords_present);
assert_eq!(flags.to_byte(), 0x01);
// Reserved bits preserved
let flags = DataFlags::from_byte(0x81); // coords + reserved bit 7
assert!(flags.coords_present);
assert_eq!(flags.to_byte(), 0x81);
// Coords bit toggles independently
let flags = DataFlags::from_byte(0x80); // only reserved bit 7
assert!(!flags.coords_present);
assert_eq!(flags.to_byte(), 0x80);
}
// ===== LookupRequest Tests =====
#[test]
@@ -1003,6 +1116,36 @@ mod tests {
assert!(forwarded2.forwarded().is_none());
}
#[test]
fn test_filter_announce_size_class() {
let filter = BloomFilter::new();
let announce = FilterAnnounce::new(filter.clone(), 2, 100);
// v1 defaults
assert_eq!(announce.size_class, 1);
assert_eq!(announce.hash_count, 5);
assert!(announce.is_v1_compliant());
assert!(announce.is_valid());
assert_eq!(announce.filter_size_bytes(), 1024);
// Forwarded preserves size_class
let forwarded = announce.forwarded().unwrap();
assert_eq!(forwarded.size_class, 1);
assert_eq!(forwarded.hash_count, 5);
}
#[test]
fn test_filter_announce_with_size_class() {
let filter = BloomFilter::with_params(2048 * 8, 7).unwrap();
let announce = FilterAnnounce::with_size_class(filter, 2, 100, 2);
assert_eq!(announce.size_class, 2);
assert_eq!(announce.hash_count, 7);
assert!(!announce.is_v1_compliant());
assert!(announce.is_valid());
assert_eq!(announce.filter_size_bytes(), 2048);
}
// ===== SessionSetup Tests =====
#[test]

448
src/rate_limit.rs Normal file
View File

@@ -0,0 +1,448 @@
//! Rate Limiting for FIPS Protocol
//!
//! Provides token bucket rate limiting for protecting against DoS attacks,
//! particularly on the Noise handshake path where msg1 processing involves
//! expensive cryptographic operations.
//!
//! ## Design
//!
//! - Token bucket algorithm with configurable burst and refill rate
//! - Global rate limit (not per-source, since UDP sources are spoofable)
//! - Applied before expensive DH operations in handshake processing
//!
//! ## Default Parameters
//!
//! - Burst capacity: 100 tokens (max concurrent handshakes)
//! - Refill rate: 10 tokens/second (sustained handshake rate)
//! - This allows handling burst traffic while limiting sustained attack impact
use std::time::{Duration, Instant};
/// Default burst capacity (max tokens).
pub const DEFAULT_BURST_CAPACITY: u32 = 100;
/// Default refill rate (tokens per second).
pub const DEFAULT_REFILL_RATE: f64 = 10.0;
/// Maximum pending inbound connections.
pub const MAX_PENDING_INBOUND: usize = 1000;
/// Handshake timeout in seconds.
pub const HANDSHAKE_TIMEOUT_SECS: u64 = 30;
/// Token bucket rate limiter.
///
/// Uses a classic token bucket algorithm where tokens are consumed for each
/// operation and refilled at a constant rate. When tokens are exhausted,
/// operations are rate-limited until tokens refill.
#[derive(Debug, Clone)]
pub struct TokenBucket {
/// Maximum number of tokens (burst capacity).
capacity: u32,
/// Current number of available tokens (may be fractional during refill).
tokens: f64,
/// Tokens added per second.
refill_rate: f64,
/// Last time tokens were refilled.
last_refill: Instant,
}
impl TokenBucket {
/// Create a new token bucket with default parameters.
///
/// - Burst capacity: 100 tokens
/// - Refill rate: 10 tokens/second
pub fn new() -> Self {
Self::with_params(DEFAULT_BURST_CAPACITY, DEFAULT_REFILL_RATE)
}
/// Create a token bucket with custom parameters.
///
/// # Arguments
///
/// * `capacity` - Maximum number of tokens (burst capacity)
/// * `refill_rate` - Tokens added per second
pub fn with_params(capacity: u32, refill_rate: f64) -> Self {
Self {
capacity,
tokens: capacity as f64,
refill_rate,
last_refill: Instant::now(),
}
}
/// Try to consume one token.
///
/// Returns `true` if a token was available and consumed, `false` if
/// rate limited (no tokens available).
pub fn try_acquire(&mut self) -> bool {
self.try_acquire_n(1)
}
/// Try to consume n tokens.
///
/// Returns `true` if n tokens were available and consumed, `false` if
/// rate limited (insufficient tokens).
pub fn try_acquire_n(&mut self, n: u32) -> bool {
self.refill();
if self.tokens >= n as f64 {
self.tokens -= n as f64;
true
} else {
false
}
}
/// Check if tokens are available without consuming them.
pub fn available(&mut self) -> bool {
self.refill();
self.tokens >= 1.0
}
/// Get the current number of available tokens.
pub fn tokens(&mut self) -> f64 {
self.refill();
self.tokens
}
/// Get the capacity (max tokens).
pub fn capacity(&self) -> u32 {
self.capacity
}
/// Get the refill rate (tokens per second).
pub fn refill_rate(&self) -> f64 {
self.refill_rate
}
/// Refill tokens based on elapsed time.
fn refill(&mut self) {
let now = Instant::now();
let elapsed = now.duration_since(self.last_refill);
let elapsed_secs = elapsed.as_secs_f64();
// Add tokens based on time elapsed
self.tokens += elapsed_secs * self.refill_rate;
// Cap at capacity
if self.tokens > self.capacity as f64 {
self.tokens = self.capacity as f64;
}
self.last_refill = now;
}
/// Reset to full capacity.
pub fn reset(&mut self) {
self.tokens = self.capacity as f64;
self.last_refill = Instant::now();
}
/// Set a new capacity.
pub fn set_capacity(&mut self, capacity: u32) {
self.capacity = capacity;
if self.tokens > capacity as f64 {
self.tokens = capacity as f64;
}
}
/// Set a new refill rate.
pub fn set_refill_rate(&mut self, rate: f64) {
self.refill_rate = rate;
}
/// Time until the next token is available.
///
/// Returns `Duration::ZERO` if tokens are available, otherwise the
/// estimated time until one token will be available.
pub fn time_until_available(&mut self) -> Duration {
self.refill();
if self.tokens >= 1.0 {
Duration::ZERO
} else {
let needed = 1.0 - self.tokens;
let secs = needed / self.refill_rate;
Duration::from_secs_f64(secs)
}
}
}
impl Default for TokenBucket {
fn default() -> Self {
Self::new()
}
}
/// Rate limiter for handshake message 1 processing.
///
/// Combines token bucket rate limiting with connection counting to
/// protect against DoS attacks on the handshake path.
#[derive(Debug)]
pub struct HandshakeRateLimiter {
/// Token bucket for rate limiting.
bucket: TokenBucket,
/// Current count of pending inbound connections.
pending_count: usize,
/// Maximum pending inbound connections.
max_pending: usize,
}
impl HandshakeRateLimiter {
/// Create a new handshake rate limiter with default parameters.
pub fn new() -> Self {
Self {
bucket: TokenBucket::new(),
pending_count: 0,
max_pending: MAX_PENDING_INBOUND,
}
}
/// Create with custom parameters.
pub fn with_params(bucket: TokenBucket, max_pending: usize) -> Self {
Self {
bucket,
pending_count: 0,
max_pending,
}
}
/// Check if a new handshake can be started.
///
/// Returns `true` if:
/// - Token bucket has available tokens (rate limit not exceeded)
/// - Pending connection count is below maximum
///
/// Does NOT consume a token - call `start_handshake` for that.
pub fn can_start_handshake(&mut self) -> bool {
self.bucket.available() && self.pending_count < self.max_pending
}
/// Start a new handshake, consuming a token and incrementing pending count.
///
/// Returns `true` if the handshake was allowed, `false` if rate limited.
pub fn start_handshake(&mut self) -> bool {
if self.pending_count >= self.max_pending {
return false;
}
if self.bucket.try_acquire() {
self.pending_count += 1;
true
} else {
false
}
}
/// Mark a handshake as complete (successful or failed).
///
/// Decrements the pending connection count.
pub fn complete_handshake(&mut self) {
if self.pending_count > 0 {
self.pending_count -= 1;
}
}
/// Get the current pending connection count.
pub fn pending_count(&self) -> usize {
self.pending_count
}
/// Get the maximum pending connections.
pub fn max_pending(&self) -> usize {
self.max_pending
}
/// Set the maximum pending connections.
pub fn set_max_pending(&mut self, max: usize) {
self.max_pending = max;
}
/// Get a reference to the token bucket.
pub fn bucket(&self) -> &TokenBucket {
&self.bucket
}
/// Get a mutable reference to the token bucket.
pub fn bucket_mut(&mut self) -> &mut TokenBucket {
&mut self.bucket
}
/// Reset the rate limiter.
pub fn reset(&mut self) {
self.bucket.reset();
self.pending_count = 0;
}
}
impl Default for HandshakeRateLimiter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
use std::time::Duration;
#[test]
fn test_token_bucket_basic() {
let mut bucket = TokenBucket::with_params(10, 1.0);
// Should have full capacity
assert_eq!(bucket.capacity(), 10);
assert!(bucket.tokens() >= 9.9); // Allow for timing
// Consume all tokens
for _ in 0..10 {
assert!(bucket.try_acquire());
}
// Should be empty
assert!(!bucket.try_acquire());
assert!(!bucket.available());
}
#[test]
fn test_token_bucket_refill() {
let mut bucket = TokenBucket::with_params(10, 100.0); // 100 tokens/sec
// Drain completely
for _ in 0..10 {
bucket.try_acquire();
}
assert!(!bucket.available());
// Wait for refill
thread::sleep(Duration::from_millis(50)); // Should refill ~5 tokens
// Should have tokens now
let tokens = bucket.tokens();
assert!(tokens >= 4.0 && tokens <= 6.0, "tokens: {}", tokens);
}
#[test]
fn test_token_bucket_try_acquire_n() {
let mut bucket = TokenBucket::with_params(10, 1.0);
// Acquire 5
assert!(bucket.try_acquire_n(5));
assert!(bucket.tokens() >= 4.9 && bucket.tokens() <= 5.1);
// Acquire 5 more
assert!(bucket.try_acquire_n(5));
// Can't acquire more
assert!(!bucket.try_acquire_n(1));
}
#[test]
fn test_token_bucket_reset() {
let mut bucket = TokenBucket::with_params(10, 1.0);
// Drain
for _ in 0..10 {
bucket.try_acquire();
}
// Reset
bucket.reset();
// Should be full again
assert!(bucket.tokens() >= 9.9);
}
#[test]
fn test_token_bucket_time_until_available() {
let mut bucket = TokenBucket::with_params(10, 10.0); // 10 tokens/sec
// When full, should be zero
assert_eq!(bucket.time_until_available(), Duration::ZERO);
// Drain completely
for _ in 0..10 {
bucket.try_acquire();
}
// Should need ~100ms for one token at 10/sec
let wait = bucket.time_until_available();
assert!(wait.as_millis() >= 90 && wait.as_millis() <= 110);
}
#[test]
fn test_handshake_rate_limiter_basic() {
let mut limiter = HandshakeRateLimiter::new();
assert!(limiter.can_start_handshake());
assert_eq!(limiter.pending_count(), 0);
// Start a handshake
assert!(limiter.start_handshake());
assert_eq!(limiter.pending_count(), 1);
// Complete it
limiter.complete_handshake();
assert_eq!(limiter.pending_count(), 0);
}
#[test]
fn test_handshake_rate_limiter_max_pending() {
let bucket = TokenBucket::with_params(1000, 100.0);
let mut limiter = HandshakeRateLimiter::with_params(bucket, 3);
// Start 3 handshakes
assert!(limiter.start_handshake());
assert!(limiter.start_handshake());
assert!(limiter.start_handshake());
// Fourth should fail (max pending)
assert!(!limiter.can_start_handshake());
assert!(!limiter.start_handshake());
// Complete one
limiter.complete_handshake();
// Now should be able to start another
assert!(limiter.can_start_handshake());
assert!(limiter.start_handshake());
}
#[test]
fn test_handshake_rate_limiter_token_exhaustion() {
let bucket = TokenBucket::with_params(5, 0.0); // No refill
let mut limiter = HandshakeRateLimiter::with_params(bucket, 100);
// Start 5 handshakes (exhausts tokens)
for _ in 0..5 {
assert!(limiter.start_handshake());
}
// Complete them all
for _ in 0..5 {
limiter.complete_handshake();
}
// Tokens exhausted, even though pending is 0
assert!(!limiter.can_start_handshake());
assert!(!limiter.start_handshake());
}
#[test]
fn test_handshake_rate_limiter_reset() {
let mut limiter = HandshakeRateLimiter::new();
// Start some handshakes
limiter.start_handshake();
limiter.start_handshake();
assert_eq!(limiter.pending_count(), 2);
// Reset
limiter.reset();
assert_eq!(limiter.pending_count(), 0);
assert!(limiter.bucket().tokens >= DEFAULT_BURST_CAPACITY as f64 - 0.1);
}
}

360
src/wire.rs Normal file
View File

@@ -0,0 +1,360 @@
//! Wire Format Parsing and Serialization
//!
//! Defines the FIPS link-layer wire format for packet dispatch.
//! All packets begin with a discriminator byte followed by type-specific payload.
//!
//! ## Packet Types
//!
//! | Byte | Type | Size | Description |
//! |------|-----------------|-----------|--------------------------------|
//! | 0x00 | Encrypted frame | 29+ bytes | Post-handshake encrypted data |
//! | 0x01 | Noise IK msg1 | 87 bytes | Handshake initiation |
//! | 0x02 | Noise IK msg2 | 42 bytes | Handshake response |
use crate::index::SessionIndex;
use crate::noise::{HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE, TAG_SIZE};
// ============================================================================
// Constants
// ============================================================================
/// Discriminator for encrypted frames (post-handshake data).
pub const DISCRIMINATOR_ENCRYPTED: u8 = 0x00;
/// Discriminator for Noise IK message 1 (handshake initiation).
pub const DISCRIMINATOR_MSG1: u8 = 0x01;
/// Discriminator for Noise IK message 2 (handshake response).
pub const DISCRIMINATOR_MSG2: u8 = 0x02;
/// Size of Noise IK message 1 wire packet: discriminator + sender_idx + noise_msg1.
pub const MSG1_WIRE_SIZE: usize = 1 + 4 + HANDSHAKE_MSG1_SIZE; // 87 bytes
/// Size of Noise IK message 2 wire packet: discriminator + sender_idx + receiver_idx + noise_msg2.
pub const MSG2_WIRE_SIZE: usize = 1 + 4 + 4 + HANDSHAKE_MSG2_SIZE; // 42 bytes
/// Minimum size for encrypted frame: discriminator + receiver_idx + counter + tag.
pub const ENCRYPTED_MIN_SIZE: usize = 1 + 4 + 8 + TAG_SIZE; // 29 bytes
/// Overhead added by encrypted frame wrapper.
pub const ENCRYPTED_OVERHEAD: usize = ENCRYPTED_MIN_SIZE;
// ============================================================================
// Encrypted Frame Header
// ============================================================================
/// Parsed encrypted frame header.
///
/// Wire format:
/// ```text
/// [0x00][receiver_idx:4 LE][counter:8 LE][ciphertext+tag]
/// ```
#[derive(Clone, Debug)]
pub struct EncryptedHeader {
/// Session index chosen by the receiver (for O(1) lookup).
pub receiver_idx: SessionIndex,
/// Monotonic counter used as AEAD nonce.
pub counter: u64,
/// Offset where ciphertext begins in the original packet.
pub ciphertext_offset: usize,
}
impl EncryptedHeader {
/// Parse an encrypted frame header from packet data.
///
/// Returns None if the packet is too short or has wrong discriminator.
pub fn parse(data: &[u8]) -> Option<Self> {
if data.len() < ENCRYPTED_MIN_SIZE {
return None;
}
if data[0] != DISCRIMINATOR_ENCRYPTED {
return None;
}
let receiver_idx = SessionIndex::from_le_bytes([data[1], data[2], data[3], data[4]]);
let counter = u64::from_le_bytes([
data[5], data[6], data[7], data[8], data[9], data[10], data[11], data[12],
]);
Some(Self {
receiver_idx,
counter,
ciphertext_offset: 13,
})
}
/// Get the ciphertext slice from the original packet.
pub fn ciphertext<'a>(&self, data: &'a [u8]) -> &'a [u8] {
&data[self.ciphertext_offset..]
}
}
// ============================================================================
// Msg1 Header
// ============================================================================
/// Parsed Noise IK message 1 header.
///
/// Wire format:
/// ```text
/// [0x01][sender_idx:4 LE][noise_msg1:82]
/// ```
#[derive(Clone, Debug)]
pub struct Msg1Header {
/// Session index chosen by the sender (becomes receiver_idx for responses).
pub sender_idx: SessionIndex,
/// Offset where Noise msg1 payload begins.
pub noise_msg1_offset: usize,
}
impl Msg1Header {
/// Parse a msg1 header from packet data.
///
/// Returns None if the packet has wrong size or discriminator.
pub fn parse(data: &[u8]) -> Option<Self> {
if data.len() != MSG1_WIRE_SIZE {
return None;
}
if data[0] != DISCRIMINATOR_MSG1 {
return None;
}
let sender_idx = SessionIndex::from_le_bytes([data[1], data[2], data[3], data[4]]);
Some(Self {
sender_idx,
noise_msg1_offset: 5,
})
}
/// Get the Noise msg1 payload from the original packet.
pub fn noise_msg1<'a>(&self, data: &'a [u8]) -> &'a [u8] {
&data[self.noise_msg1_offset..]
}
}
// ============================================================================
// Msg2 Header
// ============================================================================
/// Parsed Noise IK message 2 header.
///
/// Wire format:
/// ```text
/// [0x02][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:33]
/// ```
#[derive(Clone, Debug)]
pub struct Msg2Header {
/// Session index chosen by the responder.
pub sender_idx: SessionIndex,
/// Echo of the initiator's sender_idx from msg1.
pub receiver_idx: SessionIndex,
/// Offset where Noise msg2 payload begins.
pub noise_msg2_offset: usize,
}
impl Msg2Header {
/// Parse a msg2 header from packet data.
///
/// Returns None if the packet has wrong size or discriminator.
pub fn parse(data: &[u8]) -> Option<Self> {
if data.len() != MSG2_WIRE_SIZE {
return None;
}
if data[0] != DISCRIMINATOR_MSG2 {
return None;
}
let sender_idx = SessionIndex::from_le_bytes([data[1], data[2], data[3], data[4]]);
let receiver_idx = SessionIndex::from_le_bytes([data[5], data[6], data[7], data[8]]);
Some(Self {
sender_idx,
receiver_idx,
noise_msg2_offset: 9,
})
}
/// Get the Noise msg2 payload from the original packet.
pub fn noise_msg2<'a>(&self, data: &'a [u8]) -> &'a [u8] {
&data[self.noise_msg2_offset..]
}
}
// ============================================================================
// Serialization Helpers
// ============================================================================
/// Build a wire-format msg1 packet.
///
/// Format: `[0x01][sender_idx:4 LE][noise_msg1:82]`
pub fn build_msg1(sender_idx: SessionIndex, noise_msg1: &[u8]) -> Vec<u8> {
debug_assert_eq!(noise_msg1.len(), HANDSHAKE_MSG1_SIZE);
let mut packet = Vec::with_capacity(MSG1_WIRE_SIZE);
packet.push(DISCRIMINATOR_MSG1);
packet.extend_from_slice(&sender_idx.to_le_bytes());
packet.extend_from_slice(noise_msg1);
packet
}
/// Build a wire-format msg2 packet.
///
/// Format: `[0x02][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:33]`
pub fn build_msg2(sender_idx: SessionIndex, receiver_idx: SessionIndex, noise_msg2: &[u8]) -> Vec<u8> {
debug_assert_eq!(noise_msg2.len(), HANDSHAKE_MSG2_SIZE);
let mut packet = Vec::with_capacity(MSG2_WIRE_SIZE);
packet.push(DISCRIMINATOR_MSG2);
packet.extend_from_slice(&sender_idx.to_le_bytes());
packet.extend_from_slice(&receiver_idx.to_le_bytes());
packet.extend_from_slice(noise_msg2);
packet
}
/// Build a wire-format encrypted frame.
///
/// Format: `[0x00][receiver_idx:4 LE][counter:8 LE][ciphertext+tag]`
pub fn build_encrypted(receiver_idx: SessionIndex, counter: u64, ciphertext: &[u8]) -> Vec<u8> {
let mut packet = Vec::with_capacity(13 + ciphertext.len());
packet.push(DISCRIMINATOR_ENCRYPTED);
packet.extend_from_slice(&receiver_idx.to_le_bytes());
packet.extend_from_slice(&counter.to_le_bytes());
packet.extend_from_slice(ciphertext);
packet
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encrypted_header_parse() {
// Build a valid encrypted frame
let receiver_idx = SessionIndex::new(0x12345678);
let counter = 42u64;
let ciphertext = vec![0xaa; 32]; // 16 plaintext + 16 tag
let packet = build_encrypted(receiver_idx, counter, &ciphertext);
assert_eq!(packet.len(), 13 + 32);
assert_eq!(packet[0], DISCRIMINATOR_ENCRYPTED);
// Parse it back
let header = EncryptedHeader::parse(&packet).expect("should parse");
assert_eq!(header.receiver_idx, receiver_idx);
assert_eq!(header.counter, 42);
assert_eq!(header.ciphertext_offset, 13);
assert_eq!(header.ciphertext(&packet), &ciphertext[..]);
}
#[test]
fn test_encrypted_header_too_short() {
let packet = vec![0x00; 28]; // One byte too short
assert!(EncryptedHeader::parse(&packet).is_none());
}
#[test]
fn test_encrypted_header_wrong_discriminator() {
let mut packet = vec![0x00; 30];
packet[0] = 0x01; // Wrong discriminator
assert!(EncryptedHeader::parse(&packet).is_none());
}
#[test]
fn test_msg1_header_parse() {
let sender_idx = SessionIndex::new(0xABCDEF01);
let noise_msg1 = vec![0xbb; HANDSHAKE_MSG1_SIZE];
let packet = build_msg1(sender_idx, &noise_msg1);
assert_eq!(packet.len(), MSG1_WIRE_SIZE);
assert_eq!(packet[0], DISCRIMINATOR_MSG1);
let header = Msg1Header::parse(&packet).expect("should parse");
assert_eq!(header.sender_idx, sender_idx);
assert_eq!(header.noise_msg1_offset, 5);
assert_eq!(header.noise_msg1(&packet), &noise_msg1[..]);
}
#[test]
fn test_msg1_header_wrong_size() {
let packet = vec![0x01; 86]; // One byte too short
assert!(Msg1Header::parse(&packet).is_none());
let packet = vec![0x01; 88]; // One byte too long
assert!(Msg1Header::parse(&packet).is_none());
}
#[test]
fn test_msg1_header_wrong_discriminator() {
let mut packet = vec![0x00; MSG1_WIRE_SIZE];
packet[0] = 0x02; // Wrong discriminator
assert!(Msg1Header::parse(&packet).is_none());
}
#[test]
fn test_msg2_header_parse() {
let sender_idx = SessionIndex::new(0x11223344);
let receiver_idx = SessionIndex::new(0x55667788);
let noise_msg2 = vec![0xcc; HANDSHAKE_MSG2_SIZE];
let packet = build_msg2(sender_idx, receiver_idx, &noise_msg2);
assert_eq!(packet.len(), MSG2_WIRE_SIZE);
assert_eq!(packet[0], DISCRIMINATOR_MSG2);
let header = Msg2Header::parse(&packet).expect("should parse");
assert_eq!(header.sender_idx, sender_idx);
assert_eq!(header.receiver_idx, receiver_idx);
assert_eq!(header.noise_msg2_offset, 9);
assert_eq!(header.noise_msg2(&packet), &noise_msg2[..]);
}
#[test]
fn test_msg2_header_wrong_size() {
let packet = vec![0x02; 41]; // One byte too short
assert!(Msg2Header::parse(&packet).is_none());
let packet = vec![0x02; 43]; // One byte too long
assert!(Msg2Header::parse(&packet).is_none());
}
#[test]
fn test_msg2_header_wrong_discriminator() {
let mut packet = vec![0x00; MSG2_WIRE_SIZE];
packet[0] = 0x00; // Wrong discriminator
assert!(Msg2Header::parse(&packet).is_none());
}
#[test]
fn test_wire_sizes() {
// Verify constants match spec
assert_eq!(MSG1_WIRE_SIZE, 87); // 1 + 4 + 82
assert_eq!(MSG2_WIRE_SIZE, 42); // 1 + 4 + 4 + 33
assert_eq!(ENCRYPTED_MIN_SIZE, 29); // 1 + 4 + 8 + 16
}
#[test]
fn test_roundtrip_indices() {
// Test that indices survive the roundtrip correctly (endianness)
let idx = SessionIndex::new(0xDEADBEEF);
let msg1 = build_msg1(idx, &[0u8; HANDSHAKE_MSG1_SIZE]);
let parsed = Msg1Header::parse(&msg1).unwrap();
assert_eq!(parsed.sender_idx.as_u32(), 0xDEADBEEF);
// Verify little-endian encoding
assert_eq!(msg1[1], 0xEF);
assert_eq!(msg1[2], 0xBE);
assert_eq!(msg1[3], 0xAD);
assert_eq!(msg1[4], 0xDE);
}
}