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.
This commit is contained in:
Johnathan Corgan
2026-02-01 01:28:01 +00:00
parent 86c49dfb04
commit 9a6fa07e77
8 changed files with 1449 additions and 117 deletions
Generated
+115
View File
@@ -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"
+2
View File
@@ -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"
+46 -1
View File
@@ -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<PublicKey>,
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())
+4
View File
@@ -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};
+51 -8
View File
@@ -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!(
+991
View File
@@ -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<Vec<u8>, 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<Vec<u8>, 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<Nonce, NoiseError> {
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::<Sha256>::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<Vec<u8>, 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<Vec<u8>, 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::<Sha256>::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<Keypair>,
/// Remote static public key.
/// For initiator: known before handshake (from config).
/// For responder: learned from message 1.
remote_static: Option<PublicKey>,
/// Remote ephemeral public key (learned during handshake).
remote_ephemeral: Option<PublicKey>,
/// Secp256k1 context.
secp: Secp256k1<secp256k1::All>,
}
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<Vec<u8>, 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<Vec<u8>, 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<NoiseSession, NoiseError> {
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<Vec<u8>, NoiseError> {
self.send_cipher.encrypt(plaintext)
}
/// Decrypt a received message.
pub fn decrypt(&mut self, ciphertext: &[u8]) -> Result<Vec<u8>, 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.
}
}
+239 -107
View File
@@ -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<PeerIdentity>,
// === Noise Session State ===
// TODO: Add actual Noise protocol state when implementing crypto
// noise_state: Option<NoiseSession>,
/// Noise handshake state (consumes on completion).
noise_handshake: Option<noise::HandshakeState>,
/// Completed Noise session (available after handshake complete).
noise_session: Option<NoiseSession>,
// === 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<Vec<u8>, 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<Vec<u8>, 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<NoiseSession> {
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());
}
}
+1 -1
View File
@@ -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),