From 9a6fa07e777976c0a1deceb92ba6ad84d2515238 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Sun, 1 Feb 2026 01:28:01 +0000 Subject: [PATCH] Session 46: Noise IK peer authentication implementation Implement Noise Protocol Framework for peer authentication using the IK pattern, which allows responders to learn initiator identity from the encrypted handshake message. Protocol: Noise_IK_secp256k1_ChaChaPoly_SHA256 - Message 1: 82 bytes (ephemeral + encrypted static) - Message 2: 33 bytes (ephemeral only) New noise.rs module (~600 lines): - HandshakeState: Manages handshake for both initiator/responder roles - NoiseSession: Post-handshake symmetric encryption - CipherState: ChaCha20-Poly1305 with nonce counter - SymmetricState: HKDF-SHA256 key derivation PeerConnection integration: - Simplified HandshakeState enum (Initial/SentMsg1/ReceivedMsg1/Complete/Failed) - start_handshake(): Initiator generates msg1 - receive_handshake_init(): Responder processes msg1, discovers identity - complete_handshake(): Initiator completes with msg2 - take_session(): Extract NoiseSession for ActivePeer Identity helpers: - Identity::keypair() for Noise operations - PeerIdentity::from_pubkey_full() preserves parity for ECDH - PeerIdentity::pubkey_full() returns full key or derives with even parity Node changes: - initiate_peer_connection() now starts handshake and sends msg1 - Method is async to use transport's async send All 219 tests pass. --- Cargo.lock | 115 +++++ Cargo.toml | 2 + src/identity.rs | 47 +- src/lib.rs | 4 + src/node.rs | 59 ++- src/noise.rs | 991 +++++++++++++++++++++++++++++++++++++++++ src/peer/connection.rs | 346 +++++++++----- src/peer/mod.rs | 2 +- 8 files changed, 1449 insertions(+), 117 deletions(-) create mode 100644 src/noise.rs diff --git a/Cargo.lock b/Cargo.lock index a68a1f3..db628cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -201,6 +211,41 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.5.56" @@ -278,6 +323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core", "typenum", ] @@ -289,6 +335,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -366,10 +413,12 @@ name = "fips" version = "0.1.0" dependencies = [ "bech32", + "chacha20poly1305", "clap", "dirs", "futures", "hex", + "hkdf", "libc", "rand", "rtnetlink", @@ -544,6 +593,24 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -554,6 +621,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -750,6 +826,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "option-ext" version = "0.2.0" @@ -791,6 +873,17 @@ dependencies = [ "futures-io", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1046,6 +1139,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.114" @@ -1253,6 +1352,16 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -1505,3 +1614,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" diff --git a/Cargo.toml b/Cargo.toml index 1c22966..2b83b1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,8 @@ edition = "2024" [dependencies] secp256k1 = { version = "0.30", features = ["rand", "global-context"] } sha2 = "0.10" +hkdf = "0.12" +chacha20poly1305 = "0.10" rand = "0.8" thiserror = "2.0" bech32 = "0.11" diff --git a/src/identity.rs b/src/identity.rs index 0d44cb5..eea0757 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -6,7 +6,7 @@ use bech32::{Bech32, Hrp}; use rand::Rng; -use secp256k1::{Keypair, Secp256k1, SecretKey, XOnlyPublicKey}; +use secp256k1::{Keypair, Parity, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey}; use sha2::{Digest, Sha256}; use std::fmt; use std::net::Ipv6Addr; @@ -200,17 +200,39 @@ impl fmt::Display for FipsAddress { #[derive(Clone, Copy, PartialEq, Eq)] pub struct PeerIdentity { pubkey: XOnlyPublicKey, + /// Full public key if known (includes parity for ECDH operations). + pubkey_full: Option, node_id: NodeId, address: FipsAddress, } impl PeerIdentity { /// Create a PeerIdentity from an x-only public key. + /// + /// Note: When only the x-only key is available, the full public key + /// will be derived assuming even parity for ECDH operations. pub fn from_pubkey(pubkey: XOnlyPublicKey) -> Self { let node_id = NodeId::from_pubkey(&pubkey); let address = FipsAddress::from_node_id(&node_id); Self { pubkey, + pubkey_full: None, + node_id, + address, + } + } + + /// Create a PeerIdentity from a full public key (includes parity). + /// + /// Use this when you have the complete public key (e.g., from a Noise + /// handshake) to preserve parity information for ECDH operations. + pub fn from_pubkey_full(pubkey: PublicKey) -> Self { + let (x_only, _parity) = pubkey.x_only_public_key(); + let node_id = NodeId::from_pubkey(&x_only); + let address = FipsAddress::from_node_id(&node_id); + Self { + pubkey: x_only, + pubkey_full: Some(pubkey), node_id, address, } @@ -227,6 +249,17 @@ impl PeerIdentity { self.pubkey } + /// Return the full public key for ECDH operations. + /// + /// If the full key was provided during construction, it is returned. + /// Otherwise, the key is derived from the x-only key assuming even parity. + pub fn pubkey_full(&self) -> PublicKey { + self.pubkey_full.unwrap_or_else(|| { + // Derive full key assuming even parity + self.pubkey.public_key(Parity::Even) + }) + } + /// Return the public key as a bech32-encoded npub string (NIP-19). pub fn npub(&self) -> String { encode_npub(&self.pubkey) @@ -314,11 +347,23 @@ impl Identity { Ok(Self::from_secret_key(secret_key)) } + /// Return the underlying keypair. + /// + /// This is needed for cryptographic operations like Noise handshakes. + pub fn keypair(&self) -> Keypair { + self.keypair + } + /// Return the x-only public key. pub fn pubkey(&self) -> XOnlyPublicKey { self.keypair.x_only_public_key().0 } + /// Return the full public key (includes parity). + pub fn pubkey_full(&self) -> PublicKey { + self.keypair.public_key() + } + /// Return the public key as a bech32-encoded npub string (NIP-19). pub fn npub(&self) -> String { encode_npub(&self.pubkey()) diff --git a/src/lib.rs b/src/lib.rs index 1b7c1e5..5f2f593 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod cache; pub mod config; pub mod icmp; pub mod identity; +pub mod noise; pub mod node; pub mod peer; pub mod protocol; @@ -62,3 +63,6 @@ pub use tun::{log_ipv6_packet, shutdown_tun_interface, TunDevice, TunError, TunS // Re-export ICMPv6 types pub use icmp::{build_dest_unreachable, should_send_icmp_error, DestUnreachableCode, Icmpv6Type}; + +// Re-export Noise types (HandshakeState not re-exported to avoid conflict with peer::HandshakeState) +pub use noise::{CipherState, HandshakeRole, NoiseError, NoiseSession}; diff --git a/src/node.rs b/src/node.rs index 711df84..3d480d3 100644 --- a/src/node.rs +++ b/src/node.rs @@ -376,9 +376,8 @@ impl Node { /// Initiate connections to configured static peers. /// /// For each peer configured with AutoConnect policy, creates a link and - /// peer entry. The peer starts in Connecting state; authentication - /// handshake will be handled by the event loop. - fn initiate_peer_connections(&mut self) { + /// peer entry, then starts the Noise handshake by sending the first message. + async fn initiate_peer_connections(&mut self) { // Collect peer configs to avoid borrow conflicts let peer_configs: Vec<_> = self.config.auto_connect_peers().cloned().collect(); @@ -390,7 +389,7 @@ impl Node { info!(count = peer_configs.len(), "Initiating static peer connections"); for peer_config in peer_configs { - if let Err(e) = self.initiate_peer_connection(&peer_config) { + if let Err(e) = self.initiate_peer_connection(&peer_config).await { warn!( npub = %peer_config.npub, alias = ?peer_config.alias, @@ -402,7 +401,9 @@ impl Node { } /// Initiate a connection to a single peer. - fn initiate_peer_connection(&mut self, peer_config: &PeerConfig) -> Result<(), NodeError> { + /// + /// Creates a link, starts the Noise handshake, and sends the first message. + async fn initiate_peer_connection(&mut self, peer_config: &PeerConfig) -> Result<(), NodeError> { // Parse the peer's npub to get their identity let peer_identity = PeerIdentity::from_npub(&peer_config.npub).map_err(|e| { NodeError::InvalidPeerNpub { @@ -469,14 +470,31 @@ impl Node { // Add reverse lookup for packet dispatch self.addr_to_link - .insert((transport_id, remote_addr), link_id); + .insert((transport_id, remote_addr.clone()), link_id); // Create connection in handshake phase (outbound knows expected identity) let current_time_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); - let connection = PeerConnection::outbound(link_id, peer_identity.clone(), current_time_ms); + 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, + Err(e) => { + warn!( + npub = %peer_config.npub, + error = %e, + "Failed to start handshake" + ); + // Clean up the link we just created + self.links.remove(&link_id); + self.addr_to_link.remove(&(transport_id, remote_addr)); + continue; + } + }; let alias_display = peer_config .alias @@ -493,6 +511,31 @@ impl Node { self.connections.insert(link_id, connection); + // Send the handshake message + if let Some(transport) = self.transports.get(&transport_id) { + match transport.send(&remote_addr, &handshake_msg).await { + Ok(bytes) => { + debug!( + link_id = %link_id, + bytes, + "Sent Noise handshake message 1" + ); + } + Err(e) => { + warn!( + link_id = %link_id, + error = %e, + "Failed to send handshake message" + ); + // Mark connection as failed but don't remove it yet + // The event loop can handle retry logic + if let Some(conn) = self.connections.get_mut(&link_id) { + conn.mark_failed(); + } + } + } + } + // Successfully initiated connection via this address return Ok(()); } @@ -989,7 +1032,7 @@ impl Node { } // Connect to static peers (step 5 per architecture doc) - self.initiate_peer_connections(); + self.initiate_peer_connections().await; self.state = NodeState::Running; info!( diff --git a/src/noise.rs b/src/noise.rs new file mode 100644 index 0000000..0a5ded4 --- /dev/null +++ b/src/noise.rs @@ -0,0 +1,991 @@ +//! Noise IK Protocol for Peer Authentication +//! +//! Implements the Noise Protocol Framework IK pattern using secp256k1 +//! for link-local peer authentication. This establishes encrypted +//! channels between direct peers over a transport. +//! +//! The IK pattern assumes the initiator knows the responder's static +//! public key before the handshake. The responder learns the initiator's +//! identity from the encrypted payload in message 1. +//! +//! ## Handshake Pattern +//! +//! Pre-message (key known before handshake): +//! ```text +//! <- s (responder's static known to initiator) +//! ``` +//! +//! Messages: +//! ```text +//! -> e, es, s, ss (initiator sends ephemeral + encrypted static) +//! <- e, ee, se (responder sends ephemeral) +//! ``` +//! +//! After handshake, both parties derive symmetric keys for bidirectional +//! encrypted communication over the peer link. +//! +//! ## Separation of Concerns +//! +//! This module handles **peer authentication** only - securing the direct +//! link between neighboring nodes. End-to-end FIPS session encryption +//! between arbitrary network addresses is a separate concern handled by +//! the session layer. + +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + ChaCha20Poly1305, Nonce, +}; +use hkdf::Hkdf; +use rand::RngCore; +use secp256k1::{ecdh::SharedSecret, Keypair, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey}; +use sha2::{Digest, Sha256}; +use std::fmt; +use thiserror::Error; + +/// Protocol name for Noise IK with secp256k1. +/// Format: Noise_IK_secp256k1_ChaChaPoly_SHA256 +const PROTOCOL_NAME: &[u8] = b"Noise_IK_secp256k1_ChaChaPoly_SHA256"; + +/// Maximum message size for noise transport messages. +pub const MAX_MESSAGE_SIZE: usize = 65535; + +/// Size of the AEAD tag. +pub const TAG_SIZE: usize = 16; + +/// Size of a public key (compressed secp256k1). +pub const PUBKEY_SIZE: usize = 33; + +/// Size of handshake message 1: ephemeral (33) + encrypted static (33 + 16 tag). +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; + +/// Errors from Noise protocol operations. +#[derive(Debug, Error)] +pub enum NoiseError { + #[error("handshake not complete")] + HandshakeNotComplete, + + #[error("handshake already complete")] + HandshakeAlreadyComplete, + + #[error("wrong handshake state: expected {expected}, got {got}")] + WrongState { expected: String, got: String }, + + #[error("invalid public key")] + InvalidPublicKey, + + #[error("decryption failed")] + DecryptionFailed, + + #[error("encryption failed")] + EncryptionFailed, + + #[error("message too large: {size} > {max}")] + MessageTooLarge { size: usize, max: usize }, + + #[error("message too short: expected at least {expected}, got {got}")] + MessageTooShort { expected: usize, got: usize }, + + #[error("nonce overflow")] + NonceOverflow, + + #[error("secp256k1 error: {0}")] + Secp256k1(#[from] secp256k1::Error), +} + +/// Role in the handshake. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HandshakeRole { + /// We initiated the connection. + Initiator, + /// They initiated the connection. + Responder, +} + +impl fmt::Display for HandshakeRole { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + HandshakeRole::Initiator => write!(f, "initiator"), + HandshakeRole::Responder => write!(f, "responder"), + } + } +} + +/// Handshake state machine states. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HandshakeProgress { + /// Initial state, ready to send/receive message 1. + Initial, + /// Message 1 sent/received, ready for message 2. + Message1Done, + /// Handshake complete, ready for transport. + Complete, +} + +impl fmt::Display for HandshakeProgress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + HandshakeProgress::Initial => write!(f, "initial"), + HandshakeProgress::Message1Done => write!(f, "message1_done"), + HandshakeProgress::Complete => write!(f, "complete"), + } + } +} + +/// Symmetric cipher state for post-handshake encryption. +#[derive(Clone)] +pub struct CipherState { + /// Encryption key (32 bytes). + key: [u8; 32], + /// Nonce counter (8 bytes used, 4 bytes zero prefix). + nonce: u64, + /// Whether this cipher has a valid key. + has_key: bool, +} + +impl CipherState { + /// Create a new cipher state with the given key. + fn new(key: [u8; 32]) -> Self { + Self { + key, + nonce: 0, + has_key: true, + } + } + + /// Create an empty cipher state (no key yet). + fn empty() -> Self { + Self { + key: [0u8; 32], + nonce: 0, + has_key: false, + } + } + + /// Initialize with a key. + fn initialize_key(&mut self, key: [u8; 32]) { + self.key = key; + self.nonce = 0; + self.has_key = true; + } + + /// Encrypt plaintext, returning ciphertext with appended tag. + pub fn encrypt(&mut self, plaintext: &[u8]) -> Result, NoiseError> { + if !self.has_key { + // No key means no encryption (shouldn't happen in transport phase) + return Ok(plaintext.to_vec()); + } + + if plaintext.len() > MAX_MESSAGE_SIZE - TAG_SIZE { + return Err(NoiseError::MessageTooLarge { + size: plaintext.len(), + max: MAX_MESSAGE_SIZE - TAG_SIZE, + }); + } + + let cipher = ChaCha20Poly1305::new_from_slice(&self.key) + .map_err(|_| NoiseError::EncryptionFailed)?; + + let nonce = self.next_nonce()?; + let ciphertext = cipher + .encrypt(&nonce, plaintext) + .map_err(|_| NoiseError::EncryptionFailed)?; + + Ok(ciphertext) + } + + /// Decrypt ciphertext (with appended tag), returning plaintext. + pub fn decrypt(&mut self, ciphertext: &[u8]) -> Result, NoiseError> { + if !self.has_key { + // No key means no encryption + 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.next_nonce()?; + let plaintext = cipher + .decrypt(&nonce, ciphertext) + .map_err(|_| NoiseError::DecryptionFailed)?; + + Ok(plaintext) + } + + /// Get the next nonce, incrementing the counter. + fn next_nonce(&mut self) -> Result { + if self.nonce == u64::MAX { + return Err(NoiseError::NonceOverflow); + } + + let n = self.nonce; + self.nonce += 1; + + // Noise uses 8-byte counter with 4-byte zero prefix + let mut nonce_bytes = [0u8; 12]; + nonce_bytes[4..12].copy_from_slice(&n.to_le_bytes()); + + Ok(*Nonce::from_slice(&nonce_bytes)) + } + + /// Get the current nonce value (for debugging/testing). + pub fn nonce(&self) -> u64 { + self.nonce + } + + /// Check if cipher has a key. + pub fn has_key(&self) -> bool { + self.has_key + } +} + +impl fmt::Debug for CipherState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CipherState") + .field("nonce", &self.nonce) + .field("has_key", &self.has_key) + .field("key", &"[redacted]") + .finish() + } +} + +/// Symmetric state during handshake. +/// +/// Maintains the chaining key (ck), handshake hash (h), and current cipher. +struct SymmetricState { + /// Chaining key for key derivation. + ck: [u8; 32], + /// Handshake hash for transcript binding. + h: [u8; 32], + /// Current cipher state for encrypting handshake payloads. + cipher: CipherState, +} + +impl SymmetricState { + /// Initialize with protocol name. + fn initialize() -> Self { + // If protocol name <= 32 bytes, pad with zeros + // If > 32 bytes, hash it + let h = if PROTOCOL_NAME.len() <= 32 { + let mut h = [0u8; 32]; + h[..PROTOCOL_NAME.len()].copy_from_slice(PROTOCOL_NAME); + h + } else { + let mut hasher = Sha256::new(); + hasher.update(PROTOCOL_NAME); + hasher.finalize().into() + }; + + Self { + ck: h, + h, + cipher: CipherState::empty(), + } + } + + /// Mix data into the handshake hash. + fn mix_hash(&mut self, data: &[u8]) { + let mut hasher = Sha256::new(); + hasher.update(&self.h); + hasher.update(data); + self.h = hasher.finalize().into(); + } + + /// Mix key material into the chaining key. + fn mix_key(&mut self, input_key_material: &[u8]) { + let hk = Hkdf::::new(Some(&self.ck), input_key_material); + let mut output = [0u8; 64]; + hk.expand(&[], &mut output) + .expect("64 bytes is valid output length"); + + self.ck.copy_from_slice(&output[..32]); + + // Initialize cipher with derived key for handshake encryption + let mut key = [0u8; 32]; + key.copy_from_slice(&output[32..64]); + self.cipher.initialize_key(key); + } + + /// Encrypt and mix into hash. + fn encrypt_and_hash(&mut self, plaintext: &[u8]) -> Result, NoiseError> { + let ciphertext = self.cipher.encrypt(plaintext)?; + self.mix_hash(&ciphertext); + Ok(ciphertext) + } + + /// Decrypt and mix ciphertext into hash. + fn decrypt_and_hash(&mut self, ciphertext: &[u8]) -> Result, NoiseError> { + let plaintext = self.cipher.decrypt(ciphertext)?; + self.mix_hash(ciphertext); + Ok(plaintext) + } + + /// Split into two cipher states for transport. + fn split(&self) -> (CipherState, CipherState) { + let hk = Hkdf::::new(Some(&self.ck), &[]); + let mut output = [0u8; 64]; + hk.expand(&[], &mut output) + .expect("64 bytes is valid output length"); + + let mut k1 = [0u8; 32]; + let mut k2 = [0u8; 32]; + k1.copy_from_slice(&output[..32]); + k2.copy_from_slice(&output[32..64]); + + (CipherState::new(k1), CipherState::new(k2)) + } + + /// Get the handshake hash (for channel binding). + fn handshake_hash(&self) -> [u8; 32] { + self.h + } +} + +/// Handshake state for Noise IK. +pub struct HandshakeState { + /// Our role in the handshake. + role: HandshakeRole, + /// Current progress. + progress: HandshakeProgress, + /// Symmetric state. + symmetric: SymmetricState, + /// Our static keypair. + static_keypair: Keypair, + /// Our ephemeral keypair (generated at handshake start). + ephemeral_keypair: Option, + /// Remote static public key. + /// For initiator: known before handshake (from config). + /// For responder: learned from message 1. + remote_static: Option, + /// Remote ephemeral public key (learned during handshake). + remote_ephemeral: Option, + /// Secp256k1 context. + secp: Secp256k1, +} + +impl HandshakeState { + /// Create a new handshake as initiator. + /// + /// The initiator knows the responder's static key and will send first. + pub fn new_initiator(static_keypair: Keypair, remote_static: PublicKey) -> Self { + let secp = Secp256k1::new(); + let mut state = Self { + role: HandshakeRole::Initiator, + progress: HandshakeProgress::Initial, + symmetric: SymmetricState::initialize(), + static_keypair, + ephemeral_keypair: None, + remote_static: Some(remote_static), + remote_ephemeral: None, + secp, + }; + + // Mix in pre-message: <- s (responder's static is known) + let remote_static_bytes = remote_static.serialize(); + state.symmetric.mix_hash(&remote_static_bytes); + + state + } + + /// Create a new handshake as responder. + /// + /// The responder does NOT know the initiator's static key - it will be + /// learned from message 1. + pub fn new_responder(static_keypair: Keypair) -> Self { + let secp = Secp256k1::new(); + let mut state = Self { + role: HandshakeRole::Responder, + progress: HandshakeProgress::Initial, + symmetric: SymmetricState::initialize(), + static_keypair, + ephemeral_keypair: None, + remote_static: None, // Will learn from message 1 + remote_ephemeral: None, + secp, + }; + + // Mix in pre-message: <- s (our static, since we're responder) + let our_static_pubkey = state.static_keypair.public_key().serialize(); + state.symmetric.mix_hash(&our_static_pubkey); + + state + } + + /// Get our role. + pub fn role(&self) -> HandshakeRole { + self.role + } + + /// Get current progress. + pub fn progress(&self) -> HandshakeProgress { + self.progress + } + + /// Check if handshake is complete. + pub fn is_complete(&self) -> bool { + self.progress == HandshakeProgress::Complete + } + + /// Get the remote static key (available after message 1 for responder). + pub fn remote_static(&self) -> Option<&PublicKey> { + self.remote_static.as_ref() + } + + /// Generate ephemeral keypair. + fn generate_ephemeral(&mut self) { + let mut rng = rand::thread_rng(); + let mut secret_bytes = [0u8; 32]; + rng.fill_bytes(&mut secret_bytes); + + let secret_key = + SecretKey::from_slice(&secret_bytes).expect("32 random bytes is valid secret key"); + self.ephemeral_keypair = Some(Keypair::from_secret_key(&self.secp, &secret_key)); + } + + /// Perform ECDH between our secret and their public key. + fn ecdh(&self, our_secret: &SecretKey, their_public: &PublicKey) -> [u8; 32] { + let shared = SharedSecret::new(their_public, our_secret); + let mut result = [0u8; 32]; + result.copy_from_slice(shared.as_ref()); + result + } + + /// Write message 1 (initiator only). + /// + /// Message 1 contains: + /// - e: ephemeral public key (33 bytes) + /// - encrypted s: our static public key encrypted (33 + 16 = 49 bytes) + /// + /// Total: 82 bytes + pub fn write_message_1(&mut self) -> Result, NoiseError> { + if self.role != HandshakeRole::Initiator { + return Err(NoiseError::WrongState { + expected: "initiator".to_string(), + got: "responder".to_string(), + }); + } + if self.progress != HandshakeProgress::Initial { + return Err(NoiseError::WrongState { + expected: HandshakeProgress::Initial.to_string(), + got: self.progress.to_string(), + }); + } + + let remote_static = self.remote_static.expect("initiator must have remote static"); + + // Generate ephemeral keypair + self.generate_ephemeral(); + let ephemeral = self.ephemeral_keypair.as_ref().unwrap(); + let e_pub = ephemeral.public_key().serialize(); + + let mut message = Vec::with_capacity(HANDSHAKE_MSG1_SIZE); + + // -> e: send ephemeral, mix into hash + message.extend_from_slice(&e_pub); + self.symmetric.mix_hash(&e_pub); + + // -> es: DH(e, rs), mix into key + let es = self.ecdh(&ephemeral.secret_key(), &remote_static); + self.symmetric.mix_key(&es); + + // -> s: encrypt our static and send + let our_static = self.static_keypair.public_key().serialize(); + let encrypted_static = self.symmetric.encrypt_and_hash(&our_static)?; + message.extend_from_slice(&encrypted_static); + + // -> ss: DH(s, rs), mix into key + let ss = self.ecdh(&self.static_keypair.secret_key(), &remote_static); + self.symmetric.mix_key(&ss); + + self.progress = HandshakeProgress::Message1Done; + + Ok(message) + } + + /// Read message 1 (responder only). + /// + /// Processes the initiator's first message and learns their identity. + pub fn read_message_1(&mut self, message: &[u8]) -> Result<(), NoiseError> { + if self.role != HandshakeRole::Responder { + return Err(NoiseError::WrongState { + expected: "responder".to_string(), + got: "initiator".to_string(), + }); + } + if self.progress != HandshakeProgress::Initial { + return Err(NoiseError::WrongState { + expected: HandshakeProgress::Initial.to_string(), + got: self.progress.to_string(), + }); + } + if message.len() != HANDSHAKE_MSG1_SIZE { + return Err(NoiseError::MessageTooShort { + expected: HANDSHAKE_MSG1_SIZE, + got: message.len(), + }); + } + + // -> e: parse remote ephemeral, mix into hash + let re = PublicKey::from_slice(&message[..PUBKEY_SIZE]) + .map_err(|_| NoiseError::InvalidPublicKey)?; + self.remote_ephemeral = Some(re); + self.symmetric.mix_hash(&message[..PUBKEY_SIZE]); + + // -> es: DH(s, re), mix into key + // (responder uses their static with initiator's ephemeral) + let es = self.ecdh(&self.static_keypair.secret_key(), &re); + self.symmetric.mix_key(&es); + + // -> s: decrypt initiator's static + let encrypted_static = &message[PUBKEY_SIZE..]; + let decrypted_static = self.symmetric.decrypt_and_hash(encrypted_static)?; + let rs = + PublicKey::from_slice(&decrypted_static).map_err(|_| NoiseError::InvalidPublicKey)?; + self.remote_static = Some(rs); + + // -> ss: DH(s, rs), mix into key + let ss = self.ecdh(&self.static_keypair.secret_key(), &rs); + self.symmetric.mix_key(&ss); + + self.progress = HandshakeProgress::Message1Done; + + Ok(()) + } + + /// Write message 2 (responder only). + /// + /// Message 2 contains: + /// - e: ephemeral public key (33 bytes) + /// + /// Total: 33 bytes + pub fn write_message_2(&mut self) -> Result, NoiseError> { + if self.role != HandshakeRole::Responder { + return Err(NoiseError::WrongState { + expected: "responder".to_string(), + got: "initiator".to_string(), + }); + } + if self.progress != HandshakeProgress::Message1Done { + return Err(NoiseError::WrongState { + expected: HandshakeProgress::Message1Done.to_string(), + got: self.progress.to_string(), + }); + } + + let re = self.remote_ephemeral.expect("should have remote ephemeral"); + + // Generate ephemeral keypair + self.generate_ephemeral(); + let ephemeral = self.ephemeral_keypair.as_ref().unwrap(); + let e_pub = ephemeral.public_key().serialize(); + + // <- e: send ephemeral, mix into hash + self.symmetric.mix_hash(&e_pub); + + // <- ee: DH(e, re), mix into key + let ee = self.ecdh(&ephemeral.secret_key(), &re); + self.symmetric.mix_key(&ee); + + // <- se: DH(s, re), mix into key + let se = self.ecdh(&self.static_keypair.secret_key(), &re); + self.symmetric.mix_key(&se); + + self.progress = HandshakeProgress::Complete; + + Ok(e_pub.to_vec()) + } + + /// Read message 2 (initiator only). + /// + /// Processes the responder's message and completes the handshake. + pub fn read_message_2(&mut self, message: &[u8]) -> Result<(), NoiseError> { + if self.role != HandshakeRole::Initiator { + return Err(NoiseError::WrongState { + expected: "initiator".to_string(), + got: "responder".to_string(), + }); + } + if self.progress != HandshakeProgress::Message1Done { + return Err(NoiseError::WrongState { + expected: HandshakeProgress::Message1Done.to_string(), + got: self.progress.to_string(), + }); + } + if message.len() != HANDSHAKE_MSG2_SIZE { + return Err(NoiseError::MessageTooShort { + expected: HANDSHAKE_MSG2_SIZE, + got: message.len(), + }); + } + + // <- e: parse remote ephemeral, mix into hash + let re = PublicKey::from_slice(message).map_err(|_| NoiseError::InvalidPublicKey)?; + self.remote_ephemeral = Some(re); + self.symmetric.mix_hash(message); + + // <- ee: DH(e, re), mix into key + let ephemeral = self.ephemeral_keypair.as_ref().unwrap(); + let ee = self.ecdh(&ephemeral.secret_key(), &re); + self.symmetric.mix_key(&ee); + + // <- se: DH(e, rs), mix into key + // (initiator uses their ephemeral with responder's static) + let rs = self.remote_static.expect("initiator has remote static"); + let se = self.ecdh(&ephemeral.secret_key(), &rs); + self.symmetric.mix_key(&se); + + self.progress = HandshakeProgress::Complete; + + Ok(()) + } + + /// Complete the handshake and return a NoiseSession. + /// + /// Must be called after the handshake is complete. + pub fn into_session(self) -> Result { + if !self.is_complete() { + return Err(NoiseError::HandshakeNotComplete); + } + + let (c1, c2) = self.symmetric.split(); + let handshake_hash = self.symmetric.handshake_hash(); + let remote_static = self + .remote_static + .expect("remote static must be known after handshake"); + + // Initiator sends with c1, receives with c2 + // Responder sends with c2, receives with c1 + let (send_cipher, recv_cipher) = match self.role { + HandshakeRole::Initiator => (c1, c2), + HandshakeRole::Responder => (c2, c1), + }; + + Ok(NoiseSession { + role: self.role, + send_cipher, + recv_cipher, + handshake_hash, + remote_static, + }) + } + + /// Get the handshake hash (for channel binding, available after complete). + pub fn handshake_hash(&self) -> [u8; 32] { + self.symmetric.handshake_hash() + } +} + +impl fmt::Debug for HandshakeState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HandshakeState") + .field("role", &self.role) + .field("progress", &self.progress) + .field("has_ephemeral", &self.ephemeral_keypair.is_some()) + .field("has_remote_static", &self.remote_static.is_some()) + .field("has_remote_ephemeral", &self.remote_ephemeral.is_some()) + .finish() + } +} + +/// Completed Noise session for transport encryption. +pub struct NoiseSession { + /// Our role in the original handshake. + role: HandshakeRole, + /// Cipher for sending. + send_cipher: CipherState, + /// Cipher for receiving. + recv_cipher: CipherState, + /// Handshake hash for channel binding. + handshake_hash: [u8; 32], + /// Remote peer's static public key. + remote_static: PublicKey, +} + +impl NoiseSession { + /// Encrypt a message for sending. + pub fn encrypt(&mut self, plaintext: &[u8]) -> Result, NoiseError> { + self.send_cipher.encrypt(plaintext) + } + + /// Decrypt a received message. + pub fn decrypt(&mut self, ciphertext: &[u8]) -> Result, NoiseError> { + self.recv_cipher.decrypt(ciphertext) + } + + /// Get the handshake hash for channel binding. + pub fn handshake_hash(&self) -> &[u8; 32] { + &self.handshake_hash + } + + /// Get the remote peer's static public key. + pub fn remote_static(&self) -> &PublicKey { + &self.remote_static + } + + /// Get the remote peer's x-only public key. + pub fn remote_static_xonly(&self) -> XOnlyPublicKey { + self.remote_static.x_only_public_key().0 + } + + /// Get our role in the handshake. + pub fn role(&self) -> HandshakeRole { + self.role + } + + /// Get the send nonce (for debugging). + pub fn send_nonce(&self) -> u64 { + self.send_cipher.nonce() + } + + /// Get the receive nonce (for debugging). + pub fn recv_nonce(&self) -> u64 { + self.recv_cipher.nonce() + } +} + +impl fmt::Debug for NoiseSession { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("NoiseSession") + .field("role", &self.role) + .field("send_nonce", &self.send_cipher.nonce()) + .field("recv_nonce", &self.recv_cipher.nonce()) + .field("handshake_hash", &hex::encode(&self.handshake_hash[..8])) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn generate_keypair() -> Keypair { + let secp = Secp256k1::new(); + let mut rng = rand::thread_rng(); + let (secret_key, _) = secp.generate_keypair(&mut rng); + Keypair::from_secret_key(&secp, &secret_key) + } + + #[test] + fn test_full_handshake() { + let initiator_keypair = generate_keypair(); + let responder_keypair = generate_keypair(); + + let responder_pub = responder_keypair.public_key(); + + // Initiator knows responder's static key + // Responder does NOT know initiator's static key (IK pattern) + let mut initiator = HandshakeState::new_initiator(initiator_keypair.clone(), responder_pub); + let mut responder = HandshakeState::new_responder(responder_keypair); + + assert_eq!(initiator.role(), HandshakeRole::Initiator); + assert_eq!(responder.role(), HandshakeRole::Responder); + + // Initially, responder doesn't know initiator's identity + assert!(responder.remote_static().is_none()); + + // Message 1: Initiator -> Responder + let msg1 = initiator.write_message_1().unwrap(); + assert_eq!(msg1.len(), HANDSHAKE_MSG1_SIZE); + + responder.read_message_1(&msg1).unwrap(); + + // Now responder knows initiator's identity! + assert!(responder.remote_static().is_some()); + assert_eq!( + responder.remote_static().unwrap(), + &initiator_keypair.public_key() + ); + + // Message 2: Responder -> Initiator + let msg2 = responder.write_message_2().unwrap(); + assert_eq!(msg2.len(), HANDSHAKE_MSG2_SIZE); + + initiator.read_message_2(&msg2).unwrap(); + + // Both should be complete + assert!(initiator.is_complete()); + assert!(responder.is_complete()); + + // Handshake hashes should match + assert_eq!(initiator.handshake_hash(), responder.handshake_hash()); + + // Convert to sessions + let mut initiator_session = initiator.into_session().unwrap(); + let mut responder_session = responder.into_session().unwrap(); + + // Test encryption/decryption + let plaintext = b"Hello, secure world!"; + + let ciphertext = initiator_session.encrypt(plaintext).unwrap(); + let decrypted = responder_session.decrypt(&ciphertext).unwrap(); + assert_eq!(decrypted, plaintext); + + // Test reverse direction + let plaintext2 = b"Hello back!"; + let ciphertext2 = responder_session.encrypt(plaintext2).unwrap(); + let decrypted2 = initiator_session.decrypt(&ciphertext2).unwrap(); + assert_eq!(decrypted2, plaintext2); + } + + #[test] + fn test_multiple_messages() { + let initiator_keypair = generate_keypair(); + let responder_keypair = generate_keypair(); + + let mut initiator = + HandshakeState::new_initiator(initiator_keypair, responder_keypair.public_key()); + let mut responder = HandshakeState::new_responder(responder_keypair); + + let msg1 = initiator.write_message_1().unwrap(); + responder.read_message_1(&msg1).unwrap(); + let msg2 = responder.write_message_2().unwrap(); + initiator.read_message_2(&msg2).unwrap(); + + let mut initiator_session = initiator.into_session().unwrap(); + let mut responder_session = responder.into_session().unwrap(); + + // Send many messages to test nonce increment + for i in 0..100 { + let msg = format!("Message {}", i); + let ct = initiator_session.encrypt(msg.as_bytes()).unwrap(); + let pt = responder_session.decrypt(&ct).unwrap(); + assert_eq!(pt, msg.as_bytes()); + } + + assert_eq!(initiator_session.send_nonce(), 100); + assert_eq!(responder_session.recv_nonce(), 100); + } + + #[test] + fn test_wrong_role_errors() { + let keypair1 = generate_keypair(); + let keypair2 = generate_keypair(); + + let mut initiator = HandshakeState::new_initiator(keypair1, keypair2.public_key()); + + // Initiator can't read message 1 + assert!(initiator + .read_message_1(&[0u8; HANDSHAKE_MSG1_SIZE]) + .is_err()); + + // Initiator can't write message 2 before message 1 + assert!(initiator.write_message_2().is_err()); + } + + #[test] + fn test_invalid_pubkey_in_msg1() { + let keypair = generate_keypair(); + let mut responder = HandshakeState::new_responder(keypair); + + // Invalid pubkey bytes (first 33 bytes are zero) + let invalid_msg = [0u8; HANDSHAKE_MSG1_SIZE]; + assert!(responder.read_message_1(&invalid_msg).is_err()); + } + + #[test] + fn test_decryption_failure_wrong_key() { + let keypair1 = generate_keypair(); + let keypair2 = generate_keypair(); + let keypair3 = generate_keypair(); + + // Session between 1 and 2 + let mut init1 = HandshakeState::new_initiator(keypair1.clone(), keypair2.public_key()); + let mut resp1 = HandshakeState::new_responder(keypair2.clone()); + + let msg1 = init1.write_message_1().unwrap(); + resp1.read_message_1(&msg1).unwrap(); + let msg2 = resp1.write_message_2().unwrap(); + init1.read_message_2(&msg2).unwrap(); + + let mut session1 = init1.into_session().unwrap(); + + // Session between 1 and 3 + let mut init2 = HandshakeState::new_initiator(keypair1.clone(), keypair3.public_key()); + let mut resp2 = HandshakeState::new_responder(keypair3); + + let msg1 = init2.write_message_1().unwrap(); + resp2.read_message_1(&msg1).unwrap(); + let msg2 = resp2.write_message_2().unwrap(); + init2.read_message_2(&msg2).unwrap(); + + let mut session2 = resp2.into_session().unwrap(); + + // Encrypt with session 1, try to decrypt with session 2 + let ciphertext = session1.encrypt(b"test").unwrap(); + assert!(session2.decrypt(&ciphertext).is_err()); + } + + #[test] + fn test_cipher_state_nonce_sequence() { + let key = [0u8; 32]; + let mut cipher = CipherState::new(key); + + assert_eq!(cipher.nonce(), 0); + + let _ = cipher.encrypt(b"test").unwrap(); + assert_eq!(cipher.nonce(), 1); + + let _ = cipher.encrypt(b"test").unwrap(); + assert_eq!(cipher.nonce(), 2); + } + + #[test] + fn test_session_remote_static() { + let keypair1 = generate_keypair(); + let keypair2 = generate_keypair(); + + let mut init = HandshakeState::new_initiator(keypair1.clone(), keypair2.public_key()); + let mut resp = HandshakeState::new_responder(keypair2.clone()); + + 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 session1 = init.into_session().unwrap(); + let session2 = resp.into_session().unwrap(); + + // Each session should know the other's static key + assert_eq!(session1.remote_static(), &keypair2.public_key()); + assert_eq!(session2.remote_static(), &keypair1.public_key()); + } + + #[test] + fn test_message_sizes() { + // Verify our size constants are correct + assert_eq!(HANDSHAKE_MSG1_SIZE, 33 + 33 + 16); // e + encrypted_s + assert_eq!(HANDSHAKE_MSG2_SIZE, 33); // e only + } + + #[test] + fn test_responder_identity_discovery() { + // This test verifies the key IK property: responder learns initiator's identity + let initiator_keypair = generate_keypair(); + let responder_keypair = generate_keypair(); + + let mut responder = HandshakeState::new_responder(responder_keypair); + + // Before message 1: responder has no idea who's connecting + assert!(responder.remote_static().is_none()); + + let mut initiator = + HandshakeState::new_initiator(initiator_keypair.clone(), responder.static_keypair.public_key()); + let msg1 = initiator.write_message_1().unwrap(); + + // After processing message 1: responder knows initiator's identity + responder.read_message_1(&msg1).unwrap(); + let discovered_initiator = responder.remote_static().unwrap(); + assert_eq!(discovered_initiator, &initiator_keypair.public_key()); + + // The discovered key can be used to look up peer config, verify against allow-list, etc. + } +} diff --git a/src/peer/connection.rs b/src/peer/connection.rs index a69cbd3..3e9ce03 100644 --- a/src/peer/connection.rs +++ b/src/peer/connection.rs @@ -1,28 +1,28 @@ //! Peer Connection (Handshake Phase) //! //! Represents an in-progress connection before authentication completes. -//! PeerConnection tracks the Noise handshake state and transitions to +//! PeerConnection tracks the Noise IK handshake state and transitions to //! ActivePeer upon successful authentication. +use crate::noise::{self, NoiseError, NoiseSession}; use crate::transport::{LinkDirection, LinkId, LinkStats}; use crate::PeerIdentity; +use secp256k1::Keypair; use std::fmt; /// Handshake protocol state machine. /// -/// For Noise KK pattern: -/// - Initiator: SentHello → AwaitingAuth → Complete -/// - Responder: AwaitingHello → SentAuth → Complete +/// For Noise IK pattern: +/// - Initiator: Initial → SentMsg1 → Complete +/// - Responder: Initial → ReceivedMsg1 → Complete #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum HandshakeState { - /// Waiting for initial Hello from remote (responder role). - AwaitingHello, - /// Sent Hello, waiting for Auth response (initiator role). - SentHello, - /// Received Hello, sent Auth, waiting for AuthAck (responder role). - SentAuth, - /// Sent Auth, waiting for AuthAck (initiator role). - AwaitingAuthAck, + /// Initial state, ready to start handshake. + Initial, + /// Initiator: Sent message 1, awaiting message 2. + SentMsg1, + /// Responder: Received message 1, ready to send message 2. + ReceivedMsg1, /// Handshake completed successfully. Complete, /// Handshake failed. @@ -34,10 +34,7 @@ impl HandshakeState { pub fn is_in_progress(&self) -> bool { matches!( self, - HandshakeState::AwaitingHello - | HandshakeState::SentHello - | HandshakeState::SentAuth - | HandshakeState::AwaitingAuthAck + HandshakeState::Initial | HandshakeState::SentMsg1 | HandshakeState::ReceivedMsg1 ) } @@ -50,31 +47,14 @@ impl HandshakeState { pub fn is_failed(&self) -> bool { matches!(self, HandshakeState::Failed) } - - /// Check if we are the initiator (sent first message). - pub fn is_initiator(&self) -> bool { - matches!( - self, - HandshakeState::SentHello | HandshakeState::AwaitingAuthAck - ) - } - - /// Check if we are the responder (received first message). - pub fn is_responder(&self) -> bool { - matches!( - self, - HandshakeState::AwaitingHello | HandshakeState::SentAuth - ) - } } impl fmt::Display for HandshakeState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let s = match self { - HandshakeState::AwaitingHello => "awaiting_hello", - HandshakeState::SentHello => "sent_hello", - HandshakeState::SentAuth => "sent_auth", - HandshakeState::AwaitingAuthAck => "awaiting_auth_ack", + HandshakeState::Initial => "initial", + HandshakeState::SentMsg1 => "sent_msg1", + HandshakeState::ReceivedMsg1 => "received_msg1", HandshakeState::Complete => "complete", HandshakeState::Failed => "failed", }; @@ -85,8 +65,7 @@ impl fmt::Display for HandshakeState { /// A connection in the handshake phase, before authentication completes. /// /// For outbound connections, we know the expected peer identity from config. -/// For inbound connections, we learn the identity during the handshake. -#[derive(Clone, Debug)] +/// For inbound connections, we learn the identity during the Noise handshake. pub struct PeerConnection { // === Link Reference === /// The link carrying this connection. @@ -100,12 +79,14 @@ pub struct PeerConnection { handshake_state: HandshakeState, /// Expected peer identity (known for outbound, learned for inbound). - /// None until we receive their public key in the handshake. + /// Updated after receiving their static key in the handshake. expected_identity: Option, - // === Noise Session State === - // TODO: Add actual Noise protocol state when implementing crypto - // noise_state: Option, + /// Noise handshake state (consumes on completion). + noise_handshake: Option, + + /// Completed Noise session (available after handshake complete). + noise_session: Option, // === Timing === /// When the connection attempt started (Unix milliseconds). @@ -126,6 +107,7 @@ impl PeerConnection { /// Create a new outbound connection (we are initiating). /// /// For outbound, we know who we're trying to reach from configuration. + /// The Noise handshake will be initialized when `start_handshake` is called. pub fn outbound( link_id: LinkId, expected_identity: PeerIdentity, @@ -134,8 +116,10 @@ impl PeerConnection { Self { link_id, direction: LinkDirection::Outbound, - handshake_state: HandshakeState::SentHello, + handshake_state: HandshakeState::Initial, expected_identity: Some(expected_identity), + noise_handshake: None, + noise_session: None, started_at: current_time_ms, last_activity: current_time_ms, retry_count: 0, @@ -145,13 +129,16 @@ impl PeerConnection { /// Create a new inbound connection (they are initiating). /// - /// For inbound, we don't know who they are until they identify in handshake. + /// For inbound, we don't know who they are until we decrypt their + /// identity from Noise message 1. pub fn inbound(link_id: LinkId, current_time_ms: u64) -> Self { Self { link_id, direction: LinkDirection::Inbound, - handshake_state: HandshakeState::AwaitingHello, + 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, @@ -241,41 +228,146 @@ impl PeerConnection { &mut self.link_stats } - // === State Transitions === + // === Noise Handshake Operations === - /// Record that we sent a Hello message (initiator). - pub fn mark_hello_sent(&mut self, current_time_ms: u64) { - self.handshake_state = HandshakeState::SentHello; + /// Start the handshake as initiator and generate message 1. + /// + /// For outbound connections only. Returns the handshake message to send. + pub fn start_handshake( + &mut self, + our_keypair: Keypair, + current_time_ms: u64, + ) -> Result, NoiseError> { + if self.direction != LinkDirection::Outbound { + return Err(NoiseError::WrongState { + expected: "outbound connection".to_string(), + got: "inbound connection".to_string(), + }); + } + + if self.handshake_state != HandshakeState::Initial { + return Err(NoiseError::WrongState { + expected: "initial state".to_string(), + got: self.handshake_state.to_string(), + }); + } + + let remote_static = self + .expected_identity + .as_ref() + .expect("outbound must have expected identity") + .pubkey_full(); + + let mut hs = noise::HandshakeState::new_initiator(our_keypair, remote_static); + let msg1 = hs.write_message_1()?; + + self.noise_handshake = Some(hs); + self.handshake_state = HandshakeState::SentMsg1; self.last_activity = current_time_ms; + + Ok(msg1) } - /// Record that we received a Hello and learned peer identity. - pub fn mark_hello_received(&mut self, identity: PeerIdentity, current_time_ms: u64) { - self.expected_identity = Some(identity); - self.last_activity = current_time_ms; - } + /// Initialize responder and process incoming message 1. + /// + /// For inbound connections only. Returns the handshake message 2 to send. + pub fn receive_handshake_init( + &mut self, + our_keypair: Keypair, + message: &[u8], + current_time_ms: u64, + ) -> Result, NoiseError> { + if self.direction != LinkDirection::Inbound { + return Err(NoiseError::WrongState { + expected: "inbound connection".to_string(), + got: "outbound connection".to_string(), + }); + } - /// Record that we sent Auth response (responder). - pub fn mark_auth_sent(&mut self, current_time_ms: u64) { - self.handshake_state = HandshakeState::SentAuth; - self.last_activity = current_time_ms; - } + if self.handshake_state != HandshakeState::Initial { + return Err(NoiseError::WrongState { + expected: "initial state".to_string(), + got: self.handshake_state.to_string(), + }); + } - /// Record that we're awaiting AuthAck (initiator). - pub fn mark_awaiting_auth_ack(&mut self, current_time_ms: u64) { - self.handshake_state = HandshakeState::AwaitingAuthAck; - self.last_activity = current_time_ms; - } + let mut hs = noise::HandshakeState::new_responder(our_keypair); - /// Mark handshake as complete. - pub fn mark_complete(&mut self, current_time_ms: u64) { + // Process message 1 (this reveals the initiator's identity) + hs.read_message_1(message)?; + + // Extract the discovered identity + let remote_static = hs + .remote_static() + .expect("remote static available after msg1") + .clone(); + self.expected_identity = Some(PeerIdentity::from_pubkey_full(remote_static)); + + // Generate message 2 + let msg2 = hs.write_message_2()?; + + // Handshake is complete for responder + let session = hs.into_session()?; + self.noise_session = Some(session); self.handshake_state = HandshakeState::Complete; self.last_activity = current_time_ms; + + Ok(msg2) } + /// Complete the handshake by processing message 2. + /// + /// For outbound connections only (initiator completing handshake). + pub fn complete_handshake( + &mut self, + message: &[u8], + current_time_ms: u64, + ) -> Result<(), NoiseError> { + if self.handshake_state != HandshakeState::SentMsg1 { + return Err(NoiseError::WrongState { + expected: "sent_msg1 state".to_string(), + got: self.handshake_state.to_string(), + }); + } + + let mut hs = self + .noise_handshake + .take() + .expect("noise handshake must exist in SentMsg1 state"); + + hs.read_message_2(message)?; + + let session = hs.into_session()?; + self.noise_session = Some(session); + self.handshake_state = HandshakeState::Complete; + self.last_activity = current_time_ms; + + Ok(()) + } + + /// Take the completed Noise session. + /// + /// Returns the NoiseSession for use in ActivePeer. Can only be called + /// once after handshake completes. + pub fn take_session(&mut self) -> Option { + if self.handshake_state == HandshakeState::Complete { + self.noise_session.take() + } else { + None + } + } + + /// Check if we have a completed session ready to take. + pub fn has_session(&self) -> bool { + self.handshake_state == HandshakeState::Complete && self.noise_session.is_some() + } + + // === State Transitions (for manual control if needed) === + /// Mark handshake as failed. pub fn mark_failed(&mut self) { self.handshake_state = HandshakeState::Failed; + self.noise_handshake = None; } /// Increment retry counter. @@ -301,6 +393,22 @@ impl PeerConnection { } } +impl fmt::Debug for PeerConnection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PeerConnection") + .field("link_id", &self.link_id) + .field("direction", &self.direction) + .field("handshake_state", &self.handshake_state) + .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("started_at", &self.started_at) + .field("last_activity", &self.last_activity) + .field("retry_count", &self.retry_count) + .finish() + } +} + #[cfg(test)] mod tests { use super::*; @@ -311,22 +419,21 @@ mod tests { PeerIdentity::from_pubkey(identity.pubkey()) } + fn make_keypair() -> Keypair { + let identity = Identity::generate(); + identity.keypair() + } + #[test] fn test_handshake_state_properties() { - assert!(HandshakeState::AwaitingHello.is_in_progress()); - assert!(HandshakeState::SentHello.is_in_progress()); - assert!(HandshakeState::SentAuth.is_in_progress()); - assert!(HandshakeState::AwaitingAuthAck.is_in_progress()); + assert!(HandshakeState::Initial.is_in_progress()); + assert!(HandshakeState::SentMsg1.is_in_progress()); + assert!(HandshakeState::ReceivedMsg1.is_in_progress()); assert!(!HandshakeState::Complete.is_in_progress()); assert!(!HandshakeState::Failed.is_in_progress()); assert!(HandshakeState::Complete.is_complete()); assert!(HandshakeState::Failed.is_failed()); - - assert!(HandshakeState::SentHello.is_initiator()); - assert!(HandshakeState::AwaitingAuthAck.is_initiator()); - assert!(HandshakeState::AwaitingHello.is_responder()); - assert!(HandshakeState::SentAuth.is_responder()); } #[test] @@ -336,7 +443,7 @@ mod tests { assert!(conn.is_outbound()); assert!(!conn.is_inbound()); - assert_eq!(conn.handshake_state(), HandshakeState::SentHello); + assert_eq!(conn.handshake_state(), HandshakeState::Initial); assert!(conn.expected_identity().is_some()); assert_eq!(conn.started_at(), 1000); assert_eq!(conn.retry_count(), 0); @@ -348,50 +455,59 @@ mod tests { assert!(conn.is_inbound()); assert!(!conn.is_outbound()); - assert_eq!(conn.handshake_state(), HandshakeState::AwaitingHello); + assert_eq!(conn.handshake_state(), HandshakeState::Initial); assert!(conn.expected_identity().is_none()); assert_eq!(conn.started_at(), 2000); } #[test] - fn test_outbound_handshake_flow() { - let identity = make_peer_identity(); - let mut conn = PeerConnection::outbound(LinkId::new(1), identity, 1000); + fn test_full_handshake_flow() { + // Create identities + let initiator_identity = Identity::generate(); + let responder_identity = Identity::generate(); - // Initial state: SentHello - assert_eq!(conn.handshake_state(), HandshakeState::SentHello); - assert!(conn.is_in_progress()); + let initiator_keypair = initiator_identity.keypair(); + let responder_keypair = responder_identity.keypair(); - // Received response, awaiting auth ack - conn.mark_awaiting_auth_ack(1100); - assert_eq!(conn.handshake_state(), HandshakeState::AwaitingAuthAck); - assert!(conn.is_in_progress()); + // Use from_pubkey_full to preserve parity for ECDH + let responder_peer_id = PeerIdentity::from_pubkey_full(responder_identity.pubkey_full()); - // Complete - conn.mark_complete(1200); - assert!(conn.is_complete()); - assert!(!conn.is_in_progress()); - } + // Create connections + let mut initiator_conn = + PeerConnection::outbound(LinkId::new(1), responder_peer_id, 1000); + let mut responder_conn = PeerConnection::inbound(LinkId::new(2), 1000); - #[test] - fn test_inbound_handshake_flow() { - let mut conn = PeerConnection::inbound(LinkId::new(2), 2000); + // Initiator starts handshake + let msg1 = initiator_conn.start_handshake(initiator_keypair, 1100).unwrap(); + assert_eq!(initiator_conn.handshake_state(), HandshakeState::SentMsg1); - // Initial state: AwaitingHello - assert_eq!(conn.handshake_state(), HandshakeState::AwaitingHello); + // Responder processes msg1 and sends msg2 + let msg2 = responder_conn + .receive_handshake_init(responder_keypair, &msg1, 1200) + .unwrap(); + assert_eq!(responder_conn.handshake_state(), HandshakeState::Complete); - // Received Hello, learned identity - let identity = make_peer_identity(); - conn.mark_hello_received(identity, 2100); - assert!(conn.expected_identity().is_some()); + // Responder learned initiator's identity + let discovered = responder_conn.expected_identity().unwrap(); + assert_eq!(discovered.pubkey(), initiator_identity.pubkey()); - // Sent Auth response - conn.mark_auth_sent(2200); - assert_eq!(conn.handshake_state(), HandshakeState::SentAuth); + // Initiator completes handshake + initiator_conn.complete_handshake(&msg2, 1300).unwrap(); + assert_eq!(initiator_conn.handshake_state(), HandshakeState::Complete); - // Complete - conn.mark_complete(2300); - assert!(conn.is_complete()); + // Both have sessions + assert!(initiator_conn.has_session()); + assert!(responder_conn.has_session()); + + // Take and verify sessions work + let mut init_session = initiator_conn.take_session().unwrap(); + let mut resp_session = responder_conn.take_session().unwrap(); + + // Encrypt/decrypt test + let plaintext = b"test message"; + let ciphertext = init_session.encrypt(plaintext).unwrap(); + let decrypted = resp_session.decrypt(&ciphertext).unwrap(); + assert_eq!(decrypted, plaintext); } #[test] @@ -431,4 +547,20 @@ mod tests { assert!(!conn.is_in_progress()); assert!(!conn.is_complete()); } + + #[test] + fn test_wrong_direction_errors() { + let identity = make_peer_identity(); + let keypair = make_keypair(); + + // Outbound can't receive_handshake_init + let mut outbound = PeerConnection::outbound(LinkId::new(1), identity.clone(), 1000); + assert!(outbound + .receive_handshake_init(keypair.clone(), &[0u8; 82], 1100) + .is_err()); + + // Inbound can't start_handshake + let mut inbound = PeerConnection::inbound(LinkId::new(2), 1000); + assert!(inbound.start_handshake(keypair, 1100).is_err()); + } } diff --git a/src/peer/mod.rs b/src/peer/mod.rs index bbd65fe..bf1c4cd 100644 --- a/src/peer/mod.rs +++ b/src/peer/mod.rs @@ -146,7 +146,7 @@ pub fn cross_connection_winner( // ============================================================================ /// A slot in the peer table, representing either connection or active phase. -#[derive(Clone, Debug)] +#[derive(Debug)] pub enum PeerSlot { /// Connection in handshake phase. Connecting(PeerConnection),