diff --git a/common/nym-lp/DESIGN.md b/common/nym-lp/DESIGN.md index 4723a64b10..095a87c034 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 aa98659694..a4e4100e30 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, - } - }); + 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, + }) } - }; - - // Cleartext path: parse message from payload - let message = parse_message_from_type_and_content(message_type_raw, message_content)?; - - 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 33edd7fef3..89bd4e72f4 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 c98353c867..0bcc5fe7e0 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 c6eabff671..e0c70df368 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);