mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-22 07:48:26 +00:00
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:
32
src/bloom.rs
32
src/bloom.rs
@@ -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
284
src/index.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/lib.rs
16
src/lib.rs
@@ -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,
|
||||
};
|
||||
|
||||
608
src/node.rs
608
src/node.rs
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
397
src/noise.rs
397
src/noise.rs
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
153
src/protocol.rs
153
src/protocol.rs
@@ -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
448
src/rate_limit.rs
Normal 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
360
src/wire.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user