From fca9f6ab13e34d0a11ae95ffaadf7bea6dbf0431 Mon Sep 17 00:00:00 2001 From: durch Date: Tue, 25 Nov 2025 10:33:28 +0100 Subject: [PATCH 01/14] Telescoping, first pass --- .gitattributes | 3 + common/http-api-client/src/lib.rs | 7 +- common/nym-lp/src/codec.rs | 50 +- common/nym-lp/src/message.rs | 30 +- docker/localnet/build_topology.py | 29 +- docker/localnet/localnet.sh | 87 ++- gateway/src/node/lp_listener/handler.rs | 240 +++++++- nym-gateway-probe/src/lib.rs | 462 +++++++++++++-- nym-gateway-probe/src/run.rs | 41 +- nym-registration-client/src/lib.rs | 183 +++--- .../src/lp_client/client.rs | 135 +++++ nym-registration-client/src/lp_client/mod.rs | 3 +- .../src/lp_client/nested_session.rs | 543 ++++++++++++++++++ 13 files changed, 1650 insertions(+), 163 deletions(-) create mode 100644 nym-registration-client/src/lp_client/nested_session.rs diff --git a/.gitattributes b/.gitattributes index 43c43ce94a3..6dea9476f43 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ nym-validator-rewarder/.sqlx/** diff=nodiff nym-node-status-api/nym-node-status-api/.sqlx/** diff=nodiff + +# Use bd merge for beads JSONL files +.beads/beads.jsonl merge=beads diff --git a/common/http-api-client/src/lib.rs b/common/http-api-client/src/lib.rs index a74a141fe6f..568cea2b56e 100644 --- a/common/http-api-client/src/lib.rs +++ b/common/http-api-client/src/lib.rs @@ -896,7 +896,8 @@ impl Client { } fn matches_current_host(&self, url: &Url) -> bool { - if cfg!(feature = "tunneling") { + #[cfg(feature = "tunneling")] + { if let Some(ref front) = self.front && front.is_enabled() { @@ -904,7 +905,9 @@ impl Client { } else { url.host_str() == self.current_url().host_str() } - } else { + } + #[cfg(not(feature = "tunneling"))] + { url.host_str() == self.current_url().host_str() } } diff --git a/common/nym-lp/src/codec.rs b/common/nym-lp/src/codec.rs index 260d344ec6c..f9109e66416 100644 --- a/common/nym-lp/src/codec.rs +++ b/common/nym-lp/src/codec.rs @@ -3,8 +3,8 @@ use crate::LpError; use crate::message::{ - ClientHelloData, EncryptedDataPayload, HandshakeData, KKTRequestData, KKTResponseData, - LpMessage, MessageType, + ClientHelloData, EncryptedDataPayload, ForwardPacketData, HandshakeData, KKTRequestData, + KKTResponseData, LpMessage, MessageType, }; use crate::packet::{LpHeader, LpPacket, TRAILER_LEN}; use bytes::BytesMut; @@ -74,6 +74,12 @@ pub fn parse_lp_packet(src: &[u8]) -> Result { // KKT response contains serialized KKTFrame bytes LpMessage::KKTResponse(KKTResponseData(payload_slice.to_vec())) } + MessageType::ForwardPacket => { + // ForwardPacket has structured data + let data: ForwardPacketData = bincode::deserialize(payload_slice) + .map_err(|e| LpError::DeserializationError(e.to_string()))?; + LpMessage::ForwardPacket(data) + } }; // Extract trailer @@ -572,4 +578,44 @@ mod tests { } } } + + #[test] + fn test_forward_packet_encode_decode_roundtrip() { + let mut dst = BytesMut::new(); + + let forward_data = crate::message::ForwardPacketData { + target_gateway_identity: [77u8; 32], + target_lp_address: "1.2.3.4:41264".to_string(), + inner_packet_bytes: vec![0xa, 0xb, 0xc, 0xd], + }; + + let packet = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + session_id: 999, + counter: 555, + }, + message: LpMessage::ForwardPacket(forward_data), + trailer: [0xff; TRAILER_LEN], + }; + + // Serialize + serialize_lp_packet(&packet, &mut dst).unwrap(); + + // Parse back + let decoded = parse_lp_packet(&dst).unwrap(); + + // Verify LP protocol handling works correctly + assert_eq!(decoded.header.session_id, 999); + assert!(matches!(decoded.message.typ(), MessageType::ForwardPacket)); + + if let LpMessage::ForwardPacket(data) = decoded.message { + assert_eq!(data.target_gateway_identity, [77u8; 32]); + assert_eq!(data.target_lp_address, "1.2.3.4:41264"); + assert_eq!(data.inner_packet_bytes, vec![0xa, 0xb, 0xc, 0xd]); + } else { + panic!("Expected ForwardPacket message"); + } + } } diff --git a/common/nym-lp/src/message.rs b/common/nym-lp/src/message.rs index bbc0ea99040..1f53c2b76db 100644 --- a/common/nym-lp/src/message.rs +++ b/common/nym-lp/src/message.rs @@ -72,6 +72,7 @@ pub enum MessageType { ClientHello = 0x0003, KKTRequest = 0x0004, KKTResponse = 0x0005, + ForwardPacket = 0x0006, } impl MessageType { @@ -98,6 +99,20 @@ pub struct KKTRequestData(pub Vec); #[derive(Debug, Clone, PartialEq, Eq)] pub struct KKTResponseData(pub Vec); +/// Packet forwarding request with embedded inner LP packet +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ForwardPacketData { + /// Target gateway's Ed25519 identity (32 bytes) + pub target_gateway_identity: [u8; 32], + + /// Target gateway's LP address (IP:port string) + pub target_lp_address: String, + + /// Complete inner LP packet bytes (serialized LpPacket) + /// This is the CLIENT→EXIT gateway packet, encrypted for exit + pub inner_packet_bytes: Vec, +} + #[derive(Debug, Clone)] pub enum LpMessage { Busy, @@ -106,6 +121,7 @@ pub enum LpMessage { ClientHello(ClientHelloData), KKTRequest(KKTRequestData), KKTResponse(KKTResponseData), + ForwardPacket(ForwardPacketData), } impl Display for LpMessage { @@ -117,6 +133,7 @@ impl Display for LpMessage { LpMessage::ClientHello(_) => write!(f, "ClientHello"), LpMessage::KKTRequest(_) => write!(f, "KKTRequest"), LpMessage::KKTResponse(_) => write!(f, "KKTResponse"), + LpMessage::ForwardPacket(_) => write!(f, "ForwardPacket"), } } } @@ -127,9 +144,10 @@ impl LpMessage { LpMessage::Busy => &[], LpMessage::Handshake(payload) => payload.0.as_slice(), LpMessage::EncryptedData(payload) => payload.0.as_slice(), - LpMessage::ClientHello(_) => unimplemented!(), // Structured data, serialized in encode_content + LpMessage::ClientHello(_) => &[], // Structured data, serialized in encode_content LpMessage::KKTRequest(payload) => payload.0.as_slice(), LpMessage::KKTResponse(payload) => payload.0.as_slice(), + LpMessage::ForwardPacket(_) => &[], // Structured data, serialized in encode_content } } @@ -141,6 +159,7 @@ impl LpMessage { LpMessage::ClientHello(_) => false, // Always has data LpMessage::KKTRequest(payload) => payload.0.is_empty(), LpMessage::KKTResponse(payload) => payload.0.is_empty(), + LpMessage::ForwardPacket(_) => false, // Always has data } } @@ -152,6 +171,9 @@ impl LpMessage { LpMessage::ClientHello(_) => 97, // 32 bytes x25519 key + 32 bytes ed25519 key + 32 bytes salt + 1 byte bincode overhead LpMessage::KKTRequest(payload) => payload.0.len(), LpMessage::KKTResponse(payload) => payload.0.len(), + LpMessage::ForwardPacket(data) => { + 32 + data.target_lp_address.len() + data.inner_packet_bytes.len() + 10 + } } } @@ -163,6 +185,7 @@ impl LpMessage { LpMessage::ClientHello(_) => MessageType::ClientHello, LpMessage::KKTRequest(_) => MessageType::KKTRequest, LpMessage::KKTResponse(_) => MessageType::KKTResponse, + LpMessage::ForwardPacket(_) => MessageType::ForwardPacket, } } @@ -187,6 +210,11 @@ impl LpMessage { LpMessage::KKTResponse(payload) => { dst.put_slice(&payload.0); } + LpMessage::ForwardPacket(data) => { + let serialized = + bincode::serialize(data).expect("Failed to serialize ForwardPacketData"); + dst.put_slice(&serialized); + } } } } diff --git a/docker/localnet/build_topology.py b/docker/localnet/build_topology.py index 88c2bfe8139..bdf6f459fc7 100644 --- a/docker/localnet/build_topology.py +++ b/docker/localnet/build_topology.py @@ -178,30 +178,31 @@ def create_mixnode_entry(base_dir, mix_id, port_delta, suffix, host_ip): return entry -def create_gateway_entry(base_dir, node_id, port_delta, suffix, host_ip): +def create_gateway_entry(base_dir, node_id, port_delta, suffix, host_ip, gateway_name="gateway"): """Create a node_details entry for a gateway""" - debug(f"\n=== Creating gateway entry ===") - gateway_file = Path(base_dir) / "gateway.json" + debug(f"\n=== Creating {gateway_name} entry ===") + gateway_file = Path(base_dir) / f"{gateway_name}.json" debug(f"Reading bonding JSON from: {gateway_file}") with gateway_file.open("r") as json_blob: gateway_data = json.load(json_blob) - node_details = read_node_details("gateway", suffix) + node_details = read_node_details(gateway_name, suffix) # Get identity key from bonding JSON (already byte array) identity = gateway_data.get("identity_key") if not identity: - raise RuntimeError("Missing identity_key in gateway.json") + raise RuntimeError(f"Missing identity_key in {gateway_name}.json") debug(f" ✓ Got identity_key from bonding JSON: {len(identity)} bytes") # Get sphinx key from node-details (decoded from Base58) sphinx_key = node_details.get("sphinx_key") if not sphinx_key: - raise RuntimeError("Missing sphinx_key from node-details for gateway") + raise RuntimeError(f"Missing sphinx_key from node-details for {gateway_name}") host = host_ip mix_port = 10000 + port_delta - clients_port = 9000 + # Calculate clients_port: gateway uses 9000, gateway2 uses 9001, etc. + clients_port = 9000 + (port_delta - 4) debug(f" Using host: {host} (mix:{mix_port}, clients:{clients_port})") entry = { @@ -229,7 +230,7 @@ def create_gateway_entry(base_dir, node_id, port_delta, suffix, host_ip): def main(args): if not args: - raise SystemExit("Usage: build_topology.py [node_suffix] [mix1_ip] [mix2_ip] [mix3_ip] [gateway_ip]") + raise SystemExit("Usage: build_topology.py [node_suffix] [mix1_ip] [mix2_ip] [mix3_ip] [gateway_ip] [gateway2_ip]") base_dir = args[0] suffix = args[1] if len(args) > 1 and args[1] else DEFAULT_SUFFIX @@ -239,18 +240,20 @@ def main(args): mix2_ip = args[3] if len(args) > 3 else "127.0.0.1" mix3_ip = args[4] if len(args) > 4 else "127.0.0.1" gateway_ip = args[5] if len(args) > 5 else "127.0.0.1" + gateway2_ip = args[6] if len(args) > 6 else "127.0.0.1" debug(f"\n=== Starting topology generation ===") debug(f"Output directory: {base_dir}") debug(f"Node suffix: {suffix}") - debug(f"Container IPs: mix1={mix1_ip}, mix2={mix2_ip}, mix3={mix3_ip}, gateway={gateway_ip}") + debug(f"Container IPs: mix1={mix1_ip}, mix2={mix2_ip}, mix3={mix3_ip}, gateway={gateway_ip}, gateway2={gateway2_ip}") # Create node_details entries with integer keys node_details = { 1: create_mixnode_entry(base_dir, 1, 1, suffix, mix1_ip), 2: create_mixnode_entry(base_dir, 2, 2, suffix, mix2_ip), 3: create_mixnode_entry(base_dir, 3, 3, suffix, mix3_ip), - 4: create_gateway_entry(base_dir, 4, 4, suffix, gateway_ip) + 4: create_gateway_entry(base_dir, 4, 4, suffix, gateway_ip, "gateway"), + 5: create_gateway_entry(base_dir, 5, 5, suffix, gateway2_ip, "gateway2") } # Create the NymTopology structure @@ -262,8 +265,8 @@ def main(args): }, "rewarded_set": { "epoch_id": 0, - "entry_gateways": [4], - "exit_gateways": [4], + "entry_gateways": [4, 5], + "exit_gateways": [4, 5], "layer1": [1], "layer2": [2], "layer3": [3], @@ -279,7 +282,7 @@ def main(args): print(f"✓ Generated topology with {len(node_details)} nodes") print(f" - 3 mixnodes (layers 1, 2, 3)") - print(f" - 1 gateway (entry + exit)") + print(f" - 2 gateways (entry + exit)") debug(f"\n=== Topology generation complete ===\n") diff --git a/docker/localnet/localnet.sh b/docker/localnet/localnet.sh index 03478fa55eb..850df6258b1 100755 --- a/docker/localnet/localnet.sh +++ b/docker/localnet/localnet.sh @@ -20,6 +20,7 @@ MIXNODE1_CONTAINER="nym-mixnode1" MIXNODE2_CONTAINER="nym-mixnode2" MIXNODE3_CONTAINER="nym-mixnode3" GATEWAY_CONTAINER="nym-gateway" +GATEWAY2_CONTAINER="nym-gateway2" REQUESTER_CONTAINER="nym-network-requester" SOCKS5_CONTAINER="nym-socks5-client" @@ -28,6 +29,7 @@ ALL_CONTAINERS=( "$MIXNODE2_CONTAINER" "$MIXNODE3_CONTAINER" "$GATEWAY_CONTAINER" + "$GATEWAY2_CONTAINER" "$REQUESTER_CONTAINER" "$SOCKS5_CONTAINER" ) @@ -57,7 +59,7 @@ log_error() { cleanup_host_state() { log_info "Cleaning local nym-node state for suffix ${SUFFIX}" - for node in mix1 mix2 mix3 gateway; do + for node in mix1 mix2 mix3 gateway gateway2; do rm -rf "$HOME/.nym/nym-nodes/${node}-${SUFFIX}" done } @@ -283,6 +285,73 @@ start_gateway() { done log_success "Gateway is ready on port 9000" } + +# Start gateway2 +start_gateway2() { + log_info "Starting $GATEWAY2_CONTAINER..." + + container run \ + --name "$GATEWAY2_CONTAINER" \ + -m 2G \ + --network "$NETWORK_NAME" \ + -p 9001:9001 \ + -p 10005:10005 \ + -p 20005:20005 \ + -p 30005:30005 \ + -p 41265:41265 \ + -p 51265:51265 \ + -v "$VOLUME_PATH:/localnet" \ + -v "$NYM_VOLUME_PATH:/root/.nym" \ + -d \ + -e "NYM_NODE_SUFFIX=$SUFFIX" \ + "$IMAGE_NAME" \ + sh -c ' + CONTAINER_IP=$(hostname -i); + echo "Container IP: $CONTAINER_IP"; + echo "Initializing gateway2..."; + nym-node run --id gateway2-localnet --init-only \ + --unsafe-disable-replay-protection \ + --local \ + --mode entry-gateway \ + --mode exit-gateway \ + --mixnet-bind-address=0.0.0.0:10005 \ + --entry-bind-address=0.0.0.0:9001 \ + --verloc-bind-address=0.0.0.0:20005 \ + --http-bind-address=0.0.0.0:30005 \ + --http-access-token=lala \ + --public-ips $CONTAINER_IP \ + --enable-lp true \ + --lp-use-mock-ecash true \ + --output=json \ + --wireguard-enabled true \ + --wireguard-userspace true \ + --bonding-information-output="/localnet/gateway2.json"; + + echo "Waiting for network.json..."; + while [ ! -f /localnet/network.json ]; do + sleep 2; + done; + echo "Starting gateway2 with LP listener (mock ecash)..."; + exec nym-node run --id gateway2-localnet --unsafe-disable-replay-protection --local --wireguard-enabled true --wireguard-userspace true --lp-use-mock-ecash true + ' + + log_success "$GATEWAY2_CONTAINER started" + + # Wait for gateway2 to be ready + log_info "Waiting for gateway2 to listen on port 9001..." + local retries=0 + local max_retries=30 + while ! nc -z 127.0.0.1 9001 2>/dev/null; do + sleep 2 + retries=$((retries + 1)) + if [ $retries -ge $max_retries ]; then + log_error "Gateway2 failed to start on port 9001" + return 1 + fi + done + log_success "Gateway2 is ready on port 9001" +} + # Start network requester start_network_requester() { log_info "Starting $REQUESTER_CONTAINER..." @@ -473,7 +542,7 @@ build_topology() { # Wait for all bonding JSON files to be created log_info "Waiting for all nodes to complete initialization..." - for file in mix1.json mix2.json mix3.json gateway.json; do + for file in mix1.json mix2.json mix3.json gateway.json gateway2.json; do while [ ! -f "$VOLUME_PATH/$file" ]; do echo " Waiting for $file..." sleep 1 @@ -487,12 +556,14 @@ build_topology() { MIX2_IP=$(container exec "$MIXNODE2_CONTAINER" hostname -i) MIX3_IP=$(container exec "$MIXNODE3_CONTAINER" hostname -i) GATEWAY_IP=$(container exec "$GATEWAY_CONTAINER" hostname -i) + GATEWAY2_IP=$(container exec "$GATEWAY2_CONTAINER" hostname -i) log_info "Container IPs:" - echo " mix1: $MIX1_IP" - echo " mix2: $MIX2_IP" - echo " mix3: $MIX3_IP" - echo " gateway: $GATEWAY_IP" + echo " mix1: $MIX1_IP" + echo " mix2: $MIX2_IP" + echo " mix3: $MIX3_IP" + echo " gateway: $GATEWAY_IP" + echo " gateway2: $GATEWAY2_IP" # Run build_topology.py in a container with access to the volumes container run \ @@ -508,7 +579,8 @@ build_topology() { "$MIX1_IP" \ "$MIX2_IP" \ "$MIX3_IP" \ - "$GATEWAY_IP" + "$GATEWAY_IP" \ + "$GATEWAY2_IP" # Verify network.json was created if [ -f "$VOLUME_PATH/network.json" ]; then @@ -532,6 +604,7 @@ start_all() { start_mixnode 2 "$MIXNODE2_CONTAINER" start_mixnode 3 "$MIXNODE3_CONTAINER" start_gateway + start_gateway2 build_topology start_network_requester start_socks5_client diff --git a/gateway/src/node/lp_listener/handler.rs b/gateway/src/node/lp_listener/handler.rs index 3fd487e76ae..0aaaf1e26dc 100644 --- a/gateway/src/node/lp_listener/handler.rs +++ b/gateway/src/node/lp_listener/handler.rs @@ -6,7 +6,7 @@ use super::messages::{LpRegistrationRequest, LpRegistrationResponse}; use super::registration::process_registration; use super::LpHandlerState; use crate::error::GatewayError; -use nym_lp::{keypair::PublicKey, LpMessage, LpPacket, LpSession}; +use nym_lp::{keypair::PublicKey, message::ForwardPacketData, LpMessage, LpPacket, LpSession}; use nym_metrics::{add_histogram_obs, inc}; use std::net::SocketAddr; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -186,6 +186,21 @@ impl LpConnectionHandler { "LP registration successful for {} (session {})", self.remote_addr, response.session_id ); + + // After successful registration, keep connection open for forwarding + info!( + "Entering forwarding mode for {} (session {})", + self.remote_addr, + session.id() + ); + if let Err(e) = self.handle_forwarding_loop(&session).await { + warn!( + "Forwarding loop error for {} (session {}): {}", + self.remote_addr, + session.id(), + e + ); + } } else { warn!( "LP registration failed for {}: {:?}", @@ -363,6 +378,229 @@ impl LpConnectionHandler { self.send_lp_packet(&packet).await } + /// Forward an LP packet to another gateway + /// + /// This method connects to the target gateway, forwards the inner packet bytes, + /// and returns the response. Used for hiding client IP from exit gateway. + /// + /// # Arguments + /// * `forward_data` - ForwardPacketData containing target gateway info and inner packet + /// + /// # Returns + /// * `Ok(Vec)` - Raw response bytes from target gateway + /// * `Err(GatewayError)` - If forwarding fails + async fn handle_forward_packet( + &mut self, + forward_data: ForwardPacketData, + ) -> Result, GatewayError> { + use tokio::time::timeout; + use std::time::Duration; + + inc!("lp_forward_total"); + let start = std::time::Instant::now(); + + // Parse target gateway address + let target_addr: SocketAddr = forward_data.target_lp_address.parse().map_err(|e| { + inc!("lp_forward_failed"); + GatewayError::LpProtocolError(format!("Invalid target address: {}", e)) + })?; + + // Connect to target gateway with timeout + let mut target_stream = match timeout(Duration::from_secs(5), TcpStream::connect(target_addr)).await { + Ok(Ok(stream)) => stream, + Ok(Err(e)) => { + inc!("lp_forward_failed"); + return Err(GatewayError::LpConnectionError(format!( + "Failed to connect to target gateway: {}", + e + ))); + } + Err(_) => { + inc!("lp_forward_failed"); + return Err(GatewayError::LpConnectionError( + "Target gateway connection timeout".to_string(), + )); + } + }; + + debug!( + "Forwarding packet to {} (target: {})", + target_addr, forward_data.target_lp_address + ); + + // Forward inner packet bytes (4-byte length prefix + packet data) + let len = forward_data.inner_packet_bytes.len() as u32; + target_stream + .write_all(&len.to_be_bytes()) + .await + .map_err(|e| { + inc!("lp_forward_failed"); + GatewayError::LpConnectionError(format!("Failed to send length to target: {}", e)) + })?; + + target_stream + .write_all(&forward_data.inner_packet_bytes) + .await + .map_err(|e| { + inc!("lp_forward_failed"); + GatewayError::LpConnectionError(format!("Failed to send packet to target: {}", e)) + })?; + + target_stream.flush().await.map_err(|e| { + inc!("lp_forward_failed"); + GatewayError::LpConnectionError(format!("Failed to flush target stream: {}", e)) + })?; + + // Read response from target gateway (4-byte length prefix + packet data) + let mut len_buf = [0u8; 4]; + target_stream.read_exact(&mut len_buf).await.map_err(|e| { + inc!("lp_forward_failed"); + GatewayError::LpConnectionError(format!("Failed to read response length from target: {}", e)) + })?; + + let response_len = u32::from_be_bytes(len_buf) as usize; + + // Sanity check + const MAX_PACKET_SIZE: usize = 65536; + if response_len > MAX_PACKET_SIZE { + inc!("lp_forward_failed"); + return Err(GatewayError::LpProtocolError(format!( + "Response size {} exceeds maximum {}", + response_len, MAX_PACKET_SIZE + ))); + } + + let mut response_buf = vec![0u8; response_len]; + target_stream + .read_exact(&mut response_buf) + .await + .map_err(|e| { + inc!("lp_forward_failed"); + GatewayError::LpConnectionError(format!("Failed to read response from target: {}", e)) + })?; + + // Record metrics + let duration = start.elapsed().as_secs_f64(); + add_histogram_obs!( + "lp_forward_duration_seconds", + duration, + LP_DURATION_BUCKETS + ); + + debug!( + "Forwarding successful to {} ({} bytes response, {:.3}s)", + target_addr, + response_len, + duration + ); + + Ok(response_buf) + } + + /// Handle incoming forwarding requests in a loop + /// + /// After successful registration, the connection stays open to handle + /// ForwardPacket messages. This allows the entry gateway to relay packets + /// to exit gateways, hiding the client's IP address. + /// + /// # Arguments + /// * `session` - The established LP session with the client + /// + /// # Returns + /// * `Ok(())` - When connection closes gracefully + /// * `Err(GatewayError)` - On protocol errors + async fn handle_forwarding_loop(&mut self, session: &LpSession) -> Result<(), GatewayError> { + debug!( + "Entering forwarding loop for {} (session {})", + self.remote_addr, + session.id() + ); + + loop { + // Receive packet from client + let packet = match self.receive_lp_packet().await { + Ok(p) => p, + Err(e) => { + // Connection closed or error - exit loop gracefully + debug!( + "Forwarding loop ended for {} (session {}): {}", + self.remote_addr, + session.id(), + e + ); + return Ok(()); + } + }; + + // Verify session ID + if packet.header().session_id != session.id() { + warn!( + "Session ID mismatch in forwarding loop: expected {}, got {}", + session.id(), + packet.header().session_id + ); + return Err(GatewayError::LpProtocolError(format!( + "Session ID mismatch: expected {}, got {}", + session.id(), + packet.header().session_id + ))); + } + + // Decrypt packet + let decrypted_bytes = session.decrypt_data(packet.message()).map_err(|e| { + GatewayError::LpProtocolError(format!("Failed to decrypt forwarding packet: {}", e)) + })?; + + // Deserialize to ForwardPacketData + let forward_request: ForwardPacketData = + bincode::deserialize(&decrypted_bytes).map_err(|e| { + GatewayError::LpProtocolError(format!( + "Failed to deserialize forward request: {}", + e + )) + })?; + + debug!( + "Forwarding request from {} to {}", + self.remote_addr, forward_request.target_lp_address + ); + + // Forward the packet + let response_bytes = match self.handle_forward_packet(forward_request).await { + Ok(bytes) => bytes, + Err(e) => { + warn!( + "Forwarding failed for {}: {}. Continuing loop.", + self.remote_addr, e + ); + // Send error response back to client + // For now, continue the loop - client will retry if needed + continue; + } + }; + + // Encrypt response + let encrypted_msg = session.encrypt_data(&response_bytes).map_err(|e| { + GatewayError::LpProtocolError(format!("Failed to encrypt response: {}", e)) + })?; + + let response_packet = session.next_packet(encrypted_msg).map_err(|e| { + GatewayError::LpProtocolError(format!("Failed to create response packet: {}", e)) + })?; + + // Send response back to client + if let Err(e) = self.send_lp_packet(&response_packet).await { + warn!( + "Failed to send forwarding response to {}: {}", + self.remote_addr, e + ); + return Err(e); + } + + trace!("Forwarding response sent to {}", self.remote_addr); + } + } + /// Receive an LP packet from the stream with proper length-prefixed framing async fn receive_lp_packet(&mut self) -> Result { use nym_lp::codec::parse_lp_packet; diff --git a/nym-gateway-probe/src/lib.rs b/nym-gateway-probe/src/lib.rs index 54243865571..d2413be4254 100644 --- a/nym-gateway-probe/src/lib.rs +++ b/nym-gateway-probe/src/lib.rs @@ -139,7 +139,7 @@ impl TestedNode { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct TestedNodeDetails { identity: NodeIdentity, exit_router_address: Option, @@ -157,6 +157,8 @@ pub struct Probe { credentials_args: CredentialArgs, /// Pre-queried gateway node (used when --gateway-ip is specified) direct_gateway_node: Option, + /// Pre-queried exit gateway node (used when --exit-gateway-ip is specified for LP forwarding) + exit_gateway_node: Option, } impl Probe { @@ -173,6 +175,7 @@ impl Probe { netstack_args, credentials_args, direct_gateway_node: None, + exit_gateway_node: None, } } @@ -191,6 +194,27 @@ impl Probe { netstack_args, credentials_args, direct_gateway_node: Some(gateway_node), + exit_gateway_node: None, + } + } + + /// Create a probe with both entry and exit gateways pre-queried (for LP forwarding tests) + pub fn new_with_gateways( + entrypoint: NodeIdentity, + tested_node: TestedNode, + netstack_args: NetstackArgs, + credentials_args: CredentialArgs, + entry_gateway_node: DirectoryNode, + exit_gateway_node: DirectoryNode, + ) -> Self { + Self { + entrypoint, + tested_node, + amnezia_args: "".into(), + netstack_args, + credentials_args, + direct_gateway_node: Some(entry_gateway_node), + exit_gateway_node: Some(exit_gateway_node), } } @@ -206,6 +230,7 @@ impl Probe { ignore_egress_epoch_role: bool, only_wireguard: bool, only_lp_registration: bool, + test_lp_wg: bool, min_mixnet_performance: Option, ) -> anyhow::Result { let tickets_materials = self.credentials_args.decode_attached_ticket_materials()?; @@ -234,14 +259,16 @@ impl Probe { let mixnet_client = Box::pin(disconnected_mixnet_client.connect_to_mixnet()).await; self.do_probe_test( - mixnet_client, + Some(mixnet_client), storage, mixnet_entry_gateway_id, node_info, + directory.as_ref(), nyxd_url, tested_entry, only_wireguard, only_lp_registration, + test_lp_wg, false, // Not using mock ecash in regular probe mode ) .await @@ -257,9 +284,46 @@ impl Probe { ignore_egress_epoch_role: bool, only_wireguard: bool, only_lp_registration: bool, + test_lp_wg: bool, min_mixnet_performance: Option, use_mock_ecash: bool, ) -> anyhow::Result { + // If both gateways are pre-queried via --gateway-ip and --exit-gateway-ip, + // skip mixnet setup entirely - we have all the data we need + if self.direct_gateway_node.is_some() && self.exit_gateway_node.is_some() { + let entry_node = self.direct_gateway_node.as_ref().unwrap(); + let exit_node = self.exit_gateway_node.as_ref().unwrap(); + + // Initialize storage (needed for credentials) + if !config_dir.exists() { + std::fs::create_dir_all(config_dir)?; + } + let storage_paths = StoragePaths::new_from_dir(config_dir)?; + let storage = storage_paths + .initialise_default_persistent_storage() + .await?; + + // Get node details from pre-queried nodes + let mixnet_entry_gateway_id = entry_node.identity(); + let node_info = exit_node.to_testable_node()?; + + return self + .do_probe_test( + None, + storage, + mixnet_entry_gateway_id, + node_info, + directory.as_ref(), + nyxd_url, + false, // tested_entry + only_wireguard, + only_lp_registration, + test_lp_wg, + use_mock_ecash, + ) + .await; + } + // If only testing LP registration, use the dedicated LP-only path // This skips mixnet setup entirely and allows testing local gateways if only_lp_registration { @@ -332,14 +396,16 @@ impl Probe { let mixnet_client = Box::pin(disconnected_mixnet_client.connect_to_mixnet()).await; self.do_probe_test( - mixnet_client, + Some(mixnet_client), storage, mixnet_entry_gateway_id, node_info, + directory.as_ref(), nyxd_url, tested_entry, only_wireguard, only_lp_registration, + test_lp_wg, use_mock_ecash, ) .await @@ -476,14 +542,16 @@ impl Probe { #[allow(clippy::too_many_arguments)] pub async fn do_probe_test( &self, - mixnet_client: nym_sdk::Result, + mixnet_client: Option>, storage: T, mixnet_entry_gateway_id: NodeIdentity, node_info: TestedNodeDetails, + directory: Option<&NymApiDirectory>, nyxd_url: Url, tested_entry: bool, only_wireguard: bool, only_lp_registration: bool, + test_lp_wg: bool, use_mock_ecash: bool, ) -> anyhow::Result where @@ -492,8 +560,8 @@ impl Probe { { let mut rng = rand::thread_rng(); let mixnet_client = match mixnet_client { - Ok(mixnet_client) => mixnet_client, - Err(err) => { + Some(Ok(mixnet_client)) => Some(mixnet_client), + Some(Err(err)) => { error!("Failed to connect to mixnet: {err}"); return Ok(ProbeResult { node: node_info.identity.to_string(), @@ -510,45 +578,131 @@ impl Probe { }, }); } + None => None, }; - let nym_address = *mixnet_client.nym_address(); - let entry_gateway = nym_address.gateway().to_base58_string(); + let (outcome, mixnet_client) = if let Some(mixnet_client) = mixnet_client { + let nym_address = *mixnet_client.nym_address(); + let entry_gateway = nym_address.gateway().to_base58_string(); - info!("Successfully connected to entry gateway: {entry_gateway}"); - info!("Our nym address: {nym_address}"); + info!("Successfully connected to entry gateway: {entry_gateway}"); + info!("Our nym address: {nym_address}"); - // Now that we have a connected mixnet client, we can start pinging - let (outcome, mixnet_client) = if only_wireguard || only_lp_registration { - ( - Ok(ProbeOutcome { - as_entry: if tested_entry { - Entry::success() - } else { - Entry::NotTested - }, - as_exit: None, - wg: None, - lp: None, - }), - mixnet_client, - ) + // Now that we have a connected mixnet client, we can start pinging + let (outcome, mixnet_client) = if only_wireguard || only_lp_registration { + ( + Ok(ProbeOutcome { + as_entry: if tested_entry { + Entry::success() + } else { + Entry::NotTested + }, + as_exit: None, + wg: None, + lp: None, + }), + mixnet_client, + ) + } else { + do_ping( + mixnet_client, + nym_address, + node_info.exit_router_address, + tested_entry, + ) + .await + }; + (outcome, Some(mixnet_client)) + } else if test_lp_wg { + // No mixnet client needed for LP-WG test with pre-queried nodes + // Create default outcome and continue to LP-WG test below + (Ok(ProbeOutcome { + as_entry: Entry::NotTested, + as_exit: None, + wg: None, + lp: None, + }), None) } else { - do_ping( - mixnet_client, - nym_address, - node_info.exit_router_address, - tested_entry, - ) - .await + // For non-LP-WG modes, missing mixnet client is a failure + (Ok(ProbeOutcome { + as_entry: if tested_entry { + Entry::fail_to_connect() + } else { + Entry::EntryFailure + }, + as_exit: None, + wg: None, + lp: None, + }), None) }; let wg_outcome = if only_lp_registration { // Skip WireGuard test when only testing LP registration WgProbeResults::default() + } else if test_lp_wg { + // Test WireGuard via LP registration (nested session forwarding) + info!("Testing WireGuard via LP registration (no mixnet)"); + + // Create bandwidth controller for LP registration + let config = nym_validator_client::nyxd::Config::try_from_nym_network_details( + &NymNetworkDetails::new_from_env(), + )?; + let client = + nym_validator_client::nyxd::NyxdClient::connect(config, nyxd_url.as_str())?; + let bw_controller = nym_bandwidth_controller::BandwidthController::new( + storage.credential_store().clone(), + client, + ); + + // Determine entry and exit gateways + let (entry_gateway, exit_gateway) = if let Some(exit_node) = &self.exit_gateway_node { + // Both entry and exit gateways were pre-queried (direct IP mode) + info!("Using pre-queried entry and exit gateways for LP forwarding test"); + let entry_node = self + .direct_gateway_node + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Entry gateway not available"))?; + + let entry_gateway = entry_node.to_testable_node()?; + let exit_gateway = exit_node.to_testable_node()?; + + (entry_gateway, exit_gateway) + } else { + // Original behavior: query from directory + // The tested node is the exit + let exit_gateway = node_info.clone(); + + let directory = directory + .ok_or_else(|| anyhow::anyhow!("Directory is required for LP-WG test mode"))?; + let entry_gateway_node = directory.entry_gateway(&mixnet_entry_gateway_id)?; + let entry_gateway = entry_gateway_node.to_testable_node()?; + + (entry_gateway, exit_gateway) + }; + + wg_probe_lp( + &entry_gateway, + &exit_gateway, + &bw_controller, + storage.credential_store().clone(), + use_mock_ecash, + self.amnezia_args.clone(), + self.netstack_args.clone(), + ) + .await + .unwrap_or_default() } else if let (Some(authenticator), Some(ip_address)) = (node_info.authenticator_address, node_info.ip_address) { + let mixnet_client = if let Some(mixnet_client) = mixnet_client { + mixnet_client + } else { + bail!( + "Mixnet client is required for authenticator WireGuard probe, run in LP mode instead" + ); + }; + + let nym_address = *mixnet_client.nym_address(); // Start the mixnet listener that the auth clients use to receive messages. let mixnet_listener_task = AuthClientMixnetListener::new(mixnet_client, CancellationToken::new()).start(); @@ -595,7 +749,6 @@ impl Probe { outcome } else { - mixnet_client.disconnect().await; WgProbeResults::default() }; @@ -983,6 +1136,249 @@ where Ok(lp_outcome) } +/// LP-based WireGuard probe: Tests LP nested session registration + WireGuard tunnel connectivity +/// +/// This function tests the full VPN flow using LP registration instead of mixnet+authenticator: +/// 1. Connects to entry gateway (outer LP session) +/// 2. Registers with exit gateway via entry forwarding (nested LP session) +/// 3. Receives WireGuard configuration from both gateways +/// 4. Tests WireGuard tunnel connectivity (IPv4/IPv6) +/// +/// This validates that IP hiding works (exit sees entry IP, not client IP) and that the +/// full VPN tunnel operates correctly after LP registration. +async fn wg_probe_lp( + entry_gateway: &TestedNodeDetails, + exit_gateway: &TestedNodeDetails, + bandwidth_controller: &nym_bandwidth_controller::BandwidthController< + nym_validator_client::nyxd::NyxdClient, + St, + >, + _storage: St, + use_mock_ecash: bool, + awg_args: String, + netstack_args: NetstackArgs, +) -> anyhow::Result +where + St: nym_sdk::mixnet::CredentialStorage + Clone + Send + Sync + 'static, + ::StorageError: Send + Sync, +{ + use nym_crypto::asymmetric::{ed25519, x25519}; + use nym_registration_client::{LpRegistrationClient, NestedLpSession}; + + info!("Starting LP-based WireGuard probe (entry→exit via forwarding)"); + + let mut wg_outcome = WgProbeResults::default(); + + // Validate that both gateways have required information + let entry_lp_address = entry_gateway + .lp_address + .ok_or_else(|| anyhow::anyhow!("Entry gateway missing LP address"))?; + let exit_lp_address = exit_gateway + .lp_address + .ok_or_else(|| anyhow::anyhow!("Exit gateway missing LP address"))?; + let entry_ip = entry_gateway + .ip_address + .ok_or_else(|| anyhow::anyhow!("Entry gateway missing IP address"))?; + let exit_ip = exit_gateway + .ip_address + .ok_or_else(|| anyhow::anyhow!("Exit gateway missing IP address"))?; + + // Generate Ed25519 keypairs for LP protocol + let mut rng = rand::thread_rng(); + let entry_lp_keypair = Arc::new(ed25519::KeyPair::new(&mut rng)); + let exit_lp_keypair = Arc::new(ed25519::KeyPair::new(&mut rng)); + + // Generate WireGuard keypairs for VPN registration + let entry_wg_keypair = x25519::KeyPair::new(&mut rng); + let exit_wg_keypair = x25519::KeyPair::new(&mut rng); + + // STEP 1: Establish outer LP session with entry gateway + info!("Connecting to entry gateway via LP..."); + let mut entry_client = LpRegistrationClient::new_with_default_psk( + entry_lp_keypair, + entry_gateway.identity, + entry_lp_address, + entry_ip, + ); + + // Connect to entry gateway + if let Err(e) = entry_client.connect().await { + error!("Failed to connect to entry gateway: {}", e); + return Ok(wg_outcome); + } + + // Perform handshake with entry gateway + if let Err(e) = entry_client.perform_handshake().await { + error!("Failed to handshake with entry gateway: {}", e); + return Ok(wg_outcome); + } + info!("Outer LP session with entry gateway established"); + + // STEP 2: Use nested session to register with exit gateway via forwarding + info!("Registering with exit gateway via entry forwarding..."); + let mut nested_session = NestedLpSession::new( + exit_gateway.identity.to_bytes(), + exit_lp_address.to_string(), + exit_lp_keypair, + ed25519::PublicKey::from_bytes(&exit_gateway.identity.to_bytes()) + .map_err(|e| anyhow::anyhow!("Invalid exit gateway identity: {}", e))?, + ); + + // Convert exit gateway identity to ed25519 public key for registration + let exit_gateway_pubkey = ed25519::PublicKey::from_bytes(&exit_gateway.identity.to_bytes()) + .map_err(|e| anyhow::anyhow!("Invalid exit gateway identity: {}", e))?; + + // Perform handshake and registration with exit gateway via forwarding + if use_mock_ecash { + info!("Note: Using mock ecash mode - gateways must be started with --lp-use-mock-ecash"); + } + let exit_gateway_data = match nested_session + .handshake_and_register( + &mut entry_client, + &exit_wg_keypair, + &exit_gateway_pubkey, + bandwidth_controller, + TicketType::V1WireguardExit, + exit_ip, + ) + .await + { + Ok(data) => data, + Err(e) => { + error!("Failed to register with exit gateway: {}", e); + return Ok(wg_outcome); + } + }; + info!("Exit gateway registration successful via forwarding"); + + // STEP 3: Register with entry gateway + info!("Registering with entry gateway..."); + let entry_gateway_pubkey = + ed25519::PublicKey::from_bytes(&entry_gateway.identity.to_bytes()) + .map_err(|e| anyhow::anyhow!("Invalid entry gateway identity: {}", e))?; + + if let Err(e) = entry_client + .send_registration_request( + &entry_wg_keypair, + &entry_gateway_pubkey, + bandwidth_controller, + TicketType::V1WireguardEntry, + ) + .await + { + error!("Failed to send entry registration request: {}", e); + return Ok(wg_outcome); + } + + let _entry_gateway_data = match entry_client.receive_registration_response().await { + Ok(data) => data, + Err(e) => { + error!("Failed to receive entry registration response: {}", e); + return Ok(wg_outcome); + } + }; + info!("Entry gateway registration successful"); + + info!("LP registration successful for both gateways!"); + wg_outcome.can_register = true; + + // STEP 4: Test WireGuard tunnels using exit gateway configuration + // Convert keys to hex for netstack + let private_key_hex = hex::encode(exit_wg_keypair.private_key().to_bytes()); + let public_key_hex = hex::encode(exit_gateway_data.public_key.to_bytes()); + + // Build WireGuard endpoint address + let wg_endpoint = format!("{}:{}", exit_ip, exit_gateway_data.endpoint.port()); + + info!("Exit WireGuard configuration:"); + info!(" Private IPv4: {}", exit_gateway_data.private_ipv4); + info!(" Private IPv6: {}", exit_gateway_data.private_ipv6); + info!(" Endpoint: {}", wg_endpoint); + + // Run tunnel tests (copied from wg_probe) + let netstack_request = crate::netstack::NetstackRequest::new( + &exit_gateway_data.private_ipv4.to_string(), + &exit_gateway_data.private_ipv6.to_string(), + &private_key_hex, + &public_key_hex, + &wg_endpoint, + &format!("http://{WG_TUN_DEVICE_IP_ADDRESS_V4}:{WG_METADATA_PORT}"), + netstack_args.netstack_download_timeout_sec, + &awg_args, + netstack_args, + ); + + // Perform IPv4 ping test + info!("Testing IPv4 tunnel connectivity..."); + let ipv4_request = crate::netstack::NetstackRequestGo::from_rust_v4(&netstack_request); + + match crate::netstack::ping(&ipv4_request) { + Ok(NetstackResult::Response(netstack_response_v4)) => { + info!( + "Wireguard probe response for IPv4: {:#?}", + netstack_response_v4 + ); + wg_outcome.can_query_metadata_v4 = netstack_response_v4.can_query_metadata; + wg_outcome.can_handshake_v4 = netstack_response_v4.can_handshake; + wg_outcome.can_resolve_dns_v4 = netstack_response_v4.can_resolve_dns; + wg_outcome.ping_hosts_performance_v4 = + netstack_response_v4.received_hosts as f32 / netstack_response_v4.sent_hosts as f32; + wg_outcome.ping_ips_performance_v4 = + netstack_response_v4.received_ips as f32 / netstack_response_v4.sent_ips as f32; + + wg_outcome.download_duration_sec_v4 = netstack_response_v4.download_duration_sec; + wg_outcome.download_duration_milliseconds_v4 = + netstack_response_v4.download_duration_milliseconds; + wg_outcome.downloaded_file_size_bytes_v4 = + netstack_response_v4.downloaded_file_size_bytes; + wg_outcome.downloaded_file_v4 = netstack_response_v4.downloaded_file; + wg_outcome.download_error_v4 = netstack_response_v4.download_error; + } + Ok(NetstackResult::Error { error }) => { + error!("Netstack runtime error (IPv4): {error}") + } + Err(error) => { + error!("Internal error (IPv4): {error}") + } + } + + // Perform IPv6 ping test + info!("Testing IPv6 tunnel connectivity..."); + let ipv6_request = crate::netstack::NetstackRequestGo::from_rust_v6(&netstack_request); + + match crate::netstack::ping(&ipv6_request) { + Ok(NetstackResult::Response(netstack_response_v6)) => { + info!( + "Wireguard probe response for IPv6: {:#?}", + netstack_response_v6 + ); + wg_outcome.can_handshake_v6 = netstack_response_v6.can_handshake; + wg_outcome.can_resolve_dns_v6 = netstack_response_v6.can_resolve_dns; + wg_outcome.ping_hosts_performance_v6 = + netstack_response_v6.received_hosts as f32 / netstack_response_v6.sent_hosts as f32; + wg_outcome.ping_ips_performance_v6 = + netstack_response_v6.received_ips as f32 / netstack_response_v6.sent_ips as f32; + + wg_outcome.download_duration_sec_v6 = netstack_response_v6.download_duration_sec; + wg_outcome.download_duration_milliseconds_v6 = + netstack_response_v6.download_duration_milliseconds; + wg_outcome.downloaded_file_size_bytes_v6 = + netstack_response_v6.downloaded_file_size_bytes; + wg_outcome.downloaded_file_v6 = netstack_response_v6.downloaded_file; + wg_outcome.download_error_v6 = netstack_response_v6.download_error; + } + Ok(NetstackResult::Error { error }) => { + error!("Netstack runtime error (IPv6): {error}") + } + Err(error) => { + error!("Internal error (IPv6): {error}") + } + } + + info!("LP-based WireGuard probe completed"); + Ok(wg_outcome) +} + fn mixnet_debug_config( min_gateway_performance: Option, ignore_egress_epoch_role: bool, diff --git a/nym-gateway-probe/src/run.rs b/nym-gateway-probe/src/run.rs index f293733d2e6..05cffed838f 100644 --- a/nym-gateway-probe/src/run.rs +++ b/nym-gateway-probe/src/run.rs @@ -42,6 +42,12 @@ struct CliArgs { #[arg(long, global = true)] gateway_ip: Option, + /// The address of the exit gateway for LP forwarding tests (used with --test-lp-wg) + /// When specified, --gateway-ip becomes the entry gateway and this becomes the exit gateway + /// Supports formats: IP (192.168.66.5), IP:PORT (192.168.66.5:8080), HOST:PORT (localhost:30004) + #[arg(long, global = true)] + exit_gateway_ip: Option, + /// Identity of the node to test #[arg(long, short, value_parser = validate_node_identity, global = true)] node: Option, @@ -58,6 +64,10 @@ struct CliArgs { #[arg(long, global = true)] only_lp_registration: bool, + /// Test WireGuard via LP registration (no mixnet) - uses nested session forwarding + #[arg(long, global = true)] + test_lp_wg: bool, + /// Disable logging during probe #[arg(long, global = true)] ignore_egress_epoch_role: bool, @@ -129,11 +139,19 @@ pub(crate) async fn run() -> anyhow::Result { .map(|ep| ep.nyxd_url()) .ok_or(anyhow::anyhow!("missing nyxd url"))?; // If gateway IP is provided, query it directly without using the directory - let (entry, directory, gateway_node) = if let Some(gateway_ip) = args.gateway_ip { + let (entry, directory, gateway_node, exit_gateway_node) = if let Some(gateway_ip) = args.gateway_ip { info!("Using direct IP query mode for gateway: {}", gateway_ip); let gateway_node = query_gateway_by_ip(gateway_ip).await?; let identity = gateway_node.identity(); + // Query exit gateway if provided (for LP forwarding tests) + let exit_node = if let Some(exit_gateway_ip) = args.exit_gateway_ip { + info!("Using direct IP query mode for exit gateway: {}", exit_gateway_ip); + Some(query_gateway_by_ip(exit_gateway_ip).await?) + } else { + None + }; + // Still create the directory for potential secondary lookups, // but only if API URL is available let directory = if let Some(api_url) = network.endpoints.first().and_then(|ep| ep.api_url()) @@ -143,7 +161,7 @@ pub(crate) async fn run() -> anyhow::Result { None }; - (identity, directory, Some(gateway_node)) + (identity, directory, Some(gateway_node), exit_node) } else { // Original behavior: use directory service let api_url = network @@ -160,7 +178,7 @@ pub(crate) async fn run() -> anyhow::Result { directory.random_exit_with_ipr()? }; - (entry, Some(directory), None) + (entry, Some(directory), None, None) }; let test_point = if let Some(node) = args.node { @@ -169,7 +187,19 @@ pub(crate) async fn run() -> anyhow::Result { TestedNode::SameAsEntry }; - let mut trial = if let Some(gw_node) = gateway_node { + let mut trial = if let (Some(entry_node), Some(exit_node)) = (&gateway_node, &exit_gateway_node) { + // Both entry and exit gateways provided (for LP telescoping tests) + info!("Using both entry and exit gateways for LP forwarding test"); + nym_gateway_probe::Probe::new_with_gateways( + entry, + test_point, + args.netstack_args, + args.credential_args, + entry_node.clone(), + exit_node.clone(), + ) + } else if let Some(gw_node) = gateway_node { + // Only entry gateway provided nym_gateway_probe::Probe::new_with_gateway( entry, test_point, @@ -178,6 +208,7 @@ pub(crate) async fn run() -> anyhow::Result { gw_node, ) } else { + // No direct gateways, use directory lookup nym_gateway_probe::Probe::new(entry, test_point, args.netstack_args, args.credential_args) }; @@ -208,6 +239,7 @@ pub(crate) async fn run() -> anyhow::Result { args.ignore_egress_epoch_role, args.only_wireguard, args.only_lp_registration, + args.test_lp_wg, args.min_gateway_mixnet_performance, *use_mock_ecash, )) @@ -220,6 +252,7 @@ pub(crate) async fn run() -> anyhow::Result { args.ignore_egress_epoch_role, args.only_wireguard, args.only_lp_registration, + args.test_lp_wg, args.min_gateway_mixnet_performance, )) .await diff --git a/nym-registration-client/src/lib.rs b/nym-registration-client/src/lib.rs index 05d5ceeb7de..7e7313578b0 100644 --- a/nym-registration-client/src/lib.rs +++ b/nym-registration-client/src/lib.rs @@ -12,7 +12,6 @@ use nym_sdk::mixnet::{EventReceiver, MixnetClient, Recipient}; use std::sync::Arc; use crate::config::RegistrationClientConfig; -use crate::lp_client::{LpClientError, LpTransport}; mod builder; mod config; @@ -27,7 +26,7 @@ pub use builder::config::{ }; pub use config::RegistrationMode; pub use error::RegistrationClientError; -pub use lp_client::{LpConfig, LpRegistrationClient}; +pub use lp_client::{LpConfig, LpRegistrationClient, NestedLpSession}; pub use types::{ LpRegistrationResult, MixnetRegistrationResult, RegistrationResult, WireguardRegistrationResult, }; @@ -153,6 +152,8 @@ impl RegistrationClient { } async fn register_lp(self) -> Result { + use crate::lp_client::{LpRegistrationClient, NestedLpSession}; + // Extract and validate LP addresses let entry_lp_address = self.config.entry.node.lp_address.ok_or( RegistrationClientError::LpRegistrationNotPossible { @@ -176,118 +177,102 @@ impl RegistrationClient { let entry_lp_keypair = Arc::new(ed25519::KeyPair::new(&mut OsRng)); let exit_lp_keypair = Arc::new(ed25519::KeyPair::new(&mut OsRng)); - // Register entry gateway via LP - let entry_fut = { - let bandwidth_controller = &self.bandwidth_controller; - let entry_keys = self.config.entry.keys.clone(); - let entry_identity = self.config.entry.node.identity; - let entry_ip = self.config.entry.node.ip_address; - let entry_lp_keys = entry_lp_keypair.clone(); - - async move { - let mut client = LpRegistrationClient::new_with_default_psk( - entry_lp_keys, - entry_identity, - entry_lp_address, - entry_ip, - ); - - // Connect - client.connect().await?; - - // Perform handshake - client.perform_handshake().await?; - - // Send registration request - client - .send_registration_request( - &entry_keys, - &entry_identity, - &**bandwidth_controller, - TicketType::V1WireguardEntry, - ) - .await?; - - // Receive registration response - let gateway_data = client.receive_registration_response().await?; - - // Convert to transport for ongoing communication - let transport = client.into_transport()?; - - Ok::<(LpTransport, _), LpClientError>((transport, gateway_data)) - } - }; - - // Register exit gateway via LP - let exit_fut = { - let bandwidth_controller = &self.bandwidth_controller; - let exit_keys = self.config.exit.keys.clone(); - let exit_identity = self.config.exit.node.identity; - let exit_ip = self.config.exit.node.ip_address; - let exit_lp_keys = exit_lp_keypair; - - async move { - let mut client = LpRegistrationClient::new_with_default_psk( - exit_lp_keys, - exit_identity, - exit_lp_address, - exit_ip, - ); - - // Connect - client.connect().await?; - - // Perform handshake - client.perform_handshake().await?; - - // Send registration request - client - .send_registration_request( - &exit_keys, - &exit_identity, - &**bandwidth_controller, - TicketType::V1WireguardExit, - ) - .await?; - - // Receive registration response - let gateway_data = client.receive_registration_response().await?; - - // Convert to transport for ongoing communication - let transport = client.into_transport()?; - - Ok::<(LpTransport, _), LpClientError>((transport, gateway_data)) - } - }; - - // Execute registrations in parallel - let (entry_result, exit_result) = - Box::pin(async { tokio::join!(entry_fut, exit_fut) }).await; - - // Handle entry gateway result - // Note: entry_transport is dropped here, closing the LP connection - let (_entry_transport, entry_gateway_data) = - entry_result.map_err(|source| RegistrationClientError::EntryGatewayRegisterLp { + // STEP 1: Establish outer session with entry gateway + // This creates the LP connection that will be used to forward packets to exit + tracing::info!("Establishing outer session with entry gateway"); + let mut entry_client = LpRegistrationClient::new_with_default_psk( + entry_lp_keypair.clone(), + self.config.entry.node.identity, + entry_lp_address, + self.config.entry.node.ip_address, + ); + + // Connect to entry gateway + entry_client + .connect() + .await + .map_err(|source| RegistrationClientError::EntryGatewayRegisterLp { + gateway_id: self.config.entry.node.identity.to_base58_string(), + lp_address: entry_lp_address, + source: Box::new(source), + })?; + + // Perform handshake with entry gateway (outer session now established) + entry_client + .perform_handshake() + .await + .map_err(|source| RegistrationClientError::EntryGatewayRegisterLp { gateway_id: self.config.entry.node.identity.to_base58_string(), lp_address: entry_lp_address, source: Box::new(source), })?; - // Handle exit gateway result - // Note: exit_transport is dropped here, closing the LP connection - let (_exit_transport, exit_gateway_data) = - exit_result.map_err(|source| RegistrationClientError::ExitGatewayRegisterLp { + tracing::info!("Outer session with entry gateway established"); + + // STEP 2: Use nested session to register with exit gateway via forwarding + // This hides the client's IP address from the exit gateway + tracing::info!("Registering with exit gateway via entry forwarding"); + let mut nested_session = NestedLpSession::new( + self.config.exit.node.identity.to_bytes(), + exit_lp_address.to_string(), + exit_lp_keypair, + self.config.exit.node.identity, + ); + + // Perform handshake and registration with exit gateway (all via entry forwarding) + let exit_gateway_data = nested_session + .handshake_and_register( + &mut entry_client, + &self.config.exit.keys, + &self.config.exit.node.identity, + &*self.bandwidth_controller, + TicketType::V1WireguardExit, + self.config.exit.node.ip_address, + ) + .await + .map_err(|source| RegistrationClientError::ExitGatewayRegisterLp { gateway_id: self.config.exit.node.identity.to_base58_string(), lp_address: exit_lp_address, source: Box::new(source), })?; + tracing::info!("Exit gateway registration completed via forwarding"); + + // STEP 3: Send registration request to entry gateway + tracing::info!("Sending registration request to entry gateway"); + entry_client + .send_registration_request( + &self.config.entry.keys, + &self.config.entry.node.identity, + &*self.bandwidth_controller, + TicketType::V1WireguardEntry, + ) + .await + .map_err(|source| RegistrationClientError::EntryGatewayRegisterLp { + gateway_id: self.config.entry.node.identity.to_base58_string(), + lp_address: entry_lp_address, + source: Box::new(source), + })?; + + // Receive registration response from entry + let entry_gateway_data = entry_client + .receive_registration_response() + .await + .map_err(|source| RegistrationClientError::EntryGatewayRegisterLp { + gateway_id: self.config.entry.node.identity.to_base58_string(), + lp_address: entry_lp_address, + source: Box::new(source), + })?; + + tracing::info!("Entry gateway registration successful"); + tracing::info!( "LP registration successful for both gateways (LP connections will be closed)" ); // LP is registration-only. All data flows through WireGuard after this point. - // The LP transports have been dropped, automatically closing TCP connections. + // The entry LP connection will be dropped, automatically closing TCP connection. + // Exit registration was completed via forwarding through entry, so no direct connection exists. Ok(RegistrationResult::Lp(Box::new(LpRegistrationResult { entry_gateway_data, exit_gateway_data, diff --git a/nym-registration-client/src/lp_client/client.rs b/nym-registration-client/src/lp_client/client.rs index e541a587665..ffd40d59ad5 100644 --- a/nym-registration-client/src/lp_client/client.rs +++ b/nym-registration-client/src/lp_client/client.rs @@ -12,6 +12,7 @@ use nym_credentials_interface::{CredentialSpendingData, TicketType}; use nym_crypto::asymmetric::{ed25519, x25519}; use nym_lp::LpPacket; use nym_lp::codec::{parse_lp_packet, serialize_lp_packet}; +use nym_lp::message::ForwardPacketData; use nym_lp::state_machine::{LpAction, LpInput, LpStateMachine}; use nym_registration_common::{GatewayData, LpRegistrationRequest, LpRegistrationResponse}; use nym_wireguard_types::PeerPublicKey; @@ -734,6 +735,140 @@ impl LpRegistrationClient { Ok(gateway_data) } + /// Sends a ForwardPacket message to the entry gateway for forwarding to the exit gateway. + /// + /// This method constructs a ForwardPacket containing the target gateway's identity, + /// address, and the inner LP packet bytes, encrypts it through the outer session + /// (client-entry), and receives the response from the exit gateway via the entry gateway. + /// + /// # Arguments + /// * `target_identity` - Target gateway's Ed25519 identity (32 bytes) + /// * `target_address` - Target gateway's LP address (e.g., "1.1.1.1:41264") + /// * `inner_packet_bytes` - Complete inner LP packet bytes to forward to exit gateway + /// + /// # Returns + /// * `Ok(Vec)` - Decrypted response bytes from the exit gateway + /// + /// # Errors + /// Returns an error if: + /// - No connection is established + /// - Handshake has not been completed + /// - Serialization fails + /// - Encryption or network transmission fails + /// - Response decryption fails + /// + /// # Example Flow + /// ```ignore + /// // Construct inner packet for exit gateway (ClientHello, handshake, etc.) + /// let inner_packet = LpPacket::new(...); + /// let inner_bytes = serialize_lp_packet(&inner_packet, &mut BytesMut::new())?; + /// + /// // Forward through entry gateway + /// let response_bytes = client.send_forward_packet( + /// exit_identity, + /// "2.2.2.2:41264".to_string(), + /// inner_bytes.to_vec(), + /// ).await?; + /// ``` + pub async fn send_forward_packet( + &mut self, + target_identity: [u8; 32], + target_address: String, + inner_packet_bytes: Vec, + ) -> Result> { + // Ensure we have a TCP connection + let stream = self.tcp_stream.as_mut().ok_or_else(|| { + LpClientError::Transport("Cannot send forward packet: not connected".to_string()) + })?; + + // Ensure handshake is complete (state machine exists and is in Transport state) + let state_machine = self.state_machine.as_mut().ok_or_else(|| { + LpClientError::Transport( + "Cannot send forward packet: handshake not completed".to_string(), + ) + })?; + + tracing::debug!( + "Sending ForwardPacket to {} ({} inner bytes)", + target_address, + inner_packet_bytes.len() + ); + + // 1. Construct ForwardPacketData + let forward_data = ForwardPacketData { + target_gateway_identity: target_identity, + target_lp_address: target_address.clone(), + inner_packet_bytes, + }; + + // 2. Serialize the ForwardPacketData + let forward_data_bytes = bincode::serialize(&forward_data).map_err(|e| { + LpClientError::Transport(format!("Failed to serialize ForwardPacketData: {}", e)) + })?; + + tracing::trace!( + "Serialized ForwardPacketData ({} bytes)", + forward_data_bytes.len() + ); + + // 3. Encrypt and prepare packet via state machine + let action = state_machine + .process_input(LpInput::SendData(forward_data_bytes)) + .ok_or_else(|| { + LpClientError::Transport("State machine returned no action".to_string()) + })? + .map_err(|e| { + LpClientError::Transport(format!("Failed to encrypt ForwardPacket: {}", e)) + })?; + + // 4. Send the encrypted packet + match action { + LpAction::SendPacket(packet) => { + Self::send_packet(stream, &packet).await?; + tracing::trace!("Sent encrypted ForwardPacket to entry gateway"); + } + other => { + return Err(LpClientError::Transport(format!( + "Unexpected action when sending ForwardPacket: {:?}", + other + ))); + } + } + + // 5. Receive the response from entry gateway + let response_packet = Self::receive_packet(stream).await?; + tracing::trace!("Received response packet from entry gateway"); + + // 6. Decrypt via state machine + let action = state_machine + .process_input(LpInput::ReceivePacket(response_packet)) + .ok_or_else(|| { + LpClientError::Transport("State machine returned no action".to_string()) + })? + .map_err(|e| { + LpClientError::Transport(format!("Failed to decrypt forward response: {}", e)) + })?; + + // 7. Extract decrypted response data + let response_data = match action { + LpAction::DeliverData(data) => data, + other => { + return Err(LpClientError::Transport(format!( + "Unexpected action when receiving forward response: {:?}", + other + ))); + } + }; + + tracing::debug!( + "Successfully received forward response from {} ({} bytes)", + target_address, + response_data.len() + ); + + Ok(response_data.to_vec()) + } + /// Converts this client into an LpTransport for ongoing post-handshake communication. /// /// This consumes the client and transfers ownership of the TCP stream and state machine diff --git a/nym-registration-client/src/lp_client/mod.rs b/nym-registration-client/src/lp_client/mod.rs index 6a145fdaca1..7be34c37aef 100644 --- a/nym-registration-client/src/lp_client/mod.rs +++ b/nym-registration-client/src/lp_client/mod.rs @@ -33,9 +33,10 @@ mod client; mod config; mod error; +mod nested_session; mod transport; pub use client::LpRegistrationClient; pub use config::LpConfig; pub use error::LpClientError; -pub use transport::LpTransport; +pub use nested_session::NestedLpSession; diff --git a/nym-registration-client/src/lp_client/nested_session.rs b/nym-registration-client/src/lp_client/nested_session.rs new file mode 100644 index 00000000000..a2a4536af57 --- /dev/null +++ b/nym-registration-client/src/lp_client/nested_session.rs @@ -0,0 +1,543 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! Nested LP session for client-exit handshake through entry gateway forwarding. +//! +//! This module implements the inner LP session management where a client establishes +//! a secure connection with an exit gateway by forwarding LP packets through an +//! entry gateway. This hides the client's IP address from the exit gateway. +//! +//! # Architecture +//! +//! ```text +//! Client ←→ Entry Gateway (outer session, encrypted) +//! ↓ forwards +//! Exit Gateway (inner session, client establishes handshake) +//! ``` +//! +//! The entry gateway sees the client's IP but doesn't know the final destination. +//! The exit gateway processes the LP handshake but only sees the entry gateway's IP. + +use super::client::LpRegistrationClient; +use super::error::{LpClientError, Result}; +use bytes::BytesMut; +use nym_bandwidth_controller::BandwidthTicketProvider; +use nym_credentials_interface::TicketType; +use nym_crypto::asymmetric::{ed25519, x25519}; +use nym_lp::codec::{parse_lp_packet, serialize_lp_packet}; +use nym_lp::state_machine::{LpAction, LpInput, LpStateMachine}; +use nym_lp::{LpMessage, LpPacket}; +use nym_registration_common::{GatewayData, LpRegistrationRequest, LpRegistrationResponse}; +use nym_wireguard_types::PeerPublicKey; +use std::net::IpAddr; +use std::sync::Arc; + +/// Manages a nested LP session where the client establishes a handshake with +/// an exit gateway by forwarding packets through an entry gateway. +/// +/// # Example +/// +/// ```ignore +/// // Outer session already established with entry gateway +/// let mut outer_client = LpRegistrationClient::new(...); +/// outer_client.connect().await?; +/// outer_client.perform_handshake().await?; +/// +/// // Now establish inner session with exit gateway +/// let nested = NestedLpSession::new( +/// exit_identity, +/// "2.2.2.2:41264".to_string(), +/// client_keypair, +/// exit_public_key, +/// ); +/// +/// let exit_session = nested.perform_handshake(&mut outer_client).await?; +/// ``` +pub struct NestedLpSession { + /// Exit gateway's Ed25519 identity (32 bytes) + exit_identity: [u8; 32], + + /// Exit gateway's LP address (e.g., "2.2.2.2:41264") + exit_address: String, + + /// Client's Ed25519 keypair (for PSQ authentication and X25519 derivation) + client_keypair: Arc, + + /// Exit gateway's Ed25519 public key + exit_public_key: ed25519::PublicKey, + + /// LP state machine for exit gateway session (populated after handshake) + state_machine: Option, +} + +impl NestedLpSession { + /// Creates a new nested LP session handler. + /// + /// # Arguments + /// * `exit_identity` - Exit gateway's Ed25519 identity (32 bytes) + /// * `exit_address` - Exit gateway's LP address (e.g., "2.2.2.2:41264") + /// * `client_keypair` - Client's Ed25519 keypair + /// * `exit_public_key` - Exit gateway's Ed25519 public key + pub fn new( + exit_identity: [u8; 32], + exit_address: String, + client_keypair: Arc, + exit_public_key: ed25519::PublicKey, + ) -> Self { + Self { + exit_identity, + exit_address, + client_keypair, + exit_public_key, + state_machine: None, + } + } + + /// Performs the LP handshake with the exit gateway by forwarding packets + /// through the entry gateway. + /// + /// This method: + /// 1. Generates ClientHello for exit gateway + /// 2. Creates LP state machine for exit handshake + /// 3. Runs handshake loop, forwarding all packets through entry gateway + /// 4. Stores established session in internal state machine + /// + /// # Arguments + /// * `outer_client` - Connected LP client with established outer session to entry gateway + /// + /// # Errors + /// Returns an error if: + /// - Packet serialization/parsing fails + /// - Forwarding through entry gateway fails + /// - Exit gateway handshake fails + /// - Cryptographic operations fail + async fn perform_handshake( + &mut self, + outer_client: &mut LpRegistrationClient, + ) -> Result<()> { + tracing::debug!( + "Starting nested LP handshake with exit gateway {}", + self.exit_address + ); + + // Step 1: Derive X25519 keys from Ed25519 for Noise protocol + let client_x25519_public = self + .client_keypair + .public_key() + .to_x25519() + .map_err(|e| { + LpClientError::Crypto(format!("Failed to derive X25519 public key: {}", e)) + })?; + + // Step 2: Generate ClientHello for exit gateway + let client_hello_data = nym_lp::ClientHelloData::new_with_fresh_salt( + client_x25519_public.to_bytes(), + self.client_keypair.public_key().to_bytes(), + ); + let salt = client_hello_data.salt; + + tracing::trace!( + "Generated ClientHello for exit gateway (timestamp: {})", + client_hello_data.extract_timestamp() + ); + + // Step 3: Send ClientHello to exit gateway via forwarding + let client_hello_header = nym_lp::packet::LpHeader::new( + 0, // session_id not yet established + 0, // counter starts at 0 + ); + let client_hello_packet = nym_lp::LpPacket::new( + client_hello_header, + LpMessage::ClientHello(client_hello_data), + ); + + // Serialize and forward ClientHello + let client_hello_bytes = Self::serialize_packet(&client_hello_packet)?; + let _response_bytes = outer_client + .send_forward_packet( + self.exit_identity, + self.exit_address.clone(), + client_hello_bytes, + ) + .await?; + + tracing::debug!("Sent ClientHello to exit gateway via entry"); + + // Step 4: Create state machine for exit gateway handshake + let mut state_machine = LpStateMachine::new( + true, // is_initiator + ( + self.client_keypair.private_key(), + self.client_keypair.public_key(), + ), + &self.exit_public_key, + &salt, + )?; + + // Step 5: Start handshake - send initial handshake packet + if let Some(action) = state_machine.process_input(LpInput::StartHandshake) { + match action? { + LpAction::SendPacket(packet) => { + tracing::trace!("Sending initial handshake packet to exit"); + let packet_bytes = Self::serialize_packet(&packet)?; + let response_bytes = outer_client + .send_forward_packet( + self.exit_identity, + self.exit_address.clone(), + packet_bytes, + ) + .await?; + + // Parse response and feed to state machine + let response_packet = Self::parse_packet(&response_bytes)?; + tracing::trace!("Received handshake response from exit"); + + // Process response through state machine + if let Some(action) = + state_machine.process_input(LpInput::ReceivePacket(response_packet)) + { + match action? { + LpAction::SendPacket(response_packet) => { + // Send response packet + tracing::trace!("Sending handshake response to exit"); + let packet_bytes = Self::serialize_packet(&response_packet)?; + let response_bytes = outer_client + .send_forward_packet( + self.exit_identity, + self.exit_address.clone(), + packet_bytes, + ) + .await?; + + // Check if handshake completed after sending + if state_machine.session()?.is_handshake_complete() { + tracing::info!( + "Nested LP handshake completed with exit gateway" + ); + self.state_machine = Some(state_machine); + return Ok(()); + } + + // Process the response from exit gateway + let response_packet = Self::parse_packet(&response_bytes)?; + if let Some(action) = state_machine + .process_input(LpInput::ReceivePacket(response_packet)) + { + match action? { + LpAction::HandshakeComplete => { + tracing::info!( + "Nested LP handshake completed with exit gateway" + ); + self.state_machine = Some(state_machine); + return Ok(()); + } + LpAction::SendPacket(_) => { + // More rounds needed - fall through to loop + tracing::trace!("More handshake rounds needed"); + } + other => { + tracing::trace!("Action after send: {:?}", other); + } + } + } + } + LpAction::HandshakeComplete => { + tracing::info!("Nested LP handshake completed with exit gateway"); + self.state_machine = Some(state_machine); + return Ok(()); + } + LpAction::KKTComplete => { + tracing::info!("KKT exchange completed with exit, starting Noise"); + // After KKT completes, initiator must send first Noise handshake message + let noise_msg = state_machine + .session()? + .prepare_handshake_message() + .ok_or_else(|| { + LpClientError::Transport( + "No handshake message available after KKT".to_string(), + ) + })??; + let noise_packet = state_machine.session()?.next_packet(noise_msg)?; + tracing::trace!("Sending first Noise handshake message to exit"); + let packet_bytes = Self::serialize_packet(&noise_packet)?; + let response_bytes = outer_client + .send_forward_packet( + self.exit_identity, + self.exit_address.clone(), + packet_bytes, + ) + .await?; + + // Process the Noise response from exit gateway + let response_packet = Self::parse_packet(&response_bytes)?; + if let Some(action) = state_machine + .process_input(LpInput::ReceivePacket(response_packet)) + { + match action? { + LpAction::HandshakeComplete => { + tracing::info!( + "Nested LP handshake completed with exit gateway" + ); + self.state_machine = Some(state_machine); + return Ok(()); + } + LpAction::SendPacket(final_packet) => { + tracing::trace!("Sending final handshake packet to exit"); + let packet_bytes = Self::serialize_packet(&final_packet)?; + let _ = outer_client + .send_forward_packet( + self.exit_identity, + self.exit_address.clone(), + packet_bytes, + ) + .await?; + + // Check if complete after sending final packet + if state_machine.session()?.is_handshake_complete() { + tracing::info!( + "Nested LP handshake completed with exit gateway" + ); + self.state_machine = Some(state_machine); + return Ok(()); + } + } + other => { + tracing::trace!( + "Action after Noise response: {:?}", + other + ); + } + } + } + } + other => { + tracing::trace!("Received action during handshake: {:?}", other); + } + } + } + } + other => { + return Err(LpClientError::Transport(format!( + "Unexpected action at handshake start: {:?}", + other + ))); + } + } + } + + // If we reach here, the handshake didn't complete properly + Err(LpClientError::Transport( + "Nested handshake completed without reaching HandshakeComplete state".to_string(), + )) + } + + /// Performs handshake and registration with the exit gateway via forwarding. + /// + /// This is the main entry point for nested LP registration. It: + /// 1. Performs handshake with exit gateway (via `perform_handshake`) + /// 2. Builds and sends registration request through the forwarded connection + /// 3. Receives and processes registration response + /// 4. Returns gateway data on successful registration + /// + /// # Arguments + /// * `outer_client` - Connected LP client with established outer session to entry gateway + /// * `wg_keypair` - Client's WireGuard x25519 keypair + /// * `gateway_identity` - Exit gateway's Ed25519 identity (for credential verification) + /// * `bandwidth_controller` - Provider for bandwidth credentials + /// * `ticket_type` - Type of bandwidth ticket to use + /// * `client_ip` - Client IP address for registration metadata + /// + /// # Returns + /// * `Ok(GatewayData)` - Exit gateway configuration data on successful registration + /// + /// # Errors + /// Returns an error if: + /// - Handshake fails + /// - Credential acquisition fails + /// - Request serialization/encryption fails + /// - Forwarding through entry gateway fails + /// - Response decryption/deserialization fails + /// - Gateway rejects the registration + pub async fn handshake_and_register( + &mut self, + outer_client: &mut LpRegistrationClient, + wg_keypair: &x25519::KeyPair, + gateway_identity: &ed25519::PublicKey, + bandwidth_controller: &dyn BandwidthTicketProvider, + ticket_type: TicketType, + client_ip: IpAddr, + ) -> Result { + // Step 1: Perform handshake with exit gateway via forwarding + self.perform_handshake(outer_client).await?; + + // Step 2: Get the state machine (must exist after successful handshake) + let state_machine = self.state_machine.as_mut().ok_or_else(|| { + LpClientError::Transport("State machine missing after handshake".to_string()) + })?; + + tracing::debug!("Building registration request for exit gateway"); + + // Step 3: Acquire bandwidth credential + let credential = bandwidth_controller + .get_ecash_ticket(ticket_type, *gateway_identity, nym_bandwidth_controller::DEFAULT_TICKETS_TO_SPEND) + .await + .map_err(|e| { + LpClientError::Transport(format!( + "Failed to acquire bandwidth credential: {}", + e + )) + })? + .data; + + // Step 4: Build registration request + let wg_public_key = PeerPublicKey::new(wg_keypair.public_key().to_bytes().into()); + let request = LpRegistrationRequest::new_dvpn(wg_public_key, credential, ticket_type, client_ip); + + tracing::trace!("Built registration request: {:?}", request); + + // Step 5: Serialize the request + let request_bytes = bincode::serialize(&request).map_err(|e| { + LpClientError::Transport(format!("Failed to serialize registration request: {}", e)) + })?; + + tracing::debug!( + "Sending registration request to exit gateway via forwarding ({} bytes)", + request_bytes.len() + ); + + // Step 6: Encrypt and prepare packet via state machine + let action = state_machine + .process_input(LpInput::SendData(request_bytes)) + .ok_or_else(|| { + LpClientError::Transport("State machine returned no action".to_string()) + })? + .map_err(|e| { + LpClientError::Transport(format!( + "Failed to encrypt registration request: {}", + e + )) + })?; + + // Step 7: Send the encrypted packet via forwarding + let response_bytes = match action { + LpAction::SendPacket(packet) => { + let packet_bytes = Self::serialize_packet(&packet)?; + outer_client + .send_forward_packet( + self.exit_identity, + self.exit_address.clone(), + packet_bytes, + ) + .await? + } + other => { + return Err(LpClientError::Transport(format!( + "Unexpected action when sending registration data: {:?}", + other + ))); + } + }; + + tracing::trace!("Received registration response from exit gateway"); + + // Step 8: Parse response bytes to LP packet + let response_packet = Self::parse_packet(&response_bytes)?; + + // Step 9: Decrypt via state machine + let action = state_machine + .process_input(LpInput::ReceivePacket(response_packet)) + .ok_or_else(|| { + LpClientError::Transport("State machine returned no action".to_string()) + })? + .map_err(|e| { + LpClientError::Transport(format!( + "Failed to decrypt registration response: {}", + e + )) + })?; + + // Step 10: Extract decrypted data + let response_data = match action { + LpAction::DeliverData(data) => data, + other => { + return Err(LpClientError::Transport(format!( + "Unexpected action when receiving registration response: {:?}", + other + ))); + } + }; + + // Step 11: Deserialize the response + let response: LpRegistrationResponse = + bincode::deserialize(&response_data).map_err(|e| { + LpClientError::Transport(format!( + "Failed to deserialize registration response: {}", + e + )) + })?; + + tracing::debug!( + "Received registration response from exit: success={}, session_id={}", + response.success, + response.session_id + ); + + // Step 12: Validate and extract GatewayData + if !response.success { + let error_msg = response + .error + .unwrap_or_else(|| "Unknown error".to_string()); + tracing::warn!("Exit gateway rejected registration: {}", error_msg); + return Err(LpClientError::RegistrationRejected { reason: error_msg }); + } + + // Extract gateway_data + let gateway_data = response.gateway_data.ok_or_else(|| { + LpClientError::Transport( + "Gateway response missing gateway_data despite success=true".to_string(), + ) + })?; + + tracing::info!( + "Exit gateway registration successful! Session ID: {}, Allocated bandwidth: {} bytes", + response.session_id, + response.allocated_bandwidth + ); + + Ok(gateway_data) + } + + /// Serializes an LP packet to bytes. + /// + /// # Arguments + /// * `packet` - The LP packet to serialize + /// + /// # Returns + /// * `Ok(Vec)` - Serialized packet bytes + /// + /// # Errors + /// Returns an error if serialization fails + fn serialize_packet(packet: &LpPacket) -> Result> { + let mut buf = BytesMut::new(); + serialize_lp_packet(packet, &mut buf).map_err(|e| { + LpClientError::Transport(format!("Failed to serialize LP packet: {}", e)) + })?; + Ok(buf.to_vec()) + } + + /// Parses an LP packet from bytes. + /// + /// # Arguments + /// * `bytes` - The bytes to parse + /// + /// # Returns + /// * `Ok(LpPacket)` - Parsed LP packet + /// + /// # Errors + /// Returns an error if parsing fails + fn parse_packet(bytes: &[u8]) -> Result { + parse_lp_packet(bytes).map_err(|e| { + LpClientError::Transport(format!("Failed to parse LP packet: {}", e)) + }) + } +} From c1258f761f1a0c51444d94e5789f8a281dcc07a8 Mon Sep 17 00:00:00 2001 From: durch Date: Tue, 25 Nov 2025 10:41:35 +0100 Subject: [PATCH 02/14] Add session state storage to LpHandlerState [nym-qtji] Add in-memory state storage for handshake and session state to enable stateless transport layer. This is the foundation for packet-per-connection forwarding and future UDP support. Changes: - Add handshake_states: Arc> Keyed by client Ed25519 public key for in-progress handshakes - Add session_states: Arc> Keyed by session_id for established sessions - Initialize both maps in production code (node/mod.rs) - Initialize both maps in test code (handler.rs) No logic changes yet - pure infrastructure addition. All existing tests pass. Fixes: nym-qtji --- gateway/src/node/lp_listener/handler.rs | 2 ++ gateway/src/node/lp_listener/mod.rs | 16 ++++++++++++++++ gateway/src/node/mod.rs | 2 ++ 3 files changed, 20 insertions(+) diff --git a/gateway/src/node/lp_listener/handler.rs b/gateway/src/node/lp_listener/handler.rs index 0aaaf1e26dc..4289c0c0628 100644 --- a/gateway/src/node/lp_listener/handler.rs +++ b/gateway/src/node/lp_listener/handler.rs @@ -743,6 +743,8 @@ mod tests { active_clients_store: ActiveClientsStore::new(), wg_peer_controller: None, wireguard_data: None, + handshake_states: Arc::new(dashmap::DashMap::new()), + session_states: Arc::new(dashmap::DashMap::new()), } } diff --git a/gateway/src/node/lp_listener/mod.rs b/gateway/src/node/lp_listener/mod.rs index 11c989c16d0..a2ac9c08c7b 100644 --- a/gateway/src/node/lp_listener/mod.rs +++ b/gateway/src/node/lp_listener/mod.rs @@ -59,8 +59,11 @@ use crate::error::GatewayError; use crate::node::ActiveClientsStore; +use dashmap::DashMap; use nym_crypto::asymmetric::ed25519; use nym_gateway_storage::GatewayStorage; +use nym_lp::state_machine::LpStateMachine; +use nym_lp::LpSession; use nym_node_metrics::NymNodeMetrics; use nym_task::ShutdownTracker; use nym_wireguard::{PeerControlRequest, WireguardGatewayData}; @@ -186,6 +189,19 @@ pub struct LpHandlerState { /// LP configuration (for timestamp validation, etc.) pub lp_config: LpConfig, + + /// In-progress handshakes keyed by client Ed25519 public key (from ClientHello) + /// + /// Used during handshake phase before session_id is established (session_id=0). + /// After handshake completes, state moves to session_states map. + pub handshake_states: Arc>, + + /// Established sessions keyed by session_id + /// + /// Used after handshake completes (session_id is deterministically computed from + /// both parties' X25519 keys). Enables stateless transport - each packet lookup + /// by session_id, decrypt/process, respond. + pub session_states: Arc>, } /// LP listener that accepts TCP connections on port 41264 diff --git a/gateway/src/node/mod.rs b/gateway/src/node/mod.rs index 1edbdd3e24c..fe0e95f6ed3 100644 --- a/gateway/src/node/mod.rs +++ b/gateway/src/node/mod.rs @@ -337,6 +337,8 @@ impl GatewayTasksBuilder { wg_peer_controller, wireguard_data: self.wireguard_data.as_ref().map(|wd| wd.inner.clone()), lp_config: self.config.lp.clone(), + handshake_states: Arc::new(dashmap::DashMap::new()), + session_states: Arc::new(dashmap::DashMap::new()), }; // Parse bind address from config From 953cbb52d11a3c17fcf479193a5c038b3a18844d Mon Sep 17 00:00:00 2001 From: durch Date: Tue, 25 Nov 2025 10:56:05 +0100 Subject: [PATCH 03/14] Refactor exit gateway to single-packet processing [nym-21th] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major architectural change: decouple handshake state from TCP connection to enable packet-per-connection forwarding and future UDP support. ## Changes ### Gateway Handler (handler.rs) - Refactor `handle()` from persistent-connection loop to single-packet processing - Add `handle_client_hello()`: Process ClientHello (session_id=0), create LpStateMachine, store in handshake_states map - Add `handle_handshake_packet()`: Process handshake packets incrementally, transition to session_states when complete - Add `handle_transport_packet()`: Handle registration/forward requests via established sessions ### State Management - Use handshake_states (DashMap) for in-progress handshakes - Use session_states (DashMap) for established sessions - Session ID computed deterministically from X25519 keys immediately after ClientHello - State persists across connection closes ### Packet Flow ``` packet arrives → parse session_id from header if session_id == 0: handle_client_hello (create state) elif in handshake_states: handle_handshake_packet (advance state) elif in session_states: handle_transport_packet (decrypt, process) else: error (unknown session) send response → close connection ``` ## Testing - All 13 existing unit tests pass - Tests verify low-level packet I/O, timestamp validation, etc. - Integration testing via nym-gateway-probe (next step) ## Backwards Compatibility - Old handshake.rs methods kept but unused (may remove later) - Metrics unchanged - Protocol wire format unchanged ## Next Steps - Test with docker/localnet topology - Verify telescoping works (nym-gateway-probe) - Apply same architecture to entry gateway (nym-31hl) Fixes: nym-21th Blocks: nym-31hl --- gateway/src/node/lp_listener/handler.rs | 363 ++++++++++++++++++------ gateway/src/node/lp_listener/mod.rs | 9 +- 2 files changed, 277 insertions(+), 95 deletions(-) diff --git a/gateway/src/node/lp_listener/handler.rs b/gateway/src/node/lp_listener/handler.rs index 4289c0c0628..20c42ba2765 100644 --- a/gateway/src/node/lp_listener/handler.rs +++ b/gateway/src/node/lp_listener/handler.rs @@ -1,7 +1,6 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use super::handshake::LpGatewayHandshake; use super::messages::{LpRegistrationRequest, LpRegistrationResponse}; use super::registration::process_registration; use super::LpHandlerState; @@ -13,22 +12,8 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tracing::*; -// Histogram buckets for LP operation duration tracking -// Covers typical LP operations from 10ms to 10 seconds -// - Most handshakes should complete in < 100ms -// - Registration with credential verification typically 100ms - 1s -// - Slow operations (network issues, DB contention) up to 10s -const LP_DURATION_BUCKETS: &[f64] = &[ - 0.01, // 10ms - 0.05, // 50ms - 0.1, // 100ms - 0.25, // 250ms - 0.5, // 500ms - 1.0, // 1s - 2.5, // 2.5s - 5.0, // 5s - 10.0, // 10s -]; +// Histogram buckets for LP operation duration (legacy - used by unused forwarding methods) +const LP_DURATION_BUCKETS: &[f64] = &[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]; // Histogram buckets for LP connection lifecycle duration // LP connections can be very short (registration only: ~1s) or very long (dVPN sessions: hours/days) @@ -101,116 +86,312 @@ impl LpConnectionHandler { // Track total LP connections handled inc!("lp_connections_total"); - // The state machine now accepts only Ed25519 keys and internally derives X25519 keys. - // This simplifies the API by removing manual key conversion from the caller. - // Gateway's Ed25519 identity is used for both PSQ authentication and X25519 derivation. + // ============================================================ + // SINGLE-PACKET PROCESSING: Process ONE packet then close + // State persists in LpHandlerState maps between connections + // ============================================================ - // Receive client's public key and salt via ClientHello message - // The client initiates by sending ClientHello as first packet - let (_client_pubkey, client_ed25519_pubkey, salt) = match self.receive_client_hello().await - { - Ok(result) => result, + // Step 1: Receive the packet + let packet = match self.receive_lp_packet().await { + Ok(p) => p, Err(e) => { - // Track ClientHello failures (timestamp validation, protocol errors, etc.) - inc!("lp_client_hello_failed"); - // Emit lifecycle metrics before returning + inc!("lp_errors_receive_packet"); self.emit_lifecycle_metrics(false); return Err(e); } }; - // Create LP handshake as responder - // Pass Ed25519 keys directly - X25519 derivation and PSK generation happen internally - let handshake = LpGatewayHandshake::new_responder( + let header = packet.header(); + let session_id = header.session_id; + + trace!( + "Received packet from {} (session_id={}, counter={})", + self.remote_addr, + session_id, + header.counter + ); + + // Step 2: Route packet based on session_id + if session_id == 0 { + // ClientHello - first packet in handshake + self.handle_client_hello(packet).await + } else { + // Check if this is an in-progress handshake or established session + if self.state.handshake_states.contains_key(&session_id) { + // Handshake in progress + self.handle_handshake_packet(session_id, packet).await + } else if self.state.session_states.contains_key(&session_id) { + // Established session - transport mode + self.handle_transport_packet(session_id, packet).await + } else { + // Unknown session - possibly stale or client error + warn!( + "Received packet for unknown session {} from {}", + session_id, self.remote_addr + ); + inc!("lp_errors_unknown_session"); + self.emit_lifecycle_metrics(false); + Err(GatewayError::LpProtocolError(format!( + "Unknown session ID: {}", + session_id + ))) + } + } + } + + /// Handle ClientHello packet (session_id=0, first packet) + async fn handle_client_hello(&mut self, packet: LpPacket) -> Result<(), GatewayError> { + use nym_lp::state_machine::{LpInput, LpStateMachine, LpAction}; + + // Extract ClientHello data + let (_client_pubkey, client_ed25519_pubkey, salt) = match packet.message() { + LpMessage::ClientHello(hello_data) => { + // Validate timestamp + let timestamp = hello_data.extract_timestamp(); + Self::validate_timestamp(timestamp, self.state.lp_config.timestamp_tolerance_secs)?; + + // Extract keys + let client_pubkey = nym_lp::keypair::PublicKey::from_bytes(&hello_data.client_lp_public_key) + .map_err(|e| GatewayError::LpProtocolError(format!("Invalid client public key: {}", e)))?; + + let client_ed25519_pubkey = nym_crypto::asymmetric::ed25519::PublicKey::from_bytes( + &hello_data.client_ed25519_public_key, + ) + .map_err(|e| GatewayError::LpProtocolError(format!("Invalid client Ed25519 public key: {}", e)))?; + + (client_pubkey, client_ed25519_pubkey, hello_data.salt) + } + other => { + inc!("lp_client_hello_failed"); + self.emit_lifecycle_metrics(false); + return Err(GatewayError::LpProtocolError(format!( + "Expected ClientHello, got {}", + other + ))); + } + }; + + debug!("Processing ClientHello from {}", self.remote_addr); + + // Create state machine for this handshake + let mut state_machine = LpStateMachine::new( + false, // responder ( self.state.local_identity.private_key(), self.state.local_identity.public_key(), ), &client_ed25519_pubkey, &salt, - )?; - - // Complete the LP handshake with duration tracking - let handshake_start = std::time::Instant::now(); - let session = match handshake.complete(&mut self.stream).await { - Ok(s) => { - let duration = handshake_start.elapsed().as_secs_f64(); - add_histogram_obs!( - "lp_handshake_duration_seconds", - duration, - LP_DURATION_BUCKETS + ) + .map_err(|e| { + inc!("lp_client_hello_failed"); + GatewayError::LpHandshakeError(format!("Failed to create state machine: {}", e)) + })?; + + // Get the computed session ID + let session_id = state_machine.session() + .map_err(|e| GatewayError::LpHandshakeError(format!("Failed to get session: {}", e)))? + .id(); + + debug!( + "Created handshake state for {} (session_id={})", + self.remote_addr, session_id + ); + + // Start handshake and get initial response + let response_packet = if let Some(action) = state_machine.process_input(LpInput::StartHandshake) { + match action.map_err(|e| { + inc!("lp_client_hello_failed"); + GatewayError::LpHandshakeError(format!("Failed to start handshake: {}", e)) + })? { + LpAction::SendPacket(packet) => packet, + other => { + inc!("lp_client_hello_failed"); + return Err(GatewayError::LpHandshakeError(format!( + "Unexpected action after StartHandshake: {:?}", + other + ))); + } + } + } else { + inc!("lp_client_hello_failed"); + return Err(GatewayError::LpHandshakeError( + "No action after StartHandshake".to_string(), + )); + }; + + // Store state machine for subsequent handshake packets + self.state.handshake_states.insert(session_id, state_machine); + + // Send response + self.send_lp_packet(&response_packet).await?; + + debug!( + "Sent ClientHello response to {} (session_id={})", + self.remote_addr, session_id + ); + + self.emit_lifecycle_metrics(true); + Ok(()) + } + + /// Handle handshake packet (session_id!=0, handshake not complete) + async fn handle_handshake_packet( + &mut self, + session_id: u32, + packet: LpPacket, + ) -> Result<(), GatewayError> { + use nym_lp::state_machine::{LpInput, LpAction}; + + debug!( + "Processing handshake packet from {} (session_id={})", + self.remote_addr, session_id + ); + + // Get mutable reference to state machine + let mut state_entry = self.state.handshake_states.get_mut(&session_id).ok_or_else(|| { + GatewayError::LpProtocolError(format!("Handshake state not found for session {}", session_id)) + })?; + + let state_machine = state_entry.value_mut(); + + // Process packet through state machine + let action = state_machine + .process_input(LpInput::ReceivePacket(packet)) + .ok_or_else(|| { + GatewayError::LpHandshakeError("State machine returned no action".to_string()) + })? + .map_err(|e| GatewayError::LpHandshakeError(format!("Handshake error: {}", e)))?; + + let should_send_packet = match action { + LpAction::SendPacket(response_packet) => { + drop(state_entry); // Release borrow before send + Some(response_packet) + } + LpAction::HandshakeComplete => { + info!( + "Handshake completed for {} (session_id={})", + self.remote_addr, session_id ); + + // Extract session and move to session_states + drop(state_entry); // Release mutable borrow + + let (_session_id, state_machine) = self.state.handshake_states.remove(&session_id) + .ok_or_else(|| GatewayError::LpHandshakeError("Failed to remove handshake state".to_string()))?; + + let session = state_machine.into_session() + .map_err(|e| GatewayError::LpHandshakeError(format!("Failed to extract session: {}", e)))?; + + self.state.session_states.insert(session_id, session); + inc!("lp_handshakes_success"); - s + + // No response packet to send - HandshakeComplete means we're done + trace!("Moved session {} to transport mode", session_id); + None } - Err(e) => { - inc!("lp_handshakes_failed"); - inc!("lp_errors_handshake"); - // Emit lifecycle metrics before returning - self.emit_lifecycle_metrics(false); - return Err(e); + other => { + debug!("Received action during handshake: {:?}", other); + drop(state_entry); + None } }; - info!( - "LP handshake completed for {} (session {})", - self.remote_addr, - session.id() - ); + // Send response packet if needed + if let Some(packet) = should_send_packet { + self.send_lp_packet(&packet).await?; + trace!("Sent handshake response to {}", self.remote_addr); + } - // After handshake, receive registration request - let request = self.receive_registration_request(&session).await?; + self.emit_lifecycle_metrics(true); + Ok(()) + } + /// Handle transport packet (session_id!=0, session established) + async fn handle_transport_packet( + &mut self, + session_id: u32, + packet: LpPacket, + ) -> Result<(), GatewayError> { debug!( - "LP registration request from {}: mode={:?}", - self.remote_addr, request.mode + "Processing transport packet from {} (session_id={})", + self.remote_addr, session_id ); - // Process registration (verify credentials, add peer, etc.) - let response = process_registration(request, &self.state).await; + // Get session from storage, decrypt, and create response packet + // Split into two phases to avoid borrow checker issues + let (response_packet, response_was_success, response_session_id, response_error) = { + let session_entry = self.state.session_states.get(&session_id).ok_or_else(|| { + GatewayError::LpProtocolError(format!("Session not found: {}", session_id)) + })?; - // Send response - if let Err(e) = self - .send_registration_response(&session, response.clone()) - .await - { - warn!("Failed to send LP response to {}: {}", self.remote_addr, e); - inc!("lp_errors_send_response"); - // Emit lifecycle metrics before returning - self.emit_lifecycle_metrics(false); - return Err(e); - } + let session = session_entry.value(); - if response.success { - info!( - "LP registration successful for {} (session {})", - self.remote_addr, response.session_id + // Decrypt packet + let decrypted_bytes = session.decrypt_data(packet.message()).map_err(|e| { + GatewayError::LpProtocolError(format!("Failed to decrypt packet: {}", e)) + })?; + + // Try to deserialize as registration request + let request: LpRegistrationRequest = bincode::deserialize(&decrypted_bytes).map_err(|e| { + GatewayError::LpProtocolError(format!( + "Failed to deserialize transport payload: {}", + e + )) + })?; + + debug!( + "LP registration request from {} (session_id={}): mode={:?}", + self.remote_addr, session_id, request.mode ); - // After successful registration, keep connection open for forwarding + // Release session lock before processing registration (which might acquire other locks) + drop(session_entry); + + // Process registration (this might modify state) + let response = process_registration(request, &self.state).await; + + // Acquire session lock again for encryption + let session_entry = self.state.session_states.get(&session_id).ok_or_else(|| { + GatewayError::LpProtocolError(format!("Session not found: {}", session_id)) + })?; + let session = session_entry.value(); + + // Serialize and encrypt response + let response_bytes = bincode::serialize(&response).map_err(|e| { + GatewayError::LpProtocolError(format!("Failed to serialize response: {}", e)) + })?; + + let encrypted_message = session.encrypt_data(&response_bytes).map_err(|e| { + GatewayError::LpProtocolError(format!("Failed to encrypt response: {}", e)) + })?; + + let response_packet = session.next_packet(encrypted_message).map_err(|e| { + GatewayError::LpProtocolError(format!("Failed to create response packet: {}", e)) + })?; + + drop(session_entry); // Release borrow before send + + (response_packet, response.success, response.session_id, response.error.clone()) + }; + + // Now send the response (no more borrows held) + self.send_lp_packet(&response_packet).await?; + + if response_was_success { info!( - "Entering forwarding mode for {} (session {})", - self.remote_addr, - session.id() + "LP registration successful for {} (session_id={})", + self.remote_addr, response_session_id ); - if let Err(e) = self.handle_forwarding_loop(&session).await { - warn!( - "Forwarding loop error for {} (session {}): {}", - self.remote_addr, - session.id(), - e - ); - } } else { warn!( - "LP registration failed for {}: {:?}", - self.remote_addr, response.error + "LP registration failed for {} (session_id={}): {:?}", + self.remote_addr, response_session_id, response_error ); } - // Emit lifecycle metrics on graceful completion self.emit_lifecycle_metrics(true); - Ok(()) } diff --git a/gateway/src/node/lp_listener/mod.rs b/gateway/src/node/lp_listener/mod.rs index a2ac9c08c7b..a8302ac9175 100644 --- a/gateway/src/node/lp_listener/mod.rs +++ b/gateway/src/node/lp_listener/mod.rs @@ -190,11 +190,12 @@ pub struct LpHandlerState { /// LP configuration (for timestamp validation, etc.) pub lp_config: LpConfig, - /// In-progress handshakes keyed by client Ed25519 public key (from ClientHello) + /// In-progress handshakes keyed by session_id /// - /// Used during handshake phase before session_id is established (session_id=0). - /// After handshake completes, state moves to session_states map. - pub handshake_states: Arc>, + /// Session ID is deterministically computed from both parties' X25519 keys immediately + /// after ClientHello. Used during handshake phase. After handshake completes, + /// state moves to session_states map. + pub handshake_states: Arc>, /// Established sessions keyed by session_id /// From e8a3d5720c9615766e8322a2083ced13840c5422 Mon Sep 17 00:00:00 2001 From: durch Date: Tue, 25 Nov 2025 11:08:24 +0100 Subject: [PATCH 04/14] Fix ClientHello handling + add BOOTSTRAP_SESSION_ID constant [nym-v9un] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway responder's StartHandshake doesn't return a packet - it just transitions to KKTExchange state and waits for client's KKT request. Fixed handle_client_hello() to not expect a response packet. ## Changes ### Add BOOTSTRAP_SESSION_ID constant - `common/nym-lp/src/packet.rs`: Add `pub const BOOTSTRAP_SESSION_ID: u32 = 0` - Document that session_id=0 is only used for ClientHello bootstrap - Export from lib.rs for public use ### Fix handle_client_hello() logic - `gateway/src/node/lp_listener/handler.rs:201-225`: - Remove expectation of SendPacket action from StartHandshake - Responder transitions to KKTExchange without sending - Store state machine and close connection - Client sends KKT request on next connection with computed session_id ### Use constant throughout codebase - `gateway/src/node/lp_listener/handler.rs:115`: Use BOOTSTRAP_SESSION_ID in routing - `nym-registration-client/src/lp_client/client.rs:259`: Use constant instead of literal 0 ## Protocol Flow (Fixed) ``` Connection 1: Client sends ClientHello (session_id=0) → Gateway stores state, closes (no response) Connection 2: Client sends KKT request (session_id=X) → Gateway finds state, processes, responds Connection 3+: Handshake continues until complete... ``` ## Testing - All 13 unit tests pass - Real test: docker/localnet + nym-gateway-probe (next step) Fixes: nym-v9un Unblocks: nym-21th --- common/nym-lp/src/lib.rs | 2 +- common/nym-lp/src/packet.rs | 8 ++++ gateway/src/node/lp_listener/handler.rs | 40 +++++++------------ .../src/lp_client/client.rs | 4 +- 4 files changed, 26 insertions(+), 28 deletions(-) diff --git a/common/nym-lp/src/lib.rs b/common/nym-lp/src/lib.rs index c23fec53413..6c41f6ed072 100644 --- a/common/nym-lp/src/lib.rs +++ b/common/nym-lp/src/lib.rs @@ -19,7 +19,7 @@ use std::hash::{DefaultHasher, Hasher as _}; pub use error::LpError; use keypair::PublicKey; pub use message::{ClientHelloData, LpMessage}; -pub use packet::LpPacket; +pub use packet::{LpPacket, BOOTSTRAP_SESSION_ID}; pub use replay::{ReceivingKeyCounterValidator, ReplayError}; pub use session::{LpSession, generate_fresh_salt}; pub use session_manager::SessionManager; diff --git a/common/nym-lp/src/packet.rs b/common/nym-lp/src/packet.rs index 193fa0704d6..4f3017f0bb1 100644 --- a/common/nym-lp/src/packet.rs +++ b/common/nym-lp/src/packet.rs @@ -122,6 +122,14 @@ impl LpPacket { } } +/// Session ID used for ClientHello bootstrap packets before session is established. +/// +/// When a client first connects, it sends a ClientHello packet with session_id=0 +/// because neither side can compute the deterministic session ID yet (requires +/// both parties' X25519 keys). After ClientHello is processed, both sides derive +/// the same session ID from their keys, and all subsequent packets use that ID. +pub const BOOTSTRAP_SESSION_ID: u32 = 0; + // VERSION [1B] || RESERVED [3B] || SENDER_INDEX [4B] || COUNTER [8B] #[derive(Debug, Clone)] pub struct LpHeader { diff --git a/gateway/src/node/lp_listener/handler.rs b/gateway/src/node/lp_listener/handler.rs index 20c42ba2765..10376c15241 100644 --- a/gateway/src/node/lp_listener/handler.rs +++ b/gateway/src/node/lp_listener/handler.rs @@ -112,7 +112,7 @@ impl LpConnectionHandler { ); // Step 2: Route packet based on session_id - if session_id == 0 { + if session_id == nym_lp::BOOTSTRAP_SESSION_ID { // ClientHello - first packet in handshake self.handle_client_hello(packet).await } else { @@ -198,39 +198,29 @@ impl LpConnectionHandler { self.remote_addr, session_id ); - // Start handshake and get initial response - let response_packet = if let Some(action) = state_machine.process_input(LpInput::StartHandshake) { - match action.map_err(|e| { + // Transition state machine to KKTExchange (responder waits for client's KKT request) + // For responder, StartHandshake returns None (just transitions state) + // For initiator, StartHandshake returns SendPacket (KKT request) + if let Some(action) = state_machine.process_input(LpInput::StartHandshake) { + if let Err(e) = action { inc!("lp_client_hello_failed"); - GatewayError::LpHandshakeError(format!("Failed to start handshake: {}", e)) - })? { - LpAction::SendPacket(packet) => packet, - other => { - inc!("lp_client_hello_failed"); - return Err(GatewayError::LpHandshakeError(format!( - "Unexpected action after StartHandshake: {:?}", - other - ))); - } + return Err(GatewayError::LpHandshakeError(format!( + "StartHandshake failed: {}", + e + ))); } - } else { - inc!("lp_client_hello_failed"); - return Err(GatewayError::LpHandshakeError( - "No action after StartHandshake".to_string(), - )); - }; + // Responder (gateway) gets Ok but no packet to send - we just wait for client's next packet + } - // Store state machine for subsequent handshake packets + // Store state machine for subsequent handshake packets (KKT request with session_id=X) self.state.handshake_states.insert(session_id, state_machine); - // Send response - self.send_lp_packet(&response_packet).await?; - debug!( - "Sent ClientHello response to {} (session_id={})", + "Stored handshake state for {} (session_id={}) - waiting for KKT request", self.remote_addr, session_id ); + // NO packet sent - connection closes, client will send KKT request on new connection self.emit_lifecycle_metrics(true); Ok(()) } diff --git a/nym-registration-client/src/lp_client/client.rs b/nym-registration-client/src/lp_client/client.rs index ffd40d59ad5..36840c4dbd6 100644 --- a/nym-registration-client/src/lp_client/client.rs +++ b/nym-registration-client/src/lp_client/client.rs @@ -256,8 +256,8 @@ impl LpRegistrationClient { // Step 3: Send ClientHello as first packet (before Noise handshake) let client_hello_header = nym_lp::packet::LpHeader::new( - 0, // session_id not yet established - 0, // counter starts at 0 + nym_lp::BOOTSTRAP_SESSION_ID, // session_id not yet established + 0, // counter starts at 0 ); let client_hello_packet = nym_lp::LpPacket::new( client_hello_header, From 496f22ff67e0816a3b45ba69b8080ac19ddfd1fe Mon Sep 17 00:00:00 2001 From: durch Date: Tue, 25 Nov 2025 11:28:38 +0100 Subject: [PATCH 05/14] Refactor entry gateway for single-packet forwarding [nym-31hl] - Extend handle_transport_packet() to conditionally deserialize both LpRegistrationRequest and ForwardPacketData messages - Add handle_registration_request() helper for registration flow - Add handle_forwarding_request() helper for telescoping flow - Delete dead handle_forwarding_loop() method (persistent connection model) - Clean up unused imports and dead code warnings - All 13 tests passing Entry gateway now fully supports single-packet-per-connection architecture for both direct client registration and packet forwarding to exit gateways. Each ForwardPacket arrives on a new connection, gets processed, response sent, and connection closes - consistent with exit gateway behavior from nym-21th. --- gateway/src/node/lp_listener/handler.rs | 298 +++++++++--------------- 1 file changed, 105 insertions(+), 193 deletions(-) diff --git a/gateway/src/node/lp_listener/handler.rs b/gateway/src/node/lp_listener/handler.rs index 10376c15241..2307b049044 100644 --- a/gateway/src/node/lp_listener/handler.rs +++ b/gateway/src/node/lp_listener/handler.rs @@ -1,11 +1,11 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use super::messages::{LpRegistrationRequest, LpRegistrationResponse}; +use super::messages::LpRegistrationRequest; use super::registration::process_registration; use super::LpHandlerState; use crate::error::GatewayError; -use nym_lp::{keypair::PublicKey, message::ForwardPacketData, LpMessage, LpPacket, LpSession}; +use nym_lp::{keypair::PublicKey, message::ForwardPacketData, LpMessage, LpPacket}; use nym_metrics::{add_histogram_obs, inc}; use std::net::SocketAddr; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -141,7 +141,7 @@ impl LpConnectionHandler { /// Handle ClientHello packet (session_id=0, first packet) async fn handle_client_hello(&mut self, packet: LpPacket) -> Result<(), GatewayError> { - use nym_lp::state_machine::{LpInput, LpStateMachine, LpAction}; + use nym_lp::state_machine::{LpInput, LpStateMachine}; // Extract ClientHello data let (_client_pubkey, client_ed25519_pubkey, salt) = match packet.message() { @@ -299,6 +299,10 @@ impl LpConnectionHandler { } /// Handle transport packet (session_id!=0, session established) + /// + /// This handles packets on established sessions, which can be either: + /// 1. LpRegistrationRequest - Client registering for dVPN/Mixnet access + /// 2. ForwardPacketData - Client forwarding packets to exit gateway (telescoping) async fn handle_transport_packet( &mut self, session_id: u32, @@ -309,9 +313,8 @@ impl LpConnectionHandler { self.remote_addr, session_id ); - // Get session from storage, decrypt, and create response packet - // Split into two phases to avoid borrow checker issues - let (response_packet, response_was_success, response_session_id, response_error) = { + // Get session and decrypt payload + let decrypted_bytes = { let session_entry = self.state.session_states.get(&session_id).ok_or_else(|| { GatewayError::LpProtocolError(format!("Session not found: {}", session_id)) })?; @@ -319,30 +322,52 @@ impl LpConnectionHandler { let session = session_entry.value(); // Decrypt packet - let decrypted_bytes = session.decrypt_data(packet.message()).map_err(|e| { + session.decrypt_data(packet.message()).map_err(|e| { GatewayError::LpProtocolError(format!("Failed to decrypt packet: {}", e)) - })?; - - // Try to deserialize as registration request - let request: LpRegistrationRequest = bincode::deserialize(&decrypted_bytes).map_err(|e| { - GatewayError::LpProtocolError(format!( - "Failed to deserialize transport payload: {}", - e - )) - })?; + })? + }; + // Try to deserialize as LpRegistrationRequest first (most common case after handshake) + if let Ok(request) = bincode::deserialize::(&decrypted_bytes) { debug!( "LP registration request from {} (session_id={}): mode={:?}", self.remote_addr, session_id, request.mode ); + return self.handle_registration_request(session_id, request).await; + } - // Release session lock before processing registration (which might acquire other locks) - drop(session_entry); + // Try to deserialize as ForwardPacketData (entry gateway forwarding to exit) + if let Ok(forward_data) = bincode::deserialize::(&decrypted_bytes) { + debug!( + "LP forward request from {} (session_id={}) to {}", + self.remote_addr, session_id, forward_data.target_lp_address + ); + return self.handle_forwarding_request(session_id, forward_data).await; + } + + // Neither registration nor forwarding - unknown payload type + warn!( + "Unknown transport payload type from {} (session_id={})", + self.remote_addr, session_id + ); + inc!("lp_errors_unknown_payload_type"); + self.emit_lifecycle_metrics(false); + Err(GatewayError::LpProtocolError(format!( + "Unknown transport payload type (not registration or forwarding)" + ))) + } - // Process registration (this might modify state) - let response = process_registration(request, &self.state).await; + /// Handle registration request on an established session + async fn handle_registration_request( + &mut self, + session_id: u32, + request: LpRegistrationRequest, + ) -> Result<(), GatewayError> { + // Process registration (might modify state) + let response = process_registration(request, &self.state).await; - // Acquire session lock again for encryption + // Acquire session lock for encryption + let response_packet = { let session_entry = self.state.session_states.get(&session_id).ok_or_else(|| { GatewayError::LpProtocolError(format!("Session not found: {}", session_id)) })?; @@ -357,27 +382,23 @@ impl LpConnectionHandler { GatewayError::LpProtocolError(format!("Failed to encrypt response: {}", e)) })?; - let response_packet = session.next_packet(encrypted_message).map_err(|e| { + session.next_packet(encrypted_message).map_err(|e| { GatewayError::LpProtocolError(format!("Failed to create response packet: {}", e)) - })?; - - drop(session_entry); // Release borrow before send - - (response_packet, response.success, response.session_id, response.error.clone()) + })? }; - // Now send the response (no more borrows held) + // Send response self.send_lp_packet(&response_packet).await?; - if response_was_success { + if response.success { info!( "LP registration successful for {} (session_id={})", - self.remote_addr, response_session_id + self.remote_addr, response.session_id ); } else { warn!( "LP registration failed for {} (session_id={}): {:?}", - self.remote_addr, response_session_id, response_error + self.remote_addr, response.session_id, response.error ); } @@ -385,6 +406,47 @@ impl LpConnectionHandler { Ok(()) } + /// Handle forwarding request on an established session + /// + /// Entry gateway receives ForwardPacketData from client, forwards inner packet + /// to exit gateway, receives response, encrypts it, and sends back to client. + /// Connection closes after response is sent (single-packet model). + async fn handle_forwarding_request( + &mut self, + session_id: u32, + forward_data: ForwardPacketData, + ) -> Result<(), GatewayError> { + // Forward the packet to the target gateway + let response_bytes = self.handle_forward_packet(forward_data).await?; + + // Encrypt response for client + let response_packet = { + let session_entry = self.state.session_states.get(&session_id).ok_or_else(|| { + GatewayError::LpProtocolError(format!("Session not found: {}", session_id)) + })?; + let session = session_entry.value(); + + let encrypted_message = session.encrypt_data(&response_bytes).map_err(|e| { + GatewayError::LpProtocolError(format!("Failed to encrypt forward response: {}", e)) + })?; + + session.next_packet(encrypted_message).map_err(|e| { + GatewayError::LpProtocolError(format!("Failed to create response packet: {}", e)) + })? + }; + + // Send encrypted response to client + self.send_lp_packet(&response_packet).await?; + + debug!( + "LP forwarding completed for {} (session_id={})", + self.remote_addr, session_id + ); + + self.emit_lifecycle_metrics(true); + Ok(()) + } + /// Validates that a ClientHello timestamp is within the acceptable time window. /// /// # Arguments @@ -433,6 +495,11 @@ impl LpConnectionHandler { } /// Receive client's public key and salt via ClientHello message + /// + /// Note: This method is currently unused but retained for potential future use + /// in alternative handshake flows. The current implementation uses `handle_client_hello()` + /// which processes ClientHello as part of the single-packet model. + #[allow(dead_code)] async fn receive_client_hello( &mut self, ) -> Result< @@ -493,66 +560,14 @@ impl LpConnectionHandler { } } - /// Receive registration request after handshake - async fn receive_registration_request( - &mut self, - session: &LpSession, - ) -> Result { - // Read LP packet containing the registration request - let packet = self.receive_lp_packet().await?; - - // Verify it's from the correct session - if packet.header().session_id != session.id() { - return Err(GatewayError::LpProtocolError(format!( - "Session ID mismatch: expected {}, got {}", - session.id(), - packet.header().session_id - ))); - } - - // Decrypt the packet payload using the established session - let decrypted_bytes = session.decrypt_data(packet.message()).map_err(|e| { - GatewayError::LpProtocolError(format!("Failed to decrypt registration request: {}", e)) - })?; - - // Deserialize the decrypted bytes into LpRegistrationRequest - bincode::deserialize(&decrypted_bytes).map_err(|e| { - GatewayError::LpProtocolError(format!( - "Failed to deserialize registration request: {}", - e - )) - }) - } - - /// Send registration response after processing - async fn send_registration_response( - &mut self, - session: &LpSession, - response: LpRegistrationResponse, - ) -> Result<(), GatewayError> { - // Serialize response - let data = bincode::serialize(&response).map_err(|e| { - GatewayError::LpProtocolError(format!("Failed to serialize response: {}", e)) - })?; - - // Encrypt data first (this increments Noise internal counter) - let encrypted_message = session - .encrypt_data(&data) - .map_err(|e| GatewayError::LpProtocolError(format!("Failed to encrypt data: {}", e)))?; - - // Create LP packet with encrypted message (this increments LP protocol counter) - let packet = session.next_packet(encrypted_message).map_err(|e| { - GatewayError::LpProtocolError(format!("Failed to create packet: {}", e)) - })?; - - // Send the packet - self.send_lp_packet(&packet).await - } - - /// Forward an LP packet to another gateway + /// Forward an LP packet to another gateway (single-packet model) /// /// This method connects to the target gateway, forwards the inner packet bytes, - /// and returns the response. Used for hiding client IP from exit gateway. + /// receives the response, and returns it. Used for telescoping (hiding client IP). + /// + /// Called from `handle_forwarding_request()` as part of the single-packet-per-connection + /// architecture. Each forward request arrives on a new connection, gets processed, + /// response sent, and connection closes. /// /// # Arguments /// * `forward_data` - ForwardPacketData containing target gateway info and inner packet @@ -658,6 +673,7 @@ impl LpConnectionHandler { LP_DURATION_BUCKETS ); + inc!("lp_forward_success"); debug!( "Forwarding successful to {} ({} bytes response, {:.3}s)", target_addr, @@ -668,110 +684,6 @@ impl LpConnectionHandler { Ok(response_buf) } - /// Handle incoming forwarding requests in a loop - /// - /// After successful registration, the connection stays open to handle - /// ForwardPacket messages. This allows the entry gateway to relay packets - /// to exit gateways, hiding the client's IP address. - /// - /// # Arguments - /// * `session` - The established LP session with the client - /// - /// # Returns - /// * `Ok(())` - When connection closes gracefully - /// * `Err(GatewayError)` - On protocol errors - async fn handle_forwarding_loop(&mut self, session: &LpSession) -> Result<(), GatewayError> { - debug!( - "Entering forwarding loop for {} (session {})", - self.remote_addr, - session.id() - ); - - loop { - // Receive packet from client - let packet = match self.receive_lp_packet().await { - Ok(p) => p, - Err(e) => { - // Connection closed or error - exit loop gracefully - debug!( - "Forwarding loop ended for {} (session {}): {}", - self.remote_addr, - session.id(), - e - ); - return Ok(()); - } - }; - - // Verify session ID - if packet.header().session_id != session.id() { - warn!( - "Session ID mismatch in forwarding loop: expected {}, got {}", - session.id(), - packet.header().session_id - ); - return Err(GatewayError::LpProtocolError(format!( - "Session ID mismatch: expected {}, got {}", - session.id(), - packet.header().session_id - ))); - } - - // Decrypt packet - let decrypted_bytes = session.decrypt_data(packet.message()).map_err(|e| { - GatewayError::LpProtocolError(format!("Failed to decrypt forwarding packet: {}", e)) - })?; - - // Deserialize to ForwardPacketData - let forward_request: ForwardPacketData = - bincode::deserialize(&decrypted_bytes).map_err(|e| { - GatewayError::LpProtocolError(format!( - "Failed to deserialize forward request: {}", - e - )) - })?; - - debug!( - "Forwarding request from {} to {}", - self.remote_addr, forward_request.target_lp_address - ); - - // Forward the packet - let response_bytes = match self.handle_forward_packet(forward_request).await { - Ok(bytes) => bytes, - Err(e) => { - warn!( - "Forwarding failed for {}: {}. Continuing loop.", - self.remote_addr, e - ); - // Send error response back to client - // For now, continue the loop - client will retry if needed - continue; - } - }; - - // Encrypt response - let encrypted_msg = session.encrypt_data(&response_bytes).map_err(|e| { - GatewayError::LpProtocolError(format!("Failed to encrypt response: {}", e)) - })?; - - let response_packet = session.next_packet(encrypted_msg).map_err(|e| { - GatewayError::LpProtocolError(format!("Failed to create response packet: {}", e)) - })?; - - // Send response back to client - if let Err(e) = self.send_lp_packet(&response_packet).await { - warn!( - "Failed to send forwarding response to {}: {}", - self.remote_addr, e - ); - return Err(e); - } - - trace!("Forwarding response sent to {}", self.remote_addr); - } - } - /// Receive an LP packet from the stream with proper length-prefixed framing async fn receive_lp_packet(&mut self) -> Result { use nym_lp::codec::parse_lp_packet; From 9f3a96116d432838e614214d10bccdfc8616d820 Mon Sep 17 00:00:00 2001 From: durch Date: Tue, 25 Nov 2025 11:44:53 +0100 Subject: [PATCH 06/14] Add TTL-based state cleanup for stale sessions [nym-a241] - Create TimestampedState wrapper with created_at and last_activity tracking - Add TTL config fields to LpConfig: * handshake_ttl_secs (default: 90s) * session_ttl_secs (default: 24h) * state_cleanup_interval_secs (default: 5min) - Update LpHandlerState maps to use TimestampedState wrappers - Update all handler.rs access points to wrap/unwrap states - Implement background cleanup task in LpListener: * Spawns on startup, stops on shutdown * Removes handshakes older than handshake_ttl * Removes sessions with no activity > session_ttl * Tracks metrics: lp_states_cleanup_handshake_removed, lp_states_cleanup_session_removed - Touch last_activity on every packet in handle_transport_packet() - All 13 tests passing Prevents memory leaks from abandoned handshakes and expired sessions in long-running gateways. Configurable TTLs for different use cases. --- gateway/src/node/lp_listener/handler.rs | 19 +- gateway/src/node/lp_listener/mod.rs | 230 +++++++++++++++++++++++- 2 files changed, 239 insertions(+), 10 deletions(-) diff --git a/gateway/src/node/lp_listener/handler.rs b/gateway/src/node/lp_listener/handler.rs index 2307b049044..96647ad8742 100644 --- a/gateway/src/node/lp_listener/handler.rs +++ b/gateway/src/node/lp_listener/handler.rs @@ -213,7 +213,7 @@ impl LpConnectionHandler { } // Store state machine for subsequent handshake packets (KKT request with session_id=X) - self.state.handshake_states.insert(session_id, state_machine); + self.state.handshake_states.insert(session_id, super::TimestampedState::new(state_machine)); debug!( "Stored handshake state for {} (session_id={}) - waiting for KKT request", @@ -243,7 +243,7 @@ impl LpConnectionHandler { GatewayError::LpProtocolError(format!("Handshake state not found for session {}", session_id)) })?; - let state_machine = state_entry.value_mut(); + let state_machine = &mut state_entry.value_mut().state; // Process packet through state machine let action = state_machine @@ -267,13 +267,13 @@ impl LpConnectionHandler { // Extract session and move to session_states drop(state_entry); // Release mutable borrow - let (_session_id, state_machine) = self.state.handshake_states.remove(&session_id) + let (_session_id, timestamped_state) = self.state.handshake_states.remove(&session_id) .ok_or_else(|| GatewayError::LpHandshakeError("Failed to remove handshake state".to_string()))?; - let session = state_machine.into_session() + let session = timestamped_state.state.into_session() .map_err(|e| GatewayError::LpHandshakeError(format!("Failed to extract session: {}", e)))?; - self.state.session_states.insert(session_id, session); + self.state.session_states.insert(session_id, super::TimestampedState::new(session)); inc!("lp_handshakes_success"); @@ -319,7 +319,10 @@ impl LpConnectionHandler { GatewayError::LpProtocolError(format!("Session not found: {}", session_id)) })?; - let session = session_entry.value(); + // Update last activity timestamp + session_entry.value().touch(); + + let session = &session_entry.value().state; // Decrypt packet session.decrypt_data(packet.message()).map_err(|e| { @@ -371,7 +374,7 @@ impl LpConnectionHandler { let session_entry = self.state.session_states.get(&session_id).ok_or_else(|| { GatewayError::LpProtocolError(format!("Session not found: {}", session_id)) })?; - let session = session_entry.value(); + let session = &session_entry.value().state; // Serialize and encrypt response let response_bytes = bincode::serialize(&response).map_err(|e| { @@ -424,7 +427,7 @@ impl LpConnectionHandler { let session_entry = self.state.session_states.get(&session_id).ok_or_else(|| { GatewayError::LpProtocolError(format!("Session not found: {}", session_id)) })?; - let session = session_entry.value(); + let session = &session_entry.value().state; let encrypted_message = session.encrypt_data(&response_bytes).map_err(|e| { GatewayError::LpProtocolError(format!("Failed to encrypt forward response: {}", e)) diff --git a/gateway/src/node/lp_listener/mod.rs b/gateway/src/node/lp_listener/mod.rs index a8302ac9175..b75b6523dc2 100644 --- a/gateway/src/node/lp_listener/mod.rs +++ b/gateway/src/node/lp_listener/mod.rs @@ -53,6 +53,10 @@ // - lp_connections_completed_gracefully: Counter for connections that completed successfully // - lp_connections_completed_with_error: Counter for connections that terminated with an error // +// ## State Cleanup Metrics (in cleanup task) +// - lp_states_cleanup_handshake_removed: Counter for stale handshakes removed by cleanup task +// - lp_states_cleanup_session_removed: Counter for stale sessions removed by cleanup task +// // ## Usage Example // To view metrics, the nym-metrics registry automatically collects all metrics. // They can be exported via Prometheus format using the metrics endpoint. @@ -122,6 +126,36 @@ pub struct LpConfig { /// WARNING: Only use this for local testing! Never enable in production. #[serde(default = "default_use_mock_ecash")] pub use_mock_ecash: bool, + + /// Maximum age of in-progress handshakes before cleanup (default: 90s) + /// + /// Handshakes should complete quickly (3-5 packets). This TTL accounts for: + /// - Network latency and retransmits + /// - Slow clients + /// - Clock skew tolerance + /// + /// Stale handshakes are removed by the cleanup task to prevent memory leaks. + #[serde(default = "default_handshake_ttl_secs")] + pub handshake_ttl_secs: u64, + + /// Maximum age of established sessions before cleanup (default: 24h) + /// + /// Sessions can be long-lived for dVPN tunnels. This TTL should be set + /// high enough to accommodate expected usage patterns: + /// - dVPN sessions: hours to days + /// - Registration: minutes + /// + /// Sessions with no activity for this duration are removed by the cleanup task. + #[serde(default = "default_session_ttl_secs")] + pub session_ttl_secs: u64, + + /// How often to run the state cleanup task (default: 5 minutes) + /// + /// The cleanup task scans for and removes stale handshakes and sessions. + /// Lower values = more frequent cleanup but higher overhead. + /// Higher values = less overhead but slower memory reclamation. + #[serde(default = "default_state_cleanup_interval_secs")] + pub state_cleanup_interval_secs: u64, } impl Default for LpConfig { @@ -134,6 +168,9 @@ impl Default for LpConfig { max_connections: default_max_connections(), timestamp_tolerance_secs: default_timestamp_tolerance_secs(), use_mock_ecash: default_use_mock_ecash(), + handshake_ttl_secs: default_handshake_ttl_secs(), + session_ttl_secs: default_session_ttl_secs(), + state_cleanup_interval_secs: default_state_cleanup_interval_secs(), } } } @@ -162,6 +199,77 @@ fn default_use_mock_ecash() -> bool { false // Always default to real ecash for security } +fn default_handshake_ttl_secs() -> u64 { + 90 // 90 seconds - handshakes should complete quickly +} + +fn default_session_ttl_secs() -> u64 { + 86400 // 24 hours - for long-lived dVPN sessions +} + +fn default_state_cleanup_interval_secs() -> u64 { + 300 // 5 minutes - balances memory reclamation with task overhead +} + +/// Wrapper for state entries with timestamp tracking for cleanup +/// +/// This wrapper adds `created_at` and `last_activity` timestamps to state entries, +/// enabling TTL-based cleanup of stale handshakes and sessions. +pub struct TimestampedState { + /// The actual state (LpStateMachine or LpSession) + pub state: T, + + /// When this state was created (never changes) + created_at: std::time::Instant, + + /// Last activity timestamp (unix seconds, atomically updated) + /// + /// For handshakes: never updated (use created_at for TTL) + /// For sessions: updated on every packet received + last_activity: std::sync::atomic::AtomicU64, +} + +impl TimestampedState { + /// Create a new timestamped state + pub fn new(state: T) -> Self { + let now_instant = std::time::Instant::now(); + let now_unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + Self { + state, + created_at: now_instant, + last_activity: std::sync::atomic::AtomicU64::new(now_unix), + } + } + + /// Update last_activity timestamp (cheap, lock-free operation) + pub fn touch(&self) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + self.last_activity.store(now, std::sync::atomic::Ordering::Relaxed); + } + + /// Get age since creation + pub fn age(&self) -> std::time::Duration { + self.created_at.elapsed() + } + + /// Get time since last activity (in seconds) + pub fn seconds_since_activity(&self) -> u64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let last = self.last_activity.load(std::sync::atomic::Ordering::Relaxed); + now.saturating_sub(last) + } +} + /// Shared state for LP connection handlers #[derive(Clone)] pub struct LpHandlerState { @@ -195,14 +303,18 @@ pub struct LpHandlerState { /// Session ID is deterministically computed from both parties' X25519 keys immediately /// after ClientHello. Used during handshake phase. After handshake completes, /// state moves to session_states map. - pub handshake_states: Arc>, + /// + /// Wrapped in TimestampedState for TTL-based cleanup of stale handshakes. + pub handshake_states: Arc>>, /// Established sessions keyed by session_id /// /// Used after handshake completes (session_id is deterministically computed from /// both parties' X25519 keys). Enables stateless transport - each packet lookup /// by session_id, decrypt/process, respond. - pub session_states: Arc>, + /// + /// Wrapped in TimestampedState for TTL-based cleanup of inactive sessions. + pub session_states: Arc>>, } /// LP listener that accepts TCP connections on port 41264 @@ -259,6 +371,9 @@ impl LpListener { let shutdown_token = self.shutdown.clone_shutdown_token(); + // Spawn background task for state cleanup + let _cleanup_handle = self.spawn_state_cleanup_task(); + loop { tokio::select! { biased; @@ -329,6 +444,117 @@ impl LpListener { ); } + /// Spawn background task for cleaning up stale state entries + /// + /// This task runs periodically (every `state_cleanup_interval_secs`) to remove: + /// - Handshake states older than `handshake_ttl_secs` + /// - Session states with no activity for `session_ttl_secs` + /// + /// The task automatically stops when the shutdown signal is received. + fn spawn_state_cleanup_task(&self) -> tokio::task::JoinHandle<()> { + let handshake_states = Arc::clone(&self.handler_state.handshake_states); + let session_states = Arc::clone(&self.handler_state.session_states); + let handshake_ttl = self.handler_state.lp_config.handshake_ttl_secs; + let session_ttl = self.handler_state.lp_config.session_ttl_secs; + let interval_secs = self.handler_state.lp_config.state_cleanup_interval_secs; + let shutdown = self.shutdown.clone_shutdown_token(); + let metrics = self.handler_state.metrics.clone(); + + info!( + "Starting LP state cleanup task (handshake_ttl={}s, session_ttl={}s, interval={}s)", + handshake_ttl, session_ttl, interval_secs + ); + + self.shutdown.try_spawn_named( + Self::cleanup_loop( + handshake_states, + session_states, + handshake_ttl, + session_ttl, + interval_secs, + shutdown, + metrics, + ), + "LP::StateCleanup", + ) + } + + /// Background loop for cleaning up stale state entries + /// + /// Runs periodically to scan handshake_states and session_states maps, + /// removing entries that have exceeded their TTL. + async fn cleanup_loop( + handshake_states: Arc>>, + session_states: Arc>>, + handshake_ttl_secs: u64, + session_ttl_secs: u64, + interval_secs: u64, + shutdown: nym_task::ShutdownToken, + _metrics: NymNodeMetrics, + ) { + use nym_metrics::inc_by; + + let mut cleanup_interval = + tokio::time::interval(std::time::Duration::from_secs(interval_secs)); + + loop { + tokio::select! { + biased; + + _ = shutdown.cancelled() => { + debug!("LP state cleanup task: received shutdown signal"); + break; + } + + _ = cleanup_interval.tick() => { + let start = std::time::Instant::now(); + let mut hs_removed = 0u64; + let mut ss_removed = 0u64; + + // Remove stale handshakes (based on age since creation) + handshake_states.retain(|_, timestamped| { + if timestamped.age().as_secs() > handshake_ttl_secs { + hs_removed += 1; + false + } else { + true + } + }); + + // Remove stale sessions (based on time since last activity) + session_states.retain(|_, timestamped| { + if timestamped.seconds_since_activity() > session_ttl_secs { + ss_removed += 1; + false + } else { + true + } + }); + + if hs_removed > 0 || ss_removed > 0 { + let duration = start.elapsed(); + info!( + "LP state cleanup: removed {} handshakes, {} sessions (took {:.3}s)", + hs_removed, + ss_removed, + duration.as_secs_f64() + ); + + // Track metrics + if hs_removed > 0 { + inc_by!("lp_states_cleanup_handshake_removed", hs_removed as i64); + } + if ss_removed > 0 { + inc_by!("lp_states_cleanup_session_removed", ss_removed as i64); + } + } + } + } + } + + info!("LP state cleanup task shutdown complete"); + } + fn active_lp_connections(&self) -> usize { self.handler_state .metrics From d9e9b73c1d43eb09e54bae5ad2b66c11950afdc4 Mon Sep 17 00:00:00 2001 From: durch Date: Wed, 26 Nov 2025 15:58:04 +0100 Subject: [PATCH 07/14] Update README --- common/nym-lp/README.md | 332 ++++++++++++++++++++++++++++++++++------ 1 file changed, 285 insertions(+), 47 deletions(-) diff --git a/common/nym-lp/README.md b/common/nym-lp/README.md index a9fd7173d5a..185cdaceee0 100644 --- a/common/nym-lp/README.md +++ b/common/nym-lp/README.md @@ -1,71 +1,309 @@ # Nym Lewes Protocol -The Lewes Protocol (LP) is a secure network communication protocol implemented in Rust. This README provides an overview of the protocol's session management and replay protection mechanisms. +The Lewes Protocol (LP) is a secure network communication protocol implemented in Rust. It provides authenticated, encrypted sessions with replay protection and supports nested session forwarding for privacy-preserving multi-hop connections. ## Architecture Overview ``` -+-----------------+ +----------------+ +---------------+ -| Transport Layer |<--->| LP Session |<--->| LP Codec | -| (UDP/TCP) | | - Replay prot. | | - Enc/dec only| -+-----------------+ | - Crypto state | +---------------+ - +----------------+ +┌─────────────────┐ ┌────────────────┐ ┌───────────────┐ +│ Transport Layer │◄───►│ LP Session │◄───►│ LP Codec │ +│ (TCP) │ │ - State machine│ │ - Serialize │ +└─────────────────┘ │ - Noise crypto │ │ - Deserialize │ + │ - Replay prot. │ └───────────────┘ + └────────────────┘ ``` ## Packet Structure -The protocol uses a structured packet format: +The protocol uses a length-prefixed packet format over TCP: ``` -+------------------+-------------------+------------------+ -| Header (16B) | Message | Trailer (16B) | -| - Version (1B) | - Type (2B) | - Authentication | -| - Reserved (3B) | - Content | - tag/MAC | -| - SenderIdx (4B) | | | -| - Counter (8B) | | | -+------------------+-------------------+------------------+ +Wire Format: +┌────────────────────┬─────────────────────────────────────────┐ +│ Length (4B BE u32) │ LpPacket │ +└────────────────────┴─────────────────────────────────────────┘ + +LpPacket: +┌──────────────────┬───────────────────┬──────────────────┐ +│ Header (16B) │ Message │ Trailer (16B) │ +├──────────────────┼───────────────────┼──────────────────┤ +│ Version (1B) │ Type (2B LE u16) │ Reserved │ +│ Reserved (3B) │ Content (var) │ (16 bytes) │ +│ SessionID (4B LE)│ │ │ +│ Counter (8B LE) │ │ │ +└──────────────────┴───────────────────┴──────────────────┘ ``` -- Header contains protocol version, sender identification, and counter for replay protection -- Message carries the actual payload with a type identifier -- Trailer provides authentication and integrity verification (16 bytes) -- Total packet size is constrained by MTU (1500 bytes), accounting for network overhead +- **Header**: Protocol version (1), session identifier, monotonic counter +- **Message**: Type discriminant + variable-length content +- **Trailer**: Reserved for future use (16 bytes) -## Sessions +## Message Types + +| Type | Value | Purpose | +|------|-------|---------| +| `Busy` | 0x0000 | Server congestion signal | +| `Handshake` | 0x0001 | Noise protocol handshake messages | +| `EncryptedData` | 0x0002 | Encrypted application data | +| `ClientHello` | 0x0003 | Initial session negotiation | +| `KKTRequest` | 0x0004 | KEM Key Transfer request | +| `KKTResponse` | 0x0005 | KEM Key Transfer response | +| `ForwardPacket` | 0x0006 | Nested session forwarding | + +## Session Establishment + +### Session ID + +Sessions are identified by a deterministic 32-bit ID computed from both parties' X25519 public keys: + +``` +session_id = make_lp_id(client_x25519_pub, gateway_x25519_pub) +``` + +The computation is order-independent, allowing both sides to derive the same ID independently. + +**BOOTSTRAP_SESSION_ID (0)**: A special session ID used only for the initial `ClientHello` packet, since neither side can compute the final ID until both X25519 keys are known. + +### Handshake Flow + +``` +┌────────┐ ┌─────────┐ +│ Client │ │ Gateway │ +└───┬────┘ └────┬────┘ + │ │ + │ 1. ClientHello (session_id=0) │ + │ [client_x25519, client_ed25519, salt]│ + │───────────────────────────────────────►│ + │ │ (computes session_id) + │ │ (stores state machine) + │ │ + │ 2. KKTRequest (session_id=N) │ + │ [signed request for KEM key] │ + │───────────────────────────────────────►│ + │ │ + │ 3. KKTResponse │ + │ [gateway KEM key + signature] │ + │◄───────────────────────────────────────│ + │ │ + │ 4. Noise Handshake Msg 1 │ + │ [PSQ payload + noise message] │ + │───────────────────────────────────────►│ + │ │ (derives PSK from PSQ) + │ 5. Noise Handshake Msg 2 │ + │ [PSK handle + noise message] │ + │◄───────────────────────────────────────│ + │ │ + │ 6. Noise Handshake Msg 3 │ + │───────────────────────────────────────►│ + │ │ + │ ═══════ Session Established ═══════ │ + │ │ + │ 7. EncryptedData │ + │ [encrypted application data] │ + │◄──────────────────────────────────────►│ + │ │ +``` + +### ClientHello Data + +```rust +struct ClientHelloData { + client_lp_public_key: [u8; 32], // X25519 (derived from Ed25519) + client_ed25519_public_key: [u8; 32], // For authentication + salt: [u8; 32], // timestamp (8B) + nonce (24B) +} +``` + +## Packet-Per-Connection Model + +The gateway processes **exactly one packet per TCP connection**, then closes. State persists between connections via in-memory maps: + +``` +TCP Connect → Receive Packet → Process → Send Response → TCP Close +``` + +**State Storage:** +- `handshake_states`: Maps `session_id → LpStateMachine` (during handshake) +- `session_states`: Maps `session_id → LpSession` (after handshake complete) + +Both maps use TTL-based cleanup to remove stale entries (default: 5 min handshake, 1 hour session). + +### Gateway Packet Routing + +``` +Packet Received + │ + ├─► session_id == 0 (BOOTSTRAP) + │ └─► handle_client_hello() + │ └─► Create state machine, store in handshake_states + │ + ├─► session_id in handshake_states + │ └─► handle_handshake_packet() + │ └─► Process KKT/Noise, move to session_states when complete + │ + └─► session_id in session_states + └─► handle_transport_packet() + └─► Decrypt, process registration or forwarding +``` + +## Session Forwarding + +Forwarding enables a client to establish an independent session with an exit gateway through an entry gateway, providing network-level privacy. -- Sessions are managed by `LPSession` and `SessionManager` classes -- Each session has unique receiving and sending indices to identify connections -- Sessions maintain: - - Cryptographic state (currently mocked implementations) - - Counter for outgoing packets - - Replay protection mechanism for incoming packets +### Architecture -## Session Management +``` +┌──────────┐ +│ Client │ +└────┬─────┘ + │ Outer LP Session (established, encrypted) + │ + ▼ +┌────────────────┐ +│ Entry Gateway │ Sees: Client IP +│ │ Doesn't see: Exit destination +└────────┬───────┘ + │ Forwards inner packets (TCP) + │ + ▼ +┌────────────────┐ +│ Exit Gateway │ Sees: Entry Gateway IP +│ │ Doesn't see: Client IP +└────────────────┘ +``` + +### ForwardPacket Message + +```rust +struct ForwardPacketData { + target_gateway_identity: [u8; 32], // Exit gateway's Ed25519 key + target_lp_address: String, // e.g., "2.2.2.2:41264" + inner_packet_bytes: Vec, // Complete LP packet for exit +} +``` + +### Forwarding Flow + +1. **Client** establishes outer LP session with entry gateway +2. **Client** creates `ClientHello` packet for exit gateway +3. **Client** wraps inner packet in `ForwardPacketData`: + - Sets `target_gateway_identity` to exit's Ed25519 key + - Sets `target_lp_address` to exit's LP listener address + - Serializes complete LP packet as `inner_packet_bytes` +4. **Client** encrypts `ForwardPacketData` using outer session +5. **Client** sends as `EncryptedData` to entry gateway + +6. **Entry Gateway** decrypts, sees `ForwardPacketData` +7. **Entry Gateway** connects to exit gateway (new TCP) +8. **Entry Gateway** sends `inner_packet_bytes` directly +9. **Entry Gateway** receives exit's response +10. **Entry Gateway** encrypts response using outer session +11. **Entry Gateway** sends encrypted response to client -- `SessionManager` handles session lifecycle (creation, retrieval, removal) -- Sessions are stored in a thread-safe HashMap indexed by receiving index -- The manager generates unique indices for new sessions -- Sessions are Arc-wrapped for safe concurrent access +12. **Client** decrypts response, processes in inner session state -## Replay Protection +### NestedLpSession -- Implemented in the `ReceivingKeyCounterValidator` class -- Uses a bitmap-based approach to track received packet counters -- The bitmap allows reordering of up to 1024 packets (configurable) -- SIMD optimizations are used when available for performance +The `NestedLpSession` struct manages the inner session from the client's perspective: -## Replay Protection Process +```rust +struct NestedLpSession { + exit_identity: [u8; 32], // Exit gateway Ed25519 + exit_address: String, // Exit LP address + client_keypair: Arc, + exit_public_key: ed25519::PublicKey, + state_machine: Option, +} +``` + +**Usage:** +```rust +// Create nested session targeting exit gateway +let nested = NestedLpSession::new(exit_identity, exit_address, keypair, exit_pubkey); + +// Perform handshake through outer session +nested.handshake_and_register(&mut outer_client).await?; + +// Inner session now established with exit gateway +``` + +## State Machine States -1. Quick validation (`will_accept_branchless`): - - Checks if counter is valid before expensive operations - - Detects duplicates, out-of-window packets - -2. Marking packets (`mark_did_receive_branchless`): - - Updates the bitmap to mark counter as received - - Updates statistics and sliding window as needed +``` +ReadyToHandshake + │ + ▼ + KKTExchange ◄─── KKTRequest/KKTResponse + │ + ▼ + Handshaking ◄─── Noise messages + PSQ + │ + ▼ + Transport ◄─── EncryptedData + │ + ▼ + Closed +``` -3. Window Sliding: - - Automatically slides the acceptance window when new higher counters arrive - - Clears bits for old counters that fall outside the window +## Cryptography -This architecture effectively prevents replay attacks while allowing some packet reordering, an essential feature for secure network protocols. \ No newline at end of file +### Key Types +- **Ed25519**: Identity keys, signing +- **X25519**: Key exchange (derived from Ed25519 via RFC 7748) + +### Noise Protocol +- Pattern: `Noise_XKpsk3_25519_ChaChaPoly_SHA256` +- Provides: Forward secrecy, mutual authentication, PSK binding + +### PSK Derivation (PSQ) +The Pre-Shared Key is derived via Post-Quantum Secure Key Exchange: +1. Client encapsulates using authenticated KEM key from KKT +2. Produces 32-byte PSK + ciphertext +3. Gateway decapsulates to derive same PSK +4. PSK injected into Noise at position 3 + +### Replay Protection + +- **Monotonic counter**: Each packet has incrementing 64-bit counter +- **Sliding window**: Bitmap tracks received counters (1024 packet window) +- **SIMD optimized**: Branchless validation for constant-time operation + +```rust +// Validation flow +validator.will_accept_branchless(counter) // Check before decrypt +validator.mark_did_receive_branchless(counter) // Mark after decrypt +``` + +## Sessions + +### LpSession Fields +```rust +struct LpSession { + id: u32, // Session identifier + is_initiator: bool, // Client or gateway role + noise_state: NoiseState, // Noise transport state + kkt_state: KktState, // KKT exchange progress + psq_state: PsqState, // PSQ handshake progress + psk_handle: Option>,// PSK handle from responder + sending_counter: AtomicU64, // Outgoing packet counter + receiving_counter: Validator, // Replay protection + psk_injected: AtomicBool, // Safety: real PSK injected? +} +``` + +### PSK Safety +Sessions initialize with a dummy PSK. The `psk_injected` flag must be `true` before `encrypt_data()` or `decrypt_data()` will operate, preventing accidental use of the insecure dummy. + +## File Structure + +``` +common/nym-lp/src/ +├── lib.rs # Module exports +├── message.rs # LpMessage enum, ClientHelloData, ForwardPacketData +├── packet.rs # LpPacket, LpHeader, BOOTSTRAP_SESSION_ID +├── codec.rs # Serialization/deserialization +├── session.rs # LpSession, cryptographic operations +├── state_machine.rs # LpStateMachine, state transitions +├── psk.rs # PSK derivation utilities +└── error.rs # Error types +``` From 53c16890117ea23dad27ee5b820e40842dcde782 Mon Sep 17 00:00:00 2001 From: durch Date: Thu, 27 Nov 2025 12:39:48 +0100 Subject: [PATCH 08/14] Add outer AEAD encryption + Ack message type for LP protocol - Add OuterAeadKey derived from PSK via Blake3 KDF for packet encryption - Add LpMessage::Ack (0x0008) for ClientHello acknowledgment - Gateway sends Ack after processing ClientHello (packet-per-connection) - Update codec with AEAD encrypt/decrypt using ChaCha20-Poly1305 - Header remains cleartext (AAD), payload encrypted after PSK derivation - Add parse_lp_header_only() for routing before session lookup - Update session to expose outer_aead_key() getter - Various LP protocol improvements and test coverage Closes: nym-f4v1, nym-n9dr --- Cargo.lock | 1 + common/nym-lp/Cargo.toml | 1 + common/nym-lp/DESIGN.md | 331 +++++++++ common/nym-lp/src/codec.rs | 694 +++++++++++++++--- common/nym-lp/src/error.rs | 4 + common/nym-lp/src/lib.rs | 120 +-- common/nym-lp/src/message.rs | 29 +- common/nym-lp/src/packet.rs | 24 +- common/nym-lp/src/session.rs | 90 ++- common/nym-lp/src/session_integration/mod.rs | 224 +++--- common/nym-lp/src/session_manager.rs | 16 +- common/nym-lp/src/state_machine.rs | 70 +- common/registration/src/lp_messages.rs | 71 +- gateway/src/node/lp_listener/handler.rs | 342 +++++---- gateway/src/node/lp_listener/handshake.rs | 10 +- gateway/src/node/lp_listener/registration.rs | 38 +- .../src/lp_client/client.rs | 18 +- .../src/lp_client/nested_session.rs | 54 +- .../src/lp_client/transport.rs | 22 +- 19 files changed, 1531 insertions(+), 628 deletions(-) create mode 100644 common/nym-lp/DESIGN.md diff --git a/Cargo.lock b/Cargo.lock index 11a1bfddf08..5b68782a596 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6591,6 +6591,7 @@ dependencies = [ "bincode", "bs58", "bytes", + "chacha20poly1305", "criterion", "dashmap", "libcrux-kem", diff --git a/common/nym-lp/Cargo.toml b/common/nym-lp/Cargo.toml index ea88885e81e..a8b4c447070 100644 --- a/common/nym-lp/Cargo.toml +++ b/common/nym-lp/Cargo.toml @@ -34,6 +34,7 @@ libcrux-kem = { git = "https://github.com/cryspen/libcrux" } libcrux-traits = { git = "https://github.com/cryspen/libcrux" } tls_codec = { workspace = true } num_enum = { workspace = true } +chacha20poly1305 = { workspace = true } [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } diff --git a/common/nym-lp/DESIGN.md b/common/nym-lp/DESIGN.md new file mode 100644 index 00000000000..4723a64b109 --- /dev/null +++ b/common/nym-lp/DESIGN.md @@ -0,0 +1,331 @@ +# LP Protocol Design + +## Overview + +The Lewes Protocol (LP) provides authenticated, encrypted sessions with replay protection. Key design principles: + +1. **Unified packet structure** - Same format for all packet types +2. **Receiver index** - Client-proposed session identifier (replaces computed session_id) +3. **Opportunistic encryption** - Header authentication and payload encryption as soon as PSK is available +4. **WireGuard-inspired simplicity** - Minimal header, clear security model + +## Packet Structure + +``` +┌─────────┬──────────┬────────────────┬─────────┬─────────────────────┬─────────┐ +│ version │ reserved │ receiver_index │ counter │ payload │ trailer │ +│ 1B │ 3B │ 4B │ 8B │ variable │ 16B │ +└─────────┴──────────┴────────────────┴─────────┴─────────────────────┴─────────┘ + 16B header 16B +``` + +**Total overhead:** 32 bytes (16B header + 16B trailer) + +### Field Descriptions + +| Field | Size | Description | +|-------|------|-------------| +| version | 1 byte | Protocol version | +| reserved | 3 bytes | Reserved for future use | +| receiver_index | 4 bytes | Session identifier, proposed by client | +| counter | 8 bytes | Monotonic counter, used as AEAD nonce and for replay protection | +| payload | variable | Message type (2B) + content (plaintext or encrypted depending on state) | +| trailer | 16 bytes | Zeros (no PSK) or AEAD Poly1305 tag (with PSK) | + +### Wire Format + +Length-prefixed over TCP: + +``` +┌────────────────────┬─────────────────────────────────────────────────────┐ +│ length (4B BE u32) │ LpPacket │ +└────────────────────┴─────────────────────────────────────────────────────┘ +``` + +## Message Types + +| Type | Value | Description | +|------|-------|-------------| +| Busy | 0x0000 | Server congestion signal | +| Handshake | 0x0001 | Noise protocol messages | +| EncryptedData | 0x0002 | Encrypted application data | +| ClientHello | 0x0003 | Initial session setup | +| KKTRequest | 0x0004 | KEM key transfer request | +| KKTResponse | 0x0005 | KEM key transfer response | +| ForwardPacket | 0x0006 | Nested session forwarding | +| Collision | 0x0007 | Receiver index collision | +| SubsessionRequest | 0x0008 | Client requests new subsession | +| SubsessionKK1 | 0x0009 | KK handshake msg 1 (responder → initiator) | +| SubsessionKK2 | 0x000A | KK handshake msg 2 (initiator → responder) | +| SubsessionReady | 0x000B | Subsession established confirmation | + +## Receiver Index + +### Assignment + +The client generates a random 4-byte receiver_index and includes it in ClientHello. The gateway uses this as the session lookup key. This replaces the previous approach of computing a deterministic session_id from both parties' keys. + +### Collision Handling + +With 4 bytes (2^32 values), collision probability is negligible: + +| Active Sessions | Collision Probability | +|-----------------|----------------------| +| 10,000 | ~0.001% | +| 100,000 | ~0.1% | + +If collision detected, gateway rejects ClientHello and client retries with new index. + +## Opportunistic Encryption + +### Principle + +As soon as PSK is derived (after processing Noise msg 1 with PSQ), all subsequent packets use outer AEAD encryption: + +- **Header**: Authenticated as associated data (AD) +- **Payload**: Encrypted (message type + content) +- **Trailer**: AEAD tag + +### Timeline + +| Packet | PSK Available | Header | Payload | Trailer | +|--------|---------------|--------|---------|---------| +| ClientHello | No | Clear | Clear | Zeros | +| KKTRequest | No | Clear | Clear | Zeros | +| KKTResponse | No | Clear | Clear | Zeros | +| Noise msg 1 | No | Clear | Clear | Zeros | +| | | **PSK derived** | | | +| Noise msg 2 | Yes | Authenticated | Encrypted | Tag | +| Noise msg 3 | Yes | Authenticated | Encrypted | Tag | +| Data | Yes | Authenticated | Encrypted | Tag | + +### Encryption Scheme + +- **AEAD**: ChaCha20-Poly1305 +- **Key**: outer_key = KDF(PSK, "lp-outer-aead") - derived from PSK, not PSK itself +- **Nonce**: counter (8 bytes, zero-padded to 12 bytes) +- **AAD**: version ‖ reserved ‖ receiver_index ‖ counter (16 bytes) + +Note: PSK is used as-is for Noise (which does internal key derivation). The outer_key derivation avoids key reuse between the two encryption layers. + +### Before PSK + +``` +┌─────────┬──────────┬────────────────┬─────────┬─────────────────────┬─────────┐ +│ version │ reserved │ receiver_index │ counter │ payload │ 00...00 │ +│ │ │ │ │ (plaintext) │ │ +└─────────┴──────────┴────────────────┴─────────┴─────────────────────┴─────────┘ +│←──────────────────────────── cleartext ──────────────────────────────────────┤ +``` + +### After PSK + +``` +┌─────────┬──────────┬────────────────┬─────────┬─────────────────────┬─────────┐ +│ version │ reserved │ receiver_index │ counter │ payload │ tag │ +│ │ │ │ │ (encrypted) │ │ +└─────────┴──────────┴────────────────┴─────────┴─────────────────────┴─────────┘ +│←───────── cleartext (authenticated via AAD) ─────────┤│← encrypted ─┤│─ auth ─┤ +``` + +## Handshake Flow + +``` +Client Gateway + │ │ + │ [hdr][ClientHello][zeros] │ + │──────────────────────────────────────►│ store state[receiver_index] + │ │ + │ [hdr][KKTRequest][zeros] │ + │──────────────────────────────────────►│ + │ │ + │ [hdr][KKTResponse][zeros] │ + │◄──────────────────────────────────────│ + │ │ + │ [hdr][Noise1+PSQ][zeros] │ + │──────────────────────────────────────►│ derive PSK + │ │ + │ [hdr][encrypted Noise2][tag] │ ← authenticated + │◄──────────────────────────────────────│ + │ │ + │ [hdr][encrypted Noise3][tag] │ ← authenticated + │──────────────────────────────────────►│ + │ │ + │ ════════ Session Established ═════════│ + │ │ + │ [hdr][encrypted Data][tag] │ + │◄─────────────────────────────────────►│ +``` + +## Data Packet Encryption + +Data packets have two encryption layers: + +``` +Application Data + │ + ▼ +┌─────────────────────┐ +│ Noise encrypt │ Inner layer (forward secrecy, ratcheting) +│ (session keys) │ +└─────────────────────┘ + │ + ▼ +┌─────────────────────┐ +│ PSK AEAD │ Outer layer (header auth, payload encryption) +│ (pre-shared key) │ +└─────────────────────┘ + │ + ▼ +Wire: [header][encrypted payload][tag] +``` + +### What Outer AEAD Encrypts + +The outer AEAD encrypts: message_type (2B) + message content + +This hides the message type from observers after PSK is available. + +## Subsessions and Rekeying + +Subsessions enable **forward secrecy** through periodic rekeying and **channel multiplexing** for independent encrypted streams. + +### Design Principles + +| Aspect | Decision | Rationale | +|--------|----------|-----------| +| Key derivation | Noise KK handshake | Clean crypto, both parties already authenticated | +| Initiation channel | Tunneled through parent | Already authenticated, no proof-of-ownership needed | +| Hierarchy | Promotion model (chain) | Simpler than tree, natural for rekeying | +| Old session after promotion | Read-only until TTL | Drains in-flight packets, provides grace period | + +### Noise KK Pattern + +Subsessions use `Noise_KK_25519_ChaChaPoly_SHA256`: + +- **KK** = Both parties already know each other's static keys +- **2 messages** to complete (vs 3 for XKpsk3) +- **No PSK needed** - already authenticated via parent session + +### Promotion Model + +When a subsession is created, it becomes the new "master" and the old session becomes read-only: + +``` +Session A (master) → Session B created → A demoted, B is master + A: read-only until TTL +``` + +This creates a chain (A → B → C) but maintains only one level of nesting conceptually. Each promotion replaces the previous master. + +### Protocol Flow + +``` +Client Gateway + │ │ + │═══════ Parent Session (A) ════════│ Transport mode + │ │ + │──[SubsessionRequest{idx=B}]──────►│ Encrypted in parent + │ │ Gateway creates KK responder + │◄──[SubsessionKK1{idx=B, e}]───────│ KK handshake msg 1 + │──[SubsessionKK2{idx=B, e,ee,se}]─►│ KK handshake msg 2 + │◄──[SubsessionReady{idx=B}]────────│ Subsession established + │ │ + │ Session A: read-only (receive) │ + │═══════ Session B (new master) ════│ New Transport mode +``` + +### Session State Transitions + +``` +Parent Session (A): + Transport → ReadOnlyTransport (on subsession creation) + ReadOnlyTransport → (expires via TTL cleanup) + +Subsession (B): + (created) → KKHandshaking → Transport (becomes new master) +``` + +### Read-Only Session Semantics + +After demotion: +- **Can receive**: Decrypt and process incoming packets (drain in-flight) +- **Cannot send**: Encryption blocked, returns error +- **Cleaned up**: Via normal TTL expiration + +### Message Formats + +```rust +SubsessionRequestData { + new_receiver_index: u32, // Client-proposed index for subsession +} + +SubsessionKK1Data { + new_receiver_index: u32, + kk_message: Vec, // Noise KK message 1 +} + +SubsessionKK2Data { + new_receiver_index: u32, + kk_message: Vec, // Noise KK message 2 +} + +SubsessionReadyData { + new_receiver_index: u32, +} +``` + +### Counter Independence + +- Each session has independent counters +- Subsession starts at counter 0 +- No counter coordination needed between parent and subsession + +### Failure Handling + +| Scenario | Action | +|----------|--------| +| KK handshake fails | Discard attempt, keep using parent | +| Receiver index collision | Retry with new receiver_index | +| Parent session not found | Return error, client reconnects | + +### Security Benefits + +1. **Forward secrecy**: Compromise of current keys doesn't expose past traffic +2. **Key rotation**: Periodic rekeying limits exposure window +3. **Channel isolation**: Independent streams can't cross-decrypt + +## Security Properties + +### Always Visible to Observer + +- Version (1 byte) +- Reserved (3 bytes) +- Receiver index (4 bytes) - opaque, unlinkable to identity +- Counter (8 bytes) - reveals packet ordering +- Packet size + +### Protected After PSK + +- Header integrity (authenticated via AEAD AAD) +- Payload confidentiality (encrypted) +- Message type (hidden) +- Application data (double encrypted) + +### Cryptographic Guarantees + +| Property | Mechanism | +|----------|-----------| +| Confidentiality | ChaCha20 (outer) + Noise ChaCha20 (inner) | +| Integrity | Poly1305 (outer) + Noise Poly1305 (inner) | +| Replay protection | Counter validation (before decryption) | +| Forward secrecy | Noise session keys (inner) + subsession rekeying | +| Header authentication | AEAD associated data | +| Key rotation | Periodic subsession creation (Noise KK) | + +## References + +- WireGuard Protocol - Inspiration for receiver_index and packet simplicity +- Noise Protocol Framework - Inner encryption layer, KK pattern for subsessions +- RFC 8439 ChaCha20-Poly1305 - AEAD cipher +- Noise Explorer KK - https://noiseexplorer.com/patterns/KK/ diff --git a/common/nym-lp/src/codec.rs b/common/nym-lp/src/codec.rs index f9109e66416..de7684d376c 100644 --- a/common/nym-lp/src/codec.rs +++ b/common/nym-lp/src/codec.rs @@ -7,94 +7,238 @@ use crate::message::{ KKTResponseData, LpMessage, MessageType, }; use crate::packet::{LpHeader, LpPacket, TRAILER_LEN}; -use bytes::BytesMut; +use bytes::{BufMut, BytesMut}; +use chacha20poly1305::{ + aead::{AeadInPlace, KeyInit}, + ChaCha20Poly1305, Key, Nonce, Tag, +}; -/// Parses a complete Lewes Protocol packet from a byte slice (e.g., a UDP datagram payload). +/// Outer AEAD key for LP packet encryption. /// -/// Assumes the input `src` contains exactly one complete packet. It does not handle -/// stream fragmentation or provide replay protection checks (these belong at the session level). -pub fn parse_lp_packet(src: &[u8]) -> Result { - // Minimum size check: LpHeader + Type + Trailer (for 0-payload message) - let min_size = LpHeader::SIZE + 2 + TRAILER_LEN; - if src.len() < min_size { - return Err(LpError::InsufficientBufferSize); +/// Derived from PSK using Blake3 KDF with domain separation. +/// Used for opportunistic encryption: before PSK packets are cleartext, +/// after PSK packets have encrypted payload and authenticated header. +/// +/// # Security: Nonce Reuse Prevention +/// +/// ChaCha20-Poly1305 requires unique nonces per key. The counter starts at 0 +/// for each session, which is safe because: +/// +/// 1. **PSK is always fresh**: Each handshake uses PSQ +/// with a client-generated random salt. This ensures a unique +/// PSK for every session, even between the same client-gateway pair. +/// +/// 2. **Key derivation**: `outer_key = Blake3_KDF("lp-outer-aead", PSK)`. +/// Different PSK → different outer_key → nonce reuse impossible. +/// +/// 3. **No PSK persistence**: PSK handles are not stored/reused across sessions. +/// Each connection performs fresh KKT+PSQ handshake. +/// +#[derive(Clone)] +pub struct OuterAeadKey { + key: [u8; 32], +} + +impl OuterAeadKey { + /// KDF context for outer AEAD key derivation (domain separation) + const KDF_CONTEXT: &'static str = "lp-outer-aead"; + + /// Derive outer AEAD key from PSK. + /// + /// Uses Blake3 KDF with domain separation to avoid key reuse + /// between the outer AEAD layer and the inner Noise layer. + pub fn from_psk(psk: &[u8; 32]) -> Self { + let key = nym_crypto::kdf::derive_key_blake3(Self::KDF_CONTEXT, psk, &[]); + Self { key } } - // Parse LpHeader - let header = LpHeader::parse(&src[..LpHeader::SIZE])?; // Uses the new LpHeader::parse + /// Get reference to the raw key bytes. + pub fn as_bytes(&self) -> &[u8; 32] { + &self.key + } +} - // Parse Message Type - let type_start = LpHeader::SIZE; - let type_end = type_start + 2; - let mut message_type_bytes = [0u8; 2]; - message_type_bytes.copy_from_slice(&src[type_start..type_end]); - let message_type_raw = u16::from_le_bytes(message_type_bytes); - let message_type = MessageType::from_u16(message_type_raw) - .ok_or_else(|| LpError::invalid_message_type(message_type_raw))?; +impl Drop for OuterAeadKey { + fn drop(&mut self) { + // Zeroize key material on drop + self.key.iter_mut().for_each(|b| *b = 0); + } +} - // Calculate payload size based on total length - let total_size = src.len(); - let message_size = total_size - min_size; // Size of the payload part +impl std::fmt::Debug for OuterAeadKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OuterAeadKey") + .field("key", &"[REDACTED]") + .finish() + } +} - // Extract payload based on message type - let message_start = type_end; - let message_end = message_start + message_size; - let payload_slice = &src[message_start..message_end]; // Bounds already checked by min_size and total_size calculation +/// Build 12-byte nonce from 8-byte counter (zero-padded). +/// +/// Format: counter (8 bytes LE) || 0x00000000 (4 bytes) +fn build_nonce(counter: u64) -> [u8; 12] { + let mut nonce = [0u8; 12]; + nonce[..8].copy_from_slice(&counter.to_le_bytes()); + // bytes 8..12 remain zero (zero-padding) + nonce +} - let message = match message_type { +/// Parse message from raw type and content bytes. +/// +/// Used when decrypting outer-encrypted packets where the message type +/// was encrypted along with the content. +fn parse_message_from_type_and_content( + msg_type_raw: u16, + content: &[u8], +) -> Result { + let message_type = MessageType::from_u16(msg_type_raw) + .ok_or_else(|| LpError::invalid_message_type(msg_type_raw))?; + + match message_type { MessageType::Busy => { - if message_size != 0 { + if !content.is_empty() { return Err(LpError::InvalidPayloadSize { expected: 0, - actual: message_size, + actual: content.len(), }); } - LpMessage::Busy - } - MessageType::Handshake => { - // No size validation needed here for Handshake, it's variable - LpMessage::Handshake(HandshakeData(payload_slice.to_vec())) + Ok(LpMessage::Busy) } + MessageType::Handshake => Ok(LpMessage::Handshake(HandshakeData(content.to_vec()))), MessageType::EncryptedData => { - // No size validation needed here for EncryptedData, it's variable - LpMessage::EncryptedData(EncryptedDataPayload(payload_slice.to_vec())) + Ok(LpMessage::EncryptedData(EncryptedDataPayload(content.to_vec()))) } MessageType::ClientHello => { - // ClientHello has structured data - // Deserialize ClientHelloData from payload - let data: ClientHelloData = bincode::deserialize(payload_slice) + let data: ClientHelloData = bincode::deserialize(content) .map_err(|e| LpError::DeserializationError(e.to_string()))?; - LpMessage::ClientHello(data) - } - MessageType::KKTRequest => { - // KKT request contains serialized KKTFrame bytes - LpMessage::KKTRequest(KKTRequestData(payload_slice.to_vec())) - } - MessageType::KKTResponse => { - // KKT response contains serialized KKTFrame bytes - LpMessage::KKTResponse(KKTResponseData(payload_slice.to_vec())) + Ok(LpMessage::ClientHello(data)) } + MessageType::KKTRequest => Ok(LpMessage::KKTRequest(KKTRequestData(content.to_vec()))), + MessageType::KKTResponse => Ok(LpMessage::KKTResponse(KKTResponseData(content.to_vec()))), MessageType::ForwardPacket => { - // ForwardPacket has structured data - let data: ForwardPacketData = bincode::deserialize(payload_slice) + let data: ForwardPacketData = bincode::deserialize(content) .map_err(|e| LpError::DeserializationError(e.to_string()))?; - LpMessage::ForwardPacket(data) + Ok(LpMessage::ForwardPacket(data)) } - }; + MessageType::Collision => { + if !content.is_empty() { + return Err(LpError::InvalidPayloadSize { + expected: 0, + actual: content.len(), + }); + } + Ok(LpMessage::Collision) + } + MessageType::Ack => { + if !content.is_empty() { + return Err(LpError::InvalidPayloadSize { + expected: 0, + actual: content.len(), + }); + } + Ok(LpMessage::Ack) + } + } +} - // Extract trailer - let trailer_start = message_end; - let trailer_end = trailer_start + TRAILER_LEN; - // Check if trailer_end exceeds src length (shouldn't happen if min_size check passed and calculation is correct, but good for safety) - if trailer_end > total_size { - // This indicates an internal logic error or buffer manipulation issue - return Err(LpError::InsufficientBufferSize); // Or a more specific internal error +/// Parse only the LP header from raw packet bytes. +/// +/// Used for routing before session lookup when the header is always cleartext. +/// This allows the caller to determine the receiver_idx and look up the appropriate +/// session to get the outer AEAD key before calling `parse_lp_packet()`. +/// +/// # Arguments +/// * `src` - Raw packet bytes (at least LpHeader::SIZE bytes) +/// +/// # Errors +/// * `LpError::InsufficientBufferSize` - Packet too small for header +pub fn parse_lp_header_only(src: &[u8]) -> Result { + if src.len() < LpHeader::SIZE { + return Err(LpError::InsufficientBufferSize); } - let trailer_slice = &src[trailer_start..trailer_end]; + LpHeader::parse(&src[..LpHeader::SIZE]) +} + +/// Parses a complete Lewes Protocol packet from a byte slice (e.g., a UDP datagram payload). +/// +/// Assumes the input `src` contains exactly one complete packet. It does not handle +/// stream fragmentation or provide replay protection checks (these belong at the session level). +/// +/// # Arguments +/// * `src` - Raw packet bytes +/// * `outer_key` - None for cleartext parsing, Some for AEAD decryption +/// +/// # Errors +/// * `LpError::AeadTagMismatch` - Tag verification failed (when outer_key provided) +/// * `LpError::InsufficientBufferSize` - Packet too small +pub fn parse_lp_packet( + src: &[u8], + outer_key: Option<&OuterAeadKey>, +) -> Result { + // Minimum size check: LpHeader + Type + Trailer (for 0-payload message) + let min_size = LpHeader::SIZE + 2 + TRAILER_LEN; + if src.len() < min_size { + return Err(LpError::InsufficientBufferSize); + } + + // Parse LpHeader (always cleartext for routing) + let header = LpHeader::parse(&src[..LpHeader::SIZE])?; + + // Extract trailer (potential AEAD tag) + let trailer_start = src.len() - TRAILER_LEN; let mut trailer = [0u8; TRAILER_LEN]; - trailer.copy_from_slice(trailer_slice); + trailer.copy_from_slice(&src[trailer_start..]); + + // Payload is everything between header and trailer + let payload_bytes = &src[LpHeader::SIZE..trailer_start]; + + // Handle decryption if outer key provided + let (message_type_raw, message_content) = match outer_key { + None => { + // Cleartext mode - parse directly + if payload_bytes.len() < 2 { + return Err(LpError::InsufficientBufferSize); + } + let msg_type = u16::from_le_bytes([payload_bytes[0], payload_bytes[1]]); + (msg_type, &payload_bytes[2..]) + } + Some(key) => { + // AEAD decryption mode + let nonce = build_nonce(header.counter); + let aad = &src[..LpHeader::SIZE]; // Header as AAD + + // Copy payload for in-place decryption + let mut decrypted = payload_bytes.to_vec(); + + // Convert trailer to Tag + let tag = Tag::from_slice(&trailer); + + // Decrypt and verify + let cipher = ChaCha20Poly1305::new(Key::from_slice(key.as_bytes())); + cipher + .decrypt_in_place_detached(Nonce::from_slice(&nonce), aad, &mut decrypted, tag) + .map_err(|_| LpError::AeadTagMismatch)?; + + // Extract message type from decrypted payload + if decrypted.len() < 2 { + return Err(LpError::InsufficientBufferSize); + } + let msg_type = u16::from_le_bytes([decrypted[0], decrypted[1]]); + + // Return decrypted content (owned, so we handle it differently) + return parse_message_from_type_and_content(msg_type, &decrypted[2..]).map(|message| { + LpPacket { + header, + message, + trailer, + } + }); + } + }; + + // Cleartext path: parse message from payload + let message = parse_message_from_type_and_content(message_type_raw, message_content)?; - // Create and return the packet Ok(LpPacket { header, message, @@ -103,11 +247,66 @@ pub fn parse_lp_packet(src: &[u8]) -> Result { } /// Serializes an LpPacket into the provided BytesMut buffer. -pub fn serialize_lp_packet(item: &LpPacket, dst: &mut BytesMut) -> Result<(), LpError> { - // Reserve approximate size - consider making this more accurate if needed - dst.reserve(LpHeader::SIZE + 2 + item.message.len() + TRAILER_LEN); - item.encode(dst); // Use the existing encode method on LpPacket - Ok(()) +/// +/// # Arguments +/// * `item` - Packet to serialize +/// * `dst` - Output buffer +/// * `outer_key` - None for cleartext (uses packet's trailer), Some for AEAD encryption +/// +/// When `outer_key` is provided: +/// - Header is written in cleartext (used as AAD) +/// - Message type + content is encrypted +/// - Trailer is set to the AEAD tag +pub fn serialize_lp_packet( + item: &LpPacket, + dst: &mut BytesMut, + outer_key: Option<&OuterAeadKey>, +) -> Result<(), LpError> { + match outer_key { + None => { + // Cleartext mode - use existing encode method + dst.reserve(LpHeader::SIZE + 2 + item.message.len() + TRAILER_LEN); + item.encode(dst); + Ok(()) + } + Some(key) => { + // AEAD encryption mode + dst.reserve(LpHeader::SIZE + 2 + item.message.len() + TRAILER_LEN); + + // 1. Encode header (AAD - not encrypted) + let header_start = dst.len(); + item.header.encode(dst); + let header_end = dst.len(); + + // 2. Build plaintext: message_type (2B) + content + let mut plaintext = BytesMut::new(); + plaintext.put_slice(&(item.message.typ() as u16).to_le_bytes()); + item.message.encode_content(&mut plaintext); + + // 3. Copy plaintext to dst for in-place encryption + let payload_start = dst.len(); + dst.put_slice(&plaintext); + + // 4. Build nonce and get AAD + let nonce = build_nonce(item.header.counter); + let aad = &dst[header_start..header_end].to_vec(); // Copy AAD since we mutate dst + + // 5. Encrypt payload in-place + let cipher = ChaCha20Poly1305::new(Key::from_slice(key.as_bytes())); + let tag = cipher + .encrypt_in_place_detached( + Nonce::from_slice(&nonce), + aad, + &mut dst[payload_start..], + ) + .map_err(|_| LpError::Internal("AEAD encryption failed".to_string()))?; + + // 6. Append tag as trailer + dst.put_slice(&tag); + + Ok(()) + } + } } // Add a new error variant for invalid message types (Moved from previous impl LpError block) @@ -120,14 +319,17 @@ impl LpError { #[cfg(test)] mod tests { // Import standalone functions - use super::{parse_lp_packet, serialize_lp_packet}; + use super::{parse_lp_packet, serialize_lp_packet, OuterAeadKey}; // Keep necessary imports use crate::LpError; use crate::message::{EncryptedDataPayload, HandshakeData, LpMessage, MessageType}; use crate::packet::{LpHeader, LpPacket, TRAILER_LEN}; use bytes::BytesMut; - // === Updated Encode/Decode Tests === + // Header length: version(1) + reserved(3) + receiver_index(4) + counter(8) = 16 bytes + const HEADER_LEN: usize = 16; + + // === Cleartext Encode/Decode Tests === #[test] fn test_serialize_parse_busy() { @@ -138,22 +340,22 @@ mod tests { header: LpHeader { protocol_version: 1, reserved: 0, - session_id: 42, + receiver_idx: 42, counter: 123, }, message: LpMessage::Busy, trailer: [0; TRAILER_LEN], }; - // Serialize the packet - serialize_lp_packet(&packet, &mut dst).unwrap(); + // Serialize the packet (cleartext) + serialize_lp_packet(&packet, &mut dst, None).unwrap(); - // Parse the packet - let decoded = parse_lp_packet(&dst).unwrap(); + // Parse the packet (cleartext) + let decoded = parse_lp_packet(&dst, None).unwrap(); // Verify the packet fields assert_eq!(decoded.header.protocol_version, 1); - assert_eq!(decoded.header.session_id, 42); + assert_eq!(decoded.header.receiver_idx, 42); assert_eq!(decoded.header.counter, 123); assert!(matches!(decoded.message, LpMessage::Busy)); assert_eq!(decoded.trailer, [0; TRAILER_LEN]); @@ -169,22 +371,22 @@ mod tests { header: LpHeader { protocol_version: 1, reserved: 0, - session_id: 42, + receiver_idx: 42, counter: 123, }, message: LpMessage::Handshake(HandshakeData(payload.clone())), trailer: [0; TRAILER_LEN], }; - // Serialize the packet - serialize_lp_packet(&packet, &mut dst).unwrap(); + // Serialize the packet (cleartext) + serialize_lp_packet(&packet, &mut dst, None).unwrap(); - // Parse the packet - let decoded = parse_lp_packet(&dst).unwrap(); + // Parse the packet (cleartext) + let decoded = parse_lp_packet(&dst, None).unwrap(); // Verify the packet fields assert_eq!(decoded.header.protocol_version, 1); - assert_eq!(decoded.header.session_id, 42); + assert_eq!(decoded.header.receiver_idx, 42); assert_eq!(decoded.header.counter, 123); // Verify message type and data @@ -207,22 +409,22 @@ mod tests { header: LpHeader { protocol_version: 1, reserved: 0, - session_id: 42, + receiver_idx: 42, counter: 123, }, message: LpMessage::EncryptedData(EncryptedDataPayload(payload.clone())), trailer: [0; TRAILER_LEN], }; - // Serialize the packet - serialize_lp_packet(&packet, &mut dst).unwrap(); + // Serialize the packet (cleartext) + serialize_lp_packet(&packet, &mut dst, None).unwrap(); - // Parse the packet - let decoded = parse_lp_packet(&dst).unwrap(); + // Parse the packet (cleartext) + let decoded = parse_lp_packet(&dst, None).unwrap(); // Verify the packet fields assert_eq!(decoded.header.protocol_version, 1); - assert_eq!(decoded.header.session_id, 42); + assert_eq!(decoded.header.receiver_idx, 42); assert_eq!(decoded.header.counter, 123); // Verify message type and data @@ -235,7 +437,7 @@ mod tests { assert_eq!(decoded.trailer, [0; TRAILER_LEN]); } - // === Updated Incomplete Data Tests === + // === Incomplete Data Tests === #[test] fn test_parse_incomplete_header() { @@ -244,7 +446,7 @@ mod tests { buf.extend_from_slice(&[1, 0, 0, 0]); // Only 4 bytes, not enough for LpHeader::SIZE // Attempt to parse - expect error - let result = parse_lp_packet(&buf); + let result = parse_lp_packet(&buf, None); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), @@ -262,7 +464,7 @@ mod tests { buf.extend_from_slice(&[0]); // Only 1 byte of message type (need 2) // Buffer length = 16 + 1 = 17. Min size = 16 + 2 + 16 = 34. - let result = parse_lp_packet(&buf); + let result = parse_lp_packet(&buf, None); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), @@ -298,7 +500,7 @@ mod tests { buf_too_short.extend_from_slice(&123u64.to_le_bytes()); // Counter buf_too_short.extend_from_slice(&MessageType::Handshake.to_u16().to_le_bytes()); // Handshake type // No payload, no trailer. Length = 16+2=18. Min size = 34. - let result_too_short = parse_lp_packet(&buf_too_short); + let result_too_short = parse_lp_packet(&buf_too_short, None); assert!(result_too_short.is_err()); assert!(matches!( result_too_short.unwrap_err(), @@ -335,7 +537,7 @@ mod tests { buf_too_short.extend_from_slice(&MessageType::Busy.to_u16().to_le_bytes()); // Type buf_too_short.extend_from_slice(&[0; TRAILER_LEN - 1]); // Missing last byte of trailer // Length = 16 + 2 + 15 = 33. Min Size = 34. - let result_too_short = parse_lp_packet(&buf_too_short); + let result_too_short = parse_lp_packet(&buf_too_short, None); assert!( result_too_short.is_err(), "Expected error for buffer size 33, min 34" @@ -360,7 +562,7 @@ mod tests { buf.extend_from_slice(&[0; TRAILER_LEN]); // Trailer // Attempt to parse - let result = parse_lp_packet(&buf); + let result = parse_lp_packet(&buf, None); assert!(result.is_err()); match result { Err(LpError::InvalidMessageType(255)) => {} // Expected error @@ -382,7 +584,7 @@ mod tests { // Total size = 16 + 2 + 1 + 16 = 35. Min size = 34. // Calculated payload size = 35 - 34 = 1. - let result = parse_lp_packet(&buf); + let result = parse_lp_packet(&buf, None); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), @@ -410,6 +612,7 @@ mod tests { let client_ed25519_key = [43u8; 32]; let salt = [99u8; 32]; let hello_data = ClientHelloData { + receiver_index: 12345, client_lp_public_key: client_key, client_ed25519_public_key: client_ed25519_key, salt, @@ -420,7 +623,7 @@ mod tests { header: LpHeader { protocol_version: 1, reserved: 0, - session_id: 42, + receiver_idx: 42, counter: 123, }, message: LpMessage::ClientHello(hello_data.clone()), @@ -428,14 +631,14 @@ mod tests { }; // Serialize the packet - serialize_lp_packet(&packet, &mut dst).unwrap(); + serialize_lp_packet(&packet, &mut dst, None).unwrap(); // Parse the packet - let decoded = parse_lp_packet(&dst).unwrap(); + let decoded = parse_lp_packet(&dst, None).unwrap(); // Verify the packet fields assert_eq!(decoded.header.protocol_version, 1); - assert_eq!(decoded.header.session_id, 42); + assert_eq!(decoded.header.receiver_idx, 42); assert_eq!(decoded.header.counter, 123); // Verify message type and data @@ -465,7 +668,7 @@ mod tests { header: LpHeader { protocol_version: 1, reserved: 0, - session_id: 100, + receiver_idx: 100, counter: 200, }, message: LpMessage::ClientHello(hello_data.clone()), @@ -473,10 +676,10 @@ mod tests { }; // Serialize the packet - serialize_lp_packet(&packet, &mut dst).unwrap(); + serialize_lp_packet(&packet, &mut dst, None).unwrap(); // Parse the packet - let decoded = parse_lp_packet(&dst).unwrap(); + let decoded = parse_lp_packet(&dst, None).unwrap(); // Verify message type and data match decoded.message { @@ -511,7 +714,7 @@ mod tests { buf.extend_from_slice(&[0; TRAILER_LEN]); // Trailer // Attempt to parse - let result = parse_lp_packet(&buf); + let result = parse_lp_packet(&buf, None); assert!(result.is_err()); match result { Err(LpError::DeserializationError(_)) => {} // Expected error @@ -534,7 +737,7 @@ mod tests { buf.extend_from_slice(&[0; TRAILER_LEN]); // Trailer // Attempt to parse - let result = parse_lp_packet(&buf); + let result = parse_lp_packet(&buf, None); assert!(result.is_err()); match result { Err(LpError::DeserializationError(_)) => {} // Expected error @@ -551,6 +754,7 @@ mod tests { let mut dst = BytesMut::new(); let hello_data = ClientHelloData { + receiver_index: version as u32, client_lp_public_key: [version; 32], client_ed25519_public_key: [version.wrapping_add(2); 32], salt: [version.wrapping_add(1); 32], @@ -560,15 +764,15 @@ mod tests { header: LpHeader { protocol_version: 1, reserved: 0, - session_id: version as u32, + receiver_idx: version as u32, counter: version as u64, }, message: LpMessage::ClientHello(hello_data.clone()), trailer: [version; TRAILER_LEN], }; - serialize_lp_packet(&packet, &mut dst).unwrap(); - let decoded = parse_lp_packet(&dst).unwrap(); + serialize_lp_packet(&packet, &mut dst, None).unwrap(); + let decoded = parse_lp_packet(&dst, None).unwrap(); match decoded.message { LpMessage::ClientHello(decoded_data) => { @@ -593,7 +797,7 @@ mod tests { header: LpHeader { protocol_version: 1, reserved: 0, - session_id: 999, + receiver_idx: 999, counter: 555, }, message: LpMessage::ForwardPacket(forward_data), @@ -601,13 +805,13 @@ mod tests { }; // Serialize - serialize_lp_packet(&packet, &mut dst).unwrap(); + serialize_lp_packet(&packet, &mut dst, None).unwrap(); // Parse back - let decoded = parse_lp_packet(&dst).unwrap(); + let decoded = parse_lp_packet(&dst, None).unwrap(); // Verify LP protocol handling works correctly - assert_eq!(decoded.header.session_id, 999); + assert_eq!(decoded.header.receiver_idx, 999); assert!(matches!(decoded.message.typ(), MessageType::ForwardPacket)); if let LpMessage::ForwardPacket(data) = decoded.message { @@ -618,4 +822,274 @@ mod tests { panic!("Expected ForwardPacket message"); } } + + // === Outer AEAD Tests === + + #[test] + fn test_aead_roundtrip_with_key() { + // Test that encrypt/decrypt roundtrip works with an AEAD key + let psk = [42u8; 32]; + let outer_key = OuterAeadKey::from_psk(&psk); + + let packet = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + receiver_idx: 12345, + counter: 999, + }, + message: LpMessage::Busy, + trailer: [0; TRAILER_LEN], + }; + + let mut encrypted = BytesMut::new(); + serialize_lp_packet(&packet, &mut encrypted, Some(&outer_key)).unwrap(); + + // Parse back with the same key + let decoded = parse_lp_packet(&encrypted, Some(&outer_key)).unwrap(); + + assert_eq!(decoded.header.protocol_version, 1); + assert_eq!(decoded.header.receiver_idx, 12345); + assert_eq!(decoded.header.counter, 999); + assert!(matches!(decoded.message, LpMessage::Busy)); + } + + #[test] + fn test_aead_ciphertext_differs_from_plaintext() { + // Verify that encrypted payload differs from plaintext + let psk = [42u8; 32]; + let outer_key = OuterAeadKey::from_psk(&psk); + + let packet = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + receiver_idx: 12345, + counter: 999, + }, + message: LpMessage::EncryptedData(crate::message::EncryptedDataPayload(vec![ + 0xAA, 0xBB, 0xCC, 0xDD, + ])), + trailer: [0; TRAILER_LEN], + }; + + let mut cleartext = BytesMut::new(); + serialize_lp_packet(&packet, &mut cleartext, None).unwrap(); + + let mut encrypted = BytesMut::new(); + serialize_lp_packet(&packet, &mut encrypted, Some(&outer_key)).unwrap(); + + // Header should be the same (it's authenticated but not encrypted) + assert_eq!(&cleartext[..HEADER_LEN], &encrypted[..HEADER_LEN]); + + // Payload should differ (it's encrypted) + let payload_start = HEADER_LEN; + let payload_end_cleartext = cleartext.len() - TRAILER_LEN; + let payload_end_encrypted = encrypted.len() - TRAILER_LEN; + + assert_ne!( + &cleartext[payload_start..payload_end_cleartext], + &encrypted[payload_start..payload_end_encrypted], + "Encrypted payload should differ from plaintext" + ); + + // Trailer should differ (zeros vs AEAD tag) + assert_ne!( + &cleartext[payload_end_cleartext..], + &encrypted[payload_end_encrypted..], + "Encrypted trailer should be a tag, not zeros" + ); + } + + #[test] + fn test_aead_tampered_tag_fails() { + // Verify that tampering with the tag causes decryption failure + let psk = [42u8; 32]; + let outer_key = OuterAeadKey::from_psk(&psk); + + let packet = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + receiver_idx: 12345, + counter: 999, + }, + message: LpMessage::Busy, + trailer: [0; TRAILER_LEN], + }; + + let mut encrypted = BytesMut::new(); + serialize_lp_packet(&packet, &mut encrypted, Some(&outer_key)).unwrap(); + + // Tamper with the tag (last byte) + let last_idx = encrypted.len() - 1; + encrypted[last_idx] ^= 0xFF; + + // Parsing should fail with AeadTagMismatch + let result = parse_lp_packet(&encrypted, Some(&outer_key)); + assert!(matches!(result, Err(LpError::AeadTagMismatch))); + } + + #[test] + fn test_aead_tampered_header_fails() { + // Verify that tampering with the header (AAD) causes decryption failure + let psk = [42u8; 32]; + let outer_key = OuterAeadKey::from_psk(&psk); + + let packet = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + receiver_idx: 12345, + counter: 999, + }, + message: LpMessage::Busy, + trailer: [0; TRAILER_LEN], + }; + + let mut encrypted = BytesMut::new(); + serialize_lp_packet(&packet, &mut encrypted, Some(&outer_key)).unwrap(); + + // Tamper with the header (flip a bit in receiver_idx) + encrypted[4] ^= 0x01; + + // Parsing should fail with AeadTagMismatch + let result = parse_lp_packet(&encrypted, Some(&outer_key)); + assert!(matches!(result, Err(LpError::AeadTagMismatch))); + } + + #[test] + fn test_aead_different_counters_produce_different_ciphertext() { + // Verify that different counters (nonces) produce different ciphertexts + let psk = [42u8; 32]; + let outer_key = OuterAeadKey::from_psk(&psk); + + let packet1 = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + receiver_idx: 12345, + counter: 1, + }, + message: LpMessage::Busy, + trailer: [0; TRAILER_LEN], + }; + + let packet2 = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + receiver_idx: 12345, + counter: 2, // Different counter + }, + message: LpMessage::Busy, + trailer: [0; TRAILER_LEN], + }; + + let mut encrypted1 = BytesMut::new(); + serialize_lp_packet(&packet1, &mut encrypted1, Some(&outer_key)).unwrap(); + + let mut encrypted2 = BytesMut::new(); + serialize_lp_packet(&packet2, &mut encrypted2, Some(&outer_key)).unwrap(); + + // The encrypted payloads should differ even though the message is the same + // (because nonce is different) + let payload_start = HEADER_LEN; + assert_ne!( + &encrypted1[payload_start..], + &encrypted2[payload_start..], + "Different counters should produce different ciphertexts" + ); + } + + #[test] + fn test_aead_wrong_key_fails() { + // Verify that decryption with wrong key fails + let psk1 = [42u8; 32]; + let psk2 = [43u8; 32]; // Different PSK + let outer_key1 = OuterAeadKey::from_psk(&psk1); + let outer_key2 = OuterAeadKey::from_psk(&psk2); + + let packet = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + receiver_idx: 12345, + counter: 999, + }, + message: LpMessage::Busy, + trailer: [0; TRAILER_LEN], + }; + + let mut encrypted = BytesMut::new(); + serialize_lp_packet(&packet, &mut encrypted, Some(&outer_key1)).unwrap(); + + // Parsing with wrong key should fail + let result = parse_lp_packet(&encrypted, Some(&outer_key2)); + assert!(matches!(result, Err(LpError::AeadTagMismatch))); + } + + #[test] + fn test_aead_encrypted_data_message_roundtrip() { + // Test AEAD with EncryptedData message type (larger payload) + let psk = [42u8; 32]; + let outer_key = OuterAeadKey::from_psk(&psk); + + let payload_data = vec![0xDE; 100]; + let packet = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + receiver_idx: 54321, + counter: 12345678, + }, + message: LpMessage::EncryptedData(crate::message::EncryptedDataPayload( + payload_data.clone(), + )), + trailer: [0; TRAILER_LEN], + }; + + let mut encrypted = BytesMut::new(); + serialize_lp_packet(&packet, &mut encrypted, Some(&outer_key)).unwrap(); + + let decoded = parse_lp_packet(&encrypted, Some(&outer_key)).unwrap(); + + match decoded.message { + LpMessage::EncryptedData(data) => { + assert_eq!(data.0, payload_data); + } + _ => panic!("Expected EncryptedData message"), + } + } + + #[test] + fn test_aead_handshake_message_roundtrip() { + // Test AEAD with Handshake message type + let psk = [42u8; 32]; + let outer_key = OuterAeadKey::from_psk(&psk); + + let handshake_data = vec![0x01, 0x02, 0x03, 0x04, 0x05]; + let packet = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + receiver_idx: 99999, + counter: 2, + }, + message: LpMessage::Handshake(HandshakeData(handshake_data.clone())), + trailer: [0; TRAILER_LEN], + }; + + let mut encrypted = BytesMut::new(); + serialize_lp_packet(&packet, &mut encrypted, Some(&outer_key)).unwrap(); + + let decoded = parse_lp_packet(&encrypted, Some(&outer_key)).unwrap(); + + match decoded.message { + LpMessage::Handshake(data) => { + assert_eq!(data.0, handshake_data); + } + _ => panic!("Expected Handshake message"), + } + } } diff --git a/common/nym-lp/src/error.rs b/common/nym-lp/src/error.rs index 456bc721546..2f5d52f8364 100644 --- a/common/nym-lp/src/error.rs +++ b/common/nym-lp/src/error.rs @@ -78,4 +78,8 @@ pub enum LpError { /// Ed25519 to X25519 conversion error. #[error("Ed25519 key conversion error: {0}")] Ed25519RecoveryError(#[from] Ed25519RecoveryError), + + /// Outer AEAD authentication tag verification failed. + #[error("AEAD authentication tag verification failed")] + AeadTagMismatch, } diff --git a/common/nym-lp/src/lib.rs b/common/nym-lp/src/lib.rs index 6c41f6ed072..33edd7fef32 100644 --- a/common/nym-lp/src/lib.rs +++ b/common/nym-lp/src/lib.rs @@ -14,12 +14,9 @@ pub mod session; mod session_integration; pub mod session_manager; -use std::hash::{DefaultHasher, Hasher as _}; - pub use error::LpError; -use keypair::PublicKey; pub use message::{ClientHelloData, LpMessage}; -pub use packet::{LpPacket, BOOTSTRAP_SESSION_ID}; +pub use packet::{LpPacket, BOOTSTRAP_RECEIVER_IDX}; pub use replay::{ReceivingKeyCounterValidator, ReplayError}; pub use session::{LpSession, generate_fresh_salt}; pub use session_manager::SessionManager; @@ -33,13 +30,15 @@ pub const NOISE_PSK_INDEX: u8 = 3; #[cfg(test)] pub fn sessions_for_tests() -> (LpSession, LpSession) { - use crate::{keypair::Keypair, make_lp_id}; + use crate::keypair::Keypair; use nym_crypto::asymmetric::ed25519; // X25519 keypairs for Noise protocol let keypair_1 = Keypair::default(); let keypair_2 = Keypair::default(); - let id = make_lp_id(keypair_1.public_key(), keypair_2.public_key()); + + // Use a fixed receiver_index for deterministic tests + let receiver_index: u32 = 12345; // Ed25519 keypairs for PSQ authentication (placeholders for testing) let ed25519_keypair_1 = ed25519::KeyPair::from_secret([1u8; 32], 0); @@ -51,7 +50,7 @@ pub fn sessions_for_tests() -> (LpSession, LpSession) { // PSQ will always derive the PSK during handshake using X25519 as DHKEM let initiator_session = LpSession::new( - id, + receiver_index, true, ( ed25519_keypair_1.private_key(), @@ -65,7 +64,7 @@ pub fn sessions_for_tests() -> (LpSession, LpSession) { .expect("Test session creation failed"); let responder_session = LpSession::new( - id, + receiver_index, false, ( ed25519_keypair_2.private_key(), @@ -81,47 +80,12 @@ pub fn sessions_for_tests() -> (LpSession, LpSession) { (initiator_session, responder_session) } -/// Generates a deterministic u32 session ID for the Lewes Protocol -/// based on two public keys. The order of the keys does not matter. -/// -/// Uses a different internal delimiter than `make_conv_id` to avoid -/// potential collisions if the same key pairs were used in both contexts. -fn make_id(key1_bytes: &[u8], key2_bytes: &[u8], sep: u8) -> u32 { - let mut hasher = DefaultHasher::new(); - - // Ensure consistent order for hashing to make the ID order-independent. - // This guarantees make_lp_id(a, b) == make_lp_id(b, a). - if key1_bytes < key2_bytes { - hasher.write(key1_bytes); - // Use a delimiter specific to Lewes Protocol ID generation - // (0xCC chosen arbitrarily, could be any value different from 0xFF) - hasher.write_u8(sep); - hasher.write(key2_bytes); - } else { - hasher.write(key2_bytes); - hasher.write_u8(sep); - hasher.write(key1_bytes); - } - - // Truncate the u64 hash result to u32 - (hasher.finish() & 0xFFFF_FFFF) as u32 -} - -pub fn make_lp_id(key1_bytes: &PublicKey, key2_bytes: &PublicKey) -> u32 { - make_id(key1_bytes.as_bytes(), key2_bytes.as_bytes(), 0xCC) -} - -pub fn make_conv_id(src: &[u8], dst: &[u8]) -> u32 { - make_id(src, dst, 0xFF) -} - #[cfg(test)] mod tests { - use crate::keypair::PublicKey; use crate::message::LpMessage; use crate::packet::{LpHeader, LpPacket, TRAILER_LEN}; use crate::session_manager::SessionManager; - use crate::{LpError, make_lp_id, sessions_for_tests}; + use crate::{LpError, sessions_for_tests}; use bytes::BytesMut; // Import the new standalone functions @@ -137,7 +101,7 @@ mod tests { header: LpHeader { protocol_version: 1, reserved: 0, - session_id: 42, // Matches session's sending_index assumption for this test + receiver_idx: 42, // Matches session's sending_index assumption for this test counter: 0, }, message: LpMessage::Busy, @@ -146,10 +110,10 @@ mod tests { // Serialize packet let mut buf1 = BytesMut::new(); - serialize_lp_packet(&packet1, &mut buf1).unwrap(); + serialize_lp_packet(&packet1, &mut buf1, None).unwrap(); // Parse packet - let parsed_packet1 = parse_lp_packet(&buf1).unwrap(); + let parsed_packet1 = parse_lp_packet(&buf1, None).unwrap(); // Perform replay check (should pass) session @@ -166,7 +130,7 @@ mod tests { header: LpHeader { protocol_version: 1, reserved: 0, - session_id: 42, + receiver_idx: 42, counter: 0, // Same counter as before (replay) }, message: LpMessage::Busy, @@ -175,10 +139,10 @@ mod tests { // Serialize packet let mut buf2 = BytesMut::new(); - serialize_lp_packet(&packet2, &mut buf2).unwrap(); + serialize_lp_packet(&packet2, &mut buf2, None).unwrap(); // Parse packet - let parsed_packet2 = parse_lp_packet(&buf2).unwrap(); + let parsed_packet2 = parse_lp_packet(&buf2, None).unwrap(); // Perform replay check (should fail) let replay_result = session.receiving_counter_quick_check(parsed_packet2.header.counter); @@ -196,7 +160,7 @@ mod tests { header: LpHeader { protocol_version: 1, reserved: 0, - session_id: 42, + receiver_idx: 42, counter: 1, // Incremented counter }, message: LpMessage::Busy, @@ -205,10 +169,10 @@ mod tests { // Serialize packet let mut buf3 = BytesMut::new(); - serialize_lp_packet(&packet3, &mut buf3).unwrap(); + serialize_lp_packet(&packet3, &mut buf3, None).unwrap(); // Parse packet - let parsed_packet3 = parse_lp_packet(&buf3).unwrap(); + let parsed_packet3 = parse_lp_packet(&buf3, None).unwrap(); // Perform replay check (should pass) session @@ -238,24 +202,8 @@ mod tests { let ed25519_keypair_local = ed25519::KeyPair::from_secret([8u8; 32], 0); let ed25519_keypair_remote = ed25519::KeyPair::from_secret([9u8; 32], 1); - // Derive X25519 keys from Ed25519 (same as state machine does internally) - let x25519_pub_local = ed25519_keypair_local - .public_key() - .to_x25519() - .expect("Failed to derive X25519 from Ed25519"); - let x25519_pub_remote = ed25519_keypair_remote - .public_key() - .to_x25519() - .expect("Failed to derive X25519 from Ed25519"); - - // Convert to LP keypair types - let lp_pub_local = PublicKey::from_bytes(x25519_pub_local.as_bytes()) - .expect("Failed to create PublicKey from bytes"); - let lp_pub_remote = PublicKey::from_bytes(x25519_pub_remote.as_bytes()) - .expect("Failed to create PublicKey from bytes"); - - // Calculate lp_id (matches state machine's internal calculation) - let lp_id = make_lp_id(&lp_pub_local, &lp_pub_remote); + // Use fixed receiver_index for deterministic test + let receiver_index: u32 = 54321; // Test salt let salt = [46u8; 32]; @@ -263,6 +211,7 @@ mod tests { // Create a session via manager let _ = local_manager .create_session_state_machine( + receiver_index, ( ed25519_keypair_local.private_key(), ed25519_keypair_local.public_key(), @@ -275,6 +224,7 @@ mod tests { let _ = remote_manager .create_session_state_machine( + receiver_index, ( ed25519_keypair_remote.private_key(), ed25519_keypair_remote.public_key(), @@ -289,7 +239,7 @@ mod tests { header: LpHeader { protocol_version: 1, reserved: 0, - session_id: lp_id, + receiver_idx: receiver_index, counter: 0, }, message: LpMessage::Busy, @@ -298,10 +248,10 @@ mod tests { // Serialize let mut buf1 = BytesMut::new(); - serialize_lp_packet(&packet1, &mut buf1).unwrap(); + serialize_lp_packet(&packet1, &mut buf1, None).unwrap(); // Parse - let parsed_packet1 = parse_lp_packet(&buf1).unwrap(); + let parsed_packet1 = parse_lp_packet(&buf1, None).unwrap(); // Process via SessionManager method (which should handle checks + marking) // NOTE: We might need a method on SessionManager/LpSession like `process_incoming_packet` @@ -310,11 +260,11 @@ mod tests { // Perform replay check local_manager - .receiving_counter_quick_check(lp_id, parsed_packet1.header.counter) + .receiving_counter_quick_check(receiver_index, parsed_packet1.header.counter) .expect("Packet 1 check failed"); // Mark received local_manager - .receiving_counter_mark(lp_id, parsed_packet1.header.counter) + .receiving_counter_mark(receiver_index, parsed_packet1.header.counter) .expect("Packet 1 mark failed"); // === Packet 2 (Counter 1 - Should succeed on same session) === @@ -322,7 +272,7 @@ mod tests { header: LpHeader { protocol_version: 1, reserved: 0, - session_id: lp_id, + receiver_idx: receiver_index, counter: 1, }, message: LpMessage::Busy, @@ -331,18 +281,18 @@ mod tests { // Serialize let mut buf2 = BytesMut::new(); - serialize_lp_packet(&packet2, &mut buf2).unwrap(); + serialize_lp_packet(&packet2, &mut buf2, None).unwrap(); // Parse - let parsed_packet2 = parse_lp_packet(&buf2).unwrap(); + let parsed_packet2 = parse_lp_packet(&buf2, None).unwrap(); // Perform replay check local_manager - .receiving_counter_quick_check(lp_id, parsed_packet2.header.counter) + .receiving_counter_quick_check(receiver_index, parsed_packet2.header.counter) .expect("Packet 2 check failed"); // Mark received local_manager - .receiving_counter_mark(lp_id, parsed_packet2.header.counter) + .receiving_counter_mark(receiver_index, parsed_packet2.header.counter) .expect("Packet 2 mark failed"); // === Packet 3 (Counter 0 - Replay, should fail check) === @@ -350,7 +300,7 @@ mod tests { header: LpHeader { protocol_version: 1, reserved: 0, - session_id: lp_id, + receiver_idx: receiver_index, counter: 0, // Replay of first packet }, message: LpMessage::Busy, @@ -359,14 +309,14 @@ mod tests { // Serialize let mut buf3 = BytesMut::new(); - serialize_lp_packet(&packet3, &mut buf3).unwrap(); + serialize_lp_packet(&packet3, &mut buf3, None).unwrap(); // Parse - let parsed_packet3 = parse_lp_packet(&buf3).unwrap(); + let parsed_packet3 = parse_lp_packet(&buf3, None).unwrap(); // Perform replay check (should fail) let replay_result = - local_manager.receiving_counter_quick_check(lp_id, parsed_packet3.header.counter); + local_manager.receiving_counter_quick_check(receiver_index, parsed_packet3.header.counter); assert!(replay_result.is_err()); match replay_result.unwrap_err() { LpError::Replay(e) => { diff --git a/common/nym-lp/src/message.rs b/common/nym-lp/src/message.rs index 1f53c2b76db..2b23d43d3fc 100644 --- a/common/nym-lp/src/message.rs +++ b/common/nym-lp/src/message.rs @@ -9,6 +9,9 @@ use serde::{Deserialize, Serialize}; /// Data structure for the ClientHello message #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClientHelloData { + /// Client-proposed receiver index for session identification (4 bytes) + /// Auto-generated randomly by the client + pub receiver_index: u32, /// Client's LP x25519 public key (32 bytes) - derived from Ed25519 key pub client_lp_public_key: [u8; 32], /// Client's Ed25519 public key (32 bytes) - for PSQ authentication @@ -46,6 +49,7 @@ impl ClientHelloData { rand::thread_rng().fill_bytes(&mut salt[8..]); Self { + receiver_index: rand::random(), // Auto-generate random receiver index client_lp_public_key, client_ed25519_public_key, salt, @@ -73,6 +77,10 @@ pub enum MessageType { KKTRequest = 0x0004, KKTResponse = 0x0005, ForwardPacket = 0x0006, + /// Receiver index collision - client should retry with new index + Collision = 0x0007, + /// Acknowledgment - gateway confirms receipt of message + Ack = 0x0008, } impl MessageType { @@ -122,6 +130,10 @@ pub enum LpMessage { KKTRequest(KKTRequestData), KKTResponse(KKTResponseData), ForwardPacket(ForwardPacketData), + /// Receiver index collision - client should retry with new receiver_index + Collision, + /// Acknowledgment - gateway confirms receipt of message + Ack, } impl Display for LpMessage { @@ -134,6 +146,8 @@ impl Display for LpMessage { LpMessage::KKTRequest(_) => write!(f, "KKTRequest"), LpMessage::KKTResponse(_) => write!(f, "KKTResponse"), LpMessage::ForwardPacket(_) => write!(f, "ForwardPacket"), + LpMessage::Collision => write!(f, "Collision"), + LpMessage::Ack => write!(f, "Ack"), } } } @@ -148,6 +162,8 @@ impl LpMessage { LpMessage::KKTRequest(payload) => payload.0.as_slice(), LpMessage::KKTResponse(payload) => payload.0.as_slice(), LpMessage::ForwardPacket(_) => &[], // Structured data, serialized in encode_content + LpMessage::Collision => &[], + LpMessage::Ack => &[], } } @@ -160,6 +176,8 @@ impl LpMessage { LpMessage::KKTRequest(payload) => payload.0.is_empty(), LpMessage::KKTResponse(payload) => payload.0.is_empty(), LpMessage::ForwardPacket(_) => false, // Always has data + LpMessage::Collision => true, + LpMessage::Ack => true, } } @@ -168,12 +186,15 @@ impl LpMessage { LpMessage::Busy => 0, LpMessage::Handshake(payload) => payload.0.len(), LpMessage::EncryptedData(payload) => payload.0.len(), - LpMessage::ClientHello(_) => 97, // 32 bytes x25519 key + 32 bytes ed25519 key + 32 bytes salt + 1 byte bincode overhead + // 4 bytes receiver_index + 32 bytes x25519 key + 32 bytes ed25519 key + 32 bytes salt + bincode overhead + LpMessage::ClientHello(_) => 101, LpMessage::KKTRequest(payload) => payload.0.len(), LpMessage::KKTResponse(payload) => payload.0.len(), LpMessage::ForwardPacket(data) => { 32 + data.target_lp_address.len() + data.inner_packet_bytes.len() + 10 } + LpMessage::Collision => 0, + LpMessage::Ack => 0, } } @@ -186,6 +207,8 @@ impl LpMessage { LpMessage::KKTRequest(_) => MessageType::KKTRequest, LpMessage::KKTResponse(_) => MessageType::KKTResponse, LpMessage::ForwardPacket(_) => MessageType::ForwardPacket, + LpMessage::Collision => MessageType::Collision, + LpMessage::Ack => MessageType::Ack, } } @@ -215,6 +238,8 @@ impl LpMessage { bincode::serialize(data).expect("Failed to serialize ForwardPacketData"); dst.put_slice(&serialized); } + LpMessage::Collision => { /* No content */ } + LpMessage::Ack => { /* No content */ } } } } @@ -232,7 +257,7 @@ mod tests { let resp_header = LpHeader { protocol_version: 1, reserved: 0, - session_id: 0, + receiver_idx: 0, counter: 0, }; diff --git a/common/nym-lp/src/packet.rs b/common/nym-lp/src/packet.rs index 4f3017f0bb1..c98353c8671 100644 --- a/common/nym-lp/src/packet.rs +++ b/common/nym-lp/src/packet.rs @@ -124,18 +124,18 @@ impl LpPacket { /// Session ID used for ClientHello bootstrap packets before session is established. /// -/// When a client first connects, it sends a ClientHello packet with session_id=0 +/// When a client first connects, it sends a ClientHello packet with receiver_idx=0 /// because neither side can compute the deterministic session ID yet (requires /// both parties' X25519 keys). After ClientHello is processed, both sides derive /// the same session ID from their keys, and all subsequent packets use that ID. -pub const BOOTSTRAP_SESSION_ID: u32 = 0; +pub const BOOTSTRAP_RECEIVER_IDX: u32 = 0; // VERSION [1B] || RESERVED [3B] || SENDER_INDEX [4B] || COUNTER [8B] #[derive(Debug, Clone)] pub struct LpHeader { pub protocol_version: u8, pub reserved: u16, - pub session_id: u32, + pub receiver_idx: u32, pub counter: u64, } @@ -144,11 +144,11 @@ impl LpHeader { } impl LpHeader { - pub fn new(session_id: u32, counter: u64) -> Self { + pub fn new(receiver_idx: u32, counter: u64) -> Self { Self { protocol_version: 1, reserved: 0, - session_id, + receiver_idx, counter, } } @@ -161,7 +161,7 @@ impl LpHeader { dst.put_slice(&[0, 0, 0]); // sender index - dst.put_slice(&self.session_id.to_le_bytes()); + dst.put_slice(&self.receiver_idx.to_le_bytes()); // counter dst.put_slice(&self.counter.to_le_bytes()); @@ -175,9 +175,9 @@ impl LpHeader { let protocol_version = src[0]; // Skip reserved bytes [1..4] - let mut session_id_bytes = [0u8; 4]; - session_id_bytes.copy_from_slice(&src[4..8]); - let session_id = u32::from_le_bytes(session_id_bytes); + let mut receiver_idx_bytes = [0u8; 4]; + receiver_idx_bytes.copy_from_slice(&src[4..8]); + let receiver_idx = u32::from_le_bytes(receiver_idx_bytes); let mut counter_bytes = [0u8; 8]; counter_bytes.copy_from_slice(&src[8..16]); @@ -186,7 +186,7 @@ impl LpHeader { Ok(LpHeader { protocol_version, reserved: 0, - session_id, + receiver_idx, counter, }) } @@ -197,8 +197,8 @@ impl LpHeader { } /// Get the sender index from the header - pub fn session_id(&self) -> u32 { - self.session_id + pub fn receiver_idx(&self) -> u32 { + self.receiver_idx } } diff --git a/common/nym-lp/src/session.rs b/common/nym-lp/src/session.rs index e58a2200f1a..2d1476f940f 100644 --- a/common/nym-lp/src/session.rs +++ b/common/nym-lp/src/session.rs @@ -6,6 +6,7 @@ //! This module implements session management functionality, including replay protection //! and Noise protocol state handling. +use crate::codec::OuterAeadKey; use crate::keypair::{PrivateKey, PublicKey}; use crate::message::{EncryptedDataPayload, HandshakeData}; use crate::noise_protocol::{NoiseError, NoiseProtocol, ReadResult}; @@ -165,6 +166,10 @@ pub struct LpSession { /// Salt for PSK derivation salt: [u8; 32], + + /// Outer AEAD key for packet encryption (derived from PSK after PSQ handshake). + /// None before PSK is available, Some after PSK injection. + outer_aead_key: Mutex>, } /// Generates a fresh salt for PSK derivation. @@ -217,6 +222,17 @@ impl LpSession { self.local_x25519_private.public_key() } + /// Returns the outer AEAD key for packet encryption/decryption. + /// + /// Returns `None` before PSK is derived (during initial handshake), + /// `Some(&OuterAeadKey)` after PSK injection via PSQ. + /// + /// Callers should use `None` for packet encryption/decryption during + /// the handshake phase, and use the returned key for transport phase. + pub fn outer_aead_key(&self) -> Option { + self.outer_aead_key.lock().clone() + } + /// Creates a new session and initializes the Noise protocol state. /// /// PSQ always runs during the handshake to derive the real PSK from X25519 DHKEM. @@ -301,6 +317,7 @@ impl LpSession { local_x25519_private: local_x25519_key.clone(), remote_x25519_public: remote_x25519_key.clone(), salt: *salt, + outer_aead_key: Mutex::new(None), }) } @@ -638,6 +655,12 @@ impl LpSession { // Mark PSK as injected for safety checks in transport mode self.psk_injected.store(true, Ordering::Release); + // Derive and store outer AEAD key from PSK + { + let mut outer_key = self.outer_aead_key.lock(); + *outer_key = Some(OuterAeadKey::from_psk(&psk)); + } + // Get the Noise handshake message let noise_msg = match noise_state.get_bytes_to_send() { Some(Ok(msg)) => msg, @@ -801,6 +824,12 @@ impl LpSession { // Mark PSK as injected for safety checks in transport mode self.psk_injected.store(true, Ordering::Release); + // Derive and store outer AEAD key from PSK + { + let mut outer_key = self.outer_aead_key.lock(); + *outer_key = Some(OuterAeadKey::from_psk(&psk)); + } + // Update PSQ state to Completed *psq_state = PSQState::Completed { psk }; @@ -946,15 +975,13 @@ mod tests { // Helper function to create a session with real keys for handshake tests fn create_handshake_test_session( + receiver_index: u32, is_initiator: bool, local_keys: &crate::keypair::Keypair, remote_pub_key: &crate::keypair::PublicKey, ) -> LpSession { use nym_crypto::asymmetric::ed25519; - // Compute the shared lp_id from both keypairs (order-independent) - let lp_id = crate::make_lp_id(local_keys.public_key(), remote_pub_key); - // Create Ed25519 keypairs that correspond to initiator/responder roles // Initiator uses [1u8], Responder uses [2u8] let (local_ed25519_seed, remote_ed25519_seed) = if is_initiator { @@ -970,7 +997,7 @@ mod tests { // PSQ will derive the PSK during handshake using X25519 as DHKEM let session = LpSession::new( - lp_id, + receiver_index, is_initiator, (local_ed25519.private_key(), local_ed25519.public_key()), local_keys.private_key(), @@ -1080,10 +1107,12 @@ mod tests { fn test_prepare_handshake_message_initial_state() { let initiator_keys = generate_keypair(); let responder_keys = generate_keypair(); + let receiver_index = 12345u32; let initiator_session = - create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + create_handshake_test_session(receiver_index, true, &initiator_keys, responder_keys.public_key()); let responder_session = create_handshake_test_session( + receiver_index, false, &responder_keys, initiator_keys.public_key(), // Responder also needs initiator's key for XK @@ -1106,11 +1135,12 @@ mod tests { fn test_process_handshake_message_first_step() { let initiator_keys = generate_keypair(); let responder_keys = generate_keypair(); + let receiver_index = 12345u32; let initiator_session = - create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + create_handshake_test_session(receiver_index, true, &initiator_keys, responder_keys.public_key()); let responder_session = - create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + create_handshake_test_session(receiver_index, false, &responder_keys, initiator_keys.public_key()); // 1. Initiator prepares the first message (-> e) let initiator_msg_result = initiator_session.prepare_handshake_message(); @@ -1145,9 +1175,9 @@ mod tests { let responder_keys = generate_keypair(); let initiator_session = - create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + create_handshake_test_session(12345u32, true, &initiator_keys, responder_keys.public_key()); let responder_session = - create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + create_handshake_test_session(12345u32, false, &responder_keys, initiator_keys.public_key()); let mut responder_to_initiator_msg = None; let mut rounds = 0; @@ -1232,9 +1262,9 @@ mod tests { let responder_keys = generate_keypair(); let initiator_session = - create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + create_handshake_test_session(12345u32, true, &initiator_keys, responder_keys.public_key()); let responder_session = - create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + create_handshake_test_session(12345u32, false, &responder_keys, initiator_keys.public_key()); // Drive handshake to completion (simplified loop from previous test) let mut i_msg = initiator_session @@ -1293,7 +1323,7 @@ mod tests { let responder_keys = generate_keypair(); let initiator_session = - create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + create_handshake_test_session(12345u32, true, &initiator_keys, responder_keys.public_key()); assert!(!initiator_session.is_handshake_complete()); @@ -1365,9 +1395,9 @@ mod tests { let responder_keys = generate_keypair(); let initiator_session = - create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + create_handshake_test_session(12345u32, true, &initiator_keys, responder_keys.public_key()); let responder_session = - create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + create_handshake_test_session(12345u32, false, &responder_keys, initiator_keys.public_key()); // Drive the handshake let mut i_msg = initiator_session @@ -1459,9 +1489,9 @@ mod tests { // Create sessions - they start with dummy PSK [0u8; 32] let initiator_session = - create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + create_handshake_test_session(12345u32, true, &initiator_keys, responder_keys.public_key()); let responder_session = - create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + create_handshake_test_session(12345u32, false, &responder_keys, initiator_keys.public_key()); // Prepare first message (initiator runs PSQ and injects PSK) let i_msg = initiator_session @@ -1524,9 +1554,9 @@ mod tests { let responder_keys = generate_keypair(); let initiator_session = - create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + create_handshake_test_session(12345u32, true, &initiator_keys, responder_keys.public_key()); let responder_session = - create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + create_handshake_test_session(12345u32, false, &responder_keys, initiator_keys.public_key()); // Verify initial state assert!(!initiator_session.is_handshake_complete()); @@ -1603,9 +1633,9 @@ mod tests { // Create sessions with explicit Ed25519 keys let initiator_session = - create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + create_handshake_test_session(12345u32, true, &initiator_keys, responder_keys.public_key()); let responder_session = - create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + create_handshake_test_session(12345u32, false, &responder_keys, initiator_keys.public_key()); // Verify sessions store Ed25519 keys // (Internal verification - keys are used in PSQ calls) @@ -1648,7 +1678,7 @@ mod tests { let initiator_keys = generate_keypair(); let responder_session = - create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + create_handshake_test_session(12345u32, false, &responder_keys, initiator_keys.public_key()); // Create a handshake message with corrupted PSQ payload let corrupted_psq_data = vec![0xFF; 128]; // Random garbage @@ -1677,11 +1707,11 @@ mod tests { let initiator_ed25519 = ed25519::KeyPair::from_secret([1u8; 32], 0); let wrong_ed25519 = ed25519::KeyPair::from_secret([99u8; 32], 99); // Different key! - let lp_id = crate::make_lp_id(initiator_keys.public_key(), responder_keys.public_key()); + let receiver_index: u32 = 55555; let salt = [0u8; 32]; let initiator_session = LpSession::new( - lp_id, + receiver_index, true, ( initiator_ed25519.private_key(), @@ -1699,7 +1729,7 @@ mod tests { let responder_ed25519 = ed25519::KeyPair::from_secret([2u8; 32], 1); let responder_session = LpSession::new( - lp_id, + receiver_index, false, ( responder_ed25519.private_key(), @@ -1748,11 +1778,11 @@ mod tests { let wrong_ed25519_keypair = ed25519::KeyPair::from_secret([99u8; 32], 99); let wrong_ed25519_public = wrong_ed25519_keypair.public_key(); - let lp_id = crate::make_lp_id(initiator_keys.public_key(), responder_keys.public_key()); + let receiver_index: u32 = 66666; let salt = [0u8; 32]; let initiator_session = LpSession::new( - lp_id, + receiver_index, true, ( initiator_ed25519.private_key(), @@ -1770,7 +1800,7 @@ mod tests { let responder_ed25519 = ed25519::KeyPair::from_secret([2u8; 32], 1); let responder_session = LpSession::new( - lp_id, + receiver_index, false, ( responder_ed25519.private_key(), @@ -1813,7 +1843,7 @@ mod tests { let initiator_keys = generate_keypair(); let responder_session = - create_handshake_test_session(false, &responder_keys, initiator_keys.public_key()); + create_handshake_test_session(12345u32, false, &responder_keys, initiator_keys.public_key()); // Capture initial PSQ state (should be ResponderWaiting) // (We can't directly access psq_state, but we can verify behavior) @@ -1831,7 +1861,7 @@ mod tests { // Session should still be functional - can process valid messages // Create a proper initiator to send valid message let initiator_session = - create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + create_handshake_test_session(12345u32, true, &initiator_keys, responder_keys.public_key()); let valid_msg = initiator_session .prepare_handshake_message() @@ -1858,7 +1888,7 @@ mod tests { // Create session but don't complete handshake (no PSK injection will occur) let session = - create_handshake_test_session(true, &initiator_keys, responder_keys.public_key()); + create_handshake_test_session(12345u32, true, &initiator_keys, responder_keys.public_key()); // Verify session was created successfully assert!(!session.is_handshake_complete()); diff --git a/common/nym-lp/src/session_integration/mod.rs b/common/nym-lp/src/session_integration/mod.rs index 4e52a6cbd37..0f5376639d5 100644 --- a/common/nym-lp/src/session_integration/mod.rs +++ b/common/nym-lp/src/session_integration/mod.rs @@ -2,7 +2,6 @@ mod tests { use crate::codec::{parse_lp_packet, serialize_lp_packet}; use crate::keypair::PublicKey; - use crate::make_lp_id; use crate::{ LpError, message::LpMessage, @@ -15,7 +14,7 @@ mod tests { // Function to create a test packet - similar to how it's done in codec.rs tests fn create_test_packet( protocol_version: u8, - session_id: u32, + receiver_idx: u32, counter: u64, message: LpMessage, ) -> LpPacket { @@ -23,7 +22,7 @@ mod tests { let header = LpHeader { protocol_version, reserved: 0u16, // reserved - session_id, + receiver_idx, counter, }; @@ -54,7 +53,7 @@ mod tests { let ed25519_keypair_a = ed25519::KeyPair::from_secret([1u8; 32], 0); let ed25519_keypair_b = ed25519::KeyPair::from_secret([2u8; 32], 1); - // Derive X25519 keys from Ed25519 (same as state machine does internally) + // Derive X25519 keys from Ed25519 (needed for KKT init test) let x25519_pub_a = ed25519_keypair_a .public_key() .to_x25519() @@ -70,8 +69,8 @@ mod tests { let lp_pub_b = PublicKey::from_bytes(x25519_pub_b.as_bytes()) .expect("Failed to create PublicKey from bytes"); - // Calculate lp_id (matches state machine's internal calculation) - let lp_id = make_lp_id(&lp_pub_a, &lp_pub_b); + // Use fixed receiver_index for deterministic test + let receiver_index: u32 = 100001; // Test salt let salt = [42u8; 32]; @@ -79,6 +78,7 @@ mod tests { // 4. Create sessions using the pre-built Noise states let peer_a_sm = session_manager_1 .create_session_state_machine( + receiver_index, ( ed25519_keypair_a.private_key(), ed25519_keypair_a.public_key(), @@ -91,6 +91,7 @@ mod tests { let peer_b_sm = session_manager_2 .create_session_state_machine( + receiver_index, ( ed25519_keypair_b.private_key(), ed25519_keypair_b.public_key(), @@ -145,13 +146,13 @@ mod tests { ); // A prepares packet - let counter = session_manager_1.next_counter(lp_id).unwrap(); - let message_a_to_b = create_test_packet(1, lp_id, counter, payload); + let counter = session_manager_1.next_counter(receiver_index).unwrap(); + let message_a_to_b = create_test_packet(1, receiver_index, counter, payload); let mut encoded_msg = BytesMut::new(); - serialize_lp_packet(&message_a_to_b, &mut encoded_msg).expect("A serialize failed"); + serialize_lp_packet(&message_a_to_b, &mut encoded_msg, None).expect("A serialize failed"); // B parses packet and checks replay - let decoded_packet = parse_lp_packet(&encoded_msg).expect("B parse failed"); + let decoded_packet = parse_lp_packet(&encoded_msg, None).expect("B parse failed"); assert_eq!(decoded_packet.header.counter, counter); // Check replay before processing handshake @@ -197,12 +198,12 @@ mod tests { // B prepares packet let counter = session_manager_2.next_counter(peer_b_sm).unwrap(); - let message_b_to_a = create_test_packet(1, lp_id, counter, payload); + let message_b_to_a = create_test_packet(1, receiver_index, counter, payload); let mut encoded_msg = BytesMut::new(); - serialize_lp_packet(&message_b_to_a, &mut encoded_msg).expect("B serialize failed"); + serialize_lp_packet(&message_b_to_a, &mut encoded_msg, None).expect("B serialize failed"); // A parses packet and checks replay - let decoded_packet = parse_lp_packet(&encoded_msg).expect("A parse failed"); + let decoded_packet = parse_lp_packet(&encoded_msg, None).expect("A parse failed"); assert_eq!(decoded_packet.header.counter, counter); // Check replay before processing handshake @@ -282,13 +283,13 @@ mod tests { // A prepares packet let counter_a = session_manager_1.next_counter(peer_a_sm).unwrap(); - let message_a_to_b = create_test_packet(1, lp_id, counter_a, ciphertext_a_to_b); + let message_a_to_b = create_test_packet(1, receiver_index, counter_a, ciphertext_a_to_b); let mut encoded_data_a_to_b = BytesMut::new(); - serialize_lp_packet(&message_a_to_b, &mut encoded_data_a_to_b) + serialize_lp_packet(&message_a_to_b, &mut encoded_data_a_to_b, None) .expect("A serialize data failed"); // B parses packet and checks replay - let decoded_packet_b = parse_lp_packet(&encoded_data_a_to_b).expect("B parse data failed"); + let decoded_packet_b = parse_lp_packet(&encoded_data_a_to_b, None).expect("B parse data failed"); assert_eq!(decoded_packet_b.header.counter, counter_a); // Check replay before decrypting @@ -316,13 +317,13 @@ mod tests { .encrypt_data(peer_b_sm, plaintext_b_to_a) .expect("B encrypt failed"); let counter_b = session_manager_2.next_counter(peer_b_sm).unwrap(); - let message_b_to_a = create_test_packet(1, lp_id, counter_b, ciphertext_b_to_a); + let message_b_to_a = create_test_packet(1, receiver_index, counter_b, ciphertext_b_to_a); let mut encoded_data_b_to_a = BytesMut::new(); - serialize_lp_packet(&message_b_to_a, &mut encoded_data_b_to_a) + serialize_lp_packet(&message_b_to_a, &mut encoded_data_b_to_a, None) .expect("B serialize data failed"); // A parses packet and checks replay - let decoded_packet_a = parse_lp_packet(&encoded_data_b_to_a).expect("A parse data failed"); + let decoded_packet_a = parse_lp_packet(&encoded_data_b_to_a, None).expect("A parse data failed"); assert_eq!(decoded_packet_a.header.counter, counter_b); // Check replay before decrypting @@ -352,18 +353,18 @@ mod tests { // Need to re-encode because decode consumes the buffer let message_b_to_a_replay = create_test_packet( 1, - lp_id, + receiver_index, counter_b, LpMessage::EncryptedData(crate::message::EncryptedDataPayload( plaintext_b_to_a.to_vec(), )), // Using plaintext here, but content doesn't matter for replay check ); let mut encoded_data_b_to_a_replay = BytesMut::new(); - serialize_lp_packet(&message_b_to_a_replay, &mut encoded_data_b_to_a_replay) + serialize_lp_packet(&message_b_to_a_replay, &mut encoded_data_b_to_a_replay, None) .expect("B serialize replay failed"); let parsed_replay_packet = - parse_lp_packet(&encoded_data_b_to_a_replay).expect("A parse replay failed"); + parse_lp_packet(&encoded_data_b_to_a_replay, None).expect("A parse replay failed"); let replay_result = session_manager_1 .receiving_counter_quick_check(peer_a_sm, parsed_replay_packet.header.counter); assert!(replay_result.is_err(), "Data replay should be prevented"); @@ -386,18 +387,18 @@ mod tests { let message_a_to_b_skip = create_test_packet( 1, // protocol version - lp_id, + receiver_index, counter_a_skip, // Send N+1 first ciphertext_skip, ); // Encode the skip message let mut encoded_skip = BytesMut::new(); - serialize_lp_packet(&message_a_to_b_skip, &mut encoded_skip) + serialize_lp_packet(&message_a_to_b_skip, &mut encoded_skip, None) .expect("Failed to serialize skip message"); // B parses skip message and checks replay - let decoded_packet_skip = parse_lp_packet(&encoded_skip).expect("B parse skip failed"); + let decoded_packet_skip = parse_lp_packet(&encoded_skip, None).expect("B parse skip failed"); session_manager_2 .receiving_counter_quick_check(peer_b_sm, decoded_packet_skip.header.counter) .expect("B replay check skip failed"); @@ -428,14 +429,14 @@ mod tests { let message_a_to_b_delayed = create_test_packet( 1, // protocol version - lp_id, + receiver_index, counter_a_next, // counter N (delayed packet) ciphertext_delayed, ); // Encode the delayed message let mut encoded_delayed = BytesMut::new(); - serialize_lp_packet(&message_a_to_b_delayed, &mut encoded_delayed) + serialize_lp_packet(&message_a_to_b_delayed, &mut encoded_delayed, None) .expect("Failed to serialize delayed message"); // Make a copy for replay test later @@ -443,7 +444,7 @@ mod tests { // B parses delayed message and checks replay let decoded_packet_delayed = - parse_lp_packet(&encoded_delayed).expect("B parse delayed failed"); + parse_lp_packet(&encoded_delayed, None).expect("B parse delayed failed"); session_manager_2 .receiving_counter_quick_check(peer_b_sm, decoded_packet_delayed.header.counter) .expect("B replay check delayed failed"); @@ -469,7 +470,7 @@ mod tests { // 11. Try to replay message with counter N (should fail) println!("Testing replay of delayed packet..."); let parsed_delayed_replay = - parse_lp_packet(&encoded_delayed_copy).expect("Parse delayed replay failed"); + parse_lp_packet(&encoded_delayed_copy, None).expect("Parse delayed replay failed"); let result = session_manager_2 .receiving_counter_quick_check(peer_b_sm, parsed_delayed_replay.header.counter); assert!(result.is_err(), "Replay attack should be prevented"); @@ -479,15 +480,15 @@ mod tests { ); // 12. Session removal - assert!(session_manager_1.remove_state_machine(lp_id)); + assert!(session_manager_1.remove_state_machine(receiver_index)); assert_eq!(session_manager_1.session_count(), 0); // Verify the session is gone - let session = session_manager_1.state_machine_exists(lp_id); + let session = session_manager_1.state_machine_exists(receiver_index); assert!(!session, "Session should be removed"); // But the other session still exists - let session = session_manager_2.state_machine_exists(lp_id); + let session = session_manager_2.state_machine_exists(receiver_index); assert!(session, "Session still exists in the other manager"); } @@ -518,14 +519,15 @@ mod tests { let lp_pub_b = PublicKey::from_bytes(x25519_pub_b.as_bytes()) .expect("Failed to create PublicKey from bytes"); - // Calculate lp_id (matches state machine's internal calculation) - let lp_id = make_lp_id(&lp_pub_a, &lp_pub_b); + // Use fixed receiver_index for test + let receiver_index: u32 = 100002; // Test salt let salt = [43u8; 32]; let peer_a_sm = session_manager_1 .create_session_state_machine( + receiver_index, ( ed25519_keypair_a.private_key(), ed25519_keypair_a.public_key(), @@ -537,6 +539,7 @@ mod tests { .unwrap(); let peer_b_sm = session_manager_2 .create_session_state_machine( + receiver_index, ( ed25519_keypair_b.private_key(), ed25519_keypair_b.public_key(), @@ -612,12 +615,12 @@ mod tests { let current_counter_a = counter_a; counter_a += 1; - let message_a = create_test_packet(1, lp_id, current_counter_a, ciphertext_a); + let message_a = create_test_packet(1, receiver_index, current_counter_a, ciphertext_a); let mut encoded_a = BytesMut::new(); - serialize_lp_packet(&message_a, &mut encoded_a).expect("A serialize failed"); + serialize_lp_packet(&message_a, &mut encoded_a, None).expect("A serialize failed"); // B parses and checks replay - let decoded_packet_b = parse_lp_packet(&encoded_a).expect("B parse failed"); + let decoded_packet_b = parse_lp_packet(&encoded_a, None).expect("B parse failed"); session_manager_2 .receiving_counter_quick_check(peer_b_sm, decoded_packet_b.header.counter) .expect("B replay check failed (A->B)"); @@ -638,12 +641,12 @@ mod tests { let current_counter_b = counter_b; counter_b += 1; - let message_b = create_test_packet(1, lp_id, current_counter_b, ciphertext_b); + let message_b = create_test_packet(1, receiver_index, current_counter_b, ciphertext_b); let mut encoded_b = BytesMut::new(); - serialize_lp_packet(&message_b, &mut encoded_b).expect("B serialize failed"); + serialize_lp_packet(&message_b, &mut encoded_b, None).expect("B serialize failed"); // A parses and checks replay - let decoded_packet_a = parse_lp_packet(&encoded_b).expect("A parse failed"); + let decoded_packet_a = parse_lp_packet(&encoded_b, None).expect("A parse failed"); session_manager_1 .receiving_counter_quick_check(peer_a_sm, decoded_packet_a.header.counter) .expect("A replay check failed (B->A)"); @@ -716,12 +719,12 @@ mod tests { .to_x25519() .expect("Failed to derive X25519 from Ed25519"); - // Convert to LP keypair type - let lp_pub = PublicKey::from_bytes(x25519_pub.as_bytes()) + // Convert to LP keypair type (still needed for init_kkt_for_test below if used) + let _lp_pub = PublicKey::from_bytes(x25519_pub.as_bytes()) .expect("Failed to create PublicKey from bytes"); - // Calculate lp_id (self-connection: both sides use same key) - let lp_id = make_lp_id(&lp_pub, &lp_pub); + // Use fixed receiver_index for test + let receiver_index: u32 = 100003; // Test salt let salt = [44u8; 32]; @@ -729,6 +732,7 @@ mod tests { // 2. Create a session (using real noise state) let _session = session_manager .create_session_state_machine( + receiver_index, (ed25519_keypair.private_key(), ed25519_keypair.public_key()), ed25519_keypair.public_key(), true, @@ -748,8 +752,10 @@ mod tests { ); // 5. Create and immediately remove a session + let receiver_index_temp: u32 = 100004; let _temp_session = session_manager .create_session_state_machine( + receiver_index_temp, (ed25519_keypair.private_key(), ed25519_keypair.public_key()), ed25519_keypair.public_key(), true, @@ -758,7 +764,7 @@ mod tests { .expect("Failed to create temp session"); assert!( - session_manager.remove_state_machine(lp_id), + session_manager.remove_state_machine(receiver_index_temp), "Should remove the session" ); @@ -770,7 +776,7 @@ mod tests { // Add header buf.extend_from_slice(&[1, 0, 0, 0]); // Version + reserved - buf.extend_from_slice(&lp_id.to_le_bytes()); // Sender index + buf.extend_from_slice(&receiver_index.to_le_bytes()); // Sender index buf.extend_from_slice(&0u64.to_le_bytes()); // Counter // Add invalid message type @@ -783,7 +789,7 @@ mod tests { buf.extend_from_slice(&[0u8; TRAILER_LEN]); // Try to parse the invalid message type - let result = parse_lp_packet(&buf); + let result = parse_lp_packet(&buf, None); assert!(result.is_err(), "Decoding invalid message type should fail"); // Add assertion for the specific error type @@ -796,7 +802,7 @@ mod tests { let partial_packet = &buf[0..10]; // Too short to be a valid packet let partial_bytes = BytesMut::from(partial_packet); - let result = parse_lp_packet(&partial_bytes); + let result = parse_lp_packet(&partial_bytes, None); assert!(result.is_err(), "Parsing partial packet should fail"); assert!(matches!( result.unwrap_err(), @@ -844,14 +850,14 @@ mod tests { .to_x25519() .expect("Failed to derive X25519 from Ed25519"); - // Convert to LP keypair types + // Convert to LP keypair types (needed for init_kkt_for_test if used) let lp_pub_a = PublicKey::from_bytes(x25519_pub_a.as_bytes()) .expect("Failed to create PublicKey from bytes"); let lp_pub_b = PublicKey::from_bytes(x25519_pub_b.as_bytes()) .expect("Failed to create PublicKey from bytes"); - // Calculate lp_id (matches state machine's internal calculation) - let lp_id = make_lp_id(&lp_pub_a, &lp_pub_b); + // Use fixed receiver_index for test + let receiver_index: u32 = 100005; // Test salt let salt = [45u8; 32]; @@ -860,6 +866,7 @@ mod tests { assert!( session_manager_1 .create_session_state_machine( + receiver_index, ( ed25519_keypair_a.private_key(), ed25519_keypair_a.public_key() @@ -873,6 +880,7 @@ mod tests { assert!( session_manager_2 .create_session_state_machine( + receiver_index, ( ed25519_keypair_b.private_key(), ed25519_keypair_b.public_key() @@ -886,16 +894,16 @@ mod tests { assert_eq!(session_manager_1.session_count(), 1); assert_eq!(session_manager_2.session_count(), 1); - assert!(session_manager_1.state_machine_exists(lp_id)); - assert!(session_manager_2.state_machine_exists(lp_id)); + assert!(session_manager_1.state_machine_exists(receiver_index)); + assert!(session_manager_2.state_machine_exists(receiver_index)); // Verify initial states are ReadyToHandshake assert_eq!( - session_manager_1.get_state(lp_id).unwrap(), + session_manager_1.get_state(receiver_index).unwrap(), LpStateBare::ReadyToHandshake ); assert_eq!( - session_manager_2.get_state(lp_id).unwrap(), + session_manager_2.get_state(receiver_index).unwrap(), LpStateBare::ReadyToHandshake ); @@ -910,7 +918,7 @@ mod tests { // --- Round 1: Initiator Starts --- println!(" Round {}: Initiator starts handshake", rounds); let action_a1 = session_manager_1 - .process_input(lp_id, LpInput::StartHandshake) + .process_input(receiver_index, LpInput::StartHandshake) .expect("Initiator StartHandshake should produce an action") .expect("Initiator StartHandshake failed"); @@ -922,7 +930,7 @@ mod tests { } // After StartHandshake, initiator should be in KKTExchange state (not Handshaking yet) assert_eq!( - session_manager_1.get_state(lp_id).unwrap(), + session_manager_1.get_state(receiver_index).unwrap(), LpStateBare::KKTExchange, "Initiator state wrong after StartHandshake (should be KKTExchange)" ); @@ -932,7 +940,7 @@ mod tests { " Round {}: Responder explicitly enters KKTExchange state", rounds ); - let action_b_start = session_manager_2.process_input(lp_id, LpInput::StartHandshake); + let action_b_start = session_manager_2.process_input(receiver_index, LpInput::StartHandshake); // Responder's StartHandshake should not produce an action to send assert!( action_b_start.as_ref().unwrap().is_none(), @@ -941,7 +949,7 @@ mod tests { ); // Verify responder transitions to KKTExchange state (not Handshaking yet) assert_eq!( - session_manager_2.get_state(lp_id).unwrap(), + session_manager_2.get_state(receiver_index).unwrap(), LpStateBare::KKTExchange, // Responder also enters KKTExchange state "Responder state should be KKTExchange after its StartHandshake" ); @@ -959,12 +967,12 @@ mod tests { // Simulate network: serialize -> parse (optional but good practice) let mut buf_a = BytesMut::new(); - serialize_lp_packet(&packet_to_process, &mut buf_a).unwrap(); - let parsed_packet_a = parse_lp_packet(&buf_a).unwrap(); + serialize_lp_packet(&packet_to_process, &mut buf_a, None).unwrap(); + let parsed_packet_a = parse_lp_packet(&buf_a, None).unwrap(); // Responder processes KKT request let action_b1 = session_manager_2 - .process_input(lp_id, LpInput::ReceivePacket(parsed_packet_a)) + .process_input(receiver_index, LpInput::ReceivePacket(parsed_packet_a)) .expect("Responder ReceivePacket should produce an action") .expect("Responder ReceivePacket failed"); @@ -976,7 +984,7 @@ mod tests { } // Responder transitions to Handshaking after KKT completes assert_eq!( - session_manager_2.get_state(lp_id).unwrap(), + session_manager_2.get_state(receiver_index).unwrap(), LpStateBare::Handshaking, "Responder state should be Handshaking after KKT exchange" ); @@ -993,12 +1001,12 @@ mod tests { // Simulate network let mut buf_b = BytesMut::new(); - serialize_lp_packet(&packet_to_process, &mut buf_b).unwrap(); - let parsed_packet_b = parse_lp_packet(&buf_b).unwrap(); + serialize_lp_packet(&packet_to_process, &mut buf_b, None).unwrap(); + let parsed_packet_b = parse_lp_packet(&buf_b, None).unwrap(); // Initiator processes KKT response let action_a2 = session_manager_1 - .process_input(lp_id, LpInput::ReceivePacket(parsed_packet_b)) + .process_input(receiver_index, LpInput::ReceivePacket(parsed_packet_b)) .expect("Initiator ReceivePacket should produce an action") .expect("Initiator ReceivePacket failed"); @@ -1010,7 +1018,7 @@ mod tests { packet_a_to_b = Some(packet); // Initiator transitions to Handshaking after KKT completes assert_eq!( - session_manager_1.get_state(lp_id).unwrap(), + session_manager_1.get_state(receiver_index).unwrap(), LpStateBare::Handshaking, "Initiator state should be Handshaking after receiving KKT response" ); @@ -1022,10 +1030,10 @@ mod tests { // KKT completed, now need to explicitly trigger handshake message // This might be the case if KKT completion doesn't automatically send the first Noise message // Let's try to prepare the handshake message - if let Some(msg_result) = session_manager_1.prepare_handshake_message(lp_id) { + if let Some(msg_result) = session_manager_1.prepare_handshake_message(receiver_index) { let msg = msg_result.expect("Failed to prepare handshake message after KKT"); // Create a packet from the message - let packet = create_test_packet(1, lp_id, 0, msg); + let packet = create_test_packet(1, receiver_index, 0, msg); packet_a_to_b = Some(packet); println!(" Prepared first Noise message after KKTComplete"); } else { @@ -1052,12 +1060,12 @@ mod tests { // Simulate network let mut buf_a2 = BytesMut::new(); - serialize_lp_packet(&packet_to_process, &mut buf_a2).unwrap(); - let parsed_packet_a2 = parse_lp_packet(&buf_a2).unwrap(); + serialize_lp_packet(&packet_to_process, &mut buf_a2, None).unwrap(); + let parsed_packet_a2 = parse_lp_packet(&buf_a2, None).unwrap(); // Responder processes first Noise message and sends second Noise message let action_b2 = session_manager_2 - .process_input(lp_id, LpInput::ReceivePacket(parsed_packet_a2)) + .process_input(receiver_index, LpInput::ReceivePacket(parsed_packet_a2)) .expect("Responder ReceivePacket should produce an action") .expect("Responder ReceivePacket failed"); @@ -1071,7 +1079,7 @@ mod tests { } // Responder still in Handshaking, waiting for final message assert_eq!( - session_manager_2.get_state(lp_id).unwrap(), + session_manager_2.get_state(receiver_index).unwrap(), LpStateBare::Handshaking, "Responder state should still be Handshaking after sending second message" ); @@ -1087,11 +1095,11 @@ mod tests { .expect("Second Noise packet from B was missing"); let mut buf_b2 = BytesMut::new(); - serialize_lp_packet(&packet_to_process, &mut buf_b2).unwrap(); - let parsed_packet_b2 = parse_lp_packet(&buf_b2).unwrap(); + serialize_lp_packet(&packet_to_process, &mut buf_b2, None).unwrap(); + let parsed_packet_b2 = parse_lp_packet(&buf_b2, None).unwrap(); let action_a3 = session_manager_1 - .process_input(lp_id, LpInput::ReceivePacket(parsed_packet_b2)) + .process_input(receiver_index, LpInput::ReceivePacket(parsed_packet_b2)) .expect("Initiator ReceivePacket should produce an action") .expect("Initiator ReceivePacket failed"); @@ -1105,7 +1113,7 @@ mod tests { } // Initiator transitions to Transport after sending third message assert_eq!( - session_manager_1.get_state(lp_id).unwrap(), + session_manager_1.get_state(receiver_index).unwrap(), LpStateBare::Transport, "Initiator state should be Transport after sending third message" ); @@ -1121,11 +1129,11 @@ mod tests { .expect("Third Noise packet from A was missing"); let mut buf_a3 = BytesMut::new(); - serialize_lp_packet(&packet_to_process, &mut buf_a3).unwrap(); - let parsed_packet_a3 = parse_lp_packet(&buf_a3).unwrap(); + serialize_lp_packet(&packet_to_process, &mut buf_a3, None).unwrap(); + let parsed_packet_a3 = parse_lp_packet(&buf_a3, None).unwrap(); let action_b3 = session_manager_2 - .process_input(lp_id, LpInput::ReceivePacket(parsed_packet_a3)) + .process_input(receiver_index, LpInput::ReceivePacket(parsed_packet_a3)) .expect("Responder final ReceivePacket should produce an action") .expect("Responder final ReceivePacket failed"); @@ -1139,7 +1147,7 @@ mod tests { ); } assert_eq!( - session_manager_2.get_state(lp_id).unwrap(), + session_manager_2.get_state(receiver_index).unwrap(), LpStateBare::Transport, "Responder state should be Transport after processing third message" ); @@ -1147,11 +1155,11 @@ mod tests { // --- Verification --- assert!(rounds < MAX_ROUNDS, "Handshake took too many rounds"); assert_eq!( - session_manager_1.get_state(lp_id).unwrap(), + session_manager_1.get_state(receiver_index).unwrap(), LpStateBare::Transport ); assert_eq!( - session_manager_2.get_state(lp_id).unwrap(), + session_manager_2.get_state(receiver_index).unwrap(), LpStateBare::Transport ); println!("Handshake simulation completed successfully via process_input."); @@ -1164,7 +1172,7 @@ mod tests { // --- A sends to B --- println!(" A sends to B"); let action_a_send = session_manager_1 - .process_input(lp_id, LpInput::SendData(plaintext_a_to_b.to_vec())) + .process_input(receiver_index, LpInput::SendData(plaintext_a_to_b.to_vec())) .expect("A SendData should produce action") .expect("A SendData failed"); @@ -1176,13 +1184,13 @@ mod tests { // Simulate network let mut buf_data_a = BytesMut::new(); - serialize_lp_packet(&data_packet_a, &mut buf_data_a).unwrap(); - let parsed_data_a = parse_lp_packet(&buf_data_a).unwrap(); + serialize_lp_packet(&data_packet_a, &mut buf_data_a, None).unwrap(); + let parsed_data_a = parse_lp_packet(&buf_data_a, None).unwrap(); // B receives println!(" B receives from A"); let action_b_recv = session_manager_2 - .process_input(lp_id, LpInput::ReceivePacket(parsed_data_a)) + .process_input(receiver_index, LpInput::ReceivePacket(parsed_data_a)) .expect("B ReceivePacket (data) should produce action") .expect("B ReceivePacket (data) failed"); @@ -1203,7 +1211,7 @@ mod tests { // --- B sends to A --- println!(" B sends to A"); let action_b_send = session_manager_2 - .process_input(lp_id, LpInput::SendData(plaintext_b_to_a.to_vec())) + .process_input(receiver_index, LpInput::SendData(plaintext_b_to_a.to_vec())) .expect("B SendData should produce action") .expect("B SendData failed"); @@ -1217,13 +1225,13 @@ mod tests { // Simulate network let mut buf_data_b = BytesMut::new(); - serialize_lp_packet(&data_packet_b, &mut buf_data_b).unwrap(); - let parsed_data_b = parse_lp_packet(&buf_data_b).unwrap(); + serialize_lp_packet(&data_packet_b, &mut buf_data_b, None).unwrap(); + let parsed_data_b = parse_lp_packet(&buf_data_b, None).unwrap(); // A receives println!(" A receives from B"); let action_a_recv = session_manager_1 - .process_input(lp_id, LpInput::ReceivePacket(parsed_data_b)) + .process_input(receiver_index, LpInput::ReceivePacket(parsed_data_b)) .expect("A ReceivePacket (data) should produce action") .expect("A ReceivePacket (data) failed"); @@ -1245,7 +1253,7 @@ mod tests { // --- 6. Replay Protection Test --- println!("Testing data packet replay protection via process_input..."); let replay_result = - session_manager_1.process_input(lp_id, LpInput::ReceivePacket(data_packet_b_replay)); // Use cloned packet + session_manager_1.process_input(receiver_index, LpInput::ReceivePacket(data_packet_b_replay)); // Use cloned packet assert!(replay_result.is_err(), "Replay should produce Err(...)"); let error = replay_result.err().unwrap(); @@ -1264,7 +1272,7 @@ mod tests { let data_n = Bytes::from_static(b"Message N"); let action_send_n1 = session_manager_1 - .process_input(lp_id, LpInput::SendData(data_n_plus_1.to_vec())) + .process_input(receiver_index, LpInput::SendData(data_n_plus_1.to_vec())) .unwrap() .unwrap(); let packet_n1 = match action_send_n1 { @@ -1273,7 +1281,7 @@ mod tests { }; let action_send_n = session_manager_1 - .process_input(lp_id, LpInput::SendData(data_n.to_vec())) + .process_input(receiver_index, LpInput::SendData(data_n.to_vec())) .unwrap() .unwrap(); let packet_n = match action_send_n { @@ -1285,7 +1293,7 @@ mod tests { // B receives N+1 first println!(" B receives N+1"); let action_recv_n1 = session_manager_2 - .process_input(lp_id, LpInput::ReceivePacket(packet_n1)) + .process_input(receiver_index, LpInput::ReceivePacket(packet_n1)) .unwrap() .unwrap(); match action_recv_n1 { @@ -1296,7 +1304,7 @@ mod tests { // B receives N second (should work) println!(" B receives N"); let action_recv_n = session_manager_2 - .process_input(lp_id, LpInput::ReceivePacket(packet_n)) + .process_input(receiver_index, LpInput::ReceivePacket(packet_n)) .unwrap() .unwrap(); match action_recv_n { @@ -1307,7 +1315,7 @@ mod tests { // B tries to replay N (should fail) println!(" B tries to replay N"); let replay_n_result = - session_manager_2.process_input(lp_id, LpInput::ReceivePacket(packet_n_replay)); + session_manager_2.process_input(receiver_index, LpInput::ReceivePacket(packet_n_replay)); assert!(replay_n_result.is_err(), "Replay N should produce Err"); assert!( matches!(replay_n_result.err().unwrap(), LpError::Replay(_)), @@ -1320,18 +1328,18 @@ mod tests { // A closes let action_a_close = session_manager_1 - .process_input(lp_id, LpInput::Close) + .process_input(receiver_index, LpInput::Close) .expect("A Close should produce action") .expect("A Close failed"); assert!(matches!(action_a_close, LpAction::ConnectionClosed)); assert_eq!( - session_manager_1.get_state(lp_id).unwrap(), + session_manager_1.get_state(receiver_index).unwrap(), LpStateBare::Closed ); // Further actions on A fail let send_after_close_a = - session_manager_1.process_input(lp_id, LpInput::SendData(b"fail".to_vec())); + session_manager_1.process_input(receiver_index, LpInput::SendData(b"fail".to_vec())); assert!(send_after_close_a.is_err()); assert!(matches!( send_after_close_a.err().unwrap(), @@ -1340,18 +1348,18 @@ mod tests { // B closes let action_b_close = session_manager_2 - .process_input(lp_id, LpInput::Close) + .process_input(receiver_index, LpInput::Close) .expect("B Close should produce action") .expect("B Close failed"); assert!(matches!(action_b_close, LpAction::ConnectionClosed)); assert_eq!( - session_manager_2.get_state(lp_id).unwrap(), + session_manager_2.get_state(receiver_index).unwrap(), LpStateBare::Closed ); // Further actions on B fail let send_after_close_b = - session_manager_2.process_input(lp_id, LpInput::SendData(b"fail".to_vec())); + session_manager_2.process_input(receiver_index, LpInput::SendData(b"fail".to_vec())); assert!(send_after_close_b.is_err()); assert!(matches!( send_after_close_b.err().unwrap(), @@ -1360,15 +1368,15 @@ mod tests { println!("Close test passed."); // --- 9. Session Removal --- - assert!(session_manager_1.remove_state_machine(lp_id)); + assert!(session_manager_1.remove_state_machine(receiver_index)); assert_eq!(session_manager_1.session_count(), 0); - assert!(!session_manager_1.state_machine_exists(lp_id)); + assert!(!session_manager_1.state_machine_exists(receiver_index)); // B's session manager still has it until removed - assert!(session_manager_2.state_machine_exists(lp_id)); - assert!(session_manager_2.remove_state_machine(lp_id)); + assert!(session_manager_2.state_machine_exists(receiver_index)); + assert!(session_manager_2.remove_state_machine(receiver_index)); assert_eq!(session_manager_2.session_count(), 0); - assert!(!session_manager_2.state_machine_exists(lp_id)); + assert!(!session_manager_2.state_machine_exists(receiver_index)); println!("Session removal test passed."); } // ... other tests ... diff --git a/common/nym-lp/src/session_manager.rs b/common/nym-lp/src/session_manager.rs index 4baa9f5a3f6..423ba749240 100644 --- a/common/nym-lp/src/session_manager.rs +++ b/common/nym-lp/src/session_manager.rs @@ -166,21 +166,22 @@ impl SessionManager { pub fn create_session_state_machine( &self, + receiver_index: u32, local_ed25519_keypair: (&ed25519::PrivateKey, &ed25519::PublicKey), remote_ed25519_key: &ed25519::PublicKey, is_initiator: bool, salt: &[u8; 32], ) -> Result { let sm = LpStateMachine::new( + receiver_index, is_initiator, local_ed25519_keypair, remote_ed25519_key, salt, )?; - let sm_id = sm.id()?; - self.state_machines.insert(sm_id, sm); - Ok(sm_id) + self.state_machines.insert(receiver_index, sm); + Ok(receiver_index) } /// Method to remove a state machine @@ -215,9 +216,11 @@ mod tests { let manager = SessionManager::new(); let ed25519_keypair = ed25519::KeyPair::from_secret([10u8; 32], 0); let salt = [47u8; 32]; + let receiver_index: u32 = 1001; let sm_1_id = manager .create_session_state_machine( + receiver_index, (ed25519_keypair.private_key(), ed25519_keypair.public_key()), ed25519_keypair.public_key(), true, @@ -237,9 +240,11 @@ mod tests { let manager = SessionManager::new(); let ed25519_keypair = ed25519::KeyPair::from_secret([11u8; 32], 0); let salt = [48u8; 32]; + let receiver_index: u32 = 2002; let sm_1_id = manager .create_session_state_machine( + receiver_index, (ed25519_keypair.private_key(), ed25519_keypair.public_key()), ed25519_keypair.public_key(), true, @@ -265,6 +270,7 @@ mod tests { let sm_1 = manager .create_session_state_machine( + 3001, ( ed25519_keypair_1.private_key(), ed25519_keypair_1.public_key(), @@ -277,6 +283,7 @@ mod tests { let sm_2 = manager .create_session_state_machine( + 3002, ( ed25519_keypair_2.private_key(), ed25519_keypair_2.public_key(), @@ -289,6 +296,7 @@ mod tests { let sm_3 = manager .create_session_state_machine( + 3003, ( ed25519_keypair_3.private_key(), ed25519_keypair_3.public_key(), @@ -315,8 +323,10 @@ mod tests { let manager = SessionManager::new(); let ed25519_keypair = ed25519::KeyPair::from_secret([15u8; 32], 0); let salt = [50u8; 32]; + let receiver_index: u32 = 4004; let sm = manager.create_session_state_machine( + receiver_index, (ed25519_keypair.private_key(), ed25519_keypair.public_key()), ed25519_keypair.public_key(), true, diff --git a/common/nym-lp/src/state_machine.rs b/common/nym-lp/src/state_machine.rs index 27cc5896cac..11352c14935 100644 --- a/common/nym-lp/src/state_machine.rs +++ b/common/nym-lp/src/state_machine.rs @@ -6,7 +6,6 @@ use crate::{ LpError, keypair::{Keypair, PrivateKey as LpPrivateKey, PublicKey as LpPublicKey}, - make_lp_id, noise_protocol::NoiseError, packet::LpPacket, session::LpSession, @@ -137,6 +136,7 @@ impl LpStateMachine { /// /// # Arguments /// + /// * `receiver_index` - Client-proposed session identifier (random 4 bytes) /// * `is_initiator` - Whether this side initiates the handshake /// * `local_ed25519_keypair` - Ed25519 keypair for PSQ authentication and X25519 derivation /// (from client identity key or gateway signing key) @@ -148,6 +148,7 @@ impl LpStateMachine { /// Returns `LpError::Ed25519RecoveryError` if Ed25519→X25519 conversion fails for the remote key. /// Local private key conversion cannot fail. pub fn new( + receiver_index: u32, is_initiator: bool, local_ed25519_keypair: (&ed25519::PrivateKey, &ed25519::PublicKey), remote_ed25519_key: &ed25519::PublicKey, @@ -161,7 +162,6 @@ impl LpStateMachine { // The derived X25519 keys are used for: // - Noise protocol ephemeral DH // - PSQ ECDH baseline security (pre-quantum) - // - lp_id calculation (session identifier) // Convert Ed25519 keys to X25519 for Noise protocol let local_x25519_private = local_ed25519_keypair.0.to_x25519(); @@ -179,15 +179,13 @@ impl LpStateMachine { let lp_public = LpPublicKey::from_bytes(local_x25519_public.as_bytes())?; let lp_remote_public = LpPublicKey::from_bytes(remote_x25519_public.as_bytes())?; - // Create X25519 keypair for Noise and lp_id calculation + // Create X25519 keypair for Noise let local_x25519_keypair = Keypair::from_keys(lp_private, lp_public); - // Calculate the shared lp_id using derived X25519 keys - let lp_id = make_lp_id(local_x25519_keypair.public_key(), &lp_remote_public); - // Create the session with both Ed25519 (for PSQ auth) and derived X25519 keys (for Noise) + // receiver_index is client-proposed, passed through directly let session = LpSession::new( - lp_id, + receiver_index, is_initiator, local_ed25519_keypair, local_x25519_keypair.private_key(), @@ -252,8 +250,8 @@ impl LpStateMachine { // --- KKTExchange State --- (LpState::KKTExchange { session }, LpInput::ReceivePacket(packet)) => { // Check if packet lp_id matches our session - if packet.header.session_id() != session.id() { - result_action = Some(Err(LpError::UnknownSessionId(packet.header.session_id()))); + if packet.header.receiver_idx() != session.id() { + result_action = Some(Err(LpError::UnknownSessionId(packet.header.receiver_idx()))); LpState::KKTExchange { session } } else { use crate::message::LpMessage; @@ -356,8 +354,8 @@ impl LpStateMachine { // --- Handshaking State --- (LpState::Handshaking { session }, LpInput::ReceivePacket(packet)) => { // Check if packet lp_id matches our session - if packet.header.session_id() != session.id() { - result_action = Some(Err(LpError::UnknownSessionId(packet.header.session_id()))); + if packet.header.receiver_idx() != session.id() { + result_action = Some(Err(LpError::UnknownSessionId(packet.header.receiver_idx()))); // Don't change state, return the original state variant LpState::Handshaking { session } } else { @@ -454,8 +452,8 @@ impl LpStateMachine { // --- Transport State --- (LpState::Transport { session }, LpInput::ReceivePacket(packet)) => { // Needs mut session for marking counter // Check if packet lp_id matches our session - if packet.header.session_id() != session.id() { - result_action = Some(Err(LpError::UnknownSessionId(packet.header.session_id()))); + if packet.header.receiver_idx() != session.id() { + result_action = Some(Err(LpError::UnknownSessionId(packet.header.receiver_idx()))); // Remain in transport state LpState::Transport { session } } else { @@ -605,7 +603,10 @@ mod tests { // Test salt let salt = [51u8; 32]; + let receiver_index: u32 = 77777; + let initiator_sm = LpStateMachine::new( + receiver_index, true, ( ed25519_keypair_init.private_key(), @@ -624,6 +625,7 @@ mod tests { assert!(init_session.is_initiator()); let responder_sm = LpStateMachine::new( + receiver_index, false, ( ed25519_keypair_resp.private_key(), @@ -641,8 +643,7 @@ mod tests { let resp_session = responder_sm.session().unwrap(); assert!(!resp_session.is_initiator()); - // Check lp_id is the same (derived internally from Ed25519 keys) - // Both state machines should have the same lp_id + // Check both state machines use the same receiver_index assert_eq!(init_session.id(), resp_session.id()); } @@ -654,9 +655,11 @@ mod tests { // Test salt let salt = [52u8; 32]; + let receiver_index: u32 = 88888; // Create state machines (already in ReadyToHandshake) let mut initiator = LpStateMachine::new( + receiver_index, true, // is_initiator ( ed25519_keypair_init.private_key(), @@ -668,6 +671,7 @@ mod tests { .unwrap(); let mut responder = LpStateMachine::new( + receiver_index, false, // is_initiator ( ed25519_keypair_resp.private_key(), @@ -678,8 +682,7 @@ mod tests { ) .unwrap(); - let lp_id = initiator.id().unwrap(); - assert_eq!(lp_id, responder.id().unwrap()); + assert_eq!(initiator.id().unwrap(), responder.id().unwrap()); // --- KKT Exchange --- println!("--- Step 1: Initiator starts handshake (sends KKT request) ---"); @@ -695,9 +698,9 @@ mod tests { "Initiator should be in KKTExchange" ); assert_eq!( - kkt_request_packet.header.session_id(), - lp_id, - "KKT request packet has wrong lp_id" + kkt_request_packet.header.receiver_idx(), + receiver_index, + "KKT request packet has wrong receiver_index" ); println!("--- Step 2: Responder starts handshake (waits for KKT) ---"); @@ -763,9 +766,9 @@ mod tests { "Responder still Handshaking" ); assert_eq!( - resp_packet_2.header.session_id(), - lp_id, - "Packet 2 has wrong lp_id" + resp_packet_2.header.receiver_idx(), + receiver_index, + "Packet 2 has wrong receiver_index" ); println!("--- Step 6: Initiator receives Noise msg 2, sends Noise msg 3 ---"); @@ -780,9 +783,9 @@ mod tests { "Initiator should be Transport" ); assert_eq!( - init_packet_3.header.session_id(), - lp_id, - "Noise packet 3 has wrong lp_id" + init_packet_3.header.receiver_idx(), + receiver_index, + "Noise packet 3 has wrong receiver_index" ); println!("--- Step 7: Responder receives Noise msg 3, completes handshake ---"); @@ -805,7 +808,7 @@ mod tests { } else { panic!("Initiator should send data packet"); }; - assert_eq!(data_packet_1.header.session_id(), lp_id); + assert_eq!(data_packet_1.header.receiver_idx(), receiver_index); println!("--- Step 9: Responder receives data ---"); let resp_actions_5 = responder.process_input(LpInput::ReceivePacket(data_packet_1)); @@ -824,7 +827,7 @@ mod tests { } else { panic!("Responder should send data packet"); }; - assert_eq!(data_packet_2.header.session_id(), lp_id); + assert_eq!(data_packet_2.header.receiver_idx(), receiver_index); println!("--- Step 11: Initiator receives data ---"); let init_actions_5 = initiator.process_input(LpInput::ReceivePacket(data_packet_2)); @@ -859,9 +862,11 @@ mod tests { let ed25519_keypair_resp = ed25519::KeyPair::from_secret([21u8; 32], 1); let salt = [53u8; 32]; + let receiver_index: u32 = 99901; // Create initiator state machine let mut initiator = LpStateMachine::new( + receiver_index, true, ( ed25519_keypair_init.private_key(), @@ -888,9 +893,11 @@ mod tests { let ed25519_keypair_resp = ed25519::KeyPair::from_secret([23u8; 32], 1); let salt = [54u8; 32]; + let receiver_index: u32 = 99902; // Create responder state machine let mut responder = LpStateMachine::new( + receiver_index, false, ( ed25519_keypair_resp.private_key(), @@ -917,9 +924,11 @@ mod tests { let ed25519_keypair_resp = ed25519::KeyPair::from_secret([25u8; 32], 1); let salt = [55u8; 32]; + let receiver_index: u32 = 99903; // Create both state machines let mut initiator = LpStateMachine::new( + receiver_index, true, ( ed25519_keypair_init.private_key(), @@ -931,6 +940,7 @@ mod tests { .unwrap(); let mut responder = LpStateMachine::new( + receiver_index, false, ( ed25519_keypair_resp.private_key(), @@ -979,9 +989,11 @@ mod tests { let ed25519_keypair_resp = ed25519::KeyPair::from_secret([27u8; 32], 1); let salt = [56u8; 32]; + let receiver_index: u32 = 99904; // Create initiator state machine let mut initiator = LpStateMachine::new( + receiver_index, true, ( ed25519_keypair_init.private_key(), @@ -1009,9 +1021,11 @@ mod tests { let ed25519_keypair_resp = ed25519::KeyPair::from_secret([29u8; 32], 1); let salt = [57u8; 32]; + let receiver_index: u32 = 99905; // Create initiator state machine let mut initiator = LpStateMachine::new( + receiver_index, true, ( ed25519_keypair_init.private_key(), diff --git a/common/registration/src/lp_messages.rs b/common/registration/src/lp_messages.rs index 8103e38314f..6c554450ccc 100644 --- a/common/registration/src/lp_messages.rs +++ b/common/registration/src/lp_messages.rs @@ -60,9 +60,6 @@ pub struct LpRegistrationResponse { /// Allocated bandwidth in bytes pub allocated_bandwidth: i64, - - /// Session identifier for future reference - pub session_id: u32, } impl LpRegistrationRequest { @@ -100,24 +97,22 @@ impl LpRegistrationRequest { impl LpRegistrationResponse { /// Create a success response with GatewayData - pub fn success(session_id: u32, allocated_bandwidth: i64, gateway_data: GatewayData) -> Self { + pub fn success(allocated_bandwidth: i64, gateway_data: GatewayData) -> Self { Self { success: true, error: None, gateway_data: Some(gateway_data), allocated_bandwidth, - session_id, } } /// Create an error response - pub fn error(session_id: u32, error: String) -> Self { + pub fn error(error: String) -> Self { Self { success: false, error: Some(error), gateway_data: None, allocated_bandwidth: 0, - session_id, } } } @@ -153,13 +148,12 @@ mod tests { let allocated_bandwidth = 1_000_000_000; let response = - LpRegistrationResponse::success(session_id, allocated_bandwidth, gateway_data.clone()); + LpRegistrationResponse::success(allocated_bandwidth, gateway_data.clone()); assert!(response.success); assert!(response.error.is_none()); assert!(response.gateway_data.is_some()); assert_eq!(response.allocated_bandwidth, allocated_bandwidth); - assert_eq!(response.session_id, session_id); let returned_gw_data = response .gateway_data @@ -172,72 +166,15 @@ mod tests { #[test] fn test_lp_registration_response_error() { - let session_id = 54321; let error_msg = String::from("Insufficient bandwidth"); - let response = LpRegistrationResponse::error(session_id, error_msg.clone()); + let response = LpRegistrationResponse::error(error_msg.clone()); assert!(!response.success); assert_eq!(response.error, Some(error_msg)); assert!(response.gateway_data.is_none()); assert_eq!(response.allocated_bandwidth, 0); - assert_eq!(response.session_id, session_id); - } - - #[test] - fn test_lp_registration_response_serialize_deserialize_success() { - let gateway_data = create_test_gateway_data(); - let original = LpRegistrationResponse::success(999, 5_000_000_000, gateway_data); - - // Serialize - let serialized = bincode::serialize(&original).expect("Failed to serialize response"); - - // Deserialize - let deserialized: LpRegistrationResponse = - bincode::deserialize(&serialized).expect("Failed to deserialize response"); - - assert_eq!(deserialized.success, original.success); - assert_eq!(deserialized.error, original.error); - assert_eq!( - deserialized.allocated_bandwidth, - original.allocated_bandwidth - ); - assert_eq!(deserialized.session_id, original.session_id); - assert!(deserialized.gateway_data.is_some()); } - - #[test] - fn test_lp_registration_response_serialize_deserialize_error() { - let original = LpRegistrationResponse::error(777, String::from("Test error message")); - - // Serialize - let serialized = bincode::serialize(&original).expect("Failed to serialize response"); - - // Deserialize - let deserialized: LpRegistrationResponse = - bincode::deserialize(&serialized).expect("Failed to deserialize response"); - - assert_eq!(deserialized.success, original.success); - assert_eq!(deserialized.error, original.error); - assert_eq!(deserialized.allocated_bandwidth, 0); - assert_eq!(deserialized.session_id, original.session_id); - assert!(deserialized.gateway_data.is_none()); - } - - #[test] - fn test_lp_registration_response_malformed_deserialize() { - // Create invalid bincode data - let invalid_data = vec![0xFF; 100]; - - // Attempt to deserialize - let result: Result = bincode::deserialize(&invalid_data); - - assert!( - result.is_err(), - "Expected deserialization to fail for malformed data" - ); - } - // ==================== RegistrationMode Tests ==================== #[test] diff --git a/gateway/src/node/lp_listener/handler.rs b/gateway/src/node/lp_listener/handler.rs index 96647ad8742..76a50ea9c83 100644 --- a/gateway/src/node/lp_listener/handler.rs +++ b/gateway/src/node/lp_listener/handler.rs @@ -5,7 +5,10 @@ use super::messages::LpRegistrationRequest; use super::registration::process_registration; use super::LpHandlerState; use crate::error::GatewayError; -use nym_lp::{keypair::PublicKey, message::ForwardPacketData, LpMessage, LpPacket}; +use nym_lp::{ + codec::OuterAeadKey, keypair::PublicKey, message::ForwardPacketData, packet::LpHeader, + LpMessage, LpPacket, +}; use nym_metrics::{add_histogram_obs, inc}; use std::net::SocketAddr; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -91,9 +94,9 @@ impl LpConnectionHandler { // State persists in LpHandlerState maps between connections // ============================================================ - // Step 1: Receive the packet - let packet = match self.receive_lp_packet().await { - Ok(p) => p, + // Step 1: Receive raw packet bytes and parse header only (for routing) + let (raw_bytes, header) = match self.receive_raw_packet().await { + Ok(result) => result, Err(e) => { inc!("lp_errors_receive_packet"); self.emit_lifecycle_metrics(false); @@ -101,65 +104,94 @@ impl LpConnectionHandler { } }; - let header = packet.header(); - let session_id = header.session_id; + let receiver_idx = header.receiver_idx; + + // Step 2: Get outer_aead_key based on receiver_idx + // Header is always cleartext for routing. Payload is encrypted after PSK. + // We lookup the session to get the key, then parse the full packet. + let outer_key: Option = if receiver_idx == nym_lp::BOOTSTRAP_RECEIVER_IDX { + // ClientHello - no encryption (PSK not yet derived) + None + } else if let Some(state_entry) = self.state.handshake_states.get(&receiver_idx) { + // Handshake in progress - check if PSK has been injected yet + state_entry + .value() + .state + .session() + .ok() + .and_then(|session| session.outer_aead_key()) + } else if let Some(session_entry) = self.state.session_states.get(&receiver_idx) { + // Established session - should always have PSK + session_entry.value().state.outer_aead_key() + } else { + // Unknown session - will error during routing, parse cleartext + None + }; + + // Step 3: Parse full packet with outer AEAD key + let packet = nym_lp::codec::parse_lp_packet(&raw_bytes, outer_key.as_ref()).map_err(|e| { + inc!("lp_errors_parse_packet"); + self.emit_lifecycle_metrics(false); + GatewayError::LpProtocolError(format!("Failed to parse LP packet: {}", e)) + })?; trace!( - "Received packet from {} (session_id={}, counter={})", + "Received packet from {} (receiver_idx={}, counter={}, encrypted={})", self.remote_addr, - session_id, - header.counter + receiver_idx, + packet.header().counter, + outer_key.is_some() ); - // Step 2: Route packet based on session_id - if session_id == nym_lp::BOOTSTRAP_SESSION_ID { + // Step 4: Route packet based on receiver_idx + if receiver_idx == nym_lp::BOOTSTRAP_RECEIVER_IDX { // ClientHello - first packet in handshake self.handle_client_hello(packet).await } else { // Check if this is an in-progress handshake or established session - if self.state.handshake_states.contains_key(&session_id) { + if self.state.handshake_states.contains_key(&receiver_idx) { // Handshake in progress - self.handle_handshake_packet(session_id, packet).await - } else if self.state.session_states.contains_key(&session_id) { + self.handle_handshake_packet(receiver_idx, packet).await + } else if self.state.session_states.contains_key(&receiver_idx) { // Established session - transport mode - self.handle_transport_packet(session_id, packet).await + self.handle_transport_packet(receiver_idx, packet).await } else { // Unknown session - possibly stale or client error warn!( "Received packet for unknown session {} from {}", - session_id, self.remote_addr + receiver_idx, self.remote_addr ); inc!("lp_errors_unknown_session"); self.emit_lifecycle_metrics(false); Err(GatewayError::LpProtocolError(format!( "Unknown session ID: {}", - session_id + receiver_idx ))) } } } - /// Handle ClientHello packet (session_id=0, first packet) + /// Handle ClientHello packet (receiver_idx=0, first packet) async fn handle_client_hello(&mut self, packet: LpPacket) -> Result<(), GatewayError> { use nym_lp::state_machine::{LpInput, LpStateMachine}; + use nym_lp::packet::LpHeader; // Extract ClientHello data - let (_client_pubkey, client_ed25519_pubkey, salt) = match packet.message() { + let (receiver_index, client_ed25519_pubkey, salt) = match packet.message() { LpMessage::ClientHello(hello_data) => { // Validate timestamp let timestamp = hello_data.extract_timestamp(); Self::validate_timestamp(timestamp, self.state.lp_config.timestamp_tolerance_secs)?; - // Extract keys - let client_pubkey = nym_lp::keypair::PublicKey::from_bytes(&hello_data.client_lp_public_key) - .map_err(|e| GatewayError::LpProtocolError(format!("Invalid client public key: {}", e)))?; + // Extract client-proposed receiver_index + let receiver_index = hello_data.receiver_index; let client_ed25519_pubkey = nym_crypto::asymmetric::ed25519::PublicKey::from_bytes( &hello_data.client_ed25519_public_key, ) .map_err(|e| GatewayError::LpProtocolError(format!("Invalid client Ed25519 public key: {}", e)))?; - (client_pubkey, client_ed25519_pubkey, hello_data.salt) + (receiver_index, client_ed25519_pubkey, hello_data.salt) } other => { inc!("lp_client_hello_failed"); @@ -171,10 +203,31 @@ impl LpConnectionHandler { } }; - debug!("Processing ClientHello from {}", self.remote_addr); + debug!("Processing ClientHello from {} (proposed receiver_index={})", self.remote_addr, receiver_index); + + // Collision check for client-proposed receiver_index + // Check both handshake_states (in-progress) and session_states (established) + if self.state.handshake_states.contains_key(&receiver_index) + || self.state.session_states.contains_key(&receiver_index) + { + warn!("Receiver index collision: {} from {}", receiver_index, self.remote_addr); + inc!("lp_receiver_index_collision"); + + // Send Collision response to tell client to retry with new receiver_index + // No outer key - this is before PSK derivation + let collision_packet = LpPacket::new( + LpHeader::new(receiver_index, 0), + LpMessage::Collision, + ); + self.send_lp_packet(&collision_packet, None).await?; + + self.emit_lifecycle_metrics(true); + return Ok(()); + } - // Create state machine for this handshake + // Create state machine for this handshake using client-proposed receiver_index let mut state_machine = LpStateMachine::new( + receiver_index, false, // responder ( self.state.local_identity.private_key(), @@ -188,14 +241,9 @@ impl LpConnectionHandler { GatewayError::LpHandshakeError(format!("Failed to create state machine: {}", e)) })?; - // Get the computed session ID - let session_id = state_machine.session() - .map_err(|e| GatewayError::LpHandshakeError(format!("Failed to get session: {}", e)))? - .id(); - debug!( - "Created handshake state for {} (session_id={})", - self.remote_addr, session_id + "Created handshake state for {} (receiver_index={})", + self.remote_addr, receiver_index ); // Transition state machine to KKTExchange (responder waits for client's KKT request) @@ -212,35 +260,42 @@ impl LpConnectionHandler { // Responder (gateway) gets Ok but no packet to send - we just wait for client's next packet } - // Store state machine for subsequent handshake packets (KKT request with session_id=X) - self.state.handshake_states.insert(session_id, super::TimestampedState::new(state_machine)); + // Store state machine for subsequent handshake packets (KKT request with receiver_index=X) + self.state.handshake_states.insert(receiver_index, super::TimestampedState::new(state_machine)); debug!( - "Stored handshake state for {} (session_id={}) - waiting for KKT request", - self.remote_addr, session_id + "Stored handshake state for {} (receiver_index={}) - waiting for KKT request", + self.remote_addr, receiver_index + ); + + // Send Ack to confirm ClientHello received (packet-per-connection model) + // No outer key - this is before PSK derivation + let ack_packet = LpPacket::new( + LpHeader::new(receiver_index, 0), + LpMessage::Ack, ); + self.send_lp_packet(&ack_packet, None).await?; - // NO packet sent - connection closes, client will send KKT request on new connection self.emit_lifecycle_metrics(true); Ok(()) } - /// Handle handshake packet (session_id!=0, handshake not complete) + /// Handle handshake packet (receiver_idx!=0, handshake not complete) async fn handle_handshake_packet( &mut self, - session_id: u32, + receiver_idx: u32, packet: LpPacket, ) -> Result<(), GatewayError> { use nym_lp::state_machine::{LpInput, LpAction}; debug!( - "Processing handshake packet from {} (session_id={})", - self.remote_addr, session_id + "Processing handshake packet from {} (receiver_idx={})", + self.remote_addr, receiver_idx ); // Get mutable reference to state machine - let mut state_entry = self.state.handshake_states.get_mut(&session_id).ok_or_else(|| { - GatewayError::LpProtocolError(format!("Handshake state not found for session {}", session_id)) + let mut state_entry = self.state.handshake_states.get_mut(&receiver_idx).ok_or_else(|| { + GatewayError::LpProtocolError(format!("Handshake state not found for session {}", receiver_idx)) })?; let state_machine = &mut state_entry.value_mut().state; @@ -253,32 +308,39 @@ impl LpConnectionHandler { })? .map_err(|e| GatewayError::LpHandshakeError(format!("Handshake error: {}", e)))?; - let should_send_packet = match action { + // Get outer_aead_key from session (if PSK has been derived) + // PSK is derived after Noise msg 1 processing, so msg 2+ are encrypted + let should_send = match action { LpAction::SendPacket(response_packet) => { + // Get key before dropping borrow + let outer_key = state_machine + .session() + .ok() + .and_then(|s| s.outer_aead_key()); drop(state_entry); // Release borrow before send - Some(response_packet) + Some((response_packet, outer_key)) } LpAction::HandshakeComplete => { info!( - "Handshake completed for {} (session_id={})", - self.remote_addr, session_id + "Handshake completed for {} (receiver_idx={})", + self.remote_addr, receiver_idx ); // Extract session and move to session_states drop(state_entry); // Release mutable borrow - let (_session_id, timestamped_state) = self.state.handshake_states.remove(&session_id) + let (_receiver_idx, timestamped_state) = self.state.handshake_states.remove(&receiver_idx) .ok_or_else(|| GatewayError::LpHandshakeError("Failed to remove handshake state".to_string()))?; let session = timestamped_state.state.into_session() .map_err(|e| GatewayError::LpHandshakeError(format!("Failed to extract session: {}", e)))?; - self.state.session_states.insert(session_id, super::TimestampedState::new(session)); + self.state.session_states.insert(receiver_idx, super::TimestampedState::new(session)); inc!("lp_handshakes_success"); // No response packet to send - HandshakeComplete means we're done - trace!("Moved session {} to transport mode", session_id); + trace!("Moved session {} to transport mode", receiver_idx); None } other => { @@ -289,34 +351,34 @@ impl LpConnectionHandler { }; // Send response packet if needed - if let Some(packet) = should_send_packet { - self.send_lp_packet(&packet).await?; - trace!("Sent handshake response to {}", self.remote_addr); + if let Some((packet, outer_key)) = should_send { + self.send_lp_packet(&packet, outer_key.as_ref()).await?; + trace!("Sent handshake response to {} (encrypted={})", self.remote_addr, outer_key.is_some()); } self.emit_lifecycle_metrics(true); Ok(()) } - /// Handle transport packet (session_id!=0, session established) + /// Handle transport packet (receiver_idx!=0, session established) /// /// This handles packets on established sessions, which can be either: /// 1. LpRegistrationRequest - Client registering for dVPN/Mixnet access /// 2. ForwardPacketData - Client forwarding packets to exit gateway (telescoping) async fn handle_transport_packet( &mut self, - session_id: u32, + receiver_idx: u32, packet: LpPacket, ) -> Result<(), GatewayError> { debug!( - "Processing transport packet from {} (session_id={})", - self.remote_addr, session_id + "Processing transport packet from {} (receiver_idx={})", + self.remote_addr, receiver_idx ); // Get session and decrypt payload let decrypted_bytes = { - let session_entry = self.state.session_states.get(&session_id).ok_or_else(|| { - GatewayError::LpProtocolError(format!("Session not found: {}", session_id)) + let session_entry = self.state.session_states.get(&receiver_idx).ok_or_else(|| { + GatewayError::LpProtocolError(format!("Session not found: {}", receiver_idx)) })?; // Update last activity timestamp @@ -333,25 +395,25 @@ impl LpConnectionHandler { // Try to deserialize as LpRegistrationRequest first (most common case after handshake) if let Ok(request) = bincode::deserialize::(&decrypted_bytes) { debug!( - "LP registration request from {} (session_id={}): mode={:?}", - self.remote_addr, session_id, request.mode + "LP registration request from {} (receiver_idx={}): mode={:?}", + self.remote_addr, receiver_idx, request.mode ); - return self.handle_registration_request(session_id, request).await; + return self.handle_registration_request(receiver_idx, request).await; } // Try to deserialize as ForwardPacketData (entry gateway forwarding to exit) if let Ok(forward_data) = bincode::deserialize::(&decrypted_bytes) { debug!( - "LP forward request from {} (session_id={}) to {}", - self.remote_addr, session_id, forward_data.target_lp_address + "LP forward request from {} (receiver_idx={}) to {}", + self.remote_addr, receiver_idx, forward_data.target_lp_address ); - return self.handle_forwarding_request(session_id, forward_data).await; + return self.handle_forwarding_request(receiver_idx, forward_data).await; } // Neither registration nor forwarding - unknown payload type warn!( - "Unknown transport payload type from {} (session_id={})", - self.remote_addr, session_id + "Unknown transport payload type from {} (receiver_idx={})", + self.remote_addr, receiver_idx ); inc!("lp_errors_unknown_payload_type"); self.emit_lifecycle_metrics(false); @@ -363,16 +425,16 @@ impl LpConnectionHandler { /// Handle registration request on an established session async fn handle_registration_request( &mut self, - session_id: u32, + receiver_idx: u32, request: LpRegistrationRequest, ) -> Result<(), GatewayError> { // Process registration (might modify state) let response = process_registration(request, &self.state).await; - // Acquire session lock for encryption - let response_packet = { - let session_entry = self.state.session_states.get(&session_id).ok_or_else(|| { - GatewayError::LpProtocolError(format!("Session not found: {}", session_id)) + // Acquire session lock for encryption and get outer AEAD key + let (response_packet, outer_key) = { + let session_entry = self.state.session_states.get(&receiver_idx).ok_or_else(|| { + GatewayError::LpProtocolError(format!("Session not found: {}", receiver_idx)) })?; let session = &session_entry.value().state; @@ -385,23 +447,27 @@ impl LpConnectionHandler { GatewayError::LpProtocolError(format!("Failed to encrypt response: {}", e)) })?; - session.next_packet(encrypted_message).map_err(|e| { + let packet = session.next_packet(encrypted_message).map_err(|e| { GatewayError::LpProtocolError(format!("Failed to create response packet: {}", e)) - })? + })?; + + // Get outer AEAD key for packet encryption + let outer_key = session.outer_aead_key(); + (packet, outer_key) }; - // Send response - self.send_lp_packet(&response_packet).await?; + // Send response (encrypted with outer AEAD) + self.send_lp_packet(&response_packet, outer_key.as_ref()).await?; if response.success { info!( - "LP registration successful for {} (session_id={})", - self.remote_addr, response.session_id + "LP registration successful for {})", + self.remote_addr ); } else { warn!( - "LP registration failed for {} (session_id={}): {:?}", - self.remote_addr, response.session_id, response.error + "LP registration failed for {}: {:?}", + self.remote_addr, response.error ); } @@ -416,16 +482,16 @@ impl LpConnectionHandler { /// Connection closes after response is sent (single-packet model). async fn handle_forwarding_request( &mut self, - session_id: u32, + receiver_idx: u32, forward_data: ForwardPacketData, ) -> Result<(), GatewayError> { // Forward the packet to the target gateway let response_bytes = self.handle_forward_packet(forward_data).await?; - // Encrypt response for client - let response_packet = { - let session_entry = self.state.session_states.get(&session_id).ok_or_else(|| { - GatewayError::LpProtocolError(format!("Session not found: {}", session_id)) + // Encrypt response for client and get outer AEAD key + let (response_packet, outer_key) = { + let session_entry = self.state.session_states.get(&receiver_idx).ok_or_else(|| { + GatewayError::LpProtocolError(format!("Session not found: {}", receiver_idx)) })?; let session = &session_entry.value().state; @@ -433,17 +499,21 @@ impl LpConnectionHandler { GatewayError::LpProtocolError(format!("Failed to encrypt forward response: {}", e)) })?; - session.next_packet(encrypted_message).map_err(|e| { + let packet = session.next_packet(encrypted_message).map_err(|e| { GatewayError::LpProtocolError(format!("Failed to create response packet: {}", e)) - })? + })?; + + // Get outer AEAD key for packet encryption + let outer_key = session.outer_aead_key(); + (packet, outer_key) }; - // Send encrypted response to client - self.send_lp_packet(&response_packet).await?; + // Send encrypted response to client (encrypted with outer AEAD) + self.send_lp_packet(&response_packet, outer_key.as_ref()).await?; debug!( - "LP forwarding completed for {} (session_id={})", - self.remote_addr, session_id + "LP forwarding completed for {} (receiver_idx={})", + self.remote_addr, receiver_idx ); self.emit_lifecycle_metrics(true); @@ -513,8 +583,10 @@ impl LpConnectionHandler { ), GatewayError, > { - // Receive first packet which should be ClientHello - let packet = self.receive_lp_packet().await?; + // Receive first packet which should be ClientHello (no outer encryption) + let (raw_bytes, _header) = self.receive_raw_packet().await?; + let packet = nym_lp::codec::parse_lp_packet(&raw_bytes, None) + .map_err(|e| GatewayError::LpProtocolError(format!("Failed to parse packet: {}", e)))?; // Verify it's a ClientHello message match packet.message() { @@ -687,9 +759,12 @@ impl LpConnectionHandler { Ok(response_buf) } - /// Receive an LP packet from the stream with proper length-prefixed framing - async fn receive_lp_packet(&mut self) -> Result { - use nym_lp::codec::parse_lp_packet; + /// Receive raw packet bytes and parse header only (for routing before session lookup). + /// + /// Returns the raw packet bytes and parsed header. The caller should look up + /// the session to get outer_aead_key, then call `parse_lp_packet()` with the key. + async fn receive_raw_packet(&mut self) -> Result<(Vec, LpHeader), GatewayError> { + use nym_lp::codec::parse_lp_header_only; // Read 4-byte length prefix (u32 big-endian) let mut len_buf = [0u8; 4]; @@ -717,18 +792,29 @@ impl LpConnectionHandler { // Track bytes received (4 byte header + packet data) self.stats.record_bytes_received(4 + packet_len); - parse_lp_packet(&packet_buf) - .map_err(|e| GatewayError::LpProtocolError(format!("Failed to parse LP packet: {}", e))) + // Parse header only (for routing - header is always cleartext) + let header = parse_lp_header_only(&packet_buf) + .map_err(|e| GatewayError::LpProtocolError(format!("Failed to parse LP header: {}", e)))?; + + Ok((packet_buf, header)) } - /// Send an LP packet over the stream with proper length-prefixed framing - async fn send_lp_packet(&mut self, packet: &LpPacket) -> Result<(), GatewayError> { + /// Send an LP packet over the stream with proper length-prefixed framing. + /// + /// # Arguments + /// * `packet` - The LP packet to send + /// * `outer_key` - Optional outer AEAD key for encryption (None for cleartext, Some for encrypted) + async fn send_lp_packet( + &mut self, + packet: &LpPacket, + outer_key: Option<&OuterAeadKey>, + ) -> Result<(), GatewayError> { use bytes::BytesMut; use nym_lp::codec::serialize_lp_packet; - // Serialize the packet first + // Serialize the packet (encrypted if outer_key provided) let mut packet_buf = BytesMut::new(); - serialize_lp_packet(packet, &mut packet_buf).map_err(|e| { + serialize_lp_packet(packet, &mut packet_buf, outer_key).map_err(|e| { GatewayError::LpProtocolError(format!("Failed to serialize packet: {}", e)) })?; @@ -840,7 +926,7 @@ mod tests { packet: &LpPacket, ) -> Result<(), std::io::Error> { let mut packet_buf = BytesMut::new(); - serialize_lp_packet(packet, &mut packet_buf) + serialize_lp_packet(packet, &mut packet_buf, None) .map_err(|e| std::io::Error::other(e.to_string()))?; // Write length prefix @@ -868,7 +954,7 @@ mod tests { stream.read_exact(&mut packet_buf).await?; // Parse packet - parse_lp_packet(&packet_buf).map_err(|e| std::io::Error::other(e.to_string())) + parse_lp_packet(&packet_buf, None).map_err(|e| std::io::Error::other(e.to_string())) } // ==================== Existing Tests ==================== @@ -957,7 +1043,7 @@ mod tests { // ==================== Packet I/O Tests ==================== #[tokio::test] - async fn test_receive_lp_packet_valid() { + async fn test_receive_raw_packet_valid() { use tokio::net::{TcpListener, TcpStream}; // Bind to localhost @@ -969,7 +1055,11 @@ mod tests { let (stream, remote_addr) = listener.accept().await.unwrap(); let state = create_minimal_test_state().await; let mut handler = LpConnectionHandler::new(stream, remote_addr, state); - handler.receive_lp_packet().await + // Two-phase: receive raw bytes + header, then parse full packet + let (raw_bytes, header) = handler.receive_raw_packet().await?; + let packet = parse_lp_packet(&raw_bytes, None) + .map_err(|e| GatewayError::LpProtocolError(format!("Failed to parse packet: {}", e)))?; + Ok::<_, GatewayError>((header, packet)) }); // Connect as client @@ -980,7 +1070,7 @@ mod tests { LpHeader { protocol_version: 1, reserved: 0, - session_id: 42, + receiver_idx: 42, counter: 0, }, LpMessage::Busy, @@ -990,14 +1080,16 @@ mod tests { .unwrap(); // Handler should receive and parse it correctly - let received = server_task.await.unwrap().unwrap(); + let (header, received) = server_task.await.unwrap().unwrap(); + assert_eq!(header.protocol_version, 1); + assert_eq!(header.receiver_idx, 42); assert_eq!(received.header().protocol_version, 1); - assert_eq!(received.header().session_id, 42); + assert_eq!(received.header().receiver_idx, 42); assert_eq!(received.header().counter, 0); } #[tokio::test] - async fn test_receive_lp_packet_exceeds_max_size() { + async fn test_receive_raw_packet_exceeds_max_size() { use tokio::net::{TcpListener, TcpStream}; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -1007,7 +1099,7 @@ mod tests { let (stream, remote_addr) = listener.accept().await.unwrap(); let state = create_minimal_test_state().await; let mut handler = LpConnectionHandler::new(stream, remote_addr, state); - handler.receive_lp_packet().await + handler.receive_raw_packet().await }); let mut client_stream = TcpStream::connect(addr).await.unwrap(); @@ -1043,12 +1135,12 @@ mod tests { LpHeader { protocol_version: 1, reserved: 0, - session_id: 99, + receiver_idx: 99, counter: 5, }, LpMessage::Busy, ); - handler.send_lp_packet(&packet).await + handler.send_lp_packet(&packet, None).await }); let mut client_stream = TcpStream::connect(addr).await.unwrap(); @@ -1060,7 +1152,7 @@ mod tests { let received = read_lp_packet_from_stream(&mut client_stream) .await .unwrap(); - assert_eq!(received.header().session_id, 99); + assert_eq!(received.header().receiver_idx, 99); assert_eq!(received.header().counter, 5); } @@ -1083,12 +1175,12 @@ mod tests { LpHeader { protocol_version: 1, reserved: 0, - session_id: 100, + receiver_idx: 100, counter: 10, }, LpMessage::Handshake(HandshakeData(handshake_data)), ); - handler.send_lp_packet(&packet).await + handler.send_lp_packet(&packet, None).await }); let mut client_stream = TcpStream::connect(addr).await.unwrap(); @@ -1097,7 +1189,7 @@ mod tests { let received = read_lp_packet_from_stream(&mut client_stream) .await .unwrap(); - assert_eq!(received.header().session_id, 100); + assert_eq!(received.header().receiver_idx, 100); assert_eq!(received.header().counter, 10); match received.message() { LpMessage::Handshake(data) => assert_eq!(data, &HandshakeData(expected_data)), @@ -1124,12 +1216,12 @@ mod tests { LpHeader { protocol_version: 1, reserved: 0, - session_id: 200, + receiver_idx: 200, counter: 20, }, LpMessage::EncryptedData(EncryptedDataPayload(encrypted_payload)), ); - handler.send_lp_packet(&packet).await + handler.send_lp_packet(&packet, None).await }); let mut client_stream = TcpStream::connect(addr).await.unwrap(); @@ -1138,7 +1230,7 @@ mod tests { let received = read_lp_packet_from_stream(&mut client_stream) .await .unwrap(); - assert_eq!(received.header().session_id, 200); + assert_eq!(received.header().receiver_idx, 200); assert_eq!(received.header().counter, 20); match received.message() { LpMessage::EncryptedData(data) => { @@ -1170,12 +1262,12 @@ mod tests { LpHeader { protocol_version: 1, reserved: 0, - session_id: 300, + receiver_idx: 300, counter: 30, }, LpMessage::ClientHello(hello_data), ); - handler.send_lp_packet(&packet).await + handler.send_lp_packet(&packet, None).await }); let mut client_stream = TcpStream::connect(addr).await.unwrap(); @@ -1184,7 +1276,7 @@ mod tests { let received = read_lp_packet_from_stream(&mut client_stream) .await .unwrap(); - assert_eq!(received.header().session_id, 300); + assert_eq!(received.header().receiver_idx, 300); assert_eq!(received.header().counter, 30); match received.message() { LpMessage::ClientHello(data) => { @@ -1229,7 +1321,7 @@ mod tests { LpHeader { protocol_version: 1, reserved: 0, - session_id: 0, + receiver_idx: 0, counter: 0, }, LpMessage::ClientHello(hello_data.clone()), @@ -1293,7 +1385,7 @@ mod tests { LpHeader { protocol_version: 1, reserved: 0, - session_id: 0, + receiver_idx: 0, counter: 0, }, LpMessage::ClientHello(hello_data), diff --git a/gateway/src/node/lp_listener/handshake.rs b/gateway/src/node/lp_listener/handshake.rs index 2e79e836879..f8bec7792d1 100644 --- a/gateway/src/node/lp_listener/handshake.rs +++ b/gateway/src/node/lp_listener/handshake.rs @@ -19,10 +19,12 @@ impl LpGatewayHandshake { /// Create a new responder (gateway side) handshake /// /// # Arguments + /// * `receiver_index` - Client-proposed receiver_index (from ClientHello) /// * `gateway_ed25519_keypair` - Gateway's Ed25519 identity keypair (for PSQ auth and X25519 derivation) /// * `client_ed25519_public_key` - Client's Ed25519 public key (from ClientHello) /// * `salt` - Salt from ClientHello (for PSK derivation) pub fn new_responder( + receiver_index: u32, gateway_ed25519_keypair: ( &nym_crypto::asymmetric::ed25519::PrivateKey, &nym_crypto::asymmetric::ed25519::PublicKey, @@ -31,6 +33,7 @@ impl LpGatewayHandshake { salt: &[u8; 32], ) -> Result { let state_machine = LpStateMachine::new( + receiver_index, false, // responder gateway_ed25519_keypair, client_ed25519_public_key, @@ -114,9 +117,9 @@ impl LpGatewayHandshake { use bytes::BytesMut; use nym_lp::codec::serialize_lp_packet; - // Serialize the packet first + // Serialize the packet first (None key during handshake phase) let mut packet_buf = BytesMut::new(); - serialize_lp_packet(packet, &mut packet_buf).map_err(|e| { + serialize_lp_packet(packet, &mut packet_buf, None).map_err(|e| { GatewayError::LpProtocolError(format!("Failed to serialize packet: {}", e)) })?; @@ -169,7 +172,8 @@ impl LpGatewayHandshake { GatewayError::LpConnectionError(format!("Failed to read packet data: {}", e)) })?; - let packet = parse_lp_packet(&packet_buf) + // Parse packet (None key during handshake phase) + let packet = parse_lp_packet(&packet_buf, None) .map_err(|e| GatewayError::LpProtocolError(format!("Failed to parse packet: {}", e)))?; debug!("Received LP packet ({} bytes + 4 byte header)", packet_len); diff --git a/gateway/src/node/lp_listener/registration.rs b/gateway/src/node/lp_listener/registration.rs index 2439721c8b0..70fca10d70a 100644 --- a/gateway/src/node/lp_listener/registration.rs +++ b/gateway/src/node/lp_listener/registration.rs @@ -142,7 +142,7 @@ pub async fn process_registration( if !request.validate_timestamp(30) { warn!("LP registration failed: timestamp too old or too far in future"); inc!("lp_registration_failed_timestamp"); - return LpRegistrationResponse::error(session_id, "Invalid timestamp".to_string()); + return LpRegistrationResponse::error("Invalid timestamp".to_string()); } // 2. Process based on mode @@ -163,10 +163,10 @@ pub async fn process_registration( error!("LP WireGuard peer registration failed: {}", e); inc!("lp_registration_dvpn_failed"); inc!("lp_errors_wg_peer_registration"); - return LpRegistrationResponse::error( - session_id, - format!("WireGuard peer registration failed: {}", e), - ); + return LpRegistrationResponse::error(format!( + "WireGuard peer registration failed: {}", + e + )); } }; @@ -196,19 +196,16 @@ pub async fn process_registration( remove_err ); } - return LpRegistrationResponse::error( - session_id, - format!("Credential verification failed: {}", e), - ); + return LpRegistrationResponse::error(format!( + "Credential verification failed: {}", + e + )); } }; - info!( - "LP dVPN registration successful for session {} (client_id: {})", - session_id, client_id - ); + info!("LP dVPN registration successful (client_id: {})", client_id); inc!("lp_registration_dvpn_success"); - LpRegistrationResponse::success(session_id, allocated_bandwidth, gateway_data) + LpRegistrationResponse::success(allocated_bandwidth, gateway_data) } RegistrationMode::Mixnet { client_id: client_id_bytes, @@ -244,18 +241,18 @@ pub async fn process_registration( client_id, e ); inc!("lp_registration_mixnet_failed"); - return LpRegistrationResponse::error( - session_id, - format!("Credential verification failed: {}", e), - ); + return LpRegistrationResponse::error(format!( + "Credential verification failed: {}", + e + )); } }; // For mixnet mode, we don't have WireGuard data // In the future, this would set up mixnet-specific state info!( - "LP Mixnet registration successful for session {} (client_id: {})", - session_id, client_id + "LP Mixnet registration successful (client_id: {})", + client_id ); inc!("lp_registration_mixnet_success"); LpRegistrationResponse { @@ -263,7 +260,6 @@ pub async fn process_registration( error: None, gateway_data: None, allocated_bandwidth, - session_id, } } }; diff --git a/nym-registration-client/src/lp_client/client.rs b/nym-registration-client/src/lp_client/client.rs index 36840c4dbd6..9c111a87f34 100644 --- a/nym-registration-client/src/lp_client/client.rs +++ b/nym-registration-client/src/lp_client/client.rs @@ -248,6 +248,7 @@ impl LpRegistrationClient { self.local_ed25519_keypair.public_key().to_bytes(), ); let salt = client_hello_data.salt; + let receiver_index = client_hello_data.receiver_index; tracing::trace!( "Generated ClientHello with timestamp: {}", @@ -256,7 +257,7 @@ impl LpRegistrationClient { // Step 3: Send ClientHello as first packet (before Noise handshake) let client_hello_header = nym_lp::packet::LpHeader::new( - nym_lp::BOOTSTRAP_SESSION_ID, // session_id not yet established + nym_lp::BOOTSTRAP_RECEIVER_IDX, // session_id not yet established 0, // counter starts at 0 ); let client_hello_packet = nym_lp::LpPacket::new( @@ -269,6 +270,7 @@ impl LpRegistrationClient { // Step 4: Create state machine as initiator with Ed25519 keys // PSK derivation happens internally in the state machine constructor let mut state_machine = LpStateMachine::new( + receiver_index, true, // is_initiator ( self.local_ed25519_keypair.private_key(), @@ -352,9 +354,9 @@ impl LpRegistrationClient { /// # Errors /// Returns an error if serialization or network transmission fails. async fn send_packet(stream: &mut TcpStream, packet: &LpPacket) -> Result<()> { - // Serialize the packet + // During handshake, outer AEAD is not used (PSK not yet established) let mut packet_buf = BytesMut::new(); - serialize_lp_packet(packet, &mut packet_buf) + serialize_lp_packet(packet, &mut packet_buf, None) .map_err(|e| LpClientError::Transport(format!("Failed to serialize packet: {}", e)))?; // Send 4-byte length prefix (u32 big-endian) @@ -416,8 +418,8 @@ impl LpRegistrationClient { .await .map_err(|e| LpClientError::Transport(format!("Failed to read packet data: {}", e)))?; - // Parse the packet - let packet = parse_lp_packet(&packet_buf) + // During handshake, outer AEAD is not used (PSK not yet established) + let packet = parse_lp_packet(&packet_buf, None) .map_err(|e| LpClientError::Transport(format!("Failed to parse packet: {}", e)))?; tracing::trace!("Received LP packet ({} bytes + 4 byte header)", packet_len); @@ -705,9 +707,8 @@ impl LpRegistrationClient { })?; tracing::debug!( - "Received registration response: success={}, session_id={}", + "Received registration response: success={}", response.success, - response.session_id ); // 5. Validate and extract GatewayData @@ -727,8 +728,7 @@ impl LpRegistrationClient { })?; tracing::info!( - "LP registration successful! Session ID: {}, Allocated bandwidth: {} bytes", - response.session_id, + "LP registration successful! Allocated bandwidth: {} bytes", response.allocated_bandwidth ); diff --git a/nym-registration-client/src/lp_client/nested_session.rs b/nym-registration-client/src/lp_client/nested_session.rs index a2a4536af57..0b7b705e72f 100644 --- a/nym-registration-client/src/lp_client/nested_session.rs +++ b/nym-registration-client/src/lp_client/nested_session.rs @@ -24,7 +24,7 @@ use bytes::BytesMut; use nym_bandwidth_controller::BandwidthTicketProvider; use nym_credentials_interface::TicketType; use nym_crypto::asymmetric::{ed25519, x25519}; -use nym_lp::codec::{parse_lp_packet, serialize_lp_packet}; +use nym_lp::codec::{parse_lp_packet, serialize_lp_packet, OuterAeadKey}; use nym_lp::state_machine::{LpAction, LpInput, LpStateMachine}; use nym_lp::{LpMessage, LpPacket}; use nym_registration_common::{GatewayData, LpRegistrationRequest, LpRegistrationResponse}; @@ -135,6 +135,7 @@ impl NestedLpSession { self.client_keypair.public_key().to_bytes(), ); let salt = client_hello_data.salt; + let receiver_index = client_hello_data.receiver_index; tracing::trace!( "Generated ClientHello for exit gateway (timestamp: {})", @@ -151,8 +152,8 @@ impl NestedLpSession { LpMessage::ClientHello(client_hello_data), ); - // Serialize and forward ClientHello - let client_hello_bytes = Self::serialize_packet(&client_hello_packet)?; + // Serialize and forward ClientHello (no state machine yet, no outer key) + let client_hello_bytes = Self::serialize_packet(&client_hello_packet, None)?; let _response_bytes = outer_client .send_forward_packet( self.exit_identity, @@ -165,6 +166,7 @@ impl NestedLpSession { // Step 4: Create state machine for exit gateway handshake let mut state_machine = LpStateMachine::new( + receiver_index, true, // is_initiator ( self.client_keypair.private_key(), @@ -179,7 +181,9 @@ impl NestedLpSession { match action? { LpAction::SendPacket(packet) => { tracing::trace!("Sending initial handshake packet to exit"); - let packet_bytes = Self::serialize_packet(&packet)?; + // Get outer key (None before PSK derivation) + let outer_key = state_machine.session().ok().and_then(|s| s.outer_aead_key()); + let packet_bytes = Self::serialize_packet(&packet, outer_key.as_ref())?; let response_bytes = outer_client .send_forward_packet( self.exit_identity, @@ -189,7 +193,8 @@ impl NestedLpSession { .await?; // Parse response and feed to state machine - let response_packet = Self::parse_packet(&response_bytes)?; + let outer_key = state_machine.session().ok().and_then(|s| s.outer_aead_key()); + let response_packet = Self::parse_packet(&response_bytes, outer_key.as_ref())?; tracing::trace!("Received handshake response from exit"); // Process response through state machine @@ -200,7 +205,8 @@ impl NestedLpSession { LpAction::SendPacket(response_packet) => { // Send response packet tracing::trace!("Sending handshake response to exit"); - let packet_bytes = Self::serialize_packet(&response_packet)?; + let outer_key = state_machine.session().ok().and_then(|s| s.outer_aead_key()); + let packet_bytes = Self::serialize_packet(&response_packet, outer_key.as_ref())?; let response_bytes = outer_client .send_forward_packet( self.exit_identity, @@ -219,7 +225,8 @@ impl NestedLpSession { } // Process the response from exit gateway - let response_packet = Self::parse_packet(&response_bytes)?; + let outer_key = state_machine.session().ok().and_then(|s| s.outer_aead_key()); + let response_packet = Self::parse_packet(&response_bytes, outer_key.as_ref())?; if let Some(action) = state_machine .process_input(LpInput::ReceivePacket(response_packet)) { @@ -249,6 +256,7 @@ impl NestedLpSession { LpAction::KKTComplete => { tracing::info!("KKT exchange completed with exit, starting Noise"); // After KKT completes, initiator must send first Noise handshake message + // PSK is now available, so outer AEAD key can be used let noise_msg = state_machine .session()? .prepare_handshake_message() @@ -259,7 +267,8 @@ impl NestedLpSession { })??; let noise_packet = state_machine.session()?.next_packet(noise_msg)?; tracing::trace!("Sending first Noise handshake message to exit"); - let packet_bytes = Self::serialize_packet(&noise_packet)?; + let outer_key = state_machine.session().ok().and_then(|s| s.outer_aead_key()); + let packet_bytes = Self::serialize_packet(&noise_packet, outer_key.as_ref())?; let response_bytes = outer_client .send_forward_packet( self.exit_identity, @@ -269,7 +278,8 @@ impl NestedLpSession { .await?; // Process the Noise response from exit gateway - let response_packet = Self::parse_packet(&response_bytes)?; + let outer_key = state_machine.session().ok().and_then(|s| s.outer_aead_key()); + let response_packet = Self::parse_packet(&response_bytes, outer_key.as_ref())?; if let Some(action) = state_machine .process_input(LpInput::ReceivePacket(response_packet)) { @@ -283,7 +293,8 @@ impl NestedLpSession { } LpAction::SendPacket(final_packet) => { tracing::trace!("Sending final handshake packet to exit"); - let packet_bytes = Self::serialize_packet(&final_packet)?; + let outer_key = state_machine.session().ok().and_then(|s| s.outer_aead_key()); + let packet_bytes = Self::serialize_packet(&final_packet, outer_key.as_ref())?; let _ = outer_client .send_forward_packet( self.exit_identity, @@ -419,9 +430,11 @@ impl NestedLpSession { })?; // Step 7: Send the encrypted packet via forwarding + // Get outer key for AEAD encryption (PSK is available after handshake) + let outer_key = state_machine.session().ok().and_then(|s| s.outer_aead_key()); let response_bytes = match action { LpAction::SendPacket(packet) => { - let packet_bytes = Self::serialize_packet(&packet)?; + let packet_bytes = Self::serialize_packet(&packet, outer_key.as_ref())?; outer_client .send_forward_packet( self.exit_identity, @@ -441,7 +454,8 @@ impl NestedLpSession { tracing::trace!("Received registration response from exit gateway"); // Step 8: Parse response bytes to LP packet - let response_packet = Self::parse_packet(&response_bytes)?; + let outer_key = state_machine.session().ok().and_then(|s| s.outer_aead_key()); + let response_packet = Self::parse_packet(&response_bytes, outer_key.as_ref())?; // Step 9: Decrypt via state machine let action = state_machine @@ -477,9 +491,8 @@ impl NestedLpSession { })?; tracing::debug!( - "Received registration response from exit: success={}, session_id={}", + "Received registration response from exit: success={}", response.success, - response.session_id ); // Step 12: Validate and extract GatewayData @@ -499,8 +512,7 @@ impl NestedLpSession { })?; tracing::info!( - "Exit gateway registration successful! Session ID: {}, Allocated bandwidth: {} bytes", - response.session_id, + "Exit gateway registration successful! Allocated bandwidth: {} bytes", response.allocated_bandwidth ); @@ -517,9 +529,10 @@ impl NestedLpSession { /// /// # Errors /// Returns an error if serialization fails - fn serialize_packet(packet: &LpPacket) -> Result> { + fn serialize_packet(packet: &LpPacket, outer_key: Option<&OuterAeadKey>) -> Result> { let mut buf = BytesMut::new(); - serialize_lp_packet(packet, &mut buf).map_err(|e| { + // Use outer AEAD key when available (after PSK derivation) + serialize_lp_packet(packet, &mut buf, outer_key).map_err(|e| { LpClientError::Transport(format!("Failed to serialize LP packet: {}", e)) })?; Ok(buf.to_vec()) @@ -535,8 +548,9 @@ impl NestedLpSession { /// /// # Errors /// Returns an error if parsing fails - fn parse_packet(bytes: &[u8]) -> Result { - parse_lp_packet(bytes).map_err(|e| { + fn parse_packet(bytes: &[u8], outer_key: Option<&OuterAeadKey>) -> Result { + // Use outer AEAD key when available (after PSK derivation) + parse_lp_packet(bytes, outer_key).map_err(|e| { LpClientError::Transport(format!("Failed to parse LP packet: {}", e)) }) } diff --git a/nym-registration-client/src/lp_client/transport.rs b/nym-registration-client/src/lp_client/transport.rs index 51f67e24060..10de38931ba 100644 --- a/nym-registration-client/src/lp_client/transport.rs +++ b/nym-registration-client/src/lp_client/transport.rs @@ -9,7 +9,7 @@ use super::error::{LpClientError, Result}; use bytes::BytesMut; use nym_lp::LpPacket; -use nym_lp::codec::{parse_lp_packet, serialize_lp_packet}; +use nym_lp::codec::{parse_lp_packet, serialize_lp_packet, OuterAeadKey}; use nym_lp::state_machine::{LpAction, LpInput, LpStateBare, LpStateMachine}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; @@ -196,9 +196,15 @@ impl LpTransport { /// /// Format: 4-byte big-endian u32 length + packet bytes async fn send_packet(&mut self, packet: &LpPacket) -> Result<()> { - // Serialize the packet + // Get outer_aead_key from session for AEAD encryption + let outer_key: Option = self + .state_machine + .session() + .ok() + .and_then(|s| s.outer_aead_key()); + let mut packet_buf = BytesMut::new(); - serialize_lp_packet(packet, &mut packet_buf) + serialize_lp_packet(packet, &mut packet_buf, outer_key.as_ref()) .map_err(|e| LpClientError::Transport(format!("Failed to serialize packet: {}", e)))?; // Send 4-byte length prefix (u32 big-endian) @@ -257,8 +263,14 @@ impl LpTransport { .await .map_err(|e| LpClientError::Transport(format!("Failed to read packet data: {}", e)))?; - // Parse the packet - let packet = parse_lp_packet(&packet_buf) + // Get outer_aead_key from session for AEAD decryption + let outer_key: Option = self + .state_machine + .session() + .ok() + .and_then(|s| s.outer_aead_key()); + + let packet = parse_lp_packet(&packet_buf, outer_key.as_ref()) .map_err(|e| LpClientError::Transport(format!("Failed to parse packet: {}", e)))?; tracing::trace!("Received LP packet ({} bytes + 4 byte header)", packet_len); From 9473f3418c828e83606efc43236a5d32c7bb448a Mon Sep 17 00:00:00 2001 From: durch Date: Thu, 27 Nov 2025 13:26:31 +0100 Subject: [PATCH 09/14] Refactor LP registration client for packet-per-connection model [nym-8wuj, nym-k0tb] Refactor LpRegistrationClient from persistent TCP connection model to packet-per-connection model, matching gateway's stateless connection pattern. Each LP packet exchange opens a new TCP connection, sends one packet, receives one response, then closes. State persists in LpStateMachine locally. Key changes: - Add connect_send_receive() helper for packet-per-connection exchanges - Rewrite perform_handshake() with clean loop-based approach - Remove LpTransport (packet-per-connection doesn't need persistent transport) - Combine send_registration_request + receive_registration_response into register() - Remove connect() method - handshake now handles connection internally NestedLpSession refactoring: - Add send_and_receive_via_forward() helper (consolidates 9 outer_key extractions) - Rewrite perform_handshake() from 6-level nesting to clean loop - Use BOOTSTRAP_RECEIVER_IDX constant instead of hardcoded 0 - Fix stale doc comment referencing removed connect() method Result: 438 fewer lines, cleaner control flow, DRY outer_key handling. --- nym-registration-client/src/lib.rs | 42 +- .../src/lp_client/client.rs | 670 ++++++++---------- nym-registration-client/src/lp_client/mod.rs | 16 +- .../src/lp_client/nested_session.rs | 257 +++---- .../src/lp_client/transport.rs | 279 -------- 5 files changed, 413 insertions(+), 851 deletions(-) delete mode 100644 nym-registration-client/src/lp_client/transport.rs diff --git a/nym-registration-client/src/lib.rs b/nym-registration-client/src/lib.rs index 7e7313578b0..4b023ea2f19 100644 --- a/nym-registration-client/src/lib.rs +++ b/nym-registration-client/src/lib.rs @@ -178,7 +178,8 @@ impl RegistrationClient { let exit_lp_keypair = Arc::new(ed25519::KeyPair::new(&mut OsRng)); // STEP 1: Establish outer session with entry gateway - // This creates the LP connection that will be used to forward packets to exit + // This creates the LP session that will be used to forward packets to exit. + // Uses packet-per-connection model: each handshake packet on new TCP connection. tracing::info!("Establishing outer session with entry gateway"); let mut entry_client = LpRegistrationClient::new_with_default_psk( entry_lp_keypair.clone(), @@ -187,16 +188,6 @@ impl RegistrationClient { self.config.entry.node.ip_address, ); - // Connect to entry gateway - entry_client - .connect() - .await - .map_err(|source| RegistrationClientError::EntryGatewayRegisterLp { - gateway_id: self.config.entry.node.identity.to_base58_string(), - lp_address: entry_lp_address, - source: Box::new(source), - })?; - // Perform handshake with entry gateway (outer session now established) entry_client .perform_handshake() @@ -238,10 +229,10 @@ impl RegistrationClient { tracing::info!("Exit gateway registration completed via forwarding"); - // STEP 3: Send registration request to entry gateway - tracing::info!("Sending registration request to entry gateway"); - entry_client - .send_registration_request( + // STEP 3: Register with entry gateway (packet-per-connection) + tracing::info!("Registering with entry gateway"); + let entry_gateway_data = entry_client + .register( &self.config.entry.keys, &self.config.entry.node.identity, &*self.bandwidth_controller, @@ -254,25 +245,14 @@ impl RegistrationClient { source: Box::new(source), })?; - // Receive registration response from entry - let entry_gateway_data = entry_client - .receive_registration_response() - .await - .map_err(|source| RegistrationClientError::EntryGatewayRegisterLp { - gateway_id: self.config.entry.node.identity.to_base58_string(), - lp_address: entry_lp_address, - source: Box::new(source), - })?; - tracing::info!("Entry gateway registration successful"); - tracing::info!( - "LP registration successful for both gateways (LP connections will be closed)" - ); + tracing::info!("LP registration successful for both gateways"); - // LP is registration-only. All data flows through WireGuard after this point. - // The entry LP connection will be dropped, automatically closing TCP connection. - // Exit registration was completed via forwarding through entry, so no direct connection exists. + // LP is registration-only (packet-per-connection model). + // All data flows through WireGuard after this point. + // Each LP packet used its own TCP connection which was closed after the exchange. + // Exit registration was completed via forwarding through entry gateway. Ok(RegistrationResult::Lp(Box::new(LpRegistrationResult { entry_gateway_data, exit_gateway_data, diff --git a/nym-registration-client/src/lp_client/client.rs b/nym-registration-client/src/lp_client/client.rs index 9c111a87f34..0d111fc4ace 100644 --- a/nym-registration-client/src/lp_client/client.rs +++ b/nym-registration-client/src/lp_client/client.rs @@ -5,13 +5,12 @@ use super::config::LpConfig; use super::error::{LpClientError, Result}; -use super::transport::LpTransport; use bytes::BytesMut; use nym_bandwidth_controller::{BandwidthTicketProvider, DEFAULT_TICKETS_TO_SPEND}; use nym_credentials_interface::{CredentialSpendingData, TicketType}; use nym_crypto::asymmetric::{ed25519, x25519}; use nym_lp::LpPacket; -use nym_lp::codec::{parse_lp_packet, serialize_lp_packet}; +use nym_lp::codec::{parse_lp_packet, serialize_lp_packet, OuterAeadKey}; use nym_lp::message::ForwardPacketData; use nym_lp::state_machine::{LpAction, LpInput, LpStateMachine}; use nym_registration_common::{GatewayData, LpRegistrationRequest, LpRegistrationResponse}; @@ -23,24 +22,18 @@ use tokio::net::TcpStream; /// LP (Lewes Protocol) registration client for direct gateway connections. /// -/// This client manages: -/// - TCP connection to the gateway's LP listener -/// - Noise protocol handshake via LP state machine -/// - Registration request/response exchange -/// - Encrypted transport after handshake +/// This client uses a packet-per-connection model where each LP packet +/// exchange opens a new TCP connection, sends one packet, receives one +/// response, then closes. Session state is maintained in the state machine +/// across connections. /// /// # Example Flow /// ```ignore -/// let client = LpRegistrationClient::new(...); -/// client.connect().await?; // nym-78: Establish TCP -/// client.perform_handshake().await?; // nym-79: Noise handshake -/// let response = client.register(...).await?; // nym-80: Send registration +/// let mut client = LpRegistrationClient::new(...); +/// client.perform_handshake().await?; // Noise handshake (multiple connections) +/// let gateway_data = client.register(...).await?; // Registration (single connection) /// ``` pub struct LpRegistrationClient { - /// TCP stream connection to the gateway. - /// Created during `connect()`, None before connection is established. - tcp_stream: Option, - /// Client's Ed25519 identity keypair (used for PSQ authentication and X25519 derivation). local_ed25519_keypair: Arc, @@ -51,13 +44,13 @@ pub struct LpRegistrationClient { gateway_lp_address: SocketAddr, /// LP state machine for managing connection lifecycle. - /// Created during handshake initiation (nym-79). + /// Created during handshake initiation. Persists across packet-per-connection calls. state_machine: Option, /// Client's IP address for registration metadata. client_ip: IpAddr, - /// Configuration for timeouts and TCP parameters (nym-87, nym-102, nym-104). + /// Configuration for timeouts and TCP parameters. config: LpConfig, } @@ -72,9 +65,8 @@ impl LpRegistrationClient { /// * `config` - Configuration for timeouts and TCP parameters (use `LpConfig::default()`) /// /// # Note - /// This creates the client but does not establish the connection. - /// Call `connect()` to establish the TCP connection. - /// PSK is derived automatically during handshake inside the state machine. + /// This creates the client. Call `perform_handshake()` to establish the LP session. + /// Each packet exchange opens a new TCP connection (packet-per-connection model). pub fn new( local_ed25519_keypair: Arc, gateway_ed25519_public_key: ed25519::PublicKey, @@ -83,7 +75,6 @@ impl LpRegistrationClient { config: LpConfig, ) -> Self { Self { - tcp_stream: None, local_ed25519_keypair, gateway_ed25519_public_key, gateway_lp_address, @@ -119,63 +110,13 @@ impl LpRegistrationClient { ) } - /// Establishes TCP connection to the gateway's LP listener. - /// - /// This must be called before attempting handshake or registration. - /// - /// # Errors - /// Returns `LpClientError::TcpConnection` if the connection fails or times out. - /// - /// # Implementation Note - /// This is implemented in nym-78. The handshake (nym-79) and registration - /// (nym-80, nym-81) will be added in subsequent tasks. - /// Timeout and TCP parameters added in nym-102 and nym-104. - pub async fn connect(&mut self) -> Result<()> { - // Apply connect timeout (nym-102) - let stream = tokio::time::timeout( - self.config.connect_timeout, - TcpStream::connect(self.gateway_lp_address), - ) - .await - .map_err(|_| LpClientError::TcpConnection { - address: self.gateway_lp_address.to_string(), - source: std::io::Error::new( - std::io::ErrorKind::TimedOut, - format!("Connection timeout after {:?}", self.config.connect_timeout), - ), - })? - .map_err(|source| LpClientError::TcpConnection { - address: self.gateway_lp_address.to_string(), - source, - })?; - - // Apply TCP_NODELAY (nym-104) - stream - .set_nodelay(self.config.tcp_nodelay) - .map_err(|source| LpClientError::TcpConnection { - address: self.gateway_lp_address.to_string(), - source, - })?; - - tracing::info!( - "Successfully connected to gateway LP listener at {} (timeout={:?}, nodelay={})", - self.gateway_lp_address, - self.config.connect_timeout, - self.config.tcp_nodelay - ); - - self.tcp_stream = Some(stream); - Ok(()) - } - - /// Returns a reference to the TCP stream if connected. - pub fn tcp_stream(&self) -> Option<&TcpStream> { - self.tcp_stream.as_ref() - } - - /// Returns whether the client is currently connected via TCP. - pub fn is_connected(&self) -> bool { - self.tcp_stream.is_some() + /// Returns whether the client has completed the handshake and is ready for registration. + pub fn is_handshake_complete(&self) -> bool { + self.state_machine + .as_ref() + .and_then(|sm| sm.session().ok()) + .map(|s| s.is_handshake_complete()) + .unwrap_or(false) } /// Returns the gateway LP address this client is configured for. @@ -191,11 +132,11 @@ impl LpRegistrationClient { /// Performs the LP Noise protocol handshake with the gateway. /// /// This establishes a secure encrypted session using the Noise protocol. - /// Must be called after `connect()` and before attempting registration. + /// Uses packet-per-connection model: each handshake message opens a new + /// TCP connection. /// /// # Errors /// Returns an error if: - /// - Not connected via TCP /// - State machine creation fails /// - Handshake protocol fails /// - Network communication fails @@ -203,12 +144,10 @@ impl LpRegistrationClient { /// /// # Implementation /// This implements the Noise protocol handshake as the initiator: - /// 1. Creates LP state machine with client as initiator - /// 2. Sends initial handshake packet - /// 3. Exchanges handshake messages until complete + /// 1. Sends ClientHello, receives Ack (connection 1) + /// 2. Creates LP state machine with client as initiator + /// 3. Exchanges handshake messages (each on new connection) /// 4. Stores the established session in the state machine - /// - /// Timeout applied in nym-102. pub async fn perform_handshake(&mut self) -> Result<()> { // Apply handshake timeout (nym-102) tokio::time::timeout( @@ -225,12 +164,11 @@ impl LpRegistrationClient { } /// Internal handshake implementation without timeout. + /// + /// Uses packet-per-connection model: each LP packet exchange opens a new + /// TCP connection, sends one packet, receives one response, then closes. async fn perform_handshake_inner(&mut self) -> Result<()> { - let stream = self.tcp_stream.as_mut().ok_or_else(|| { - LpClientError::Transport("Cannot perform handshake: not connected".to_string()) - })?; - - tracing::debug!("Starting LP handshake as initiator"); + tracing::debug!("Starting LP handshake as initiator (packet-per-connection)"); // Step 1: Derive X25519 keys from Ed25519 for Noise protocol (internal to ClientHello) // The Ed25519 keys are used for PSQ authentication and also converted to X25519 @@ -251,11 +189,12 @@ impl LpRegistrationClient { let receiver_index = client_hello_data.receiver_index; tracing::trace!( - "Generated ClientHello with timestamp: {}", - client_hello_data.extract_timestamp() + "Generated ClientHello with timestamp: {}, receiver_index: {}", + client_hello_data.extract_timestamp(), + receiver_index ); - // Step 3: Send ClientHello as first packet (before Noise handshake) + // Step 3: Send ClientHello and receive Ack (packet-per-connection) let client_hello_header = nym_lp::packet::LpHeader::new( nym_lp::BOOTSTRAP_RECEIVER_IDX, // session_id not yet established 0, // counter starts at 0 @@ -264,8 +203,27 @@ impl LpRegistrationClient { client_hello_header, nym_lp::LpMessage::ClientHello(client_hello_data), ); - Self::send_packet(stream, &client_hello_packet).await?; - tracing::debug!("Sent ClientHello packet"); + + let ack_response = Self::connect_send_receive( + self.gateway_lp_address, + &client_hello_packet, + None, // No outer key before handshake + &self.config, + ) + .await?; + + // Verify we received Ack + match ack_response.message() { + nym_lp::LpMessage::Ack => { + tracing::debug!("Received Ack for ClientHello"); + } + other => { + return Err(LpClientError::Transport(format!( + "Expected Ack for ClientHello, got: {:?}", + other + ))); + } + } // Step 4: Create state machine as initiator with Ed25519 keys // PSK derivation happens internally in the state machine constructor @@ -280,12 +238,12 @@ impl LpRegistrationClient { &salt, )?; - // Start handshake - client (initiator) sends first + // Step 5: Start handshake - get first packet to send (KKT request) + let mut pending_packet: Option = None; if let Some(action) = state_machine.process_input(LpInput::StartHandshake) { match action? { LpAction::SendPacket(packet) => { - tracing::trace!("Sending initial handshake packet"); - Self::send_packet(stream, &packet).await?; + pending_packet = Some(packet); } other => { return Err(LpClientError::Transport(format!( @@ -296,49 +254,85 @@ impl LpRegistrationClient { } } - // Continue handshake until complete + // Step 6: Handshake loop - each packet on new connection loop { - // Read incoming packet from gateway - let packet = Self::receive_packet(stream).await?; - tracing::trace!("Received handshake packet"); - - // Process the received packet - if let Some(action) = state_machine.process_input(LpInput::ReceivePacket(packet)) { - match action? { - LpAction::SendPacket(response_packet) => { - tracing::trace!("Sending handshake response packet"); - Self::send_packet(stream, &response_packet).await?; - - // Check if handshake completed after sending this packet - // (e.g., initiator completes after sending final message) - if state_machine.session()?.is_handshake_complete() { - tracing::info!("LP handshake completed after sending packet"); + // Send pending packet if we have one + if let Some(packet) = pending_packet.take() { + // Get outer key from session (None before PSK, Some after) + let outer_key = state_machine + .session() + .ok() + .and_then(|s| s.outer_aead_key()); + + tracing::trace!("Sending handshake packet (outer_key={})", outer_key.is_some()); + let response = Self::connect_send_receive( + self.gateway_lp_address, + &packet, + outer_key.as_ref(), + &self.config, + ) + .await?; + tracing::trace!("Received handshake response"); + + // Process the received packet + if let Some(action) = + state_machine.process_input(LpInput::ReceivePacket(response)) + { + match action? { + LpAction::SendPacket(response_packet) => { + // Queue the response packet to send on next iteration + pending_packet = Some(response_packet); + + // Check if handshake completed after queueing this packet + if state_machine.session()?.is_handshake_complete() { + // Send the final packet before breaking + if let Some(final_packet) = pending_packet.take() { + let outer_key = state_machine + .session() + .ok() + .and_then(|s| s.outer_aead_key()); + tracing::trace!("Sending final handshake packet"); + // Final packet - we don't need the response + let _ = Self::connect_send_receive( + self.gateway_lp_address, + &final_packet, + outer_key.as_ref(), + &self.config, + ) + .await?; + } + tracing::info!("LP handshake completed after sending final packet"); + break; + } + } + LpAction::HandshakeComplete => { + tracing::info!("LP handshake completed successfully"); break; } - } - LpAction::HandshakeComplete => { - tracing::info!("LP handshake completed successfully"); - break; - } - LpAction::KKTComplete => { - tracing::info!("KKT exchange completed, starting Noise handshake"); - // After KKT completes, initiator must send first Noise handshake message - let noise_msg = state_machine - .session()? - .prepare_handshake_message() - .ok_or_else(|| { - LpClientError::Transport( - "No handshake message available after KKT".to_string(), - ) - })??; - let noise_packet = state_machine.session()?.next_packet(noise_msg)?; - tracing::trace!("Sending first Noise handshake message"); - Self::send_packet(stream, &noise_packet).await?; - } - other => { - tracing::trace!("Received action during handshake: {:?}", other); + LpAction::KKTComplete => { + tracing::info!("KKT exchange completed, starting Noise handshake"); + // After KKT completes, initiator must send first Noise handshake message + let noise_msg = state_machine + .session()? + .prepare_handshake_message() + .ok_or_else(|| { + LpClientError::Transport( + "No handshake message available after KKT".to_string(), + ) + })??; + let noise_packet = state_machine.session()?.next_packet(noise_msg)?; + pending_packet = Some(noise_packet); + } + other => { + tracing::trace!("Received action during handshake: {:?}", other); + } } } + } else { + // No pending packet and not complete - something is wrong + return Err(LpClientError::Transport( + "Handshake stalled: no packet to send".to_string(), + )); } } @@ -347,16 +341,77 @@ impl LpRegistrationClient { Ok(()) } - /// Sends an LP packet over the TCP stream with length-prefixed framing. + /// Opens a TCP connection, sends one packet, receives one response, closes. + /// + /// This implements the packet-per-connection model where each LP packet + /// exchange uses its own TCP connection. The connection is closed when + /// this method returns (stream dropped). + /// + /// # Arguments + /// * `address` - Gateway LP listener address + /// * `packet` - The LP packet to send + /// * `outer_key` - Optional outer AEAD key (None before PSK, Some after) + /// * `config` - Configuration for timeouts and TCP parameters + /// + /// # Errors + /// Returns an error if connection, send, or receive fails. + async fn connect_send_receive( + address: SocketAddr, + packet: &LpPacket, + outer_key: Option<&OuterAeadKey>, + config: &LpConfig, + ) -> Result { + // 1. Connect with timeout + let mut stream = tokio::time::timeout(config.connect_timeout, TcpStream::connect(address)) + .await + .map_err(|_| LpClientError::TcpConnection { + address: address.to_string(), + source: std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("Connection timeout after {:?}", config.connect_timeout), + ), + })? + .map_err(|source| LpClientError::TcpConnection { + address: address.to_string(), + source, + })?; + + // 2. Set TCP_NODELAY + stream + .set_nodelay(config.tcp_nodelay) + .map_err(|source| LpClientError::TcpConnection { + address: address.to_string(), + source, + })?; + + // 3. Send packet with optional outer AEAD + Self::send_packet_with_key(&mut stream, packet, outer_key).await?; + + // 4. Receive response with optional outer AEAD + let response = Self::receive_packet_with_key(&mut stream, outer_key).await?; + + // Connection drops when stream goes out of scope + Ok(response) + } + + /// Sends an LP packet over a TCP stream with length-prefixed framing. /// /// Format: 4-byte big-endian u32 length + packet bytes /// + /// # Arguments + /// * `stream` - TCP stream to send on + /// * `packet` - The LP packet to send + /// * `outer_key` - Optional outer AEAD key for encryption + /// /// # Errors /// Returns an error if serialization or network transmission fails. - async fn send_packet(stream: &mut TcpStream, packet: &LpPacket) -> Result<()> { - // During handshake, outer AEAD is not used (PSK not yet established) + async fn send_packet_with_key( + stream: &mut TcpStream, + packet: &LpPacket, + outer_key: Option<&OuterAeadKey>, + ) -> Result<()> { let mut packet_buf = BytesMut::new(); - serialize_lp_packet(packet, &mut packet_buf, None) + serialize_lp_packet(packet, &mut packet_buf, outer_key) .map_err(|e| LpClientError::Transport(format!("Failed to serialize packet: {}", e)))?; // Send 4-byte length prefix (u32 big-endian) @@ -384,16 +439,23 @@ impl LpRegistrationClient { Ok(()) } - /// Receives an LP packet from the TCP stream with length-prefixed framing. + /// Receives an LP packet from a TCP stream with length-prefixed framing. /// /// Format: 4-byte big-endian u32 length + packet bytes /// + /// # Arguments + /// * `stream` - TCP stream to receive from + /// * `outer_key` - Optional outer AEAD key for decryption + /// /// # Errors /// Returns an error if: /// - Network read fails /// - Packet size exceeds maximum (64KB) - /// - Packet parsing fails - async fn receive_packet(stream: &mut TcpStream) -> Result { + /// - Packet parsing/decryption fails + async fn receive_packet_with_key( + stream: &mut TcpStream, + outer_key: Option<&OuterAeadKey>, + ) -> Result { // Read 4-byte length prefix (u32 big-endian) let mut len_buf = [0u8; 4]; stream.read_exact(&mut len_buf).await.map_err(|e| { @@ -418,19 +480,18 @@ impl LpRegistrationClient { .await .map_err(|e| LpClientError::Transport(format!("Failed to read packet data: {}", e)))?; - // During handshake, outer AEAD is not used (PSK not yet established) - let packet = parse_lp_packet(&packet_buf, None) + let packet = parse_lp_packet(&packet_buf, outer_key) .map_err(|e| LpClientError::Transport(format!("Failed to parse packet: {}", e)))?; tracing::trace!("Received LP packet ({} bytes + 4 byte header)", packet_len); Ok(packet) } - /// Sends an encrypted registration request to the gateway. + /// Sends registration request and receives response in a single operation. /// - /// This must be called after a successful handshake. The registration request - /// includes the client's WireGuard public key, bandwidth credential, and other - /// registration metadata. + /// This is the primary registration method. It acquires a bandwidth credential, + /// sends the registration request, and receives the response using the + /// packet-per-connection model. /// /// # Arguments /// * `wg_keypair` - Client's WireGuard x25519 keypair @@ -438,43 +499,27 @@ impl LpRegistrationClient { /// * `bandwidth_controller` - Provider for bandwidth credentials /// * `ticket_type` - Type of bandwidth ticket to use /// + /// # Returns + /// * `Ok(GatewayData)` - Gateway configuration data on successful registration + /// /// # Errors /// Returns an error if: - /// - No connection is established /// - Handshake has not been completed /// - Credential acquisition fails - /// - Request serialization fails - /// - Encryption or network transmission fails - /// - /// # Implementation Note (nym-80) - /// This implements the LP registration request sending: - /// 1. Acquires bandwidth credential from controller - /// 2. Constructs LpRegistrationRequest with dVPN mode - /// 3. Serializes request to bytes using bincode - /// 4. Encrypts via LP state machine (LpInput::SendData) - /// 5. Sends encrypted packet to gateway - pub async fn send_registration_request( + /// - Request serialization/encryption fails + /// - Network communication fails + /// - Gateway rejected the registration + /// - Response times out (see LpConfig::registration_timeout) + pub async fn register( &mut self, wg_keypair: &x25519::KeyPair, gateway_identity: &ed25519::PublicKey, bandwidth_controller: &dyn BandwidthTicketProvider, ticket_type: TicketType, - ) -> Result<()> { - // Ensure we have a TCP connection - let stream = self.tcp_stream.as_mut().ok_or_else(|| { - LpClientError::Transport("Cannot send registration: not connected".to_string()) - })?; - - // Ensure handshake is complete (state machine exists and is in Transport state) - let state_machine = self.state_machine.as_mut().ok_or_else(|| { - LpClientError::Transport( - "Cannot send registration: handshake not completed".to_string(), - ) - })?; - + ) -> Result { tracing::debug!("Acquiring bandwidth credential for registration"); - // 1. Get bandwidth credential from controller + // Get bandwidth credential from controller let credential = bandwidth_controller .get_ecash_ticket(ticket_type, *gateway_identity, DEFAULT_TICKETS_TO_SPEND) .await @@ -486,88 +531,46 @@ impl LpRegistrationClient { })? .data; - // 2. Build registration request - let wg_public_key = PeerPublicKey::new(wg_keypair.public_key().to_bytes().into()); - let request = - LpRegistrationRequest::new_dvpn(wg_public_key, credential, ticket_type, self.client_ip); - - tracing::trace!("Built registration request: {:?}", request); - - // 3. Serialize the request - let request_bytes = bincode::serialize(&request).map_err(|e| { - LpClientError::SendRegistrationRequest(format!("Failed to serialize request: {}", e)) - })?; - - tracing::debug!( - "Sending registration request ({} bytes)", - request_bytes.len() - ); - - // 4. Encrypt and prepare packet via state machine - let action = state_machine - .process_input(LpInput::SendData(request_bytes)) - .ok_or_else(|| { - LpClientError::Transport("State machine returned no action".to_string()) - })? - .map_err(|e| { - LpClientError::SendRegistrationRequest(format!( - "Failed to encrypt registration request: {}", - e - )) - })?; - - // 5. Send the encrypted packet - match action { - LpAction::SendPacket(packet) => { - Self::send_packet(stream, &packet).await?; - tracing::info!("Successfully sent registration request to gateway"); - Ok(()) - } - other => Err(LpClientError::Transport(format!( - "Unexpected action when sending registration data: {:?}", - other - ))), - } + self.register_with_credential(wg_keypair, credential, ticket_type) + .await } - /// Sends LP registration request with a pre-generated credential. + /// Sends registration request with a pre-generated credential. + /// /// This is useful for testing with mock ecash credentials. + /// Uses packet-per-connection model: opens connection, sends request, + /// receives response, closes connection. + /// + /// # Arguments + /// * `wg_keypair` - Client's WireGuard x25519 keypair + /// * `credential` - Pre-generated bandwidth credential + /// * `ticket_type` - Type of bandwidth ticket /// - /// This implements the LP registration request sending: - /// 1. Uses pre-provided bandwidth credential (skips acquisition) - /// 2. Constructs LpRegistrationRequest with dVPN mode - /// 3. Serializes request to bytes using bincode - /// 4. Encrypts via LP state machine (LpInput::SendData) - /// 5. Sends encrypted packet to gateway - pub async fn send_registration_request_with_credential( + /// # Returns + /// * `Ok(GatewayData)` - Gateway configuration data on successful registration + pub async fn register_with_credential( &mut self, wg_keypair: &x25519::KeyPair, - _gateway_identity: &ed25519::PublicKey, credential: CredentialSpendingData, ticket_type: TicketType, - ) -> Result<()> { - // Ensure we have a TCP connection - let stream = self.tcp_stream.as_mut().ok_or_else(|| { - LpClientError::Transport("Cannot send registration: not connected".to_string()) - })?; - - // Ensure handshake is complete (state machine exists and is in Transport state) + ) -> Result { + // Ensure handshake is complete (state machine exists) let state_machine = self.state_machine.as_mut().ok_or_else(|| { LpClientError::Transport( - "Cannot send registration: handshake not completed".to_string(), + "Cannot register: handshake not completed".to_string(), ) })?; - tracing::debug!("Using pre-generated credential for registration"); + tracing::debug!("Sending registration request (packet-per-connection)"); - // Build registration request with pre-generated credential + // 1. Build registration request let wg_public_key = PeerPublicKey::new(wg_keypair.public_key().to_bytes().into()); let request = LpRegistrationRequest::new_dvpn(wg_public_key, credential, ticket_type, self.client_ip); tracing::trace!("Built registration request: {:?}", request); - // Serialize the request + // 2. Serialize the request let request_bytes = bincode::serialize(&request).map_err(|e| { LpClientError::SendRegistrationRequest(format!("Failed to serialize request: {}", e)) })?; @@ -577,7 +580,7 @@ impl LpRegistrationClient { request_bytes.len() ); - // Encrypt and prepare packet via state machine + // 3. Encrypt and prepare packet via state machine let action = state_machine .process_input(LpInput::SendData(request_bytes)) .ok_or_else(|| { @@ -590,92 +593,45 @@ impl LpRegistrationClient { )) })?; - // Send the encrypted packet - match action { - LpAction::SendPacket(packet) => { - Self::send_packet(stream, &packet).await?; - tracing::info!("Successfully sent registration request to gateway"); - Ok(()) + let request_packet = match action { + LpAction::SendPacket(packet) => packet, + other => { + return Err(LpClientError::Transport(format!( + "Unexpected action when sending registration data: {:?}", + other + ))); } - other => Err(LpClientError::Transport(format!( - "Unexpected action when sending registration data: {:?}", - other - ))), - } - } + }; - /// Receives and processes the registration response from the gateway. - /// - /// This must be called after sending a registration request. The method: - /// 1. Receives an encrypted response packet from the gateway - /// 2. Decrypts it using the established LP session - /// 3. Deserializes the LpRegistrationResponse - /// 4. Validates the response and extracts GatewayData - /// - /// # Returns - /// * `Ok(GatewayData)` - Gateway configuration data on successful registration - /// - /// # Errors - /// Returns an error if: - /// - No connection is established - /// - Handshake has not been completed - /// - Network reception fails - /// - Decryption fails - /// - Response deserialization fails - /// - Gateway rejected the registration (success=false) - /// - Response is missing gateway_data - /// - Response times out (see LpConfig::registration_timeout) - /// - /// # Implementation Note (nym-81) - /// This implements the LP registration response processing: - /// 1. Receives length-prefixed packet from TCP stream - /// 2. Processes via state machine (LpInput::ReceivePacket) - /// 3. Extracts decrypted data from LpAction::DeliverData - /// 4. Deserializes as LpRegistrationResponse - /// 5. Validates and returns GatewayData - /// - /// Timeout applied in nym-102. - pub async fn receive_registration_response(&mut self) -> Result { - // Apply registration timeout (nym-102) - tokio::time::timeout( + // 4. Get outer key from session + let outer_key = state_machine + .session() + .ok() + .and_then(|s| s.outer_aead_key()); + + // 5. Send request and receive response on fresh connection with timeout + let response_packet = tokio::time::timeout( self.config.registration_timeout, - self.receive_registration_response_inner(), + Self::connect_send_receive( + self.gateway_lp_address, + &request_packet, + outer_key.as_ref(), + &self.config, + ), ) .await .map_err(|_| { LpClientError::ReceiveRegistrationResponse(format!( - "Registration response timeout after {:?}", + "Registration timeout after {:?}", self.config.registration_timeout )) - })? - } - - /// Internal registration response implementation without timeout. - async fn receive_registration_response_inner(&mut self) -> Result { - // Ensure we have a TCP connection - let stream = self.tcp_stream.as_mut().ok_or_else(|| { - LpClientError::Transport( - "Cannot receive registration response: not connected".to_string(), - ) - })?; - - // Ensure handshake is complete (state machine exists) - let state_machine = self.state_machine.as_mut().ok_or_else(|| { - LpClientError::Transport( - "Cannot receive registration response: handshake not completed".to_string(), - ) - })?; - - tracing::debug!("Waiting for registration response from gateway"); - - // 1. Receive the response packet - let packet = Self::receive_packet(stream).await?; + })??; tracing::trace!("Received registration response packet"); - // 2. Decrypt via state machine + // 6. Decrypt via state machine let action = state_machine - .process_input(LpInput::ReceivePacket(packet)) + .process_input(LpInput::ReceivePacket(response_packet)) .ok_or_else(|| { LpClientError::Transport("State machine returned no action".to_string()) })? @@ -686,7 +642,7 @@ impl LpRegistrationClient { )) })?; - // 3. Extract decrypted data + // 7. Extract decrypted data let response_data = match action { LpAction::DeliverData(data) => data, other => { @@ -697,7 +653,7 @@ impl LpRegistrationClient { } }; - // 4. Deserialize the response + // 8. Deserialize the response let response: LpRegistrationResponse = bincode::deserialize(&response_data).map_err(|e| { LpClientError::ReceiveRegistrationResponse(format!( @@ -711,7 +667,7 @@ impl LpRegistrationClient { response.success, ); - // 5. Validate and extract GatewayData + // 9. Validate and extract GatewayData if !response.success { let error_msg = response .error @@ -720,7 +676,6 @@ impl LpRegistrationClient { return Err(LpClientError::RegistrationRejected { reason: error_msg }); } - // Extract gateway_data let gateway_data = response.gateway_data.ok_or_else(|| { LpClientError::ReceiveRegistrationResponse( "Gateway response missing gateway_data despite success=true".to_string(), @@ -741,6 +696,9 @@ impl LpRegistrationClient { /// address, and the inner LP packet bytes, encrypts it through the outer session /// (client-entry), and receives the response from the exit gateway via the entry gateway. /// + /// Uses packet-per-connection model: opens connection, sends forward packet, + /// receives response, closes connection. + /// /// # Arguments /// * `target_identity` - Target gateway's Ed25519 identity (32 bytes) /// * `target_address` - Target gateway's LP address (e.g., "1.1.1.1:41264") @@ -751,7 +709,6 @@ impl LpRegistrationClient { /// /// # Errors /// Returns an error if: - /// - No connection is established /// - Handshake has not been completed /// - Serialization fails /// - Encryption or network transmission fails @@ -776,12 +733,7 @@ impl LpRegistrationClient { target_address: String, inner_packet_bytes: Vec, ) -> Result> { - // Ensure we have a TCP connection - let stream = self.tcp_stream.as_mut().ok_or_else(|| { - LpClientError::Transport("Cannot send forward packet: not connected".to_string()) - })?; - - // Ensure handshake is complete (state machine exists and is in Transport state) + // Ensure handshake is complete (state machine exists) let state_machine = self.state_machine.as_mut().ok_or_else(|| { LpClientError::Transport( "Cannot send forward packet: handshake not completed".to_string(), @@ -789,7 +741,7 @@ impl LpRegistrationClient { })?; tracing::debug!( - "Sending ForwardPacket to {} ({} inner bytes)", + "Sending ForwardPacket to {} ({} inner bytes, packet-per-connection)", target_address, inner_packet_bytes.len() ); @@ -821,22 +773,30 @@ impl LpRegistrationClient { LpClientError::Transport(format!("Failed to encrypt ForwardPacket: {}", e)) })?; - // 4. Send the encrypted packet - match action { - LpAction::SendPacket(packet) => { - Self::send_packet(stream, &packet).await?; - tracing::trace!("Sent encrypted ForwardPacket to entry gateway"); - } + let forward_packet = match action { + LpAction::SendPacket(packet) => packet, other => { return Err(LpClientError::Transport(format!( "Unexpected action when sending ForwardPacket: {:?}", other ))); } - } + }; + + // 4. Get outer key from session + let outer_key = state_machine + .session() + .ok() + .and_then(|s| s.outer_aead_key()); - // 5. Receive the response from entry gateway - let response_packet = Self::receive_packet(stream).await?; + // 5. Send and receive on fresh connection + let response_packet = Self::connect_send_receive( + self.gateway_lp_address, + &forward_packet, + outer_key.as_ref(), + &self.config, + ) + .await?; tracing::trace!("Received response packet from entry gateway"); // 6. Decrypt via state machine @@ -868,50 +828,6 @@ impl LpRegistrationClient { Ok(response_data.to_vec()) } - - /// Converts this client into an LpTransport for ongoing post-handshake communication. - /// - /// This consumes the client and transfers ownership of the TCP stream and state machine - /// to a new LpTransport instance, which can be used for arbitrary data transfer. - /// - /// # Returns - /// * `Ok(LpTransport)` - Transport handler for ongoing communication - /// - /// # Errors - /// Returns an error if: - /// - No connection is established - /// - Handshake has not been completed - /// - State machine is not in Transport state - /// - /// # Example - /// ```ignore - /// let mut client = LpRegistrationClient::new(...); - /// client.connect().await?; - /// client.perform_handshake().await?; - /// // After registration is complete... - /// let mut transport = client.into_transport()?; - /// transport.send_data(b"hello").await?; - /// ``` - /// - /// # Implementation Note (nym-82) - /// This enables ongoing communication after registration by transferring - /// the established LP session to a dedicated transport handler. - pub fn into_transport(self) -> Result { - // Ensure connection exists - let stream = self.tcp_stream.ok_or_else(|| { - LpClientError::Transport( - "Cannot create transport: no TCP connection established".to_string(), - ) - })?; - - // Ensure handshake completed - let state_machine = self.state_machine.ok_or_else(|| { - LpClientError::Transport("Cannot create transport: handshake not completed".to_string()) - })?; - - // Create and return transport (validates state is Transport) - LpTransport::from_handshake(stream, state_machine) - } } #[cfg(test)] @@ -929,7 +845,7 @@ mod tests { let client = LpRegistrationClient::new_with_default_psk(keypair, gateway_key, address, client_ip); - assert!(!client.is_connected()); + assert!(!client.is_handshake_complete()); assert_eq!(client.gateway_address(), address); assert_eq!(client.client_ip(), client_ip); } diff --git a/nym-registration-client/src/lp_client/mod.rs b/nym-registration-client/src/lp_client/mod.rs index 7be34c37aef..ef09389b641 100644 --- a/nym-registration-client/src/lp_client/mod.rs +++ b/nym-registration-client/src/lp_client/mod.rs @@ -8,33 +8,33 @@ //! registration while maintaining security through Noise protocol handshakes and credential //! verification. //! +//! Uses a packet-per-connection model: each LP packet exchange opens a new TCP connection, +//! sends one packet, receives one response, then closes. Session state is maintained in +//! the state machine across connections. +//! //! # Usage //! //! ```ignore //! use nym_registration_client::lp_client::LpRegistrationClient; //! -//! let client = LpRegistrationClient::new_with_default_psk( +//! let mut client = LpRegistrationClient::new_with_default_psk( //! keypair, //! gateway_public_key, //! gateway_lp_address, //! client_ip, //! ); //! -//! // Establish TCP connection -//! client.connect().await?; -//! -//! // Perform handshake (nym-79) +//! // Perform handshake (multiple packet-per-connection exchanges) //! client.perform_handshake().await?; //! -//! // Register with gateway (nym-80, nym-81) -//! let response = client.register(credential, ticket_type).await?; +//! // Register with gateway (single packet-per-connection exchange) +//! let gateway_data = client.register(wg_keypair, gateway_identity, bandwidth_controller, ticket_type).await?; //! ``` mod client; mod config; mod error; mod nested_session; -mod transport; pub use client::LpRegistrationClient; pub use config::LpConfig; diff --git a/nym-registration-client/src/lp_client/nested_session.rs b/nym-registration-client/src/lp_client/nested_session.rs index 0b7b705e72f..68f318852cd 100644 --- a/nym-registration-client/src/lp_client/nested_session.rs +++ b/nym-registration-client/src/lp_client/nested_session.rs @@ -40,18 +40,17 @@ use std::sync::Arc; /// ```ignore /// // Outer session already established with entry gateway /// let mut outer_client = LpRegistrationClient::new(...); -/// outer_client.connect().await?; /// outer_client.perform_handshake().await?; /// /// // Now establish inner session with exit gateway -/// let nested = NestedLpSession::new( +/// let mut nested = NestedLpSession::new( /// exit_identity, /// "2.2.2.2:41264".to_string(), /// client_keypair, /// exit_public_key, /// ); /// -/// let exit_session = nested.perform_handshake(&mut outer_client).await?; +/// let gateway_data = nested.handshake_and_register(&mut outer_client, ...).await?; /// ``` pub struct NestedLpSession { /// Exit gateway's Ed25519 identity (32 bytes) @@ -144,8 +143,8 @@ impl NestedLpSession { // Step 3: Send ClientHello to exit gateway via forwarding let client_hello_header = nym_lp::packet::LpHeader::new( - 0, // session_id not yet established - 0, // counter starts at 0 + nym_lp::BOOTSTRAP_RECEIVER_IDX, // Use constant for bootstrap session + 0, // counter starts at 0 ); let client_hello_packet = nym_lp::LpPacket::new( client_hello_header, @@ -176,156 +175,12 @@ impl NestedLpSession { &salt, )?; - // Step 5: Start handshake - send initial handshake packet + // Step 5: Get initial packet from StartHandshake + let mut pending_packet: Option = None; if let Some(action) = state_machine.process_input(LpInput::StartHandshake) { match action? { LpAction::SendPacket(packet) => { - tracing::trace!("Sending initial handshake packet to exit"); - // Get outer key (None before PSK derivation) - let outer_key = state_machine.session().ok().and_then(|s| s.outer_aead_key()); - let packet_bytes = Self::serialize_packet(&packet, outer_key.as_ref())?; - let response_bytes = outer_client - .send_forward_packet( - self.exit_identity, - self.exit_address.clone(), - packet_bytes, - ) - .await?; - - // Parse response and feed to state machine - let outer_key = state_machine.session().ok().and_then(|s| s.outer_aead_key()); - let response_packet = Self::parse_packet(&response_bytes, outer_key.as_ref())?; - tracing::trace!("Received handshake response from exit"); - - // Process response through state machine - if let Some(action) = - state_machine.process_input(LpInput::ReceivePacket(response_packet)) - { - match action? { - LpAction::SendPacket(response_packet) => { - // Send response packet - tracing::trace!("Sending handshake response to exit"); - let outer_key = state_machine.session().ok().and_then(|s| s.outer_aead_key()); - let packet_bytes = Self::serialize_packet(&response_packet, outer_key.as_ref())?; - let response_bytes = outer_client - .send_forward_packet( - self.exit_identity, - self.exit_address.clone(), - packet_bytes, - ) - .await?; - - // Check if handshake completed after sending - if state_machine.session()?.is_handshake_complete() { - tracing::info!( - "Nested LP handshake completed with exit gateway" - ); - self.state_machine = Some(state_machine); - return Ok(()); - } - - // Process the response from exit gateway - let outer_key = state_machine.session().ok().and_then(|s| s.outer_aead_key()); - let response_packet = Self::parse_packet(&response_bytes, outer_key.as_ref())?; - if let Some(action) = state_machine - .process_input(LpInput::ReceivePacket(response_packet)) - { - match action? { - LpAction::HandshakeComplete => { - tracing::info!( - "Nested LP handshake completed with exit gateway" - ); - self.state_machine = Some(state_machine); - return Ok(()); - } - LpAction::SendPacket(_) => { - // More rounds needed - fall through to loop - tracing::trace!("More handshake rounds needed"); - } - other => { - tracing::trace!("Action after send: {:?}", other); - } - } - } - } - LpAction::HandshakeComplete => { - tracing::info!("Nested LP handshake completed with exit gateway"); - self.state_machine = Some(state_machine); - return Ok(()); - } - LpAction::KKTComplete => { - tracing::info!("KKT exchange completed with exit, starting Noise"); - // After KKT completes, initiator must send first Noise handshake message - // PSK is now available, so outer AEAD key can be used - let noise_msg = state_machine - .session()? - .prepare_handshake_message() - .ok_or_else(|| { - LpClientError::Transport( - "No handshake message available after KKT".to_string(), - ) - })??; - let noise_packet = state_machine.session()?.next_packet(noise_msg)?; - tracing::trace!("Sending first Noise handshake message to exit"); - let outer_key = state_machine.session().ok().and_then(|s| s.outer_aead_key()); - let packet_bytes = Self::serialize_packet(&noise_packet, outer_key.as_ref())?; - let response_bytes = outer_client - .send_forward_packet( - self.exit_identity, - self.exit_address.clone(), - packet_bytes, - ) - .await?; - - // Process the Noise response from exit gateway - let outer_key = state_machine.session().ok().and_then(|s| s.outer_aead_key()); - let response_packet = Self::parse_packet(&response_bytes, outer_key.as_ref())?; - if let Some(action) = state_machine - .process_input(LpInput::ReceivePacket(response_packet)) - { - match action? { - LpAction::HandshakeComplete => { - tracing::info!( - "Nested LP handshake completed with exit gateway" - ); - self.state_machine = Some(state_machine); - return Ok(()); - } - LpAction::SendPacket(final_packet) => { - tracing::trace!("Sending final handshake packet to exit"); - let outer_key = state_machine.session().ok().and_then(|s| s.outer_aead_key()); - let packet_bytes = Self::serialize_packet(&final_packet, outer_key.as_ref())?; - let _ = outer_client - .send_forward_packet( - self.exit_identity, - self.exit_address.clone(), - packet_bytes, - ) - .await?; - - // Check if complete after sending final packet - if state_machine.session()?.is_handshake_complete() { - tracing::info!( - "Nested LP handshake completed with exit gateway" - ); - self.state_machine = Some(state_machine); - return Ok(()); - } - } - other => { - tracing::trace!( - "Action after Noise response: {:?}", - other - ); - } - } - } - } - other => { - tracing::trace!("Received action during handshake: {:?}", other); - } - } - } + pending_packet = Some(packet); } other => { return Err(LpClientError::Transport(format!( @@ -336,10 +191,75 @@ impl NestedLpSession { } } - // If we reach here, the handshake didn't complete properly - Err(LpClientError::Transport( - "Nested handshake completed without reaching HandshakeComplete state".to_string(), - )) + // Step 6: Handshake loop - each packet on new connection via forwarding + loop { + if let Some(packet) = pending_packet.take() { + tracing::trace!("Sending handshake packet to exit via forwarding"); + let response = self + .send_and_receive_via_forward(outer_client, &state_machine, &packet) + .await?; + tracing::trace!("Received handshake response from exit"); + + // Process the received packet + if let Some(action) = + state_machine.process_input(LpInput::ReceivePacket(response)) + { + match action? { + LpAction::SendPacket(response_packet) => { + pending_packet = Some(response_packet); + + // Check if handshake completed - send final packet if so + if state_machine.session()?.is_handshake_complete() { + if let Some(final_packet) = pending_packet.take() { + tracing::trace!("Sending final handshake packet to exit"); + let _ = self + .send_and_receive_via_forward( + outer_client, + &state_machine, + &final_packet, + ) + .await?; + } + tracing::info!( + "Nested LP handshake completed with exit gateway" + ); + break; + } + } + LpAction::HandshakeComplete => { + tracing::info!("Nested LP handshake completed with exit gateway"); + break; + } + LpAction::KKTComplete => { + tracing::info!("KKT exchange completed with exit, starting Noise"); + // After KKT completes, initiator must send first Noise handshake message + let noise_msg = state_machine + .session()? + .prepare_handshake_message() + .ok_or_else(|| { + LpClientError::Transport( + "No handshake message available after KKT".to_string(), + ) + })??; + let noise_packet = state_machine.session()?.next_packet(noise_msg)?; + pending_packet = Some(noise_packet); + } + other => { + tracing::trace!("Received action during handshake: {:?}", other); + } + } + } + } else { + // No pending packet and not complete - something is wrong + return Err(LpClientError::Transport( + "Nested handshake stalled: no packet to send".to_string(), + )); + } + } + + // Store the state machine (with established session) for later use + self.state_machine = Some(state_machine); + Ok(()) } /// Performs handshake and registration with the exit gateway via forwarding. @@ -519,6 +439,31 @@ impl NestedLpSession { Ok(gateway_data) } + /// Sends a packet via forwarding through the entry gateway and returns the parsed response. + /// + /// This helper consolidates the send/receive pattern used throughout the handshake: + /// 1. Gets outer AEAD key from state machine (if available) + /// 2. Serializes the packet with outer encryption + /// 3. Forwards via entry gateway + /// 4. Parses and returns the response + async fn send_and_receive_via_forward( + &self, + outer_client: &mut LpRegistrationClient, + state_machine: &LpStateMachine, + packet: &LpPacket, + ) -> Result { + let outer_key = state_machine.session().ok().and_then(|s| s.outer_aead_key()); + let packet_bytes = Self::serialize_packet(packet, outer_key.as_ref())?; + let response_bytes = outer_client + .send_forward_packet( + self.exit_identity, + self.exit_address.clone(), + packet_bytes, + ) + .await?; + Self::parse_packet(&response_bytes, outer_key.as_ref()) + } + /// Serializes an LP packet to bytes. /// /// # Arguments diff --git a/nym-registration-client/src/lp_client/transport.rs b/nym-registration-client/src/lp_client/transport.rs deleted file mode 100644 index 10de38931ba..00000000000 --- a/nym-registration-client/src/lp_client/transport.rs +++ /dev/null @@ -1,279 +0,0 @@ -// Copyright 2025 - Nym Technologies SA -// SPDX-License-Identifier: Apache-2.0 - -//! LP transport layer for handling post-handshake communication. -//! -//! The transport layer manages data flow after a successful Noise protocol handshake, -//! handling encryption, decryption, and reliable message delivery over the LP connection. - -use super::error::{LpClientError, Result}; -use bytes::BytesMut; -use nym_lp::LpPacket; -use nym_lp::codec::{parse_lp_packet, serialize_lp_packet, OuterAeadKey}; -use nym_lp::state_machine::{LpAction, LpInput, LpStateBare, LpStateMachine}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; - -/// Handles LP transport after successful handshake. -/// -/// This struct manages encrypted data transmission using an established LP session, -/// providing methods for sending and receiving arbitrary data over the secure channel. -/// -/// # Usage -/// ```ignore -/// // After handshake and registration -/// let transport = client.into_transport()?; -/// -/// // Send arbitrary data -/// transport.send_data(b"hello").await?; -/// -/// // Receive data -/// let response = transport.receive_data().await?; -/// -/// // Close when done -/// transport.close().await?; -/// ``` -pub struct LpTransport { - /// TCP stream for network I/O - stream: TcpStream, - - /// LP state machine managing encryption/decryption - state_machine: LpStateMachine, -} - -impl LpTransport { - /// Creates a new LP transport handler from an established connection. - /// - /// This should be called after a successful Noise protocol handshake. - /// The state machine must be in Transport state. - /// - /// # Arguments - /// * `stream` - The TCP stream connected to the gateway - /// * `state_machine` - The LP state machine in Transport state - /// - /// # Errors - /// Returns an error if the state machine is not in Transport state. - pub fn from_handshake(stream: TcpStream, state_machine: LpStateMachine) -> Result { - // Validate that handshake is complete - match state_machine.bare_state() { - LpStateBare::Transport => Ok(Self { - stream, - state_machine, - }), - other => Err(LpClientError::Transport(format!( - "Cannot create transport: state machine is in {:?} state, expected Transport", - other - ))), - } - } - - /// Sends arbitrary encrypted data over the LP connection. - /// - /// The data is encrypted using the established LP session and sent with - /// length-prefixed framing (4-byte big-endian u32 length + packet data). - /// - /// # Arguments - /// * `data` - The plaintext data to send - /// - /// # Errors - /// Returns an error if: - /// - Encryption fails - /// - Network transmission fails - /// - State machine returns unexpected action - pub async fn send_data(&mut self, data: &[u8]) -> Result<()> { - tracing::trace!("Sending {} bytes over LP transport", data.len()); - - // Encrypt via state machine - let action = self - .state_machine - .process_input(LpInput::SendData(data.to_vec())) - .ok_or_else(|| { - LpClientError::Transport( - "State machine returned no action for SendData".to_string(), - ) - })? - .map_err(|e| LpClientError::Transport(format!("Failed to encrypt data: {}", e)))?; - - // Extract and send packet - match action { - LpAction::SendPacket(packet) => { - self.send_packet(&packet).await?; - tracing::trace!("Successfully sent encrypted data packet"); - Ok(()) - } - other => Err(LpClientError::Transport(format!( - "Unexpected action when sending data: {:?}", - other - ))), - } - } - - /// Receives and decrypts data from the LP connection. - /// - /// Reads a length-prefixed packet, decrypts it using the LP session, - /// and returns the plaintext data. - /// - /// # Returns - /// The decrypted plaintext data as a Vec - /// - /// # Errors - /// Returns an error if: - /// - Network reception fails - /// - Packet parsing fails - /// - Decryption fails - /// - State machine returns unexpected action - pub async fn receive_data(&mut self) -> Result> { - tracing::trace!("Waiting to receive data over LP transport"); - - // Receive packet from network - let packet = self.receive_packet().await?; - - // Decrypt via state machine - let action = self - .state_machine - .process_input(LpInput::ReceivePacket(packet)) - .ok_or_else(|| { - LpClientError::Transport( - "State machine returned no action for ReceivePacket".to_string(), - ) - })? - .map_err(|e| LpClientError::Transport(format!("Failed to decrypt data: {}", e)))?; - - // Extract decrypted data - match action { - LpAction::DeliverData(data) => { - tracing::trace!("Successfully received and decrypted {} bytes", data.len()); - Ok(data.to_vec()) - } - other => Err(LpClientError::Transport(format!( - "Unexpected action when receiving data: {:?}", - other - ))), - } - } - - /// Gracefully closes the LP connection. - /// - /// Sends a close signal to the peer and shuts down the TCP stream. - /// - /// # Errors - /// Returns an error if the close operation fails. - pub async fn close(mut self) -> Result<()> { - tracing::debug!("Closing LP transport"); - - // Signal close to state machine - if let Some(action_result) = self.state_machine.process_input(LpInput::Close) { - match action_result { - Ok(LpAction::ConnectionClosed) => { - tracing::debug!("LP connection closed by state machine"); - } - Ok(other) => { - tracing::warn!("Unexpected action when closing connection: {:?}", other); - } - Err(e) => { - tracing::warn!("Error closing LP connection: {}", e); - } - } - } - - // Shutdown TCP stream - if let Err(e) = self.stream.shutdown().await { - tracing::warn!("Error shutting down TCP stream: {}", e); - } - - tracing::info!("LP transport closed"); - Ok(()) - } - - /// Checks if the transport is in a valid state for data transfer. - /// - /// Returns true if the state machine is in Transport state. - pub fn is_connected(&self) -> bool { - matches!(self.state_machine.bare_state(), LpStateBare::Transport) - } - - /// Sends an LP packet over the TCP stream with length-prefixed framing. - /// - /// Format: 4-byte big-endian u32 length + packet bytes - async fn send_packet(&mut self, packet: &LpPacket) -> Result<()> { - // Get outer_aead_key from session for AEAD encryption - let outer_key: Option = self - .state_machine - .session() - .ok() - .and_then(|s| s.outer_aead_key()); - - let mut packet_buf = BytesMut::new(); - serialize_lp_packet(packet, &mut packet_buf, outer_key.as_ref()) - .map_err(|e| LpClientError::Transport(format!("Failed to serialize packet: {}", e)))?; - - // Send 4-byte length prefix (u32 big-endian) - let len = packet_buf.len() as u32; - self.stream - .write_all(&len.to_be_bytes()) - .await - .map_err(|e| { - LpClientError::Transport(format!("Failed to send packet length: {}", e)) - })?; - - // Send the actual packet data - self.stream - .write_all(&packet_buf) - .await - .map_err(|e| LpClientError::Transport(format!("Failed to send packet data: {}", e)))?; - - // Flush to ensure data is sent immediately - self.stream - .flush() - .await - .map_err(|e| LpClientError::Transport(format!("Failed to flush stream: {}", e)))?; - - tracing::trace!( - "Sent LP packet ({} bytes + 4 byte header)", - packet_buf.len() - ); - Ok(()) - } - - /// Receives an LP packet from the TCP stream with length-prefixed framing. - /// - /// Format: 4-byte big-endian u32 length + packet bytes - async fn receive_packet(&mut self) -> Result { - // Read 4-byte length prefix (u32 big-endian) - let mut len_buf = [0u8; 4]; - self.stream.read_exact(&mut len_buf).await.map_err(|e| { - LpClientError::Transport(format!("Failed to read packet length: {}", e)) - })?; - - let packet_len = u32::from_be_bytes(len_buf) as usize; - - // Sanity check to prevent huge allocations - const MAX_PACKET_SIZE: usize = 65536; // 64KB max - if packet_len > MAX_PACKET_SIZE { - return Err(LpClientError::Transport(format!( - "Packet size {} exceeds maximum {}", - packet_len, MAX_PACKET_SIZE - ))); - } - - // Read the actual packet data - let mut packet_buf = vec![0u8; packet_len]; - self.stream - .read_exact(&mut packet_buf) - .await - .map_err(|e| LpClientError::Transport(format!("Failed to read packet data: {}", e)))?; - - // Get outer_aead_key from session for AEAD decryption - let outer_key: Option = self - .state_machine - .session() - .ok() - .and_then(|s| s.outer_aead_key()); - - let packet = parse_lp_packet(&packet_buf, outer_key.as_ref()) - .map_err(|e| LpClientError::Transport(format!("Failed to parse packet: {}", e)))?; - - tracing::trace!("Received LP packet ({} bytes + 4 byte header)", packet_len); - Ok(packet) - } -} From 09447f5a1c97ec9e475f0be5dcc70eb2707aa541 Mon Sep 17 00:00:00 2001 From: durch Date: Thu, 27 Nov 2025 13:56:55 +0100 Subject: [PATCH 10/14] Add response validation, zeroize, and replay protection - Validate Ack response in NestedLpSession and LP client final handshake - Replace manual Drop with derive(Zeroize, ZeroizeOnDrop) for OuterAeadKey - Add replay counter check before AEAD decryption to prevent DoS [nym-qm2q, nym-z82d, nym-9ik3, nym-62fs] --- Cargo.lock | 1 + common/nym-lp/Cargo.toml | 1 + common/nym-lp/src/codec.rs | 10 ++------- gateway/src/node/lp_listener/handler.rs | 22 ++++++++++++++++--- .../src/lp_client/client.rs | 18 +++++++++++++-- .../src/lp_client/nested_session.rs | 22 +++++++++++++++++-- 6 files changed, 59 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5b68782a596..171155a8341 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6613,6 +6613,7 @@ dependencies = [ "tls_codec", "tracing", "utoipa", + "zeroize", ] [[package]] diff --git a/common/nym-lp/Cargo.toml b/common/nym-lp/Cargo.toml index a8b4c447070..50fec3ac36d 100644 --- a/common/nym-lp/Cargo.toml +++ b/common/nym-lp/Cargo.toml @@ -35,6 +35,7 @@ libcrux-traits = { git = "https://github.com/cryspen/libcrux" } tls_codec = { workspace = true } num_enum = { workspace = true } chacha20poly1305 = { workspace = true } +zeroize = { workspace = true, features = ["zeroize_derive"] } [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } diff --git a/common/nym-lp/src/codec.rs b/common/nym-lp/src/codec.rs index de7684d376c..aa986596946 100644 --- a/common/nym-lp/src/codec.rs +++ b/common/nym-lp/src/codec.rs @@ -12,6 +12,7 @@ use chacha20poly1305::{ aead::{AeadInPlace, KeyInit}, ChaCha20Poly1305, Key, Nonce, Tag, }; +use zeroize::{Zeroize, ZeroizeOnDrop}; /// Outer AEAD key for LP packet encryption. /// @@ -34,7 +35,7 @@ use chacha20poly1305::{ /// 3. **No PSK persistence**: PSK handles are not stored/reused across sessions. /// Each connection performs fresh KKT+PSQ handshake. /// -#[derive(Clone)] +#[derive(Clone, Zeroize, ZeroizeOnDrop)] pub struct OuterAeadKey { key: [u8; 32], } @@ -58,13 +59,6 @@ impl OuterAeadKey { } } -impl Drop for OuterAeadKey { - fn drop(&mut self) { - // Zeroize key material on drop - self.key.iter_mut().for_each(|b| *b = 0); - } -} - impl std::fmt::Debug for OuterAeadKey { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("OuterAeadKey") diff --git a/gateway/src/node/lp_listener/handler.rs b/gateway/src/node/lp_listener/handler.rs index 76a50ea9c83..c6eabff6714 100644 --- a/gateway/src/node/lp_listener/handler.rs +++ b/gateway/src/node/lp_listener/handler.rs @@ -375,6 +375,8 @@ impl LpConnectionHandler { self.remote_addr, receiver_idx ); + let counter = packet.header().counter(); + // Get session and decrypt payload let decrypted_bytes = { let session_entry = self.state.session_states.get(&receiver_idx).ok_or_else(|| { @@ -386,10 +388,24 @@ impl LpConnectionHandler { let session = &session_entry.value().state; - // Decrypt packet - session.decrypt_data(packet.message()).map_err(|e| { + // AIDEV-NOTE: Validate counter BEFORE decryption to prevent replay DoS attacks. + // Counter is from cleartext header but authenticated by AEAD AAD, so this is safe. + session.receiving_counter_quick_check(counter).map_err(|e| { + inc!("lp_errors_replay_check"); + GatewayError::LpProtocolError(format!("Replay check failed: {}", e)) + })?; + + // Decrypt packet (Noise inner layer) + let decrypted = session.decrypt_data(packet.message()).map_err(|e| { GatewayError::LpProtocolError(format!("Failed to decrypt packet: {}", e)) - })? + })?; + + // Mark counter as received after successful decryption + session.receiving_counter_mark(counter).map_err(|e| { + GatewayError::LpProtocolError(format!("Failed to mark counter: {}", e)) + })?; + + decrypted }; // Try to deserialize as LpRegistrationRequest first (most common case after handshake) diff --git a/nym-registration-client/src/lp_client/client.rs b/nym-registration-client/src/lp_client/client.rs index 0d111fc4ace..210b7323da4 100644 --- a/nym-registration-client/src/lp_client/client.rs +++ b/nym-registration-client/src/lp_client/client.rs @@ -292,14 +292,28 @@ impl LpRegistrationClient { .ok() .and_then(|s| s.outer_aead_key()); tracing::trace!("Sending final handshake packet"); - // Final packet - we don't need the response - let _ = Self::connect_send_receive( + let ack_response = Self::connect_send_receive( self.gateway_lp_address, &final_packet, outer_key.as_ref(), &self.config, ) .await?; + + // Validate Ack response + match ack_response.message() { + nym_lp::LpMessage::Ack => { + tracing::debug!( + "Received Ack for final handshake packet" + ); + } + other => { + return Err(LpClientError::Transport(format!( + "Expected Ack for final handshake packet, got: {:?}", + other + ))); + } + } } tracing::info!("LP handshake completed after sending final packet"); break; diff --git a/nym-registration-client/src/lp_client/nested_session.rs b/nym-registration-client/src/lp_client/nested_session.rs index 68f318852cd..5f462bd0ae5 100644 --- a/nym-registration-client/src/lp_client/nested_session.rs +++ b/nym-registration-client/src/lp_client/nested_session.rs @@ -153,7 +153,7 @@ impl NestedLpSession { // Serialize and forward ClientHello (no state machine yet, no outer key) let client_hello_bytes = Self::serialize_packet(&client_hello_packet, None)?; - let _response_bytes = outer_client + let response_bytes = outer_client .send_forward_packet( self.exit_identity, self.exit_address.clone(), @@ -161,7 +161,25 @@ impl NestedLpSession { ) .await?; - tracing::debug!("Sent ClientHello to exit gateway via entry"); + // Parse and validate Ack response (cleartext, no outer key before PSK derivation) + let ack_response = Self::parse_packet(&response_bytes, None)?; + match ack_response.message() { + LpMessage::Ack => { + tracing::debug!("Received Ack for ClientHello from exit gateway"); + } + LpMessage::Collision => { + return Err(LpClientError::Transport(format!( + "Exit gateway returned Collision - receiver_index {} already in use", + receiver_index + ))); + } + other => { + return Err(LpClientError::Transport(format!( + "Expected Ack for ClientHello from exit gateway, got: {:?}", + other + ))); + } + } // Step 4: Create state machine for exit gateway handshake let mut state_machine = LpStateMachine::new( From b05113e5220ef1f9b8d70f82dff680f0d76485c9 Mon Sep 17 00:00:00 2001 From: durch Date: Fri, 28 Nov 2025 12:09:49 +0100 Subject: [PATCH 11/14] Implement unified packet format with outer header first Restructure LP packet format so cleartext fields (receiver_idx, counter) are always first, enabling trivial header parsing for routing before session lookup. Protocol version and reserved fields are now encrypted in the inner payload for encrypted packets. Wire format change: - Before: proto(1B) + reserved(3B) + receiver_idx(4B) + counter(8B) - After: receiver_idx(4B) + counter(8B) | proto(1B) + reserved(3B) + ... Key changes: - Add OuterHeader struct (12 bytes) for routing/replay protection - Update serialize_lp_packet/parse_lp_packet for unified format - parse_lp_header_only now returns OuterHeader - Gateway handler uses OuterHeader for session lookup - Update DESIGN.md with new wire format diagrams Security improvement: Only receiver_idx and counter visible after PSK establishment (was also exposing protocol version and reserved). --- common/nym-lp/DESIGN.md | 94 ++++++---- common/nym-lp/src/codec.rs | 217 ++++++++++++++---------- common/nym-lp/src/lib.rs | 2 +- common/nym-lp/src/packet.rs | 55 +++++- gateway/src/node/lp_listener/handler.rs | 14 +- 5 files changed, 259 insertions(+), 123 deletions(-) diff --git a/common/nym-lp/DESIGN.md b/common/nym-lp/DESIGN.md index 4723a64b109..095a87c0340 100644 --- a/common/nym-lp/DESIGN.md +++ b/common/nym-lp/DESIGN.md @@ -11,25 +11,41 @@ The Lewes Protocol (LP) provides authenticated, encrypted sessions with replay p ## Packet Structure +### Unified Format (v2) + +All packets share the same outer structure - cleartext fields are always first: + ``` -┌─────────┬──────────┬────────────────┬─────────┬─────────────────────┬─────────┐ -│ version │ reserved │ receiver_index │ counter │ payload │ trailer │ -│ 1B │ 3B │ 4B │ 8B │ variable │ 16B │ -└─────────┴──────────┴────────────────┴─────────┴─────────────────────┴─────────┘ - 16B header 16B +┌────────────────┬─────────┬─────────┬──────────┬─────────────────────┬─────────┐ +│ receiver_index │ counter │ version │ reserved │ payload │ trailer │ +│ 4B │ 8B │ 1B │ 3B │ variable │ 16B │ +└────────────────┴─────────┴─────────┴──────────┴─────────────────────┴─────────┘ +│←── 12B outer header ────┤│←── inner (cleartext or encrypted) ──────┤│─ 16B ──┤ ``` -**Total overhead:** 32 bytes (16B header + 16B trailer) +**Total overhead:** 32 bytes (12B outer + 4B inner prefix + 16B trailer) + +Key properties: +- **Outer header** (12 bytes): Always cleartext, used for routing before session lookup +- **Inner content**: Cleartext before PSK, encrypted after PSK +- **No disambiguation needed**: Format is identical for both modes ### Field Descriptions +**Outer Header** (always cleartext, 12 bytes): + +| Field | Size | Description | +|-------|------|-------------| +| receiver_index | 4 bytes | Session identifier, proposed by client (routing key) | +| counter | 8 bytes | Monotonic counter, used as AEAD nonce and for replay protection | + +**Inner Content** (cleartext or encrypted): + | Field | Size | Description | |-------|------|-------------| | version | 1 byte | Protocol version | | reserved | 3 bytes | Reserved for future use | -| receiver_index | 4 bytes | Session identifier, proposed by client | -| counter | 8 bytes | Monotonic counter, used as AEAD nonce and for replay protection | -| payload | variable | Message type (2B) + content (plaintext or encrypted depending on state) | +| payload | variable | Message type (2B) + content | | trailer | 16 bytes | Zeros (no PSK) or AEAD Poly1305 tag (with PSK) | ### Wire Format @@ -54,10 +70,16 @@ Length-prefixed over TCP: | KKTResponse | 0x0005 | KEM key transfer response | | ForwardPacket | 0x0006 | Nested session forwarding | | Collision | 0x0007 | Receiver index collision | -| SubsessionRequest | 0x0008 | Client requests new subsession | -| SubsessionKK1 | 0x0009 | KK handshake msg 1 (responder → initiator) | -| SubsessionKK2 | 0x000A | KK handshake msg 2 (initiator → responder) | -| SubsessionReady | 0x000B | Subsession established confirmation | +| Ack | 0x0008 | Gateway confirms receipt of message | + +### Planned Message Types (not yet implemented) + +| Type | Value | Description | +|------|-------|-------------| +| SubsessionRequest | 0x0009 | Client requests new subsession | +| SubsessionKK1 | 0x000A | KK handshake msg 1 (responder → initiator) | +| SubsessionKK2 | 0x000B | KK handshake msg 2 (initiator → responder) | +| SubsessionReady | 0x000C | Subsession established confirmation | ## Receiver Index @@ -91,6 +113,7 @@ As soon as PSK is derived (after processing Noise msg 1 with PSQ), all subsequen | Packet | PSK Available | Header | Payload | Trailer | |--------|---------------|--------|---------|---------| | ClientHello | No | Clear | Clear | Zeros | +| Ack | No | Clear | Clear | Zeros | | KKTRequest | No | Clear | Clear | Zeros | | KKTResponse | No | Clear | Clear | Zeros | | Noise msg 1 | No | Clear | Clear | Zeros | @@ -104,38 +127,44 @@ As soon as PSK is derived (after processing Noise msg 1 with PSQ), all subsequen - **AEAD**: ChaCha20-Poly1305 - **Key**: outer_key = KDF(PSK, "lp-outer-aead") - derived from PSK, not PSK itself - **Nonce**: counter (8 bytes, zero-padded to 12 bytes) -- **AAD**: version ‖ reserved ‖ receiver_index ‖ counter (16 bytes) +- **AAD**: receiver_index ‖ counter (12 bytes) - the outer header +- **Encrypted**: version ‖ reserved ‖ message_type ‖ content Note: PSK is used as-is for Noise (which does internal key derivation). The outer_key derivation avoids key reuse between the two encryption layers. ### Before PSK ``` -┌─────────┬──────────┬────────────────┬─────────┬─────────────────────┬─────────┐ -│ version │ reserved │ receiver_index │ counter │ payload │ 00...00 │ -│ │ │ │ │ (plaintext) │ │ -└─────────┴──────────┴────────────────┴─────────┴─────────────────────┴─────────┘ -│←──────────────────────────── cleartext ──────────────────────────────────────┤ +┌────────────────┬─────────┬─────────┬──────────┬─────────────────────┬─────────┐ +│ receiver_index │ counter │ version │ reserved │ payload │ 00...00 │ +│ │ │ │ │ (plaintext) │ │ +└────────────────┴─────────┴─────────┴──────────┴─────────────────────┴─────────┘ +│←── 12B outer ──────────┤│←────────────── cleartext inner ──────────┤│─zeros──┤ ``` ### After PSK ``` -┌─────────┬──────────┬────────────────┬─────────┬─────────────────────┬─────────┐ -│ version │ reserved │ receiver_index │ counter │ payload │ tag │ -│ │ │ │ │ (encrypted) │ │ -└─────────┴──────────┴────────────────┴─────────┴─────────────────────┴─────────┘ -│←───────── cleartext (authenticated via AAD) ─────────┤│← encrypted ─┤│─ auth ─┤ +┌────────────────┬─────────┬─────────┬──────────┬─────────────────────┬─────────┐ +│ receiver_index │ counter │ version │ reserved │ payload │ tag │ +│ │ │ (enc) │ (enc) │ (encrypted) │ │ +└────────────────┴─────────┴─────────┴──────────┴─────────────────────┴─────────┘ +│←── 12B outer (AAD) ────┤│←────────── encrypted inner ──────────────┤│─ tag ──┤ ``` ## Handshake Flow +Each arrow represents a separate TCP connection (packet-per-connection model). + ``` Client Gateway │ │ │ [hdr][ClientHello][zeros] │ │──────────────────────────────────────►│ store state[receiver_index] │ │ + │ [hdr][Ack][zeros] │ + │◄──────────────────────────────────────│ confirm ClientHello + │ │ │ [hdr][KKTRequest][zeros] │ │──────────────────────────────────────►│ │ │ @@ -299,18 +328,23 @@ SubsessionReadyData { ### Always Visible to Observer -- Version (1 byte) -- Reserved (3 bytes) +Only the outer header (12 bytes) is visible after PSK establishment: + - Receiver index (4 bytes) - opaque, unlinkable to identity - Counter (8 bytes) - reveals packet ordering - Packet size +Note: Before PSK, version, reserved, and message type are also visible. + ### Protected After PSK -- Header integrity (authenticated via AEAD AAD) -- Payload confidentiality (encrypted) -- Message type (hidden) -- Application data (double encrypted) +- Outer header integrity (authenticated via AEAD AAD) +- Inner content confidentiality (encrypted): + - Protocol version + - Reserved field + - Message type + - Payload +- Application data (double encrypted: outer AEAD + inner Noise) ### Cryptographic Guarantees diff --git a/common/nym-lp/src/codec.rs b/common/nym-lp/src/codec.rs index aa986596946..a4e4100e30b 100644 --- a/common/nym-lp/src/codec.rs +++ b/common/nym-lp/src/codec.rs @@ -6,8 +6,14 @@ use crate::message::{ ClientHelloData, EncryptedDataPayload, ForwardPacketData, HandshakeData, KKTRequestData, KKTResponseData, LpMessage, MessageType, }; -use crate::packet::{LpHeader, LpPacket, TRAILER_LEN}; +use crate::packet::{LpHeader, LpPacket, OuterHeader, TRAILER_LEN}; use bytes::{BufMut, BytesMut}; + +/// Size of outer header (receiver_idx + counter) - always cleartext +pub const OUTER_HEADER_SIZE: usize = OuterHeader::SIZE; // 12 bytes + +/// Size of inner prefix (proto + reserved) - cleartext or encrypted depending on mode +const INNER_PREFIX_SIZE: usize = 4; // proto(1) + reserved(3) use chacha20poly1305::{ aead::{AeadInPlace, KeyInit}, ChaCha20Poly1305, Key, Nonce, Tag, @@ -135,28 +141,28 @@ fn parse_message_from_type_and_content( } } -/// Parse only the LP header from raw packet bytes. +/// Parse only the outer header from raw packet bytes. /// -/// Used for routing before session lookup when the header is always cleartext. -/// This allows the caller to determine the receiver_idx and look up the appropriate -/// session to get the outer AEAD key before calling `parse_lp_packet()`. +/// Used for routing before session lookup. The outer header (receiver_idx + counter) +/// is always cleartext at bytes 0-12 in the unified packet format. /// /// # Arguments -/// * `src` - Raw packet bytes (at least LpHeader::SIZE bytes) +/// * `src` - Raw packet bytes (at least OuterHeader::SIZE bytes) /// /// # Errors -/// * `LpError::InsufficientBufferSize` - Packet too small for header -pub fn parse_lp_header_only(src: &[u8]) -> Result { - if src.len() < LpHeader::SIZE { - return Err(LpError::InsufficientBufferSize); - } - LpHeader::parse(&src[..LpHeader::SIZE]) +/// * `LpError::InsufficientBufferSize` - Packet too small for outer header +pub fn parse_lp_header_only(src: &[u8]) -> Result { + OuterHeader::parse(src) } /// Parses a complete Lewes Protocol packet from a byte slice (e.g., a UDP datagram payload). /// -/// Assumes the input `src` contains exactly one complete packet. It does not handle -/// stream fragmentation or provide replay protection checks (these belong at the session level). +/// ## Unified Packet Format +/// +/// Both cleartext and encrypted packets have the same structure: +/// - Outer header (12B): receiver_idx(4) + counter(8) - always cleartext +/// - Inner payload: proto(1) + reserved(3) + msg_type(2) + content - cleartext or encrypted +/// - Trailer (16B): zeros (cleartext) or AEAD tag (encrypted) /// /// # Arguments /// * `src` - Raw packet bytes @@ -169,40 +175,61 @@ pub fn parse_lp_packet( src: &[u8], outer_key: Option<&OuterAeadKey>, ) -> Result { - // Minimum size check: LpHeader + Type + Trailer (for 0-payload message) - let min_size = LpHeader::SIZE + 2 + TRAILER_LEN; + // Minimum size check: OuterHeader + InnerPrefix + MsgType + Trailer (for 0-payload message) + // 12 + 4 + 2 + 16 = 34 bytes + let min_size = OUTER_HEADER_SIZE + INNER_PREFIX_SIZE + 2 + TRAILER_LEN; if src.len() < min_size { return Err(LpError::InsufficientBufferSize); } - // Parse LpHeader (always cleartext for routing) - let header = LpHeader::parse(&src[..LpHeader::SIZE])?; + // Parse outer header (always cleartext at bytes 0-12) + let outer_header = OuterHeader::parse(src)?; // Extract trailer (potential AEAD tag) let trailer_start = src.len() - TRAILER_LEN; let mut trailer = [0u8; TRAILER_LEN]; trailer.copy_from_slice(&src[trailer_start..]); - // Payload is everything between header and trailer - let payload_bytes = &src[LpHeader::SIZE..trailer_start]; + // Inner payload is everything between outer header and trailer + let inner_bytes = &src[OUTER_HEADER_SIZE..trailer_start]; // Handle decryption if outer key provided - let (message_type_raw, message_content) = match outer_key { + match outer_key { None => { - // Cleartext mode - parse directly - if payload_bytes.len() < 2 { + // Cleartext mode - parse inner directly + // Inner format: proto(1) + reserved(3) + msg_type(2) + content + if inner_bytes.len() < INNER_PREFIX_SIZE + 2 { return Err(LpError::InsufficientBufferSize); } - let msg_type = u16::from_le_bytes([payload_bytes[0], payload_bytes[1]]); - (msg_type, &payload_bytes[2..]) + + let protocol_version = inner_bytes[0]; + // reserved bytes [1..4] are ignored + let msg_type = u16::from_le_bytes([inner_bytes[4], inner_bytes[5]]); + let message_content = &inner_bytes[6..]; + + let header = LpHeader { + protocol_version, + reserved: 0, + receiver_idx: outer_header.receiver_idx, + counter: outer_header.counter, + }; + + let message = parse_message_from_type_and_content(msg_type, message_content)?; + + Ok(LpPacket { + header, + message, + trailer, + }) } Some(key) => { // AEAD decryption mode - let nonce = build_nonce(header.counter); - let aad = &src[..LpHeader::SIZE]; // Header as AAD + // AAD is the outer header (12 bytes) + let nonce = build_nonce(outer_header.counter); + let aad = &src[..OUTER_HEADER_SIZE]; - // Copy payload for in-place decryption - let mut decrypted = payload_bytes.to_vec(); + // Copy inner payload for in-place decryption + let mut decrypted = inner_bytes.to_vec(); // Convert trailer to Tag let tag = Tag::from_slice(&trailer); @@ -213,67 +240,85 @@ pub fn parse_lp_packet( .decrypt_in_place_detached(Nonce::from_slice(&nonce), aad, &mut decrypted, tag) .map_err(|_| LpError::AeadTagMismatch)?; - // Extract message type from decrypted payload - if decrypted.len() < 2 { + // Decrypted format: proto(1) + reserved(3) + msg_type(2) + content + if decrypted.len() < INNER_PREFIX_SIZE + 2 { return Err(LpError::InsufficientBufferSize); } - let msg_type = u16::from_le_bytes([decrypted[0], decrypted[1]]); - - // Return decrypted content (owned, so we handle it differently) - return parse_message_from_type_and_content(msg_type, &decrypted[2..]).map(|message| { - LpPacket { - header, - message, - trailer, - } - }); - } - }; - // Cleartext path: parse message from payload - let message = parse_message_from_type_and_content(message_type_raw, message_content)?; + let protocol_version = decrypted[0]; + // reserved bytes [1..4] are ignored + let msg_type = u16::from_le_bytes([decrypted[4], decrypted[5]]); + let message_content = &decrypted[6..]; + + let header = LpHeader { + protocol_version, + reserved: 0, + receiver_idx: outer_header.receiver_idx, + counter: outer_header.counter, + }; + + let message = parse_message_from_type_and_content(msg_type, message_content)?; - Ok(LpPacket { - header, - message, - trailer, - }) + Ok(LpPacket { + header, + message, + trailer, + }) + } + } } /// Serializes an LpPacket into the provided BytesMut buffer. /// +/// ## Unified Packet Format +/// +/// Both cleartext and encrypted packets have the same structure: +/// - Outer header (12B): receiver_idx(4) + counter(8) - always cleartext +/// - Inner payload: proto(1) + reserved(3) + msg_type(2) + content - cleartext or encrypted +/// - Trailer (16B): zeros (cleartext) or AEAD tag (encrypted) +/// /// # Arguments /// * `item` - Packet to serialize /// * `dst` - Output buffer -/// * `outer_key` - None for cleartext (uses packet's trailer), Some for AEAD encryption -/// -/// When `outer_key` is provided: -/// - Header is written in cleartext (used as AAD) -/// - Message type + content is encrypted -/// - Trailer is set to the AEAD tag +/// * `outer_key` - None for cleartext, Some for AEAD encryption pub fn serialize_lp_packet( item: &LpPacket, dst: &mut BytesMut, outer_key: Option<&OuterAeadKey>, ) -> Result<(), LpError> { + // Total size: outer_header(12) + inner_prefix(4) + msg_type(2) + content + trailer(16) + let total_size = OUTER_HEADER_SIZE + INNER_PREFIX_SIZE + 2 + item.message.len() + TRAILER_LEN; + dst.reserve(total_size); + + // 1. Write outer header (always cleartext) - 12 bytes + let outer_header = OuterHeader::new(item.header.receiver_idx, item.header.counter); + outer_header.encode_into(dst); + match outer_key { None => { - // Cleartext mode - use existing encode method - dst.reserve(LpHeader::SIZE + 2 + item.message.len() + TRAILER_LEN); - item.encode(dst); + // Cleartext mode + // 2. Write inner prefix: proto(1) + reserved(3) + dst.put_u8(item.header.protocol_version); + dst.put_slice(&[0, 0, 0]); // reserved + + // 3. Write message type (2B) + content + dst.put_slice(&(item.message.typ() as u16).to_le_bytes()); + item.message.encode_content(dst); + + // 4. Write zeros trailer + dst.put_slice(&[0u8; TRAILER_LEN]); + Ok(()) } Some(key) => { // AEAD encryption mode - dst.reserve(LpHeader::SIZE + 2 + item.message.len() + TRAILER_LEN); + // AAD is the outer header (first 12 bytes) + let aad = outer_header.encode(); - // 1. Encode header (AAD - not encrypted) - let header_start = dst.len(); - item.header.encode(dst); - let header_end = dst.len(); - - // 2. Build plaintext: message_type (2B) + content + // 2. Build plaintext: proto(1) + reserved(3) + msg_type(2) + content let mut plaintext = BytesMut::new(); + plaintext.put_u8(item.header.protocol_version); + plaintext.put_slice(&[0, 0, 0]); // reserved plaintext.put_slice(&(item.message.typ() as u16).to_le_bytes()); item.message.encode_content(&mut plaintext); @@ -281,16 +326,15 @@ pub fn serialize_lp_packet( let payload_start = dst.len(); dst.put_slice(&plaintext); - // 4. Build nonce and get AAD + // 4. Build nonce from counter let nonce = build_nonce(item.header.counter); - let aad = &dst[header_start..header_end].to_vec(); // Copy AAD since we mutate dst // 5. Encrypt payload in-place let cipher = ChaCha20Poly1305::new(Key::from_slice(key.as_bytes())); let tag = cipher .encrypt_in_place_detached( Nonce::from_slice(&nonce), - aad, + &aad, &mut dst[payload_start..], ) .map_err(|_| LpError::Internal("AEAD encryption failed".to_string()))?; @@ -320,8 +364,9 @@ mod tests { use crate::packet::{LpHeader, LpPacket, TRAILER_LEN}; use bytes::BytesMut; - // Header length: version(1) + reserved(3) + receiver_index(4) + counter(8) = 16 bytes - const HEADER_LEN: usize = 16; + // AIDEV-NOTE: With unified format, outer header (receiver_idx + counter) is always first + // and is the only cleartext portion for encrypted packets + const OUTER_HDR: usize = super::OUTER_HEADER_SIZE; // 12 bytes // === Cleartext Encode/Decode Tests === @@ -483,17 +528,18 @@ mod tests { // This *should* parse successfully based on the logic, but the trailer is garbage. // Let's rethink: parse_lp_packet assumes the *entire slice* is the packet. // If the slice doesn't end exactly where the trailer should, it's an error. - // In this case, total length is 58. LpHeader(16) + Type(2) + Trailer(16) = 34. Payload = 58-34=24. + // In this case, total length is 58. OuterHdr(12) + InnerPrefix(4) + Type(2) + Trailer(16) = 34. Payload = 58-34=24. // Trailer starts at 16+2+24 = 42. Ends at 42+16=58. It fits exactly. // This test *still* doesn't test incompleteness correctly for the datagram parser. // Let's test a buffer that's *too short* even for header+type+trailer+min_payload + // Note: Buffer order doesn't matter for this test since we fail on minimum size check let mut buf_too_short = BytesMut::new(); - buf_too_short.extend_from_slice(&[1, 0, 0, 0]); // Version + reserved - buf_too_short.extend_from_slice(&42u32.to_le_bytes()); // Sender index - buf_too_short.extend_from_slice(&123u64.to_le_bytes()); // Counter - buf_too_short.extend_from_slice(&MessageType::Handshake.to_u16().to_le_bytes()); // Handshake type - // No payload, no trailer. Length = 16+2=18. Min size = 34. + buf_too_short.extend_from_slice(&42u32.to_le_bytes()); // receiver_idx (outer header) + buf_too_short.extend_from_slice(&123u64.to_le_bytes()); // counter (outer header) + buf_too_short.extend_from_slice(&[1, 0, 0, 0]); // version + reserved (inner prefix) + buf_too_short.extend_from_slice(&MessageType::Handshake.to_u16().to_le_bytes()); // msg type + // No payload, no trailer. Length = 12+4+2=18. Min size = 34. let result_too_short = parse_lp_packet(&buf_too_short, None); assert!(result_too_short.is_err()); assert!(matches!( @@ -873,11 +919,11 @@ mod tests { let mut encrypted = BytesMut::new(); serialize_lp_packet(&packet, &mut encrypted, Some(&outer_key)).unwrap(); - // Header should be the same (it's authenticated but not encrypted) - assert_eq!(&cleartext[..HEADER_LEN], &encrypted[..HEADER_LEN]); + // Outer header (receiver_idx + counter) should be the same - always cleartext + assert_eq!(&cleartext[..OUTER_HDR], &encrypted[..OUTER_HDR]); - // Payload should differ (it's encrypted) - let payload_start = HEADER_LEN; + // Inner payload (proto + reserved + msg_type + content) should differ (encrypted) + let payload_start = OUTER_HDR; let payload_end_cleartext = cleartext.len() - TRAILER_LEN; let payload_end_encrypted = encrypted.len() - TRAILER_LEN; @@ -944,7 +990,8 @@ mod tests { let mut encrypted = BytesMut::new(); serialize_lp_packet(&packet, &mut encrypted, Some(&outer_key)).unwrap(); - // Tamper with the header (flip a bit in receiver_idx) + // Tamper with the outer header AAD (flip a bit in counter at byte 4) + // New format: [receiver_idx(0-3), counter(4-11)], so byte 4 is counter's LSB encrypted[4] ^= 0x01; // Parsing should fail with AeadTagMismatch @@ -986,9 +1033,9 @@ mod tests { let mut encrypted2 = BytesMut::new(); serialize_lp_packet(&packet2, &mut encrypted2, Some(&outer_key)).unwrap(); - // The encrypted payloads should differ even though the message is the same - // (because nonce is different) - let payload_start = HEADER_LEN; + // The encrypted inner payloads should differ even though the message is the same + // (because nonce is different). Inner payload starts after outer header. + let payload_start = OUTER_HDR; assert_ne!( &encrypted1[payload_start..], &encrypted2[payload_start..], diff --git a/common/nym-lp/src/lib.rs b/common/nym-lp/src/lib.rs index 33edd7fef32..89bd4e72f44 100644 --- a/common/nym-lp/src/lib.rs +++ b/common/nym-lp/src/lib.rs @@ -16,7 +16,7 @@ pub mod session_manager; pub use error::LpError; pub use message::{ClientHelloData, LpMessage}; -pub use packet::{LpPacket, BOOTSTRAP_RECEIVER_IDX}; +pub use packet::{LpPacket, OuterHeader, BOOTSTRAP_RECEIVER_IDX}; pub use replay::{ReceivingKeyCounterValidator, ReplayError}; pub use session::{LpSession, generate_fresh_salt}; pub use session_manager::SessionManager; diff --git a/common/nym-lp/src/packet.rs b/common/nym-lp/src/packet.rs index c98353c8671..0bcc5fe7e00 100644 --- a/common/nym-lp/src/packet.rs +++ b/common/nym-lp/src/packet.rs @@ -130,7 +130,60 @@ impl LpPacket { /// the same session ID from their keys, and all subsequent packets use that ID. pub const BOOTSTRAP_RECEIVER_IDX: u32 = 0; -// VERSION [1B] || RESERVED [3B] || SENDER_INDEX [4B] || COUNTER [8B] +/// Outer header (12 bytes) - always cleartext, used for routing. +/// +/// This is the first 12 bytes of every LP packet, containing only the fields +/// needed for session lookup (receiver_idx) and replay protection (counter). +/// For encrypted packets, this is the AAD (additional authenticated data). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OuterHeader { + pub receiver_idx: u32, + pub counter: u64, +} + +impl OuterHeader { + pub const SIZE: usize = 12; // receiver_idx(4) + counter(8) + + pub fn new(receiver_idx: u32, counter: u64) -> Self { + Self { + receiver_idx, + counter, + } + } + + pub fn parse(src: &[u8]) -> Result { + if src.len() < Self::SIZE { + return Err(LpError::InsufficientBufferSize); + } + Ok(Self { + receiver_idx: u32::from_le_bytes(src[0..4].try_into().unwrap()), + counter: u64::from_le_bytes(src[4..12].try_into().unwrap()), + }) + } + + pub fn encode(&self) -> [u8; Self::SIZE] { + let mut buf = [0u8; Self::SIZE]; + buf[0..4].copy_from_slice(&self.receiver_idx.to_le_bytes()); + buf[4..12].copy_from_slice(&self.counter.to_le_bytes()); + buf + } + + /// Encode directly into a BytesMut buffer + pub fn encode_into(&self, dst: &mut BytesMut) { + dst.put_slice(&self.receiver_idx.to_le_bytes()); + dst.put_slice(&self.counter.to_le_bytes()); + } +} + +/// Internal LP header representation containing all logical header fields. +/// +/// **Note**: This struct represents the LOGICAL header, not the wire format. +/// On the wire, packets use the unified format where: +/// - `OuterHeader` (receiver_idx + counter) always comes first (12 bytes, cleartext) +/// - Inner content (version + reserved + payload) follows (cleartext or encrypted) +/// +/// The `LpHeader::encode()` method outputs the old logical format for debug purposes only. +/// Use `serialize_lp_packet()` in codec.rs for actual wire serialization. #[derive(Debug, Clone)] pub struct LpHeader { pub protocol_version: u8, diff --git a/gateway/src/node/lp_listener/handler.rs b/gateway/src/node/lp_listener/handler.rs index c6eabff6714..e0c70df3689 100644 --- a/gateway/src/node/lp_listener/handler.rs +++ b/gateway/src/node/lp_listener/handler.rs @@ -6,7 +6,7 @@ use super::registration::process_registration; use super::LpHandlerState; use crate::error::GatewayError; use nym_lp::{ - codec::OuterAeadKey, keypair::PublicKey, message::ForwardPacketData, packet::LpHeader, + codec::OuterAeadKey, keypair::PublicKey, message::ForwardPacketData, OuterHeader, LpMessage, LpPacket, }; use nym_metrics::{add_histogram_obs, inc}; @@ -775,11 +775,12 @@ impl LpConnectionHandler { Ok(response_buf) } - /// Receive raw packet bytes and parse header only (for routing before session lookup). + /// Receive raw packet bytes and parse outer header only (for routing before session lookup). /// - /// Returns the raw packet bytes and parsed header. The caller should look up - /// the session to get outer_aead_key, then call `parse_lp_packet()` with the key. - async fn receive_raw_packet(&mut self) -> Result<(Vec, LpHeader), GatewayError> { + /// Returns the raw packet bytes and parsed outer header (receiver_idx + counter). + /// The caller should look up the session to get outer_aead_key, then call + /// `parse_lp_packet()` with the key. + async fn receive_raw_packet(&mut self) -> Result<(Vec, OuterHeader), GatewayError> { use nym_lp::codec::parse_lp_header_only; // Read 4-byte length prefix (u32 big-endian) @@ -1096,9 +1097,10 @@ mod tests { .unwrap(); // Handler should receive and parse it correctly + // Note: header is OuterHeader (receiver_idx + counter only), not LpHeader let (header, received) = server_task.await.unwrap().unwrap(); - assert_eq!(header.protocol_version, 1); assert_eq!(header.receiver_idx, 42); + assert_eq!(header.counter, 0); assert_eq!(received.header().protocol_version, 1); assert_eq!(received.header().receiver_idx, 42); assert_eq!(received.header().counter, 0); From 0146bbfbb9659f5980920634dcd50e406a940846 Mon Sep 17 00:00:00 2001 From: durch Date: Sun, 30 Nov 2025 21:23:18 +0100 Subject: [PATCH 12/14] Add subsession support with KKpsk0 rekeying and race resolution - Add subsession message types: SubsessionKK1, KK2, Ready, Request, Abort - Implement SubsessionHandshake for Noise KKpsk0 tunneled through parent - Add subsession PSK derivation from parent's PQ shared secret - Handle simultaneous initiation with X25519 key comparison tie-breaker - Add stale SubsessionAbort handler for message interleaving scenarios - Add test for simultaneous subsession initiation race condition Subsessions provide forward secrecy via periodic rekeying while inheriting PQ protection from the parent session's ML-KEM shared secret. --- common/nym-lp/src/codec.rs | 215 ++++++- common/nym-lp/src/message.rs | 85 +++ common/nym-lp/src/noise_protocol.rs | 3 + common/nym-lp/src/psk.rs | 100 +++- common/nym-lp/src/session.rs | 450 +++++++++++++- common/nym-lp/src/state_machine.rs | 757 +++++++++++++++++++++++- gateway/src/node/lp_listener/handler.rs | 2 +- 7 files changed, 1562 insertions(+), 50 deletions(-) diff --git a/common/nym-lp/src/codec.rs b/common/nym-lp/src/codec.rs index a4e4100e30b..29af2fc788c 100644 --- a/common/nym-lp/src/codec.rs +++ b/common/nym-lp/src/codec.rs @@ -4,7 +4,8 @@ use crate::LpError; use crate::message::{ ClientHelloData, EncryptedDataPayload, ForwardPacketData, HandshakeData, KKTRequestData, - KKTResponseData, LpMessage, MessageType, + KKTResponseData, LpMessage, MessageType, SubsessionKK1Data, SubsessionKK2Data, + SubsessionReadyData, }; use crate::packet::{LpHeader, LpPacket, OuterHeader, TRAILER_LEN}; use bytes::{BufMut, BytesMut}; @@ -138,6 +139,39 @@ fn parse_message_from_type_and_content( } Ok(LpMessage::Ack) } + MessageType::SubsessionRequest => { + if !content.is_empty() { + return Err(LpError::InvalidPayloadSize { + expected: 0, + actual: content.len(), + }); + } + Ok(LpMessage::SubsessionRequest) + } + MessageType::SubsessionKK1 => { + let data: SubsessionKK1Data = bincode::deserialize(content) + .map_err(|e| LpError::DeserializationError(e.to_string()))?; + Ok(LpMessage::SubsessionKK1(data)) + } + MessageType::SubsessionKK2 => { + let data: SubsessionKK2Data = bincode::deserialize(content) + .map_err(|e| LpError::DeserializationError(e.to_string()))?; + Ok(LpMessage::SubsessionKK2(data)) + } + MessageType::SubsessionReady => { + let data: SubsessionReadyData = bincode::deserialize(content) + .map_err(|e| LpError::DeserializationError(e.to_string()))?; + Ok(LpMessage::SubsessionReady(data)) + } + MessageType::SubsessionAbort => { + // Empty signal message - no content to deserialize + if !content.is_empty() { + return Err(LpError::DeserializationError( + "SubsessionAbort should have no payload".to_string(), + )); + } + Ok(LpMessage::SubsessionAbort) + } } } @@ -364,7 +398,7 @@ mod tests { use crate::packet::{LpHeader, LpPacket, TRAILER_LEN}; use bytes::BytesMut; - // AIDEV-NOTE: With unified format, outer header (receiver_idx + counter) is always first + // With unified format, outer header (receiver_idx + counter) is always first // and is the only cleartext portion for encrypted packets const OUTER_HDR: usize = super::OUTER_HEADER_SIZE; // 12 bytes @@ -1133,4 +1167,181 @@ mod tests { _ => panic!("Expected Handshake message"), } } + + // === Subsession Message Tests === + + #[test] + fn test_serialize_parse_subsession_request() { + let mut dst = BytesMut::new(); + + let packet = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + receiver_idx: 42, + counter: 100, + }, + message: LpMessage::SubsessionRequest, + trailer: [0; TRAILER_LEN], + }; + + serialize_lp_packet(&packet, &mut dst, None).unwrap(); + let decoded = parse_lp_packet(&dst, None).unwrap(); + + assert_eq!(decoded.header.receiver_idx, 42); + assert_eq!(decoded.header.counter, 100); + assert!(matches!(decoded.message, LpMessage::SubsessionRequest)); + } + + #[test] + fn test_serialize_parse_subsession_kk1() { + use crate::message::SubsessionKK1Data; + + let mut dst = BytesMut::new(); + + let kk1_data = SubsessionKK1Data { + payload: vec![0xAA; 50], // 50 bytes KK payload + }; + + let packet = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + receiver_idx: 123, + counter: 456, + }, + message: LpMessage::SubsessionKK1(kk1_data.clone()), + trailer: [0; TRAILER_LEN], + }; + + serialize_lp_packet(&packet, &mut dst, None).unwrap(); + let decoded = parse_lp_packet(&dst, None).unwrap(); + + assert_eq!(decoded.header.receiver_idx, 123); + match decoded.message { + LpMessage::SubsessionKK1(data) => { + assert_eq!(data.payload, kk1_data.payload); + } + _ => panic!("Expected SubsessionKK1 message"), + } + } + + #[test] + fn test_serialize_parse_subsession_kk2() { + use crate::message::SubsessionKK2Data; + + let mut dst = BytesMut::new(); + + let kk2_data = SubsessionKK2Data { + payload: vec![0x11; 60], // 60 bytes KK response payload + }; + + let packet = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + receiver_idx: 789, + counter: 1000, + }, + message: LpMessage::SubsessionKK2(kk2_data.clone()), + trailer: [0; TRAILER_LEN], + }; + + serialize_lp_packet(&packet, &mut dst, None).unwrap(); + let decoded = parse_lp_packet(&dst, None).unwrap(); + + assert_eq!(decoded.header.receiver_idx, 789); + match decoded.message { + LpMessage::SubsessionKK2(data) => { + assert_eq!(data.payload, kk2_data.payload); + } + _ => panic!("Expected SubsessionKK2 message"), + } + } + + #[test] + fn test_serialize_parse_subsession_ready() { + use crate::message::SubsessionReadyData; + + let mut dst = BytesMut::new(); + + let ready_data = SubsessionReadyData { + receiver_index: 99999, + }; + + let packet = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + receiver_idx: 42, + counter: 200, + }, + message: LpMessage::SubsessionReady(ready_data.clone()), + trailer: [0; TRAILER_LEN], + }; + + serialize_lp_packet(&packet, &mut dst, None).unwrap(); + let decoded = parse_lp_packet(&dst, None).unwrap(); + + assert_eq!(decoded.header.receiver_idx, 42); + match decoded.message { + LpMessage::SubsessionReady(data) => { + assert_eq!(data.receiver_index, 99999); + } + _ => panic!("Expected SubsessionReady message"), + } + } + + #[test] + fn test_subsession_request_with_payload_fails() { + // SubsessionRequest should have no payload + let mut buf = BytesMut::new(); + buf.extend_from_slice(&42u32.to_le_bytes()); // receiver_idx + buf.extend_from_slice(&123u64.to_le_bytes()); // counter + buf.extend_from_slice(&[1, 0, 0, 0]); // version + reserved + buf.extend_from_slice(&MessageType::SubsessionRequest.to_u16().to_le_bytes()); + buf.extend_from_slice(&[0xFF]); // Invalid payload for SubsessionRequest + buf.extend_from_slice(&[0; TRAILER_LEN]); + + let result = parse_lp_packet(&buf, None); + assert!(matches!( + result, + Err(LpError::InvalidPayloadSize { expected: 0, actual: 1 }) + )); + } + + #[test] + fn test_aead_subsession_roundtrip() { + use crate::message::SubsessionKK1Data; + + let psk = [42u8; 32]; + let outer_key = OuterAeadKey::from_psk(&psk); + + let kk1_data = SubsessionKK1Data { + payload: vec![0xDE; 48], // 48 bytes KK payload + }; + + let packet = LpPacket { + header: LpHeader { + protocol_version: 1, + reserved: 0, + receiver_idx: 54321, + counter: 999, + }, + message: LpMessage::SubsessionKK1(kk1_data.clone()), + trailer: [0; TRAILER_LEN], + }; + + let mut encrypted = BytesMut::new(); + serialize_lp_packet(&packet, &mut encrypted, Some(&outer_key)).unwrap(); + + let decoded = parse_lp_packet(&encrypted, Some(&outer_key)).unwrap(); + + match decoded.message { + LpMessage::SubsessionKK1(data) => { + assert_eq!(data.payload, kk1_data.payload); + } + _ => panic!("Expected SubsessionKK1 message"), + } + } } diff --git a/common/nym-lp/src/message.rs b/common/nym-lp/src/message.rs index 2b23d43d3fc..006a8ca22eb 100644 --- a/common/nym-lp/src/message.rs +++ b/common/nym-lp/src/message.rs @@ -81,6 +81,16 @@ pub enum MessageType { Collision = 0x0007, /// Acknowledgment - gateway confirms receipt of message Ack = 0x0008, + /// Subsession request - client initiates subsession creation + SubsessionRequest = 0x0009, + /// Subsession KK1 - first message of Noise KK handshake + SubsessionKK1 = 0x000A, + /// Subsession KK2 - second message of Noise KK handshake + SubsessionKK2 = 0x000B, + /// Subsession ready - subsession established confirmation + SubsessionReady = 0x000C, + /// Subsession abort - race winner tells loser to become responder + SubsessionAbort = 0x000D, } impl MessageType { @@ -121,6 +131,27 @@ pub struct ForwardPacketData { pub inner_packet_bytes: Vec, } +/// Subsession KK1 message - first message of Noise KK handshake +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SubsessionKK1Data { + /// Noise KK first message payload (ephemeral key + encrypted static) + pub payload: Vec, +} + +/// Subsession KK2 message - second message of Noise KK handshake +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SubsessionKK2Data { + /// Noise KK second message payload (ephemeral key + encrypted response) + pub payload: Vec, +} + +/// Subsession ready confirmation with new session index +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SubsessionReadyData { + /// New subsession's receiver index for routing + pub receiver_index: u32, +} + #[derive(Debug, Clone)] pub enum LpMessage { Busy, @@ -134,6 +165,16 @@ pub enum LpMessage { Collision, /// Acknowledgment - gateway confirms receipt of message Ack, + /// Subsession request - client initiates subsession creation (empty, signal only) + SubsessionRequest, + /// Subsession KK1 - first message of Noise KK handshake + SubsessionKK1(SubsessionKK1Data), + /// Subsession KK2 - second message of Noise KK handshake + SubsessionKK2(SubsessionKK2Data), + /// Subsession ready - subsession established confirmation + SubsessionReady(SubsessionReadyData), + /// Subsession abort - race winner tells loser to become responder (empty, signal only) + SubsessionAbort, } impl Display for LpMessage { @@ -148,6 +189,11 @@ impl Display for LpMessage { LpMessage::ForwardPacket(_) => write!(f, "ForwardPacket"), LpMessage::Collision => write!(f, "Collision"), LpMessage::Ack => write!(f, "Ack"), + LpMessage::SubsessionRequest => write!(f, "SubsessionRequest"), + LpMessage::SubsessionKK1(_) => write!(f, "SubsessionKK1"), + LpMessage::SubsessionKK2(_) => write!(f, "SubsessionKK2"), + LpMessage::SubsessionReady(_) => write!(f, "SubsessionReady"), + LpMessage::SubsessionAbort => write!(f, "SubsessionAbort"), } } } @@ -164,6 +210,11 @@ impl LpMessage { LpMessage::ForwardPacket(_) => &[], // Structured data, serialized in encode_content LpMessage::Collision => &[], LpMessage::Ack => &[], + LpMessage::SubsessionRequest => &[], + LpMessage::SubsessionKK1(_) => &[], // Structured data, serialized in encode_content + LpMessage::SubsessionKK2(_) => &[], // Structured data, serialized in encode_content + LpMessage::SubsessionReady(_) => &[], // Structured data, serialized in encode_content + LpMessage::SubsessionAbort => &[], } } @@ -178,6 +229,11 @@ impl LpMessage { LpMessage::ForwardPacket(_) => false, // Always has data LpMessage::Collision => true, LpMessage::Ack => true, + LpMessage::SubsessionRequest => true, // Empty signal + LpMessage::SubsessionKK1(_) => false, // Always has payload + LpMessage::SubsessionKK2(_) => false, // Always has payload + LpMessage::SubsessionReady(_) => false, // Always has receiver_index + LpMessage::SubsessionAbort => true, // Empty signal } } @@ -195,6 +251,13 @@ impl LpMessage { } LpMessage::Collision => 0, LpMessage::Ack => 0, + LpMessage::SubsessionRequest => 0, + // Variable length: bincode overhead (~8 bytes for Vec length) + payload + LpMessage::SubsessionKK1(data) => 8 + data.payload.len(), + LpMessage::SubsessionKK2(data) => 8 + data.payload.len(), + // 4 bytes u32 + bincode overhead (~4 bytes) + LpMessage::SubsessionReady(_) => 8, + LpMessage::SubsessionAbort => 0, } } @@ -209,6 +272,11 @@ impl LpMessage { LpMessage::ForwardPacket(_) => MessageType::ForwardPacket, LpMessage::Collision => MessageType::Collision, LpMessage::Ack => MessageType::Ack, + LpMessage::SubsessionRequest => MessageType::SubsessionRequest, + LpMessage::SubsessionKK1(_) => MessageType::SubsessionKK1, + LpMessage::SubsessionKK2(_) => MessageType::SubsessionKK2, + LpMessage::SubsessionReady(_) => MessageType::SubsessionReady, + LpMessage::SubsessionAbort => MessageType::SubsessionAbort, } } @@ -240,6 +308,23 @@ impl LpMessage { } LpMessage::Collision => { /* No content */ } LpMessage::Ack => { /* No content */ } + LpMessage::SubsessionRequest => { /* No content - signal only */ } + LpMessage::SubsessionKK1(data) => { + let serialized = + bincode::serialize(data).expect("Failed to serialize SubsessionKK1Data"); + dst.put_slice(&serialized); + } + LpMessage::SubsessionKK2(data) => { + let serialized = + bincode::serialize(data).expect("Failed to serialize SubsessionKK2Data"); + dst.put_slice(&serialized); + } + LpMessage::SubsessionReady(data) => { + let serialized = + bincode::serialize(data).expect("Failed to serialize SubsessionReadyData"); + dst.put_slice(&serialized); + } + LpMessage::SubsessionAbort => { /* No content - signal only */ } } } } diff --git a/common/nym-lp/src/noise_protocol.rs b/common/nym-lp/src/noise_protocol.rs index 42b5e0308f2..41e601494be 100644 --- a/common/nym-lp/src/noise_protocol.rs +++ b/common/nym-lp/src/noise_protocol.rs @@ -25,6 +25,9 @@ pub enum NoiseError { #[error("Other Noise-related error: {0}")] Other(String), + + #[error("session is read-only after demotion")] + SessionReadOnly, } impl From for NoiseError { diff --git a/common/nym-lp/src/psk.rs b/common/nym-lp/src/psk.rs index f4f416b3a77..bf529e859be 100644 --- a/common/nym-lp/src/psk.rs +++ b/common/nym-lp/src/psk.rs @@ -57,6 +57,43 @@ const PSK_PSQ_CONTEXT: &str = "nym-lp-psk-psq-v1"; /// Session context for PSQ protocol. const PSQ_SESSION_CONTEXT: &[u8] = b"nym-lp-psq-session"; +/// Context string for subsession PSK derivation. +const SUBSESSION_PSK_CONTEXT: &str = "lp-subsession-psk-v1"; + +/// Result from PSQ initiator message creation. +/// +/// Contains all outputs needed for session establishment: +/// - `psk`: Final derived PSK for Noise handshake (ECDH || K_pq || salt → Blake3) +/// - `payload`: Serialized PSQ message to send to responder +/// - `pq_shared_secret`: Raw K_pq from KEM encapsulation (for subsession derivation) +#[derive(Debug)] +pub struct PsqInitiatorResult { + /// Final PSK for Noise XKpsk3 handshake + pub psk: [u8; 32], + /// Serialized PSQ payload to embed in handshake message + pub payload: Vec, + /// Raw PQ shared secret (K_pq) before KDF combination. + /// Used for deriving subsession PSKs to preserve PQ protection. + pub pq_shared_secret: [u8; 32], +} + +/// Result from PSQ responder message processing. +/// +/// Contains all outputs needed for session establishment: +/// - `psk`: Final derived PSK for Noise handshake (matches initiator's) +/// - `psk_handle`: Encrypted PSK handle (ctxt_B) to send back to initiator +/// - `pq_shared_secret`: Raw K_pq from KEM decapsulation (for subsession derivation) +#[derive(Debug)] +pub struct PsqResponderResult { + /// Final PSK for Noise XKpsk3 handshake + pub psk: [u8; 32], + /// Encrypted PSK handle (ctxt_B) from PSQ responder message + pub psk_handle: Vec, + /// Raw PQ shared secret (K_pq) before KDF combination. + /// Used for deriving subsession PSKs to preserve PQ protection. + pub pq_shared_secret: [u8; 32], +} + /// Derives a PSK using PSQ (Post-Quantum Secure PSK) protocol - Initiator side. /// /// This function combines classical ECDH with post-quantum KEM to provide forward secrecy @@ -230,7 +267,7 @@ pub fn derive_psk_with_psq_responder( /// * `session_context` - Context bytes for PSQ (e.g., b"nym-lp-psq-session") /// /// # Returns -/// `(psk, psq_payload_bytes)` - PSK for Noise and serialized PSQ payload to embed +/// `PsqInitiatorResult` containing PSK, payload, and raw PQ shared secret pub fn psq_initiator_create_message( local_x25519_private: &PrivateKey, remote_x25519_public: &PublicKey, @@ -239,7 +276,7 @@ pub fn psq_initiator_create_message( client_ed25519_pk: &ed25519::PublicKey, salt: &[u8; 32], session_context: &[u8], -) -> Result<([u8; 32], Vec), LpError> { +) -> Result { // Step 1: Classical ECDH for baseline security let ecdh_secret = local_x25519_private.diffie_hellman(remote_x25519_public); @@ -278,9 +315,13 @@ pub fn psq_initiator_create_message( LpError::Internal(format!("PSQ v1 send_initial_message failed: {:?}", e)) })?; - // Extract PSQ shared secret (unregistered PSK) + // Extract PSQ shared secret (unregistered PSK) - this is K_pq let psq_psk = state.unregistered_psk(); + // pq_shared_secret is the raw K_pq from KEM encapsulation. + // Store it for subsession derivation before it's combined with ECDH. + let pq_shared_secret: [u8; 32] = *psq_psk; + // Step 3: Combine ECDH + PSQ via Blake3 KDF let mut combined = Vec::with_capacity(64 + psq_psk.len()); combined.extend_from_slice(ecdh_secret.as_bytes()); @@ -294,7 +335,11 @@ pub fn psq_initiator_create_message( .tls_serialize_detached() .map_err(|e| LpError::Internal(format!("InitiatorMsg serialization failed: {:?}", e)))?; - Ok((final_psk, msg_bytes)) + Ok(PsqInitiatorResult { + psk: final_psk, + payload: msg_bytes, + pq_shared_secret, + }) } /// PSQ protocol wrapper for responder (gateway) side. @@ -317,11 +362,7 @@ pub fn psq_initiator_create_message( /// * `session_context` - Context bytes for PSQ /// /// # Returns -/// `psk` - Derived PSK for Noise -/// Processes a PSQ initiator message and generates a PSK with encrypted handle. -/// -/// Returns a tuple of (derived_psk, responder_msg_bytes) where responder_msg_bytes -/// contains the encrypted PSK handle (ctxt_B) that should be sent to the initiator. +/// `PsqResponderResult` containing PSK, PSK handle, and raw PQ shared secret pub fn psq_responder_process_message( local_x25519_private: &PrivateKey, remote_x25519_public: &PublicKey, @@ -330,7 +371,7 @@ pub fn psq_responder_process_message( psq_payload: &[u8], salt: &[u8; 32], session_context: &[u8], -) -> Result<([u8; 32], Vec), LpError> { +) -> Result { // Step 1: Classical ECDH for baseline security let ecdh_secret = local_x25519_private.diffie_hellman(remote_x25519_public); @@ -383,9 +424,13 @@ pub fn psq_responder_process_message( LpError::Internal(format!("PSQ v1 responder send failed: {:?}", e)) })?; - // Extract the PSQ PSK from the registered PSK + // Extract the PSQ PSK from the registered PSK - this is K_pq let psq_psk = registered_psk.psk; + // pq_shared_secret is the raw K_pq from KEM decapsulation. + // Store it for subsession derivation before it's combined with ECDH. + let pq_shared_secret: [u8; 32] = psq_psk; + // Step 6: Combine ECDH + PSQ via Blake3 KDF (same formula as initiator) let mut combined = Vec::with_capacity(64 + psq_psk.len()); combined.extend_from_slice(ecdh_secret.as_bytes()); @@ -400,7 +445,38 @@ pub fn psq_responder_process_message( .tls_serialize_detached() .map_err(|e| LpError::Internal(format!("ResponderMsg serialization failed: {:?}", e)))?; - Ok((final_psk, responder_msg_bytes)) + Ok(PsqResponderResult { + psk: final_psk, + psk_handle: responder_msg_bytes, + pq_shared_secret, + }) +} + +/// Derive subsession PSK from parent's PQ shared secret. +/// +/// Uses Blake3 KDF with domain separation to derive unique PSK for each subsession. +/// This preserves PQ protection: subsession keys inherit quantum resistance from +/// parent's KEM shared secret (K_pq). +/// +/// # Security Model +/// +/// Subsessions use Noise KKpsk0 pattern where: +/// - Both parties already know each other's static X25519 keys (from parent session) +/// - PSK provides PQ protection by deriving from parent's K_pq +/// - Each subsession gets unique PSK via index parameter (prevents key reuse) +/// +/// # Arguments +/// * `pq_shared_secret` - Parent session's K_pq (32 bytes from KEM) +/// * `subsession_index` - Monotonic index for this subsession (prevents reuse) +/// +/// # Returns +/// 32-byte PSK for Noise KKpsk0 handshake +pub fn derive_subsession_psk(pq_shared_secret: &[u8; 32], subsession_index: u64) -> [u8; 32] { + nym_crypto::kdf::derive_key_blake3( + SUBSESSION_PSK_CONTEXT, + pq_shared_secret, + &subsession_index.to_le_bytes(), + ) } #[cfg(test)] diff --git a/common/nym-lp/src/session.rs b/common/nym-lp/src/session.rs index 2d1476f940f..316849b211f 100644 --- a/common/nym-lp/src/session.rs +++ b/common/nym-lp/src/session.rs @@ -11,7 +11,7 @@ use crate::keypair::{PrivateKey, PublicKey}; use crate::message::{EncryptedDataPayload, HandshakeData}; use crate::noise_protocol::{NoiseError, NoiseProtocol, ReadResult}; use crate::packet::LpHeader; -use crate::psk::{psq_initiator_create_message, psq_responder_process_message}; +use crate::psk::{derive_subsession_psk, psq_initiator_create_message, psq_responder_process_message}; use crate::replay::ReceivingKeyCounterValidator; use crate::{LpError, LpMessage, LpPacket}; use nym_crypto::asymmetric::ed25519; @@ -19,6 +19,30 @@ use nym_kkt::ciphersuite::{DecapsulationKey, EncapsulationKey}; use parking_lot::Mutex; use snow::Builder; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// PQ shared secret wrapper with automatic memory zeroization. +/// Ensures K_pq is cleared from memory when dropped. +#[derive(Clone, Zeroize, ZeroizeOnDrop)] +pub struct PqSharedSecret([u8; 32]); + +impl PqSharedSecret { + pub fn new(secret: [u8; 32]) -> Self { + Self(secret) + } + + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl std::fmt::Debug for PqSharedSecret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PqSharedSecret") + .field("secret", &"") + .finish() + } +} /// KKT (KEM Key Transfer) exchange state. /// @@ -170,6 +194,25 @@ pub struct LpSession { /// Outer AEAD key for packet encryption (derived from PSK after PSQ handshake). /// None before PSK is available, Some after PSK injection. outer_aead_key: Mutex>, + + /// Raw PQ shared secret (K_pq) from PSQ KEM encapsulation/decapsulation. + /// Stored after PSQ handshake completes for subsession PSK derivation. + /// This preserves PQ protection when creating subsessions via KKpsk0. + /// Wrapped in PqSharedSecret for automatic memory zeroization on drop. + pq_shared_secret: Mutex>, + + /// Monotonically increasing counter for subsession indices. + /// Each subsession gets a unique index to ensure unique PSK derivation. + /// Uses u64 to make overflow practically impossible (~585k years at 1M/sec). + subsession_counter: AtomicU64, + + /// True if this session has been demoted to read-only mode. + /// Demoted sessions can still receive/decrypt but cannot send/encrypt. + read_only: AtomicBool, + + /// ID of the successor session that replaced this one. + /// Set when demote() is called. + successor_session_id: Mutex>, } /// Generates a fresh salt for PSK derivation. @@ -222,6 +265,14 @@ impl LpSession { self.local_x25519_private.public_key() } + /// Returns the remote X25519 public key. + /// + /// Used for tie-breaking in simultaneous subsession initiation. + /// Lower key loses and becomes responder. + pub fn remote_x25519_public(&self) -> &PublicKey { + &self.remote_x25519_public + } + /// Returns the outer AEAD key for packet encryption/decryption. /// /// Returns `None` before PSK is derived (during initial handshake), @@ -318,6 +369,10 @@ impl LpSession { remote_x25519_public: remote_x25519_key.clone(), salt: *salt, outer_aead_key: Mutex::new(None), + pq_shared_secret: Mutex::new(None), + subsession_counter: AtomicU64::new(0), + read_only: AtomicBool::new(false), + successor_session_id: Mutex::new(None), }) } @@ -632,7 +687,7 @@ impl LpSession { // Generate PSQ payload and PSK using KKT-authenticated KEM key let session_context = self.id.to_le_bytes(); - let (psk, psq_payload) = match psq_initiator_create_message( + let psq_result = match psq_initiator_create_message( &self.local_x25519_private, &self.remote_x25519_public, remote_kem, @@ -647,6 +702,11 @@ impl LpSession { return Some(Err(e)); } }; + let psk = psq_result.psk; + let psq_payload = psq_result.payload; + + // Store PQ shared secret for subsession PSK derivation + *self.pq_shared_secret.lock() = Some(PqSharedSecret::new(psq_result.pq_shared_secret)); // Inject PSK into Noise HandshakeState if let Err(e) = noise_state.set_psk(3, &psk) { @@ -797,7 +857,7 @@ impl LpSession { // Decapsulate PSK from PSQ payload using X25519 as DHKEM let session_context = self.id.to_le_bytes(); - let (psk, responder_msg_bytes) = match psq_responder_process_message( + let psq_result = match psq_responder_process_message( &self.local_x25519_private, &self.remote_x25519_public, (&dec_key, &enc_key), @@ -812,11 +872,15 @@ impl LpSession { return Err(e); } }; + let psk = psq_result.psk; + + // Store PQ shared secret for subsession PSK derivation + *self.pq_shared_secret.lock() = Some(PqSharedSecret::new(psq_result.pq_shared_secret)); // Store the PSK handle (ctxt_B) for transmission in next message { let mut psk_handle = self.psk_handle.lock(); - *psk_handle = Some(responder_msg_bytes); + *psk_handle = Some(psq_result.psk_handle); } // Inject PSK into Noise HandshakeState @@ -887,6 +951,49 @@ impl LpSession { self.noise_state.lock().is_handshake_finished() } + /// Returns the PQ shared secret (K_pq) if available. + /// + /// This is the raw KEM output from PSQ before Blake3 KDF combination. + /// Used for deriving subsession PSKs to maintain PQ protection. + pub fn pq_shared_secret(&self) -> Option<[u8; 32]> { + self.pq_shared_secret.lock().as_ref().map(|s| *s.as_bytes()) + } + + /// Gets the next subsession index and increments the counter. + /// + /// Each subsession requires a unique index to ensure unique PSK derivation. + /// The index is monotonically increasing per session. + pub fn next_subsession_index(&self) -> u64 { + self.subsession_counter.fetch_add(1, Ordering::Relaxed) + } + + /// Returns true if this session is in read-only mode. + /// + /// Read-only sessions have been demoted after a subsession was promoted. + /// They can still decrypt incoming messages but cannot encrypt outgoing ones. + pub fn is_read_only(&self) -> bool { + self.read_only.load(Ordering::Acquire) + } + + /// Demotes this session to read-only mode after a subsession replaces it. + /// + /// After demotion: + /// - `encrypt_data()` will return `NoiseError::SessionReadOnly` + /// - `decrypt_data()` still works (to drain in-flight messages) + /// - Session should be cleaned up after TTL expires + /// + /// # Arguments + /// * `successor_idx` - The receiver index of the session that replaced this one + pub fn demote(&self, successor_idx: u32) { + *self.successor_session_id.lock() = Some(successor_idx); + self.read_only.store(true, Ordering::Release); + } + + /// Returns the successor session ID if this session was demoted. + pub fn successor_session_id(&self) -> Option { + *self.successor_session_id.lock() + } + /// Encrypts application data payload using the established Noise transport session. /// /// This should only be called after the handshake is complete (`is_handshake_complete` returns true). @@ -900,6 +1007,11 @@ impl LpSession { /// * `Ok(Vec)` containing the encrypted Noise message ciphertext. /// * `Err(NoiseError)` if the session is not in transport mode or encryption fails. pub fn encrypt_data(&self, payload: &[u8]) -> Result { + // Check if session is read-only (demoted) + if self.read_only.load(Ordering::Acquire) { + return Err(NoiseError::SessionReadOnly); + } + let mut noise_state = self.noise_state.lock(); // Safety: Prevent transport mode with dummy PSK if !self.psk_injected.load(Ordering::Acquire) { @@ -961,6 +1073,220 @@ impl LpSession { kem_pk: Box::new(kem_pk), }; } + + /// Creates a new subsession using Noise KKpsk0 pattern. + /// + /// KKpsk0 reuses parent's static X25519 keys (both parties know each other from parent session). + /// PSK is derived from parent's PQ shared secret, preserving quantum resistance. + /// + /// # Arguments + /// * `subsession_index` - Unique index for this subsession (use `next_subsession_index()`) + /// * `is_initiator` - True if this side initiates the subsession handshake + /// + /// # Returns + /// `SubsessionHandshake` ready for KK1/KK2 message exchange + /// + /// # Errors + /// * Returns error if parent handshake not complete + /// * Returns error if PQ shared secret not available + pub fn create_subsession( + &self, + subsession_index: u64, + is_initiator: bool, + ) -> Result { + // Verify parent handshake is complete + if !self.is_handshake_complete() { + return Err(LpError::Internal( + "Parent handshake not complete".into(), + )); + } + + // Get PQ shared secret + let pq_secret = self + .pq_shared_secret() + .ok_or_else(|| LpError::Internal("PQ shared secret not available".into()))?; + + // Derive subsession PSK from parent's PQ shared secret + let subsession_psk = derive_subsession_psk(&pq_secret, subsession_index); + + // Build KKpsk0 handshake + // Pattern: Noise_KKpsk0_25519_ChaChaPoly_SHA256 + // Both parties already know each other's static keys from parent session + let pattern_name = "Noise_KKpsk0_25519_ChaChaPoly_SHA256"; + let params = pattern_name.parse()?; + + let local_key_bytes = self.local_x25519_private.to_bytes(); + let remote_key_bytes = self.remote_x25519_public.to_bytes(); + + let builder = Builder::new(params) + .local_private_key(&local_key_bytes) + .remote_public_key(&remote_key_bytes) + .psk(0, &subsession_psk); // PSK at position 0 for KKpsk0 + + let handshake_state = if is_initiator { + builder.build_initiator().map_err(LpError::SnowKeyError)? + } else { + builder.build_responder().map_err(LpError::SnowKeyError)? + }; + + Ok(SubsessionHandshake { + index: subsession_index, + noise_state: Mutex::new(NoiseProtocol::new(handshake_state)), + is_initiator, + // Copy key material from parent for into_session() conversion + local_ed25519_private: ed25519::PrivateKey::from_bytes( + &self.local_ed25519_private.to_bytes(), + ).expect("Valid Ed25519 private key from parent"), + local_ed25519_public: ed25519::PublicKey::from_bytes(&self.local_ed25519_public.to_bytes()) + .expect("Valid Ed25519 public key from parent"), + remote_ed25519_public: ed25519::PublicKey::from_bytes(&self.remote_ed25519_public.to_bytes()) + .expect("Valid Ed25519 public key from parent"), + local_x25519_private: self.local_x25519_private.clone(), + remote_x25519_public: self.remote_x25519_public.clone(), + pq_shared_secret: PqSharedSecret::new(pq_secret), + subsession_psk, + }) + } +} + +/// Subsession created via Noise KKpsk0 handshake tunneled through parent session. +/// +/// Subsessions provide fresh session keys while inheriting PQ protection from parent's +/// ML-KEM shared secret. After handshake completes, the subsession can be promoted +/// to replace the parent session. +/// +/// # Lifecycle +/// 1. Parent calls `create_subsession()` to get `SubsessionHandshake` +/// 2. Initiator calls `prepare_message()` to get KK1 +/// 3. KK1 sent through parent session (encrypted tunnel) +/// 4. Responder calls `process_message(kk1)` to process KK1 +/// 5. Responder calls `prepare_message()` to get KK2 +/// 6. KK2 sent through parent session +/// 7. Initiator calls `process_message(kk2)` to complete handshake +/// 8. Both call `is_complete()` to verify +#[derive(Debug)] +pub struct SubsessionHandshake { + /// Subsession index (unique per parent session) + pub index: u64, + /// Noise KKpsk0 handshake state + noise_state: Mutex, + /// Is this side the initiator? + is_initiator: bool, + + // Key material inherited from parent session for into_session() conversion + /// Local Ed25519 private key (for PSQ auth if needed) + local_ed25519_private: ed25519::PrivateKey, + /// Local Ed25519 public key + local_ed25519_public: ed25519::PublicKey, + /// Remote Ed25519 public key + remote_ed25519_public: ed25519::PublicKey, + /// Local X25519 private key (Noise static key) + local_x25519_private: PrivateKey, + /// Remote X25519 public key (Noise static key) + remote_x25519_public: PublicKey, + /// PQ shared secret inherited from parent (for creating further subsessions) + pq_shared_secret: PqSharedSecret, + /// Subsession PSK (for deriving outer AEAD key) + subsession_psk: [u8; 32], +} + +impl SubsessionHandshake { + /// Prepares the next KK handshake message (KK1 or KK2 depending on role/state). + /// + /// # Returns + /// Noise handshake message bytes to send through parent session tunnel. + pub fn prepare_message(&self) -> Result, LpError> { + let mut noise_state = self.noise_state.lock(); + noise_state + .get_bytes_to_send() + .ok_or_else(|| LpError::Internal("Not our turn to send".into()))? + .map_err(LpError::NoiseError) + } + + /// Processes a received KK handshake message (KK1 or KK2). + /// + /// # Arguments + /// * `message` - Noise handshake message received through parent session tunnel. + /// + /// # Returns + /// Any payload embedded in the handshake message (usually empty for KK). + pub fn process_message(&self, message: &[u8]) -> Result, LpError> { + let mut noise_state = self.noise_state.lock(); + let result = noise_state + .read_message(message) + .map_err(LpError::NoiseError)?; + match result { + ReadResult::HandshakeComplete | ReadResult::NoOp => Ok(vec![]), + ReadResult::DecryptedData(data) => Ok(data), + } + } + + /// Checks if the handshake is complete (ready for transport mode). + pub fn is_complete(&self) -> bool { + self.noise_state.lock().is_handshake_finished() + } + + /// Returns whether this side is the initiator. + pub fn is_initiator(&self) -> bool { + self.is_initiator + } + + /// Returns the subsession index. + pub fn subsession_index(&self) -> u64 { + self.index + } + + /// Convert completed subsession handshake into a full LpSession. + /// + /// This consumes the SubsessionHandshake and creates a new LpSession + /// that can be used as a replacement for the parent session. + /// + /// # Arguments + /// * `receiver_index` - New receiver index for the promoted session + /// + /// # Errors + /// Returns error if handshake is not complete + pub fn into_session(self, receiver_index: u32) -> Result { + if !self.is_complete() { + return Err(LpError::Internal( + "Cannot convert incomplete subsession to session".to_string(), + )); + } + + // Extract the noise state (now in transport mode) + let noise_state = self.noise_state.into_inner(); + + // Generate fresh salt for the new session + let salt = generate_fresh_salt(); + + // Derive outer AEAD key from the subsession PSK + let outer_key = OuterAeadKey::from_psk(&self.subsession_psk); + + Ok(LpSession { + id: receiver_index, + is_initiator: self.is_initiator, + noise_state: Mutex::new(noise_state), + // KKT: subsession inherits from parent, mark as processed + kkt_state: Mutex::new(KKTState::ResponderProcessed), + // PSQ: subsession uses PSK derived from parent's PQ secret + psq_state: Mutex::new(PSQState::Completed { psk: self.subsession_psk }), + psk_handle: Mutex::new(None), // Subsession doesn't have its own handle + sending_counter: AtomicU64::new(0), + receiving_counter: Mutex::new(ReceivingKeyCounterValidator::new(0)), + psk_injected: AtomicBool::new(true), // PSK was in KKpsk0 + local_ed25519_private: self.local_ed25519_private, + local_ed25519_public: self.local_ed25519_public, + remote_ed25519_public: self.remote_ed25519_public, + local_x25519_private: self.local_x25519_private, + remote_x25519_public: self.remote_x25519_public, + salt, + outer_aead_key: Mutex::new(Some(outer_key)), + pq_shared_secret: Mutex::new(Some(self.pq_shared_secret)), + subsession_counter: AtomicU64::new(0), + read_only: AtomicBool::new(false), + successor_session_id: Mutex::new(None), + }) + } } #[cfg(test)] @@ -1925,4 +2251,120 @@ mod tests { e => panic!("Expected PskNotInjected error, got: {:?}", e), } } + + #[test] + fn test_demote_sets_read_only() { + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + let session = + create_handshake_test_session(12345u32, true, &initiator_keys, responder_keys.public_key()); + + // Initially not read-only + assert!(!session.is_read_only()); + assert!(session.successor_session_id().is_none()); + + // Demote the session + session.demote(99999); + + // Now read-only with successor + assert!(session.is_read_only()); + assert_eq!(session.successor_session_id(), Some(99999)); + } + + #[test] + fn test_encrypt_fails_after_demotion() { + // --- Setup Handshake --- + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + let initiator_session = + create_handshake_test_session(12345u32, true, &initiator_keys, responder_keys.public_key()); + let responder_session = + create_handshake_test_session(12345u32, false, &responder_keys, initiator_keys.public_key()); + + // Drive handshake to completion + let i_msg = initiator_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + responder_session.process_handshake_message(&i_msg).unwrap(); + let r_msg = responder_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + initiator_session.process_handshake_message(&r_msg).unwrap(); + let i_msg = initiator_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + responder_session.process_handshake_message(&i_msg).unwrap(); + + assert!(initiator_session.is_handshake_complete()); + + // Encryption works before demotion + let plaintext = b"Hello before demotion"; + assert!(initiator_session.encrypt_data(plaintext).is_ok()); + + // Demote the session + initiator_session.demote(99999); + + // Encryption fails after demotion + let result = initiator_session.encrypt_data(plaintext); + assert!(result.is_err()); + match result.unwrap_err() { + NoiseError::SessionReadOnly => { + // Expected + } + e => panic!("Expected SessionReadOnly error, got: {:?}", e), + } + } + + #[test] + fn test_decrypt_works_after_demotion() { + // --- Setup Handshake --- + let initiator_keys = generate_keypair(); + let responder_keys = generate_keypair(); + + let initiator_session = + create_handshake_test_session(12345u32, true, &initiator_keys, responder_keys.public_key()); + let responder_session = + create_handshake_test_session(12345u32, false, &responder_keys, initiator_keys.public_key()); + + // Drive handshake to completion + let i_msg = initiator_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + responder_session.process_handshake_message(&i_msg).unwrap(); + let r_msg = responder_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + initiator_session.process_handshake_message(&r_msg).unwrap(); + let i_msg = initiator_session + .prepare_handshake_message() + .unwrap() + .unwrap(); + responder_session.process_handshake_message(&i_msg).unwrap(); + + assert!(initiator_session.is_handshake_complete()); + assert!(responder_session.is_handshake_complete()); + + // Responder encrypts a message + let plaintext = b"Message to demoted initiator"; + let ciphertext = responder_session + .encrypt_data(plaintext) + .expect("Encryption failed"); + + // Demote the initiator session + initiator_session.demote(99999); + assert!(initiator_session.is_read_only()); + + // Decryption still works on demoted session (drain in-flight) + let decrypted = initiator_session + .decrypt_data(&ciphertext) + .expect("Decryption should work on demoted session"); + assert_eq!(decrypted, plaintext); + } } diff --git a/common/nym-lp/src/state_machine.rs b/common/nym-lp/src/state_machine.rs index 11352c14935..c32e6c9f4e2 100644 --- a/common/nym-lp/src/state_machine.rs +++ b/common/nym-lp/src/state_machine.rs @@ -6,9 +6,10 @@ use crate::{ LpError, keypair::{Keypair, PrivateKey as LpPrivateKey, PublicKey as LpPublicKey}, + message::{LpMessage, SubsessionKK1Data, SubsessionKK2Data, SubsessionReadyData}, noise_protocol::NoiseError, packet::LpPacket, - session::LpSession, + session::{LpSession, SubsessionHandshake}, }; use bytes::BytesMut; use nym_crypto::asymmetric::ed25519; @@ -31,6 +32,18 @@ pub enum LpState { /// Handshake complete, ready for data transport. Transport { session: LpSession }, + + /// Performing subsession KK handshake while parent remains active. + /// Parent can still send/receive; subsession messages tunneled through parent. + SubsessionHandshaking { + session: LpSession, + subsession: SubsessionHandshake, + }, + + /// Parent session demoted after subsession promoted. + /// Can only receive (drain in-flight), cannot send. + ReadOnlyTransport { session: LpSession }, + /// An error occurred, or the connection was intentionally closed. Closed { reason: String }, /// Processing an input event. @@ -44,6 +57,8 @@ pub enum LpStateBare { KKTExchange, Handshaking, Transport, + SubsessionHandshaking, + ReadOnlyTransport, Closed, Processing, } @@ -55,6 +70,8 @@ impl From<&LpState> for LpStateBare { LpState::KKTExchange { .. } => LpStateBare::KKTExchange, LpState::Handshaking { .. } => LpStateBare::Handshaking, LpState::Transport { .. } => LpStateBare::Transport, + LpState::SubsessionHandshaking { .. } => LpStateBare::SubsessionHandshaking, + LpState::ReadOnlyTransport { .. } => LpStateBare::ReadOnlyTransport, LpState::Closed { .. } => LpStateBare::Closed, LpState::Processing => LpStateBare::Processing, } @@ -72,6 +89,9 @@ pub enum LpInput { SendData(Vec), // Using Bytes for efficiency /// Close the connection. Close, + /// Initiate a subsession handshake (only valid in Transport state). + /// Creates SubsessionHandshake and sends KK1 message. + InitiateSubsession, } /// Represents actions the state machine requests the environment to perform. @@ -87,6 +107,20 @@ pub enum LpAction { HandshakeComplete, /// Inform the environment that the connection is closed. ConnectionClosed, + /// Subsession KK handshake initiated by this side. + /// Contains the KK1 packet to send and the subsession index for tracking. + SubsessionInitiated { + packet: LpPacket, + subsession_index: u64, + }, + /// Subsession handshake complete, ready for promotion. + /// Contains the packet to send (Some for initiator with SubsessionReady, None for responder), + /// the completed SubsessionHandshake for into_session(), and the new receiver_index. + SubsessionComplete { + packet: Option, + subsession: SubsessionHandshake, + new_receiver_index: u32, + }, } /// The Lewes Protocol State Machine. @@ -104,7 +138,9 @@ impl LpStateMachine { LpState::ReadyToHandshake { session } | LpState::KKTExchange { session } | LpState::Handshaking { session } - | LpState::Transport { session } => Ok(session), + | LpState::Transport { session } + | LpState::SubsessionHandshaking { session, .. } + | LpState::ReadOnlyTransport { session } => Ok(session), LpState::Closed { .. } => Err(LpError::LpSessionClosed), LpState::Processing => Err(LpError::LpSessionProcessing), } @@ -118,7 +154,9 @@ impl LpStateMachine { LpState::ReadyToHandshake { session } | LpState::KKTExchange { session } | LpState::Handshaking { session } - | LpState::Transport { session } => Ok(session), + | LpState::Transport { session } + | LpState::SubsessionHandshaking { session, .. } + | LpState::ReadOnlyTransport { session } => Ok(session), LpState::Closed { .. } => Err(LpError::LpSessionClosed), LpState::Processing => Err(LpError::LpSessionProcessing), } @@ -450,43 +488,99 @@ impl LpStateMachine { } // --- Transport State --- - (LpState::Transport { session }, LpInput::ReceivePacket(packet)) => { // Needs mut session for marking counter + (LpState::Transport { session }, LpInput::ReceivePacket(packet)) => { // Check if packet lp_id matches our session if packet.header.receiver_idx() != session.id() { result_action = Some(Err(LpError::UnknownSessionId(packet.header.receiver_idx()))); - // Remain in transport state LpState::Transport { session } } else { - // --- Inline handle_data_packet logic --- - // 1. Check replay protection - if let Err(e) = session.receiving_counter_quick_check(packet.header.counter) { - let _reason = e.to_string(); - result_action = Some(Err(e)); - LpState::Transport { session } - } else { - // 2. Decrypt data - match session.decrypt_data(&packet.message) { - Ok(plaintext) => { - // 3. Mark counter as received - if let Err(e) = session.receiving_counter_mark(packet.header.counter) { - let _reason = e.to_string(); + // Check message type - handle subsession initiation from peer + match &packet.message { + // Peer initiated subsession - we become responder + LpMessage::SubsessionKK1(kk1_data) => { + // Create subsession as responder + let subsession_index = session.next_subsession_index(); + match session.create_subsession(subsession_index, false) { + Ok(subsession) => { + // Process KK1 + match subsession.process_message(&kk1_data.payload) { + Ok(_) => { + // Prepare KK2 response + match subsession.prepare_message() { + Ok(kk2_payload) => { + let kk2_msg = LpMessage::SubsessionKK2(SubsessionKK2Data { payload: kk2_payload }); + match session.next_packet(kk2_msg) { + Ok(response_packet) => { + result_action = Some(Ok(LpAction::SendPacket(response_packet))); + // Stay in SubsessionHandshaking, wait for SubsessionReady + LpState::SubsessionHandshaking { session, subsession } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + Err(e) => { + let reason = e.to_string(); result_action = Some(Err(e)); - LpState::Transport{ session } - } else { - // 4. Deliver data - result_action = Some(Ok(LpAction::DeliverData(BytesMut::from(plaintext.as_slice())))); - // Remain in transport state - LpState::Transport { session } + LpState::Closed { reason } } } - Err(e) => { // Error decrypting data - let reason = e.to_string(); - result_action = Some(Err(e.into())); - LpState::Closed { reason } + } + // Normal encrypted data + LpMessage::EncryptedData(_) => { + // 1. Check replay protection + if let Err(e) = session.receiving_counter_quick_check(packet.header.counter) { + result_action = Some(Err(e)); + LpState::Transport { session } + } else { + // 2. Decrypt data + match session.decrypt_data(&packet.message) { + Ok(plaintext) => { + // 3. Mark counter as received + if let Err(e) = session.receiving_counter_mark(packet.header.counter) { + result_action = Some(Err(e)); + LpState::Transport { session } + } else { + // 4. Deliver data + result_action = Some(Ok(LpAction::DeliverData(BytesMut::from(plaintext.as_slice())))); + LpState::Transport { session } + } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e.into())); + LpState::Closed { reason } + } + } } } + _ => { + // Unexpected message type in Transport state + let err = LpError::InvalidStateTransition { + state: "Transport".to_string(), + input: format!("Unexpected message type: {}", packet.message), + }; + result_action = Some(Err(err)); + LpState::Transport { session } + } } - // --- End inline handle_data_packet logic --- } } (LpState::Transport { session }, LpInput::SendData(data)) => { @@ -512,12 +606,377 @@ impl LpStateMachine { LpState::Transport { session } } - // --- Close Transition (applies to ReadyToHandshake, KKTExchange, Handshaking, Transport) --- + // --- Transport + InitiateSubsession → SubsessionHandshaking --- + (LpState::Transport { session }, LpInput::InitiateSubsession) => { + // Get next subsession index + let subsession_index = session.next_subsession_index(); + + // Create subsession handshake (this side is initiator) + match session.create_subsession(subsession_index, true) { + Ok(subsession) => { + // Prepare KK1 message + match subsession.prepare_message() { + Ok(kk1_payload) => { + let kk1_msg = LpMessage::SubsessionKK1(SubsessionKK1Data { payload: kk1_payload }); + match session.next_packet(kk1_msg) { + Ok(packet) => { + // Emit SubsessionInitiated with packet and index + result_action = Some(Ok(LpAction::SubsessionInitiated { + packet, + subsession_index, + })); + LpState::SubsessionHandshaking { session, subsession } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + + // --- SubsessionHandshaking State --- + (LpState::SubsessionHandshaking { session, subsession }, LpInput::ReceivePacket(packet)) => { + // Check if packet receiver_idx matches our session + if packet.header.receiver_idx() != session.id() { + result_action = Some(Err(LpError::UnknownSessionId(packet.header.receiver_idx()))); + LpState::SubsessionHandshaking { session, subsession } + } else { + match &packet.message { + LpMessage::SubsessionKK1(kk1_data) if !subsession.is_initiator() => { + // Responder processes KK1, prepares KK2 + // Responder stays in SubsessionHandshaking after sending KK2, + // waiting for SubsessionReady from initiator before completing + match subsession.process_message(&kk1_data.payload) { + Ok(_) => { + match subsession.prepare_message() { + Ok(kk2_payload) => { + let kk2_msg = LpMessage::SubsessionKK2(SubsessionKK2Data { payload: kk2_payload }); + match session.next_packet(kk2_msg) { + Ok(response_packet) => { + result_action = Some(Ok(LpAction::SendPacket(response_packet))); + // Stay in SubsessionHandshaking, wait for SubsessionReady + LpState::SubsessionHandshaking { session, subsession } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + LpMessage::SubsessionKK1(kk1_data) if subsession.is_initiator() => { + // Simultaneous initiation race detected. + // Both sides called InitiateSubsession and sent KK1 to each other. + // Use X25519 public key comparison as deterministic tie-breaker. + // Lower key loses and becomes responder. + let local_key = session.local_x25519_public(); + let remote_key = session.remote_x25519_public(); + + if local_key.as_bytes() < remote_key.as_bytes() { + // We LOSE - become responder + // Use the same index as our initiator subsession, which should + // match the winner's index if subsession counters are in sync. + // This works because both sides independently picked the same index when + // they initiated simultaneously (both counters were at the same value). + let subsession_index = subsession.index; + match session.create_subsession(subsession_index, false) { + Ok(new_subsession) => { + match new_subsession.process_message(&kk1_data.payload) { + Ok(_) => { + match new_subsession.prepare_message() { + Ok(kk2_payload) => { + let kk2_msg = LpMessage::SubsessionKK2(SubsessionKK2Data { payload: kk2_payload }); + match session.next_packet(kk2_msg) { + Ok(response_packet) => { + result_action = Some(Ok(LpAction::SendPacket(response_packet))); + // Replace old initiator subsession with new responder subsession + LpState::SubsessionHandshaking { session, subsession: new_subsession } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } else { + // We WIN - stay initiator, notify peer they lost + // Send SubsessionAbort to explicitly tell peer to become responder + let abort_msg = LpMessage::SubsessionAbort; + match session.next_packet(abort_msg) { + Ok(abort_packet) => { + result_action = Some(Ok(LpAction::SendPacket(abort_packet))); + LpState::SubsessionHandshaking { session, subsession } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + } + LpMessage::SubsessionKK2(kk2_data) if subsession.is_initiator() => { + // Initiator processes KK2, completes handshake + // Initiator emits SubsessionComplete with SubsessionReady packet + // and the subsession for caller to promote via into_session() + match subsession.process_message(&kk2_data.payload) { + Ok(_) if subsession.is_complete() => { + // Generate new receiver_index for subsession + let new_receiver_index: u32 = rand::random(); + session.demote(new_receiver_index); + + // Send SubsessionReady with new index + let ready_msg = LpMessage::SubsessionReady(SubsessionReadyData { + receiver_index: new_receiver_index, + }); + match session.next_packet(ready_msg) { + Ok(ready_packet) => { + result_action = Some(Ok(LpAction::SubsessionComplete { + packet: Some(ready_packet), + subsession, + new_receiver_index, + })); + LpState::ReadOnlyTransport { session } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + Ok(_) => { + // Handshake not complete yet, shouldn't happen for KK + let err = LpError::Internal("Subsession handshake incomplete after KK2".to_string()); + let reason = err.to_string(); + result_action = Some(Err(err)); + LpState::Closed { reason } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e)); + LpState::Closed { reason } + } + } + } + LpMessage::EncryptedData(_) => { + // Parent still processes normal traffic during subsession handshake + // Same as Transport state handling + if let Err(e) = session.receiving_counter_quick_check(packet.header.counter) { + result_action = Some(Err(e)); + LpState::SubsessionHandshaking { session, subsession } + } else { + match session.decrypt_data(&packet.message) { + Ok(plaintext) => { + if let Err(e) = session.receiving_counter_mark(packet.header.counter) { + result_action = Some(Err(e)); + LpState::SubsessionHandshaking { session, subsession } + } else { + result_action = Some(Ok(LpAction::DeliverData(BytesMut::from(plaintext.as_slice())))); + LpState::SubsessionHandshaking { session, subsession } + } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e.into())); + LpState::Closed { reason } + } + } + } + } + LpMessage::SubsessionReady(ready_data) if !subsession.is_initiator() => { + // Responder receives SubsessionReady from initiator + // Responder completes handshake here, uses initiator's receiver_index + // The subsession handshake should already be complete (after KK2) + if subsession.is_complete() { + let new_receiver_index = ready_data.receiver_index; + session.demote(new_receiver_index); + result_action = Some(Ok(LpAction::SubsessionComplete { + packet: None, // Responder has no packet to send + subsession, + new_receiver_index, + })); + LpState::ReadOnlyTransport { session } + } else { + // Shouldn't happen - handshake should be complete after KK2 + let err = LpError::Internal( + "Received SubsessionReady but handshake not complete".to_string(), + ); + let reason = err.to_string(); + result_action = Some(Err(err)); + LpState::Closed { reason } + } + } + LpMessage::SubsessionAbort if subsession.is_initiator() => { + // We received abort from peer - we lost the simultaneous initiation race. + // Peer has higher X25519 key and is staying as initiator. + // Discard our initiator subsession and return to Transport to receive peer's KK1. + // Peer's KK1 should already be in flight or queued. + result_action = None; + LpState::Transport { session } + } + LpMessage::SubsessionAbort if !subsession.is_initiator() => { + // Race was already resolved via KK1 - this abort is stale. + // We already became responder when we received KK1 and detected local < remote. + // The winner's abort message arrived after we processed their KK1. + // Silently ignore it - we're in the correct state. + result_action = None; + LpState::SubsessionHandshaking { session, subsession } + } + _ => { + // Wrong message type for subsession handshake + let err = LpError::InvalidStateTransition { + state: "SubsessionHandshaking".to_string(), + input: format!("Unexpected message type: {:?}", packet.message), + }; + let reason = err.to_string(); + result_action = Some(Err(err)); + LpState::Closed { reason } + } + } + } + } + + // Parent can still send data during subsession handshake + (LpState::SubsessionHandshaking { session, subsession }, LpInput::SendData(data)) => { + match self.prepare_data_packet(&session, &data) { + Ok(packet) => result_action = Some(Ok(LpAction::SendPacket(packet))), + Err(e) => { + result_action = Some(Err(e.into())); + } + } + LpState::SubsessionHandshaking { session, subsession } + } + + // Reject other inputs during subsession handshake + (LpState::SubsessionHandshaking { session, subsession }, LpInput::StartHandshake) => { + result_action = Some(Err(LpError::InvalidStateTransition { + state: "SubsessionHandshaking".to_string(), + input: "StartHandshake".to_string(), + })); + LpState::SubsessionHandshaking { session, subsession } + } + + (LpState::SubsessionHandshaking { session, subsession }, LpInput::InitiateSubsession) => { + result_action = Some(Err(LpError::InvalidStateTransition { + state: "SubsessionHandshaking".to_string(), + input: "InitiateSubsession".to_string(), + })); + LpState::SubsessionHandshaking { session, subsession } + } + + // --- ReadOnlyTransport State --- + (LpState::ReadOnlyTransport { session }, LpInput::ReceivePacket(packet)) => { + // Can still receive and decrypt, but state stays ReadOnlyTransport + if packet.header.receiver_idx() != session.id() { + result_action = Some(Err(LpError::UnknownSessionId(packet.header.receiver_idx()))); + LpState::ReadOnlyTransport { session } + } else { + if let Err(e) = session.receiving_counter_quick_check(packet.header.counter) { + result_action = Some(Err(e)); + LpState::ReadOnlyTransport { session } + } else { + match session.decrypt_data(&packet.message) { + Ok(plaintext) => { + if let Err(e) = session.receiving_counter_mark(packet.header.counter) { + result_action = Some(Err(e)); + LpState::ReadOnlyTransport { session } + } else { + result_action = Some(Ok(LpAction::DeliverData(BytesMut::from(plaintext.as_slice())))); + LpState::ReadOnlyTransport { session } + } + } + Err(e) => { + let reason = e.to_string(); + result_action = Some(Err(e.into())); + LpState::Closed { reason } + } + } + } + } + } + + // Reject SendData in read-only mode + (LpState::ReadOnlyTransport { session }, LpInput::SendData(_)) => { + result_action = Some(Err(LpError::NoiseError(NoiseError::SessionReadOnly))); + LpState::ReadOnlyTransport { session } + } + + // Reject other inputs in read-only mode + (LpState::ReadOnlyTransport { session }, LpInput::StartHandshake) => { + result_action = Some(Err(LpError::InvalidStateTransition { + state: "ReadOnlyTransport".to_string(), + input: "StartHandshake".to_string(), + })); + LpState::ReadOnlyTransport { session } + } + + (LpState::ReadOnlyTransport { session }, LpInput::InitiateSubsession) => { + result_action = Some(Err(LpError::InvalidStateTransition { + state: "ReadOnlyTransport".to_string(), + input: "InitiateSubsession".to_string(), + })); + LpState::ReadOnlyTransport { session } + } + + // --- Close Transition (applies to ReadyToHandshake, KKTExchange, Handshaking, Transport, SubsessionHandshaking, ReadOnlyTransport) --- ( LpState::ReadyToHandshake { .. } // We consume the session here | LpState::KKTExchange { .. } | LpState::Handshaking { .. } - | LpState::Transport { .. }, + | LpState::Transport { .. } + | LpState::SubsessionHandshaking { .. } + | LpState::ReadOnlyTransport { .. }, LpInput::Close, ) => { result_action = Some(Ok(LpAction::ConnectionClosed)); @@ -1056,4 +1515,240 @@ mod tests { )); assert!(matches!(initiator.state, LpState::KKTExchange { .. })); // Still in KKTExchange } + + /// Helper function to complete a full handshake between initiator and responder, + /// returning both in Transport state ready for subsession testing. + fn setup_transport_sessions() -> (LpStateMachine, LpStateMachine) { + // Use different seeds to get different X25519 keys. + // The tie-breaker compares X25519 public keys. + let ed25519_keypair_a = ed25519::KeyPair::from_secret([30u8; 32], 0); + let ed25519_keypair_b = ed25519::KeyPair::from_secret([31u8; 32], 1); + + let salt = [60u8; 32]; + let receiver_index: u32 = 111111; + + // Create state machines - Alice is initiator, Bob is responder + let mut alice = LpStateMachine::new( + receiver_index, + true, + ( + ed25519_keypair_a.private_key(), + ed25519_keypair_a.public_key(), + ), + ed25519_keypair_b.public_key(), + &salt, + ) + .unwrap(); + + let mut bob = LpStateMachine::new( + receiver_index, + false, + ( + ed25519_keypair_b.private_key(), + ed25519_keypair_b.public_key(), + ), + ed25519_keypair_a.public_key(), + &salt, + ) + .unwrap(); + + // --- Complete KKT Exchange --- + // Alice starts handshake + let kkt_request = if let Some(Ok(LpAction::SendPacket(p))) = + alice.process_input(LpInput::StartHandshake) + { + p + } else { + panic!("Alice should send KKT request"); + }; + + // Bob starts handshake + let _ = bob.process_input(LpInput::StartHandshake); + + // Bob receives KKT request, sends response + let kkt_response = if let Some(Ok(LpAction::SendPacket(p))) = + bob.process_input(LpInput::ReceivePacket(kkt_request)) + { + p + } else { + panic!("Bob should send KKT response"); + }; + + // Alice receives KKT response + let _ = alice.process_input(LpInput::ReceivePacket(kkt_response)); + + // --- Complete Noise Handshake --- + // Alice prepares first Noise message + let noise1_msg = alice.session().unwrap().prepare_handshake_message().unwrap().unwrap(); + let noise1_packet = alice.session().unwrap().next_packet(noise1_msg).unwrap(); + + // Bob receives noise1, sends noise2 + let noise2_packet = if let Some(Ok(LpAction::SendPacket(p))) = + bob.process_input(LpInput::ReceivePacket(noise1_packet)) + { + p + } else { + panic!("Bob should send Noise packet 2"); + }; + + // Alice receives noise2, sends noise3 + let noise3_packet = if let Some(Ok(LpAction::SendPacket(p))) = + alice.process_input(LpInput::ReceivePacket(noise2_packet)) + { + p + } else { + panic!("Alice should send Noise packet 3"); + }; + assert!(matches!(alice.state, LpState::Transport { .. })); + + // Bob receives noise3, completes handshake + let _ = bob.process_input(LpInput::ReceivePacket(noise3_packet)); + assert!(matches!(bob.state, LpState::Transport { .. })); + + (alice, bob) + } + + #[test] + fn test_simultaneous_subsession_initiation() { + // Test for simultaneous subsession initiation race condition. + // Both sides call InitiateSubsession at the same time, sending KK1 to each other. + // The tie-breaker uses X25519 public key comparison: lower key becomes responder. + + let (mut alice, mut bob) = setup_transport_sessions(); + + // Get X25519 public keys to determine expected winner + let alice_x25519 = alice.session().unwrap().local_x25519_public(); + let bob_x25519 = bob.session().unwrap().local_x25519_public(); + + // Determine who should win (higher key stays initiator) + let alice_wins = alice_x25519.as_bytes() > bob_x25519.as_bytes(); + + // --- Both sides initiate subsession simultaneously --- + // Alice initiates subsession + let alice_kk1_packet = if let Some(Ok(LpAction::SubsessionInitiated { packet, .. })) = + alice.process_input(LpInput::InitiateSubsession) + { + packet + } else { + panic!("Alice should initiate subsession with KK1"); + }; + assert!(matches!( + alice.state, + LpState::SubsessionHandshaking { .. } + )); + + // Bob initiates subsession (simultaneously) + let bob_kk1_packet = if let Some(Ok(LpAction::SubsessionInitiated { packet, .. })) = + bob.process_input(LpInput::InitiateSubsession) + { + packet + } else { + panic!("Bob should initiate subsession with KK1"); + }; + assert!(matches!(bob.state, LpState::SubsessionHandshaking { .. })); + + // --- Cross-delivery of KK1 packets (race resolution) --- + // Alice receives Bob's KK1 + let alice_response = alice.process_input(LpInput::ReceivePacket(bob_kk1_packet)); + + // Bob receives Alice's KK1 + let bob_response = bob.process_input(LpInput::ReceivePacket(alice_kk1_packet)); + + // --- Verify tie-breaker worked correctly --- + if alice_wins { + // Alice has higher key - she stays initiator, sends SubsessionAbort + assert!( + matches!(alice_response, Some(Ok(LpAction::SendPacket(_)))), + "Alice (winner) should send SubsessionAbort" + ); + assert!( + matches!(alice.state, LpState::SubsessionHandshaking { .. }), + "Alice should still be SubsessionHandshaking as initiator" + ); + + // Bob has lower key - he becomes responder, sends KK2 + let bob_kk2_packet = if let Some(Ok(LpAction::SendPacket(p))) = bob_response { + p + } else { + panic!("Bob (loser) should send KK2 as new responder"); + }; + assert!( + matches!(bob.state, LpState::SubsessionHandshaking { .. }), + "Bob should be SubsessionHandshaking as responder" + ); + + // Complete the handshake: Alice receives KK2 + let alice_completion = alice.process_input(LpInput::ReceivePacket(bob_kk2_packet)); + match alice_completion { + Some(Ok(LpAction::SubsessionComplete { + packet: Some(ready_packet), + .. + })) => { + assert!( + matches!(alice.state, LpState::ReadOnlyTransport { .. }), + "Alice should be ReadOnlyTransport after SubsessionComplete" + ); + + // Bob receives SubsessionReady + let bob_final = bob.process_input(LpInput::ReceivePacket(ready_packet)); + assert!( + matches!(bob_final, Some(Ok(LpAction::SubsessionComplete { .. }))), + "Bob should complete with SubsessionComplete" + ); + assert!( + matches!(bob.state, LpState::ReadOnlyTransport { .. }), + "Bob should be ReadOnlyTransport" + ); + } + other => panic!("Alice should complete subsession, got: {:?}", other), + } + } else { + // Bob has higher key - he stays initiator, sends SubsessionAbort + assert!( + matches!(bob_response, Some(Ok(LpAction::SendPacket(_)))), + "Bob (winner) should send SubsessionAbort" + ); + assert!( + matches!(bob.state, LpState::SubsessionHandshaking { .. }), + "Bob should still be SubsessionHandshaking as initiator" + ); + + // Alice has lower key - she becomes responder, sends KK2 + let alice_kk2_packet = if let Some(Ok(LpAction::SendPacket(p))) = alice_response { + p + } else { + panic!("Alice (loser) should send KK2 as new responder"); + }; + assert!( + matches!(alice.state, LpState::SubsessionHandshaking { .. }), + "Alice should be SubsessionHandshaking as responder" + ); + + // Complete the handshake: Bob receives KK2 + let bob_completion = bob.process_input(LpInput::ReceivePacket(alice_kk2_packet)); + match bob_completion { + Some(Ok(LpAction::SubsessionComplete { + packet: Some(ready_packet), + .. + })) => { + assert!( + matches!(bob.state, LpState::ReadOnlyTransport { .. }), + "Bob should be ReadOnlyTransport after SubsessionComplete" + ); + + // Alice receives SubsessionReady + let alice_final = alice.process_input(LpInput::ReceivePacket(ready_packet)); + assert!( + matches!(alice_final, Some(Ok(LpAction::SubsessionComplete { .. }))), + "Alice should complete with SubsessionComplete" + ); + assert!( + matches!(alice.state, LpState::ReadOnlyTransport { .. }), + "Alice should be ReadOnlyTransport" + ); + } + other => panic!("Bob should complete subsession, got: {:?}", other), + } + } + } } diff --git a/gateway/src/node/lp_listener/handler.rs b/gateway/src/node/lp_listener/handler.rs index e0c70df3689..5760c002965 100644 --- a/gateway/src/node/lp_listener/handler.rs +++ b/gateway/src/node/lp_listener/handler.rs @@ -388,7 +388,7 @@ impl LpConnectionHandler { let session = &session_entry.value().state; - // AIDEV-NOTE: Validate counter BEFORE decryption to prevent replay DoS attacks. + // Validate counter BEFORE decryption to prevent replay DoS attacks. // Counter is from cleartext header but authenticated by AEAD AAD, so this is safe. session.receiving_counter_quick_check(counter).map_err(|e| { inc!("lp_errors_replay_check"); From ca31c42794ef942d7fb85e6b96f0191ff7133aef Mon Sep 17 00:00:00 2001 From: durch Date: Sun, 30 Nov 2025 22:45:11 +0100 Subject: [PATCH 13/14] Add gateway subsession support with collision check and fast cleanup - Store LpStateMachine in session_states (not LpSession) for subsession handling - Add LpStateMachine::from_subsession() factory for promoted sessions - Rewrite handle_transport_packet() to use state machine for all messages - Add handle_subsession_complete() for session promotion flow - Add collision check for new_receiver_index before insert (nym-90rw) - Add demoted_session_ttl_secs config (default 60s) for ReadOnlyTransport sessions to be cleaned up quickly after subsession promotion (nym-atza) - Track demoted session cleanup separately with lp_states_cleanup_demoted_removed --- common/nym-lp/src/state_machine.rs | 24 +++ gateway/src/node/lp_listener/handler.rs | 208 +++++++++++++++++++----- gateway/src/node/lp_listener/mod.rs | 65 +++++++- 3 files changed, 248 insertions(+), 49 deletions(-) diff --git a/common/nym-lp/src/state_machine.rs b/common/nym-lp/src/state_machine.rs index c32e6c9f4e2..1ae84f01667 100644 --- a/common/nym-lp/src/state_machine.rs +++ b/common/nym-lp/src/state_machine.rs @@ -236,6 +236,30 @@ impl LpStateMachine { state: LpState::ReadyToHandshake { session }, }) } + + /// Creates a state machine in Transport state from a completed subsession handshake. + /// + /// This is used when a subsession (rekeying) completes and we need a new state machine + /// for the promoted session that can handle further subsession initiations (chained rekeying). + /// + /// # Arguments + /// + /// * `subsession` - The completed subsession handshake + /// * `receiver_index` - The new session's receiver index + /// + /// # Errors + /// + /// Returns error if the subsession handshake is not complete. + pub fn from_subsession( + subsession: SubsessionHandshake, + receiver_index: u32, + ) -> Result { + let session = subsession.into_session(receiver_index)?; + Ok(LpStateMachine { + state: LpState::Transport { session }, + }) + } + /// Processes an input event and returns a list of actions to perform. pub fn process_input(&mut self, input: LpInput) -> Option> { // 1. Replace current state with a placeholder, taking ownership of the real current state. diff --git a/gateway/src/node/lp_listener/handler.rs b/gateway/src/node/lp_listener/handler.rs index 5760c002965..caf3e1b0afd 100644 --- a/gateway/src/node/lp_listener/handler.rs +++ b/gateway/src/node/lp_listener/handler.rs @@ -122,7 +122,13 @@ impl LpConnectionHandler { .and_then(|session| session.outer_aead_key()) } else if let Some(session_entry) = self.state.session_states.get(&receiver_idx) { // Established session - should always have PSK - session_entry.value().state.outer_aead_key() + // session_states now stores LpStateMachine (not LpSession) for subsession support + session_entry + .value() + .state + .session() + .ok() + .and_then(|s| s.outer_aead_key()) } else { // Unknown session - will error during routing, parse cleartext None @@ -326,16 +332,15 @@ impl LpConnectionHandler { self.remote_addr, receiver_idx ); - // Extract session and move to session_states + // Move state machine to session_states (already in Transport state) + // We keep the state machine (not just session) to enable + // subsession/rekeying support during transport phase drop(state_entry); // Release mutable borrow let (_receiver_idx, timestamped_state) = self.state.handshake_states.remove(&receiver_idx) .ok_or_else(|| GatewayError::LpHandshakeError("Failed to remove handshake state".to_string()))?; - let session = timestamped_state.state.into_session() - .map_err(|e| GatewayError::LpHandshakeError(format!("Failed to extract session: {}", e)))?; - - self.state.session_states.insert(receiver_idx, super::TimestampedState::new(session)); + self.state.session_states.insert(receiver_idx, timestamped_state); inc!("lp_handshakes_success"); @@ -363,51 +368,101 @@ impl LpConnectionHandler { /// Handle transport packet (receiver_idx!=0, session established) /// /// This handles packets on established sessions, which can be either: - /// 1. LpRegistrationRequest - Client registering for dVPN/Mixnet access - /// 2. ForwardPacketData - Client forwarding packets to exit gateway (telescoping) + /// 1. EncryptedData containing LpRegistrationRequest or ForwardPacketData + /// 2. SubsessionKK1 - Client initiates subsession/rekeying + /// 3. SubsessionReady - Client confirms subsession promotion + /// + /// We process all transport packets through the state machine to enable + /// subsession support. The state machine returns appropriate actions: + /// - DeliverData: decrypted application data to process + /// - SendPacket: subsession response (KK2) to send + /// - SubsessionComplete: subsession promoted, create new session async fn handle_transport_packet( &mut self, receiver_idx: u32, packet: LpPacket, ) -> Result<(), GatewayError> { + use nym_lp::state_machine::{LpAction, LpInput}; + debug!( "Processing transport packet from {} (receiver_idx={})", self.remote_addr, receiver_idx ); - let counter = packet.header().counter(); - - // Get session and decrypt payload - let decrypted_bytes = { - let session_entry = self.state.session_states.get(&receiver_idx).ok_or_else(|| { - GatewayError::LpProtocolError(format!("Session not found: {}", receiver_idx)) - })?; - - // Update last activity timestamp - session_entry.value().touch(); + // Get state machine and process packet + let mut state_entry = self.state.session_states.get_mut(&receiver_idx).ok_or_else(|| { + GatewayError::LpProtocolError(format!("Session not found: {}", receiver_idx)) + })?; - let session = &session_entry.value().state; + // Update last activity timestamp + state_entry.value().touch(); - // Validate counter BEFORE decryption to prevent replay DoS attacks. - // Counter is from cleartext header but authenticated by AEAD AAD, so this is safe. - session.receiving_counter_quick_check(counter).map_err(|e| { - inc!("lp_errors_replay_check"); - GatewayError::LpProtocolError(format!("Replay check failed: {}", e)) - })?; + let state_machine = &mut state_entry.value_mut().state; - // Decrypt packet (Noise inner layer) - let decrypted = session.decrypt_data(packet.message()).map_err(|e| { - GatewayError::LpProtocolError(format!("Failed to decrypt packet: {}", e)) - })?; + // Process packet through state machine + let action = state_machine + .process_input(LpInput::ReceivePacket(packet)) + .ok_or_else(|| { + GatewayError::LpProtocolError("No action from state machine".to_string()) + })? + .map_err(|e| GatewayError::LpProtocolError(format!("State machine error: {}", e)))?; - // Mark counter as received after successful decryption - session.receiving_counter_mark(counter).map_err(|e| { - GatewayError::LpProtocolError(format!("Failed to mark counter: {}", e)) - })?; + // Get outer key before releasing borrow + let outer_key = state_machine.session().ok().and_then(|s| s.outer_aead_key()); + drop(state_entry); - decrypted - }; + match action { + LpAction::SendPacket(response_packet) => { + // Subsession KK2 response - gateway is responder + // This means we received SubsessionKK1 and are responding + debug!( + "Sending subsession KK2 response to {} (receiver_idx={})", + self.remote_addr, receiver_idx + ); + inc!("lp_subsession_kk2_sent"); + self.send_lp_packet(&response_packet, outer_key.as_ref()).await?; + self.emit_lifecycle_metrics(true); + Ok(()) + } + LpAction::DeliverData(data) => { + // Decrypted application data - process as registration/forwarding + self.handle_decrypted_payload(receiver_idx, data.to_vec()).await + } + LpAction::SubsessionComplete { + packet: ready_packet, + subsession, + new_receiver_index, + } => { + // Subsession complete - promote to new session + self.handle_subsession_complete( + receiver_idx, + ready_packet, + subsession, + new_receiver_index, + outer_key, + ) + .await + } + other => { + warn!( + "Unexpected action in transport from {}: {:?}", + self.remote_addr, other + ); + self.emit_lifecycle_metrics(false); + Err(GatewayError::LpProtocolError(format!( + "Unexpected action: {:?}", + other + ))) + } + } + } + /// Handle decrypted transport payload (registration or forwarding request) + async fn handle_decrypted_payload( + &mut self, + receiver_idx: u32, + decrypted_bytes: Vec, + ) -> Result<(), GatewayError> { // Try to deserialize as LpRegistrationRequest first (most common case after handshake) if let Ok(request) = bincode::deserialize::(&decrypted_bytes) { debug!( @@ -433,9 +488,72 @@ impl LpConnectionHandler { ); inc!("lp_errors_unknown_payload_type"); self.emit_lifecycle_metrics(false); - Err(GatewayError::LpProtocolError(format!( - "Unknown transport payload type (not registration or forwarding)" - ))) + Err(GatewayError::LpProtocolError( + "Unknown transport payload type (not registration or forwarding)".to_string(), + )) + } + + /// Handle subsession completion - promote subsession to new session + /// + /// When a subsession handshake completes (SubsessionReady received): + /// 1. Send SubsessionReady packet if present (for initiator - gateway is responder, so None) + /// 2. Create new state machine from completed subsession + /// 3. Store new session under new_receiver_index + /// 4. Old session stays in ReadOnlyTransport state until TTL cleanup + async fn handle_subsession_complete( + &mut self, + old_receiver_idx: u32, + ready_packet: Option, + subsession: nym_lp::session::SubsessionHandshake, + new_receiver_index: u32, + outer_key: Option, + ) -> Result<(), GatewayError> { + use nym_lp::state_machine::LpStateMachine; + + info!( + "Subsession complete from {}: old_idx={}, new_idx={}", + self.remote_addr, old_receiver_idx, new_receiver_index + ); + + // Send SubsessionReady packet if present (for initiator - gateway is responder, so typically None) + if let Some(packet) = ready_packet { + self.send_lp_packet(&packet, outer_key.as_ref()).await?; + } + + // Create new state machine from completed subsession + let new_state_machine = LpStateMachine::from_subsession(subsession, new_receiver_index) + .map_err(|e| { + GatewayError::LpProtocolError(format!("Failed to create session from subsession: {}", e)) + })?; + + // Check for receiver_index collision before inserting + // new_receiver_index is client-generated (rand::random() in state machine). + // Collisions are statistically unlikely (1 in 4 billion) but could cause DoS if exploited. + if self.state.session_states.contains_key(&new_receiver_index) + || self.state.handshake_states.contains_key(&new_receiver_index) + { + warn!( + "Subsession receiver_index collision: {} from {}", + new_receiver_index, self.remote_addr + ); + inc!("lp_subsession_receiver_index_collision"); + self.emit_lifecycle_metrics(false); + return Err(GatewayError::LpProtocolError( + "Subsession receiver index collision - client should retry".to_string(), + )); + } + + // Store new session under new_receiver_index + self.state + .session_states + .insert(new_receiver_index, super::TimestampedState::new(new_state_machine)); + + // Old session is now in ReadOnlyTransport state (handled by state machine) + // It will be cleaned up by TTL-based cleanup task + + inc!("lp_subsession_complete"); + self.emit_lifecycle_metrics(true); + Ok(()) } /// Handle registration request on an established session @@ -452,7 +570,12 @@ impl LpConnectionHandler { let session_entry = self.state.session_states.get(&receiver_idx).ok_or_else(|| { GatewayError::LpProtocolError(format!("Session not found: {}", receiver_idx)) })?; - let session = &session_entry.value().state; + // Access session via state machine for subsession support + let session = session_entry + .value() + .state + .session() + .map_err(|e| GatewayError::LpProtocolError(format!("Session error: {}", e)))?; // Serialize and encrypt response let response_bytes = bincode::serialize(&response).map_err(|e| { @@ -509,7 +632,12 @@ impl LpConnectionHandler { let session_entry = self.state.session_states.get(&receiver_idx).ok_or_else(|| { GatewayError::LpProtocolError(format!("Session not found: {}", receiver_idx)) })?; - let session = &session_entry.value().state; + // Access session via state machine for subsession support + let session = session_entry + .value() + .state + .session() + .map_err(|e| GatewayError::LpProtocolError(format!("Session error: {}", e)))?; let encrypted_message = session.encrypt_data(&response_bytes).map_err(|e| { GatewayError::LpProtocolError(format!("Failed to encrypt forward response: {}", e)) diff --git a/gateway/src/node/lp_listener/mod.rs b/gateway/src/node/lp_listener/mod.rs index b75b6523dc2..ee1f3301ede 100644 --- a/gateway/src/node/lp_listener/mod.rs +++ b/gateway/src/node/lp_listener/mod.rs @@ -56,6 +56,12 @@ // ## State Cleanup Metrics (in cleanup task) // - lp_states_cleanup_handshake_removed: Counter for stale handshakes removed by cleanup task // - lp_states_cleanup_session_removed: Counter for stale sessions removed by cleanup task +// - lp_states_cleanup_demoted_removed: Counter for demoted (read-only) sessions removed by cleanup task +// +// ## Subsession/Rekeying Metrics (in handler.rs) +// - lp_subsession_kk2_sent: Counter for SubsessionKK2 responses sent (indicates client initiated rekeying) +// - lp_subsession_complete: Counter for successful subsession promotions +// - lp_subsession_receiver_index_collision: Counter for subsession receiver_index collisions // // ## Usage Example // To view metrics, the nym-metrics registry automatically collects all metrics. @@ -67,7 +73,6 @@ use dashmap::DashMap; use nym_crypto::asymmetric::ed25519; use nym_gateway_storage::GatewayStorage; use nym_lp::state_machine::LpStateMachine; -use nym_lp::LpSession; use nym_node_metrics::NymNodeMetrics; use nym_task::ShutdownTracker; use nym_wireguard::{PeerControlRequest, WireguardGatewayData}; @@ -149,6 +154,14 @@ pub struct LpConfig { #[serde(default = "default_session_ttl_secs")] pub session_ttl_secs: u64, + /// Maximum age of demoted (read-only) sessions before cleanup (default: 60s) + /// + /// After subsession promotion, old sessions enter ReadOnlyTransport state. + /// They only need to stay alive briefly to drain in-flight packets. + /// This shorter TTL prevents memory buildup from frequent rekeying. + #[serde(default = "default_demoted_session_ttl_secs")] + pub demoted_session_ttl_secs: u64, + /// How often to run the state cleanup task (default: 5 minutes) /// /// The cleanup task scans for and removes stale handshakes and sessions. @@ -170,6 +183,7 @@ impl Default for LpConfig { use_mock_ecash: default_use_mock_ecash(), handshake_ttl_secs: default_handshake_ttl_secs(), session_ttl_secs: default_session_ttl_secs(), + demoted_session_ttl_secs: default_demoted_session_ttl_secs(), state_cleanup_interval_secs: default_state_cleanup_interval_secs(), } } @@ -207,6 +221,10 @@ fn default_session_ttl_secs() -> u64 { 86400 // 24 hours - for long-lived dVPN sessions } +fn default_demoted_session_ttl_secs() -> u64 { + 60 // 1 minute - enough to drain in-flight packets after subsession promotion +} + fn default_state_cleanup_interval_secs() -> u64 { 300 // 5 minutes - balances memory reclamation with task overhead } @@ -314,7 +332,12 @@ pub struct LpHandlerState { /// by session_id, decrypt/process, respond. /// /// Wrapped in TimestampedState for TTL-based cleanup of inactive sessions. - pub session_states: Arc>>, + /// + /// Sessions are stored as LpStateMachine (not LpSession) to enable + /// subsession/rekeying support. The state machine handles subsession initiation + /// (SubsessionKK1/KK2/Ready) during transport phase, allowing long-lived connections + /// to rekey without re-authentication. + pub session_states: Arc>>, } /// LP listener that accepts TCP connections on port 41264 @@ -456,13 +479,14 @@ impl LpListener { let session_states = Arc::clone(&self.handler_state.session_states); let handshake_ttl = self.handler_state.lp_config.handshake_ttl_secs; let session_ttl = self.handler_state.lp_config.session_ttl_secs; + let demoted_session_ttl = self.handler_state.lp_config.demoted_session_ttl_secs; let interval_secs = self.handler_state.lp_config.state_cleanup_interval_secs; let shutdown = self.shutdown.clone_shutdown_token(); let metrics = self.handler_state.metrics.clone(); info!( - "Starting LP state cleanup task (handshake_ttl={}s, session_ttl={}s, interval={}s)", - handshake_ttl, session_ttl, interval_secs + "Starting LP state cleanup task (handshake_ttl={}s, session_ttl={}s, demoted_ttl={}s, interval={}s)", + handshake_ttl, session_ttl, demoted_session_ttl, interval_secs ); self.shutdown.try_spawn_named( @@ -471,6 +495,7 @@ impl LpListener { session_states, handshake_ttl, session_ttl, + demoted_session_ttl, interval_secs, shutdown, metrics, @@ -483,15 +508,20 @@ impl LpListener { /// /// Runs periodically to scan handshake_states and session_states maps, /// removing entries that have exceeded their TTL. + /// + /// Demoted sessions (ReadOnlyTransport) use shorter TTL since they + /// only need to drain in-flight packets after subsession promotion. async fn cleanup_loop( handshake_states: Arc>>, - session_states: Arc>>, + session_states: Arc>>, handshake_ttl_secs: u64, session_ttl_secs: u64, + demoted_session_ttl_secs: u64, interval_secs: u64, shutdown: nym_task::ShutdownToken, _metrics: NymNodeMetrics, ) { + use nym_lp::state_machine::LpStateBare; use nym_metrics::inc_by; let mut cleanup_interval = @@ -510,6 +540,7 @@ impl LpListener { let start = std::time::Instant::now(); let mut hs_removed = 0u64; let mut ss_removed = 0u64; + let mut demoted_removed = 0u64; // Remove stale handshakes (based on age since creation) handshake_states.retain(|_, timestamped| { @@ -522,21 +553,34 @@ impl LpListener { }); // Remove stale sessions (based on time since last activity) + // Use shorter TTL for demoted (ReadOnlyTransport) sessions session_states.retain(|_, timestamped| { - if timestamped.seconds_since_activity() > session_ttl_secs { - ss_removed += 1; + let is_demoted = timestamped.state.bare_state() == LpStateBare::ReadOnlyTransport; + let ttl = if is_demoted { + demoted_session_ttl_secs + } else { + session_ttl_secs + }; + + if timestamped.seconds_since_activity() > ttl { + if is_demoted { + demoted_removed += 1; + } else { + ss_removed += 1; + } false } else { true } }); - if hs_removed > 0 || ss_removed > 0 { + if hs_removed > 0 || ss_removed > 0 || demoted_removed > 0 { let duration = start.elapsed(); info!( - "LP state cleanup: removed {} handshakes, {} sessions (took {:.3}s)", + "LP state cleanup: removed {} handshakes, {} sessions, {} demoted (took {:.3}s)", hs_removed, ss_removed, + demoted_removed, duration.as_secs_f64() ); @@ -547,6 +591,9 @@ impl LpListener { if ss_removed > 0 { inc_by!("lp_states_cleanup_session_removed", ss_removed as i64); } + if demoted_removed > 0 { + inc_by!("lp_states_cleanup_demoted_removed", demoted_removed as i64); + } } } } From b16051ab465593afd37ba4f033825069011049f5 Mon Sep 17 00:00:00 2001 From: durch Date: Mon, 1 Dec 2025 10:14:02 +0100 Subject: [PATCH 14/14] Pedantic fixes --- common/nym-lp/src/state_machine.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/common/nym-lp/src/state_machine.rs b/common/nym-lp/src/state_machine.rs index 1ae84f01667..8fd3b5d741e 100644 --- a/common/nym-lp/src/state_machine.rs +++ b/common/nym-lp/src/state_machine.rs @@ -14,6 +14,7 @@ use crate::{ use bytes::BytesMut; use nym_crypto::asymmetric::ed25519; use std::mem; +use tracing::debug; /// Represents the possible states of the Lewes Protocol connection. #[derive(Debug, Default)] @@ -595,6 +596,15 @@ impl LpStateMachine { } } } + // AIDEV-NOTE: Stale abort in Transport state - race already resolved. + // This can happen if abort arrives after loser already returned to Transport + // via KK1 processing (loser detected local < remote and became responder). + // The winner's abort message arrived late. Silently ignore. + LpMessage::SubsessionAbort => { + debug!("Ignoring stale SubsessionAbort in Transport state"); + result_action = None; + LpState::Transport { session } + } _ => { // Unexpected message type in Transport state let err = LpError::InvalidStateTransition {