mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
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:
Generated
+115
@@ -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"
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user