Skip to main content

phantom_protocol/transport/
types.rs

1//! Phantom Protocol - Types
2//!
3//! Core types for the Phantom Protocol:
4//! - SessionId (256-bit, salt for encryption)
5//! - StreamId, SequenceNumber
6//! - PacketHeader, PacketFlags
7//! - PhantomPacket (the single on-wire data packet)
8
9use borsh::{BorshDeserialize, BorshSerialize};
10use std::fmt;
11
12/// 256-bit Session Identifier
13///
14/// Used as salt for encryption and session persistence across IP changes.
15/// Post-quantum safe size (32 bytes = 256 bits).
16#[derive(Clone, Copy, PartialEq, Eq, Hash)]
17pub struct SessionId(pub [u8; 32]);
18
19impl SessionId {
20    /// Create a new random session ID
21    pub fn random() -> Self {
22        let mut bytes = [0u8; 32];
23        if getrandom::getrandom(&mut bytes).is_err() {
24            // Fallback to thread_rng which is always available
25            rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut bytes);
26        }
27        Self(bytes)
28    }
29
30    /// Create from bytes
31    pub fn from_bytes(bytes: [u8; 32]) -> Self {
32        Self(bytes)
33    }
34
35    /// Get as byte slice
36    pub fn as_bytes(&self) -> &[u8; 32] {
37        &self.0
38    }
39}
40
41impl fmt::Debug for SessionId {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        write!(f, "SessionId({}...)", hex::encode(&self.0[..8]))
44    }
45}
46
47impl fmt::Display for SessionId {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        write!(f, "{}...", hex::encode(&self.0[..8]))
50    }
51}
52
53/// Stream identifier within a session
54///
55/// Stream 0 is reserved for control messages.
56/// Each stream has independent sequence numbers (no HoL blocking).
57pub type StreamId = u16;
58
59/// Per-stream gap-free reliable-data offset (A.5). Stays `u32`.
60pub type SequenceNumber = u32;
61
62/// Per-direction monotonic AEAD packet number (① — Phase 4). Feeds the AEAD
63/// nonce and the per-direction replay window. `u64` so it never wraps within a
64/// session — this is what retires the C1 forced-rekey watermark.
65pub type PacketNumber = u64;
66
67/// The sole on-wire packet-header version byte. Pinned — the wire format is not
68/// negotiated (pre-1.0, no users); a decoder rejects anything else. `6` is the
69/// anti-fingerprint diet: the version byte is now itself HP-masked (the WHOLE
70/// 15-byte header `[0..15]` is masked — no constant cleartext byte), and the two
71/// cleartext `u32` length prefixes are dropped (`payload` is the message
72/// remainder — `recv_bytes` is message-framed — and `extensions` leave the wire),
73/// saving 8 bytes/packet. `5` (ε) collapsed the two connection identifiers into
74/// one rotating CID: the 32-byte inner `session_id` left the data-plane wire (it
75/// stays in the AEAD AAD, reconstructed from session context), shrinking the
76/// header to 15 bytes. `4` (T4.6) added QUIC-style header protection (RFC 9001
77/// §5.4) over a 47-byte header; `3` (Phase 4) widened the packet number to `u64`.
78/// See PROTOCOL.md § 4.2.
79pub const WIRE_VERSION: u8 = 6;
80
81/// Wire offset where the header-protected region begins. **WIRE v6
82/// (anti-fingerprint): `0`** — the masked region now covers the WHOLE 15-byte
83/// header, INCLUDING the `version(1)` byte, so the data-plane wire has no
84/// constant cleartext byte to fingerprint. (Was `1` in v5, after the cleartext
85/// version byte; `33` in v4, after `version ‖ session_id`.) Everything at
86/// `[HP_PROTECTED_OFFSET..PacketHeader::SIZE]` is XOR-masked on the wire.
87pub const HP_PROTECTED_OFFSET: usize = 0;
88
89/// Length of the header-protected region (`PacketHeader::SIZE - HP_PROTECTED_OFFSET`
90/// = 15 bytes in v6: `version ‖ packet_number ‖ flags ‖ stream_id ‖ epoch ‖
91/// path_id`). The [`HeaderProtector`] produces a full 16-byte mask block; the
92/// first `HP_PROTECTED_LEN` bytes are used.
93///
94/// [`HeaderProtector`]: crate::crypto::header_protection::HeaderProtector
95pub const HP_PROTECTED_LEN: usize = PacketHeader::SIZE - HP_PROTECTED_OFFSET;
96
97/// Error decoding a packet header / packet from its on-wire bytes.
98///
99/// The explicit codec ([`PacketHeader::from_wire`] / [`PhantomPacket::from_wire`])
100/// has exactly one failure mode: the buffer is shorter than the structure it
101/// declares (a header underrun, or a length prefix that runs past the end of the
102/// buffer). A malformed frame is dropped, never a panic.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum WireError {
105    /// The buffer is shorter than the declared structure.
106    Truncated,
107}
108
109impl fmt::Display for WireError {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        match self {
112            WireError::Truncated => write!(f, "truncated packet"),
113        }
114    }
115}
116
117impl std::error::Error for WireError {}
118
119/// Packet flags bitfield (16-bit).
120#[derive(Clone, Copy, PartialEq, Eq, Default)]
121pub struct PacketFlags(pub u16);
122
123impl PacketFlags {
124    /// Requires acknowledgment
125    pub const RELIABLE: u16 = 0x0001;
126    /// This is an ACK packet
127    pub const ACK: u16 = 0x0002;
128    /// Stream finished
129    pub const FIN: u16 = 0x0004;
130    /// Fire-and-forget (no retransmission)
131    pub const UNRELIABLE: u16 = 0x0008;
132    /// High priority (voice, video frames)
133    pub const PRIORITY: u16 = 0x0010;
134    /// Payload is encrypted
135    pub const ENCRYPTED: u16 = 0x0020;
136    /// Payload is compressed
137    pub const COMPRESSED: u16 = 0x0040;
138    /// Control message (handshake, migration)
139    pub const CONTROL: u16 = 0x0080;
140    /// Sender is rekeying — receiver must derive the next AEAD key from the
141    /// traffic-secret rekey chain (`HKDF-Expand(current, "phantom-rekey-v1", 32)`,
142    /// Invariant 5) before decrypting this packet (Phase 1.5).
143    pub const REKEY: u16 = 0x0100;
144    /// Path-validation challenge / response packet for multi-path migration
145    /// (Phase 4.2). Payload carries the 32-byte challenge or response.
146    pub const PATH_VALIDATION: u16 = 0x0200;
147    /// Payload is a coalesced bundle of inner packets in
148    /// `[count: u16][len1: u16][payload1]...` format (Phase 2.5).
149    pub const COALESCED: u16 = 0x0400;
150    /// Per-stream flow control update (Phase 4.3). Payload is a
151    /// big-endian `u32` carrying the receiver's newly-available
152    /// window in bytes (absolute window size, NOT a delta — simpler
153    /// and self-correcting under packet loss).
154    pub const WINDOW_UPDATE: u16 = 0x0800;
155    /// Idle keep-alive PING (Direction #3 — download-only liveness). A small
156    /// `ENCRYPTED | KEEPALIVE` packet with an **empty** payload that an idle
157    /// sender emits to (a) refresh the peer's inbound-activity timer and (b)
158    /// anchor its own liveness timer (the in-flight keep-alive lets a
159    /// download-only path — which otherwise sends only ACKs and has nothing in
160    /// flight — detect a silently-dead downstream). A bare `KEEPALIVE` is a
161    /// PING; `KEEPALIVE | ACK` is the peer's PONG echo. Neither carries
162    /// application bytes, so neither reaches `recv()`. (Spare flag bit — no
163    /// header layout / `WIRE_VERSION` change.)
164    pub const KEEPALIVE: u16 = 0x1000;
165    /// Anti-fingerprint size padding present (WIRE v6, deliverable (c)). When set,
166    /// the AEAD **plaintext** ends with a trailer `‹pad_n zero bytes› ‖ pad_n:u16be`
167    /// that the receiver strips after decrypt (see [`crate::transport::shaping`]).
168    /// The flag rides inside the HP-masked header, so it is invisible on the wire;
169    /// the padding itself is encrypted + authenticated, so only the bucketed
170    /// datagram size is observable. (Spare flag bit — no header layout change.)
171    pub const PADDED: u16 = 0x2000;
172    /// Anti-fingerprint cover (dummy) traffic (WIRE v6, deliverable (e)). An
173    /// `ENCRYPTED | COVER` packet carries **no application data** (empty inner
174    /// plaintext, typically `PADDED` to a bucket); its sole purpose is to normalize
175    /// the outbound traffic pattern (idle-fill + a floor packet rate) so silence and
176    /// volume no longer leak. It AEAD-authenticates like any packet (so it refreshes
177    /// the peer's liveness and cannot be off-path injected); the receiver drops it
178    /// before the data path, so it never reaches `recv()`. (Spare flag bit — no
179    /// header layout / `WIRE_VERSION` change.)
180    pub const COVER: u16 = 0x4000;
181    // 0x8000 — reserved for future amendments.
182
183    /// Create new flags with no bits set
184    pub const fn empty() -> Self {
185        Self(0)
186    }
187
188    /// Create flags with specific bits
189    pub const fn new(bits: u16) -> Self {
190        Self(bits)
191    }
192
193    /// Check if flag is set
194    #[inline]
195    pub const fn contains(&self, flag: u16) -> bool {
196        (self.0 & flag) == flag
197    }
198
199    /// Set a flag
200    #[inline]
201    pub fn set(&mut self, flag: u16) {
202        self.0 |= flag;
203    }
204
205    /// Clear a flag
206    #[inline]
207    pub fn clear(&mut self, flag: u16) {
208        self.0 &= !flag;
209    }
210
211    /// Check if reliable delivery is required
212    #[inline]
213    pub const fn is_reliable(&self) -> bool {
214        self.contains(Self::RELIABLE)
215    }
216
217    /// Check if this is an ACK packet
218    #[inline]
219    pub const fn is_ack(&self) -> bool {
220        self.contains(Self::ACK)
221    }
222
223    /// Check if stream is finished
224    #[inline]
225    pub const fn is_fin(&self) -> bool {
226        self.contains(Self::FIN)
227    }
228
229    /// Check if this is a control packet
230    #[inline]
231    pub const fn is_control(&self) -> bool {
232        self.contains(Self::CONTROL)
233    }
234
235    /// Check if this is a rekey packet
236    #[inline]
237    pub const fn is_rekey(&self) -> bool {
238        self.contains(Self::REKEY)
239    }
240}
241
242impl fmt::Debug for PacketFlags {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        let mut flags = Vec::new();
245        if self.contains(Self::RELIABLE) {
246            flags.push("RELIABLE");
247        }
248        if self.contains(Self::ACK) {
249            flags.push("ACK");
250        }
251        if self.contains(Self::FIN) {
252            flags.push("FIN");
253        }
254        if self.contains(Self::UNRELIABLE) {
255            flags.push("UNRELIABLE");
256        }
257        if self.contains(Self::PRIORITY) {
258            flags.push("PRIORITY");
259        }
260        if self.contains(Self::ENCRYPTED) {
261            flags.push("ENCRYPTED");
262        }
263        if self.contains(Self::COMPRESSED) {
264            flags.push("COMPRESSED");
265        }
266        if self.contains(Self::CONTROL) {
267            flags.push("CONTROL");
268        }
269        if self.contains(Self::REKEY) {
270            flags.push("REKEY");
271        }
272        if self.contains(Self::PATH_VALIDATION) {
273            flags.push("PATH_VALIDATION");
274        }
275        if self.contains(Self::COALESCED) {
276            flags.push("COALESCED");
277        }
278        if self.contains(Self::WINDOW_UPDATE) {
279            flags.push("WINDOW_UPDATE");
280        }
281        write!(f, "PacketFlags({})", flags.join("|"))
282    }
283}
284
285/// Packet header — 15 bytes on the wire ([`PacketHeader::SIZE`]); the AEAD AAD is
286/// the separate, larger 47-byte image ([`PacketHeader::AAD_SIZE`]).
287///
288/// Serialised by [`PacketHeader::to_wire`] as an explicit, fixed **big-endian**
289/// (network byte order) image, `version` first (no serialization library). The
290/// WIRE v6 (anti-fingerprint) wire layout is a contiguous 15-byte span, the WHOLE
291/// of which is HP-masked on the wire (`HP_PROTECTED_OFFSET = 0` — no constant
292/// cleartext byte). The 32-byte `session_id` is **off-wire**: it is a struct field
293/// bound into the AEAD AAD but never transmitted (dropped from the wire in v5 / the
294/// ε CID collapse), reconstructed by the receiver from session context.
295///
296/// ```text
297/// off  0  version        u8       (= WIRE_VERSION = 6)            HP-MASKED ┐
298/// off  1  packet_number  u64 be   (① per-direction monotonic)    HP-MASKED │
299/// off  9  flags          u16 be                                  HP-MASKED │ [0..15]
300/// off 11  stream_id      u16 be                                  HP-MASKED │
301/// off 13  epoch          u8                                      HP-MASKED │
302/// off 14  path_id        u8                                      HP-MASKED ┘
303/// ```
304///
305/// The 47-byte AEAD AAD image ([`Self::to_aad_image`]) is the cleartext header
306/// reconstructed off-wire (it additionally binds the off-wire `session_id`), so
307/// flipping any authenticated byte (`version` / `session_id` included) fails
308/// decryption. On the wire the whole `[0..15]` span is XOR-masked by the
309/// per-session [`HeaderProtector`] (a wire mutation of the masked region unmasks to
310/// a wrong header → wrong AAD → AEAD fails — no new oracle); the recv path
311/// reconstructs the cleartext header via [`RawPacket::unmask_header`] before
312/// computing the AAD. `epoch`/`stream_id`/`path_id` are authenticated in the AAD
313/// but NOT in the nonce (which is `prefix‖packet_number`). The recv path also drops
314/// a frame whose `version != WIRE_VERSION`. Frozen by
315/// `core/tests/wire_vectors/packet_header.bin`; grammar in
316/// `docs/protocol/PROTOCOL.md` § 4.2.
317///
318/// [`HeaderProtector`]: crate::crypto::header_protection::HeaderProtector
319#[derive(Clone, Copy, PartialEq, Eq)]
320#[repr(C)]
321pub struct PacketHeader {
322    /// On-wire packet-format version. Pinned to [`WIRE_VERSION`]; the first wire
323    /// byte (see [`PacketHeader::to_wire`]).
324    pub version: u8,
325    /// 256-bit session identifier, used as encryption salt
326    pub session_id: SessionId,
327    /// Stream within session (0 = control)
328    pub stream_id: StreamId,
329    /// Per-direction monotonic AEAD packet number (① — Phase 4). Feeds the AEAD
330    /// nonce and the per-direction replay window; assigned at send time.
331    pub packet_number: PacketNumber,
332    /// Packet flags
333    pub flags: PacketFlags,
334    /// Rekey generation. Zero at session establishment, incremented in lock-
335    /// step on each in-band rekey (Phase 1.5).
336    pub epoch: u8,
337    /// Connection-migration path identifier (Phase 4.2). 0 = the implicit,
338    /// always-validated handshake path; bumped per `migrate()` so the peer
339    /// challenges the new path.
340    pub path_id: u8,
341}
342
343impl PacketHeader {
344    /// On-wire header size in bytes (ε / WIRE v5: `version(1)` + the 14 HP-masked
345    /// fields; `session_id` is off-wire). The AEAD AAD is the larger
346    /// [`Self::AAD_SIZE`] image (which still binds `session_id`).
347    pub const SIZE: usize = 15;
348
349    /// Size of the AEAD AAD header image: the byte-identical 47-byte v4 logical
350    /// header (`version ‖ session_id ‖ packet_number ‖ flags ‖ stream_id ‖ epoch
351    /// ‖ path_id`), reconstructed off-wire by [`Self::to_aad_image`]. Kept at 47
352    /// so the v4 AEAD security argument carries over unchanged (design §2.2).
353    pub const AAD_SIZE: usize = 47;
354
355    /// Create a new packet header (version = [`WIRE_VERSION`], epoch = 0,
356    /// path_id = 0).
357    pub fn new(
358        session_id: SessionId,
359        stream_id: StreamId,
360        packet_number: PacketNumber,
361        flags: PacketFlags,
362    ) -> Self {
363        Self {
364            version: WIRE_VERSION,
365            session_id,
366            stream_id,
367            packet_number,
368            flags,
369            epoch: 0,
370            path_id: 0,
371        }
372    }
373
374    /// Set the rekey epoch — used by `Session::rekey` (Phase 1.5).
375    pub fn with_epoch(mut self, epoch: u8) -> Self {
376        self.epoch = epoch;
377        self
378    }
379
380    /// Set the path id — stamped by the connection-migration path machinery
381    /// (Phase 4.2; 0 = the implicit handshake path).
382    pub fn with_path_id(mut self, path_id: u8) -> Self {
383        self.path_id = path_id;
384        self
385    }
386
387    /// Serialise to the fixed 15 on-wire bytes (big-endian, `version` first;
388    /// ε / WIRE v5). `session_id` is **not** emitted — it is off-wire and lives
389    /// only in the AEAD AAD ([`Self::to_aad_image`]) and the session context.
390    pub fn to_wire(&self) -> [u8; Self::SIZE] {
391        let mut b = [0u8; Self::SIZE];
392        b[0] = self.version;
393        // Field layout after the version byte: packet_number ‖ flags ‖ stream_id ‖
394        // epoch ‖ path_id (see the struct doc grammar). This is the CLEARTEXT image;
395        // the WIRE v6 whole-header HP mask is applied separately by `to_wire_masked`.
396        b[1..9].copy_from_slice(&self.packet_number.to_be_bytes());
397        b[9..11].copy_from_slice(&self.flags.0.to_be_bytes());
398        b[11..13].copy_from_slice(&self.stream_id.to_be_bytes());
399        b[13] = self.epoch;
400        b[14] = self.path_id;
401        b
402    }
403
404    /// The AEAD AAD image: the byte-identical 47-byte v4 logical header
405    /// (`version ‖ session_id ‖ packet_number ‖ flags ‖ stream_id ‖ epoch ‖
406    /// path_id`). `session_id` is off-wire, so the data plane reconstructs it
407    /// from the session context ([`Session::id`]) into `self.session_id` before
408    /// computing the AAD; this keeps the AEAD binding (and its security
409    /// argument) byte-identical to v4 (design §2.2).
410    ///
411    /// [`Session::id`]: crate::transport::session::Session::id
412    pub fn to_aad_image(&self) -> [u8; Self::AAD_SIZE] {
413        let mut b = [0u8; Self::AAD_SIZE];
414        b[0] = self.version;
415        b[1..33].copy_from_slice(&self.session_id.0);
416        b[33..41].copy_from_slice(&self.packet_number.to_be_bytes());
417        b[41..43].copy_from_slice(&self.flags.0.to_be_bytes());
418        b[43..45].copy_from_slice(&self.stream_id.to_be_bytes());
419        b[45] = self.epoch;
420        b[46] = self.path_id;
421        b
422    }
423
424    /// Parse a header from the first [`Self::SIZE`] (= 15) bytes of `bytes`. Does
425    /// not validate `version` — the recv path gates on it separately. `session_id`
426    /// is off-wire (ε / WIRE v5): it is left the placeholder zero here and set by
427    /// [`Session::parse_protected`] from the routed session context.
428    ///
429    /// [`Session::parse_protected`]: crate::transport::session::Session::parse_protected
430    pub fn from_wire(bytes: &[u8]) -> Result<Self, WireError> {
431        if bytes.len() < Self::SIZE {
432            return Err(WireError::Truncated);
433        }
434        Ok(Self {
435            version: bytes[0],
436            // Off-wire placeholder; reconstructed by Session::parse_protected.
437            session_id: SessionId([0u8; 32]),
438            // Field layout after the version byte: packet_number ‖ flags ‖
439            // stream_id ‖ epoch ‖ path_id (the inverse of `to_wire`).
440            packet_number: u64::from_be_bytes([
441                bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8],
442            ]),
443            flags: PacketFlags(u16::from_be_bytes([bytes[9], bytes[10]])),
444            stream_id: u16::from_be_bytes([bytes[11], bytes[12]]),
445            epoch: bytes[13],
446            path_id: bytes[14],
447        })
448    }
449}
450
451impl fmt::Debug for PacketHeader {
452    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
453        f.debug_struct("PacketHeader")
454            .field("version", &self.version)
455            .field("session", &self.session_id)
456            .field("stream", &self.stream_id)
457            .field("pn", &self.packet_number)
458            .field("flags", &self.flags)
459            .field("epoch", &self.epoch)
460            .field("path_id", &self.path_id)
461            .finish()
462    }
463}
464
465/// Full packet with header and payload — the single on-wire data packet.
466#[derive(Clone, PartialEq, Eq)]
467pub struct PhantomPacket {
468    /// Packet header (15 bytes on the wire; see [`PacketHeader::SIZE`])
469    pub header: PacketHeader,
470    /// Encrypted payload (or coalesced bundle if `COALESCED` flag set)
471    pub payload: Vec<u8>,
472    /// Forward-compat TLV headroom — retained for the AEAD-AAD/codec shape but
473    /// **NOT serialised onto the WIRE v6 data-plane wire**: `to_wire` emits only
474    /// `header ‖ payload`, and `from_wire` always yields an empty `Vec` here (see
475    /// [`PhantomPacket::to_wire`]). Was a length-prefixed wire field pre-v6.
476    pub extensions: Vec<u8>,
477}
478
479impl PhantomPacket {
480    /// Create a new packet (extensions empty by default)
481    pub fn new(header: PacketHeader, payload: Vec<u8>) -> Self {
482        Self {
483            header,
484            payload,
485            extensions: Vec::new(),
486        }
487    }
488
489    /// Create an ACK packet: `ACK` flag only, empty payload, unencrypted.
490    pub fn ack(session_id: SessionId, stream_id: StreamId, ack_packet_number: u64) -> Self {
491        Self {
492            header: PacketHeader::new(
493                session_id,
494                stream_id,
495                ack_packet_number,
496                PacketFlags::new(PacketFlags::ACK),
497            ),
498            payload: Vec::new(),
499            extensions: Vec::new(),
500        }
501    }
502
503    /// Total wire size: the 15-byte header plus the payload (WIRE v6 — no length
504    /// prefixes, no `extensions` on the wire).
505    pub fn wire_size(&self) -> usize {
506        PacketHeader::SIZE + self.payload.len()
507    }
508
509    /// Serialise to the on-wire bytes: `header(15) || payload`.
510    ///
511    /// **WIRE v6 (anti-fingerprint diet):** the two cleartext `u32` length
512    /// prefixes (`payload_len` / `ext_len`) are GONE — `payload` is simply the
513    /// message remainder after the 15-byte header. `SessionTransport::recv_bytes`
514    /// is message-framed on every transport (UDP datagram / TCP 4-byte frame /
515    /// embedded frame), so the prefixes were pure redundancy *and* a verifiable
516    /// structural fingerprint (`ext_len == 0`, `payload_len == datagram − const`).
517    /// `extensions` is no longer carried on the data-plane wire (it was always
518    /// empty; the AEAD AAD still binds an empty extensions slice).
519    ///
520    /// This is the **cleartext** wire image (the 15-byte header still has
521    /// `session_id` off-wire — the AEAD AAD is the separate 47-byte
522    /// [`PacketHeader::to_aad_image`]). The data plane never puts these bytes on
523    /// the wire directly — it calls [`Self::to_wire_masked`] to apply header
524    /// protection (which now masks the whole 15-byte header, version included).
525    pub fn to_wire(&self) -> Vec<u8> {
526        let mut b = Vec::with_capacity(self.wire_size());
527        b.extend_from_slice(&self.header.to_wire());
528        b.extend_from_slice(&self.payload);
529        b
530    }
531
532    /// Serialise with header protection applied: identical to [`Self::to_wire`],
533    /// then the **whole** `[HP_PROTECTED_OFFSET..PacketHeader::SIZE]` region (WIRE
534    /// v6: the 15 bytes `version ‖ packet_number ‖ flags ‖ stream_id ‖ epoch ‖
535    /// path_id`) is XOR-masked with the caller-supplied HP `mask`. The mask is
536    /// computed from this packet's `payload` ciphertext by the session's
537    /// `HeaderProtector` (`mask_send`); the `payload` itself stays cleartext on the
538    /// wire (it *is* the AEAD ciphertext) so the recv path can locate the sample —
539    /// at the fixed offset [`PacketHeader::SIZE`] — before unmasking (the demux
540    /// routes on the outer ConnId). With the version byte now masked too, the
541    /// data-plane wire has no constant cleartext byte. Only the first
542    /// [`HP_PROTECTED_LEN`] bytes of `mask` are used; a shorter `mask` masks fewer
543    /// bytes rather than panicking (the session always supplies a full 16-byte
544    /// mask).
545    pub fn to_wire_masked(&self, mask: &[u8]) -> Vec<u8> {
546        let mut buf = self.to_wire();
547        // zip stops at the shorter of the 15-byte region and `mask`, so a short
548        // mask masks fewer bytes rather than panicking.
549        for (b, m) in buf[HP_PROTECTED_OFFSET..PacketHeader::SIZE]
550            .iter_mut()
551            .zip(mask)
552        {
553            *b ^= *m;
554        }
555        buf
556    }
557
558    /// Parse a packet from its on-wire bytes — the inverse of [`Self::to_wire`]
559    /// (WIRE v6): the 15-byte header, then `payload` is the whole remainder. A
560    /// buffer shorter than the header yields [`WireError::Truncated`]. `extensions`
561    /// is always empty (off the v6 data-plane wire).
562    pub fn from_wire(bytes: &[u8]) -> Result<Self, WireError> {
563        let header = PacketHeader::from_wire(bytes)?;
564        let payload = bytes[PacketHeader::SIZE..].to_vec();
565        Ok(Self {
566            header,
567            payload,
568            extensions: Vec::new(),
569        })
570    }
571}
572
573/// A partially-decoded v6 packet: the cleartext envelope with the **entire**
574/// 15-byte header-protected region (version included) left **opaque**, plus the
575/// `payload` (the message remainder). The recv path produces this from the raw
576/// wire bytes *before* it has the per-session HP key — it locates the ciphertext
577/// sample (`payload`, at the fixed offset 15) and calls
578/// [`RawPacket::unmask_header`] with the mask computed from `payload` to recover
579/// the [`PacketHeader`] (including its `version` byte; the off-wire `session_id`
580/// is then set by [`Session::parse_protected`]). This is the codec half of header
581/// protection; the mask itself is computed by the session's `HeaderProtector`
582/// (this module stays crypto-free). Routing is by the outer (rotating) ConnId.
583///
584/// WIRE v6: no cleartext `version` byte and no length prefixes — the version is
585/// inside `masked_header`, and `payload` is everything after the 15-byte header.
586///
587/// [`Session::parse_protected`]: crate::transport::session::Session::parse_protected
588#[derive(Clone, Debug)]
589pub struct RawPacket {
590    /// The still-masked header-protected region (the WHOLE wire `[0..15]`,
591    /// version included).
592    masked_header: [u8; HP_PROTECTED_LEN],
593    /// AEAD ciphertext = the message remainder after the 15-byte header (cleartext
594    /// on the wire; the payload itself is the AEAD ciphertext).
595    pub payload: Vec<u8>,
596    /// Forward-compat headroom — always empty on the v6 data-plane wire (retained
597    /// for the AAD/codec shape; see [`PhantomPacket::to_wire`]).
598    pub extensions: Vec<u8>,
599}
600
601impl RawPacket {
602    /// Parse the cleartext envelope of a v6 wire packet, leaving the whole 15-byte
603    /// HP-masked header region opaque and taking `payload` as the remainder.
604    /// Bounds-checked exactly like [`PhantomPacket::from_wire`] — a short / hostile
605    /// buffer yields [`WireError::Truncated`], never a panic.
606    pub fn from_wire(bytes: &[u8]) -> Result<Self, WireError> {
607        if bytes.len() < PacketHeader::SIZE {
608            return Err(WireError::Truncated);
609        }
610        let mut masked_header = [0u8; HP_PROTECTED_LEN];
611        masked_header.copy_from_slice(&bytes[HP_PROTECTED_OFFSET..PacketHeader::SIZE]);
612        let payload = bytes[PacketHeader::SIZE..].to_vec();
613        Ok(Self {
614            masked_header,
615            payload,
616            extensions: Vec::new(),
617        })
618    }
619
620    /// Recover the full cleartext [`PacketHeader`] by XOR-ing the masked region
621    /// with the caller-supplied HP `mask` (the session's
622    /// `HeaderProtector::mask_recv` over `self.payload`). A `mask` shorter than
623    /// [`HP_PROTECTED_LEN`] is rejected as [`WireError::Truncated`] rather than
624    /// panicking (the session always supplies a full 16-byte mask).
625    pub fn unmask_header(&self, mask: &[u8]) -> Result<PacketHeader, WireError> {
626        if mask.len() < HP_PROTECTED_LEN {
627            return Err(WireError::Truncated);
628        }
629        // WIRE v6: the masked region is the whole 15-byte header (offset 0), so the
630        // version byte is recovered here too — there is no cleartext prefix to copy.
631        let mut hdr = [0u8; PacketHeader::SIZE];
632        hdr[HP_PROTECTED_OFFSET..].copy_from_slice(&self.masked_header);
633        for (h, m) in hdr[HP_PROTECTED_OFFSET..].iter_mut().zip(mask) {
634            *h ^= *m;
635        }
636        // `session_id` is off-wire — PacketHeader::from_wire leaves it the
637        // placeholder zero; Session::parse_protected sets it from self.id(). The
638        // recovered `version` (hdr[0]) is checked by the recv path.
639        PacketHeader::from_wire(&hdr)
640    }
641
642    /// Reassemble a full [`PhantomPacket`] from this raw envelope plus a recovered
643    /// header, moving out `payload` / `extensions`.
644    pub fn into_packet(self, header: PacketHeader) -> PhantomPacket {
645        PhantomPacket {
646            header,
647            payload: self.payload,
648            extensions: self.extensions,
649        }
650    }
651}
652
653impl fmt::Debug for PhantomPacket {
654    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
655        f.debug_struct("PhantomPacket")
656            .field("header", &self.header)
657            .field("payload_len", &self.payload.len())
658            .field("extensions_len", &self.extensions.len())
659            .finish()
660    }
661}
662
663/// Legacy control-message enum for session management. NOTE: this is **not** the
664/// live handshake / control wire format — the real handshake uses the borsh structs
665/// in `transport::handshake` (`ClientHello` / `ServerHello` / …) and post-handshake
666/// control rides in `PacketFlags` (`REKEY` / `PATH_VALIDATION` / `KEEPALIVE` / …).
667/// This enum is currently unreferenced; retained as a definitional placeholder.
668#[derive(Clone, Copy, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
669#[borsh(use_discriminant = true)]
670#[repr(u8)]
671pub enum ControlMessage {
672    /// Initial handshake request
673    Hello = 0,
674    /// Handshake response with session ID
675    HelloAck = 1,
676    /// Session resumption (0-RTT)
677    Resume = 2,
678    /// Session resumption acknowledged
679    ResumeAck = 3,
680    /// IP migration notification
681    Migrate = 4,
682    /// Migration acknowledged
683    MigrateAck = 5,
684    /// Graceful session close
685    Close = 6,
686    /// Close acknowledged
687    CloseAck = 7,
688    /// Heartbeat/keepalive
689    Ping = 8,
690    /// Heartbeat response
691    Pong = 9,
692}
693
694/// Transport-leg discriminant, retained as the **metric/telemetry label** axis in
695/// `crate::observability` (the cardinality contract).
696///
697/// NOTE: this is *not* the live transport registry — the multipath `TransportLeg`
698/// trait and the KCP / FakeTLS / native-TCP-*leg* legs it named were removed in the
699/// PhantomUDP rewrite. `Kcp` and `FakeTls` survive only as stable label values so
700/// the observability layer's instrument set is wire-format-stable; they are never
701/// selected as a transport. The actual production transport is `Udp` (PhantomUDP).
702#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, BorshSerialize, BorshDeserialize)]
703pub enum LegType {
704    /// Retired KCP-over-UDP label (the KCP leg + `kcp-tokio` dep are gone). Kept as
705    /// a stable telemetry label value only.
706    Kcp,
707    /// Raw TCP. The native multipath TCP *leg* was removed; `TcpSessionTransport`
708    /// (a `SessionTransport`) is the current TCP byte-pipe.
709    Tcp,
710    /// Retired FakeTLS-over-TCP obfuscation label (the leg returned as the
711    /// off-by-default `mimicry` transport). Kept as a stable telemetry label only.
712    FakeTls,
713    /// PhantomUDP — the native reliable transport over raw UDP (Phase 1); the
714    /// production transport.
715    Udp,
716}
717
718impl LegType {
719    /// Whether this leg type provides reliability at transport level
720    pub fn is_reliable(&self) -> bool {
721        matches!(
722            self,
723            LegType::Kcp | LegType::Tcp | LegType::FakeTls | LegType::Udp
724        )
725    }
726
727    /// Whether this leg type uses encryption/obfuscation
728    pub fn is_obfuscated(&self) -> bool {
729        matches!(self, LegType::FakeTls)
730    }
731}
732
733/// Scheduling strategy for the `Scheduler`. NOTE: the scheduler is **vestigial** —
734/// the project does single-path connection migration, not multipath aggregation, so
735/// `select_paths` is never called on the live data path. This enum is constructed
736/// (defaulting to `LowLatency`) and threaded through `Session`/config but does not
737/// steer production traffic.
738#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, BorshSerialize, BorshDeserialize)]
739pub enum SchedulerMode {
740    /// Aggressive optimization for minimum RTT
741    LowLatency,
742    /// Bond multiple paths for maximum bandwidth
743    HighThroughput,
744    /// Redundant transmission for zero packet loss
745    Reliability,
746    /// Obfuscation prioritized over speed
747    Stealth,
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753
754    #[test]
755    fn test_session_id_random() {
756        let id1 = SessionId::random();
757        let id2 = SessionId::random();
758        assert_ne!(id1, id2);
759    }
760
761    #[test]
762    fn test_packet_flags() {
763        let mut flags = PacketFlags::empty();
764        assert!(!flags.is_reliable());
765
766        flags.set(PacketFlags::RELIABLE);
767        assert!(flags.is_reliable());
768
769        flags.set(PacketFlags::ENCRYPTED);
770        assert!(flags.contains(PacketFlags::RELIABLE));
771        assert!(flags.contains(PacketFlags::ENCRYPTED));
772
773        flags.clear(PacketFlags::RELIABLE);
774        assert!(!flags.is_reliable());
775        assert!(flags.contains(PacketFlags::ENCRYPTED));
776    }
777
778    #[test]
779    fn flags_bit_assignments() {
780        assert_eq!(PacketFlags::RELIABLE, 0x0001);
781        assert_eq!(PacketFlags::ENCRYPTED, 0x0020);
782        assert_eq!(PacketFlags::CONTROL, 0x0080);
783        assert_eq!(PacketFlags::REKEY, 0x0100);
784        assert_eq!(PacketFlags::PATH_VALIDATION, 0x0200);
785        assert_eq!(PacketFlags::COALESCED, 0x0400);
786        assert_eq!(PacketFlags::WINDOW_UPDATE, 0x0800);
787        assert_eq!(PacketFlags::KEEPALIVE, 0x1000);
788    }
789
790    #[test]
791    fn flags_contains_set_clear() {
792        let mut f = PacketFlags::empty();
793        assert!(!f.is_reliable());
794        assert!(!f.is_rekey());
795        f.set(PacketFlags::RELIABLE | PacketFlags::REKEY);
796        assert!(f.is_reliable());
797        assert!(f.is_rekey());
798        f.clear(PacketFlags::REKEY);
799        assert!(f.is_reliable());
800        assert!(!f.is_rekey());
801    }
802
803    #[test]
804    fn packet_header_serializes_to_15_bytes() {
805        assert_eq!(PacketHeader::SIZE, 15);
806        let header = PacketHeader::new(
807            SessionId::from_bytes([0u8; 32]),
808            1,
809            1,
810            PacketFlags::new(PacketFlags::ENCRYPTED),
811        );
812        let bytes = header.to_wire();
813        assert_eq!(
814            bytes.len(),
815            PacketHeader::SIZE,
816            "the v5 on-wire header is exactly 15 bytes (session_id is off-wire)"
817        );
818        // version-first, big-endian: the pinned version is the leading byte.
819        assert_eq!(bytes[0], WIRE_VERSION);
820        // session_id (zero here) round-trips as the off-wire placeholder.
821        assert_eq!(PacketHeader::from_wire(&bytes).expect("roundtrip"), header);
822    }
823
824    /// v5 (ε) drops `session_id` from the 15-byte wire image, but the AEAD AAD
825    /// stays the **byte-identical 47-byte v4 image** — `version ‖ session_id ‖
826    /// packet_number ‖ flags ‖ stream_id ‖ epoch ‖ path_id` — reconstructed
827    /// off-wire. Pinning it keeps the v4 AEAD security argument unchanged
828    /// (design §2.2): a masked-region tamper unmasks to a wrong header → wrong
829    /// AAD → AEAD fail, with no new oracle.
830    #[test]
831    fn to_aad_image_is_the_47_byte_v4_layout() {
832        let sid = SessionId::from_bytes([0x5Au8; 32]);
833        let header = PacketHeader::new(sid, 0x1122, 0x33445566778899AA, PacketFlags::new(0xBCCD))
834            .with_epoch(0xEE)
835            .with_path_id(0xFF);
836        let aad = header.to_aad_image();
837        assert_eq!(
838            aad.len(),
839            47,
840            "AAD image stays the 47-byte v4 logical header"
841        );
842        assert_eq!(aad[0], WIRE_VERSION, "version @ 0");
843        assert_eq!(
844            &aad[1..33],
845            &[0x5Au8; 32],
846            "session_id @ [1..33] (off-wire, in AAD)"
847        );
848        assert_eq!(
849            &aad[33..41],
850            &0x33445566778899AAu64.to_be_bytes(),
851            "packet_number @ [33..41]"
852        );
853        assert_eq!(&aad[41..43], &0xBCCDu16.to_be_bytes(), "flags @ [41..43]");
854        assert_eq!(
855            &aad[43..45],
856            &0x1122u16.to_be_bytes(),
857            "stream_id @ [43..45]"
858        );
859        assert_eq!(aad[45], 0xEE, "epoch @ 45");
860        assert_eq!(aad[46], 0xFF, "path_id @ 46");
861    }
862
863    /// The 15-byte wire image carries no `session_id`: a distinctive session_id
864    /// never appears in `to_wire()` output (it lives only in the AAD + session
865    /// context). This is the unlinkability-adjacent wire-diet property of ε.
866    #[test]
867    fn to_wire_omits_session_id() {
868        let sid = SessionId::from_bytes([0xABu8; 32]);
869        let header = PacketHeader::new(sid, 7, 42, PacketFlags::new(PacketFlags::ENCRYPTED));
870        let wire = header.to_wire();
871        assert_eq!(wire.len(), 15);
872        assert!(
873            !wire.windows(4).any(|w| w == [0xAB, 0xAB, 0xAB, 0xAB]),
874            "session_id must not be serialised onto the v5 wire"
875        );
876    }
877
878    #[test]
879    fn test_phantom_packet_ack() {
880        let session_id = SessionId::random();
881        let ack = PhantomPacket::ack(session_id, 5, 100);
882
883        assert!(ack.header.flags.is_ack());
884        assert_eq!(ack.header.stream_id, 5);
885        assert_eq!(ack.header.packet_number, 100);
886        assert!(ack.payload.is_empty());
887        assert!(ack.extensions.is_empty());
888    }
889
890    #[test]
891    fn packet_roundtrip_preserves_fields() {
892        let session_id = SessionId::random();
893        let header = PacketHeader::new(
894            session_id,
895            7,
896            42,
897            PacketFlags::new(PacketFlags::ENCRYPTED | PacketFlags::RELIABLE),
898        )
899        .with_epoch(3)
900        .with_path_id(1);
901        let packet = PhantomPacket::new(header, vec![0xCA, 0xFE, 0xBA, 0xBE]);
902
903        let bytes = packet.to_wire();
904        let decoded = PhantomPacket::from_wire(&bytes).expect("roundtrip");
905        // session_id is off-wire in v5 — the codec leaves it the placeholder zero
906        // (the session layer reconstructs it); every other field + the payload
907        // round-trip exactly.
908        assert_eq!(
909            decoded.header.session_id,
910            SessionId::from_bytes([0u8; 32]),
911            "session_id is off-wire (reconstructed by Session::parse_protected)"
912        );
913        assert_eq!(decoded.header.version, WIRE_VERSION);
914        assert_eq!(decoded.header.stream_id, 7);
915        assert_eq!(decoded.header.packet_number, 42);
916        assert_eq!(decoded.header.epoch, 3);
917        assert_eq!(decoded.header.path_id, 1);
918        assert!(decoded.header.flags.is_reliable());
919        assert!(decoded.header.flags.contains(PacketFlags::ENCRYPTED));
920        assert_eq!(decoded.payload, vec![0xCA, 0xFE, 0xBA, 0xBE]);
921    }
922
923    /// WIRE v6 (anti-fingerprint diet): the data-plane packet drops BOTH cleartext
924    /// `u32` length prefixes — `payload` is the message remainder after the 15-byte
925    /// header (`SessionTransport::recv_bytes` is message-framed, so the prefixes
926    /// were pure redundancy) — and carries no `extensions` on the wire. The version
927    /// byte also joins the HP-masked region (`HP_PROTECTED_OFFSET == 0`), so nothing
928    /// in the header is a constant cleartext fingerprint.
929    #[test]
930    fn v6_packet_drops_length_prefixes_and_masks_version() {
931        // The masked region now starts at byte 0 (covers the version byte) and
932        // spans the whole 15-byte header.
933        assert_eq!(HP_PROTECTED_OFFSET, 0, "v6 masks from the version byte");
934        assert_eq!(
935            HP_PROTECTED_LEN,
936            PacketHeader::SIZE,
937            "v6 masks the whole 15-byte header"
938        );
939        assert_eq!(
940            WIRE_VERSION, 6,
941            "anti-fingerprint diet bumps the wire version"
942        );
943
944        let header = PacketHeader::new(
945            SessionId::from_bytes([0u8; 32]),
946            7,
947            42,
948            PacketFlags::new(PacketFlags::ENCRYPTED),
949        );
950        let payload = vec![0xCAu8; 40];
951        let packet = PhantomPacket::new(header, payload.clone());
952        let wire = packet.to_wire();
953        // No 8 bytes of length prefixes: wire == header(15) ‖ payload.
954        assert_eq!(
955            wire.len(),
956            PacketHeader::SIZE + payload.len(),
957            "v6: header ‖ payload, no length prefixes"
958        );
959        assert_eq!(
960            &wire[PacketHeader::SIZE..],
961            &payload[..],
962            "payload is the message remainder"
963        );
964        assert_eq!(
965            packet.wire_size(),
966            PacketHeader::SIZE + payload.len(),
967            "wire_size() drops the 8 prefix bytes"
968        );
969        // Round-trips: payload is everything after the header; extensions are gone.
970        let decoded = PhantomPacket::from_wire(&wire).expect("v6 roundtrip");
971        assert_eq!(decoded.payload, payload);
972        assert!(
973            decoded.extensions.is_empty(),
974            "extensions are off the v6 data-plane wire"
975        );
976    }
977
978    /// WIRE v6 carries a contiguous masked `[0..15]` header — `version(1) ‖
979    /// packet_number(8) ‖ flags(2) ‖ stream_id(2) ‖ epoch(1) ‖ path_id(1)` — after
980    /// the outer (rotating) ConnId. The `PacketHeader::to_wire` image itself is
981    /// unchanged from v5 (still version-first); the diet changes the *masking span*
982    /// and the surrounding `PhantomPacket` framing.
983    #[test]
984    fn v6_header_layout_offsets() {
985        let header = PacketHeader::new(
986            SessionId::from_bytes([0x5Au8; 32]),
987            0x1122,             // stream_id
988            0x33445566778899AA, // packet_number
989            PacketFlags::new(0xBCCD),
990        )
991        .with_epoch(0xEE)
992        .with_path_id(0xFF);
993        let b = header.to_wire();
994        assert_eq!(b.len(), 15, "v5 wire header is 15 bytes");
995        assert_eq!(b[0], WIRE_VERSION, "version @ 0 (cleartext)");
996        assert_eq!(
997            &b[1..9],
998            &0x33445566778899AAu64.to_be_bytes(),
999            "packet_number @ [1..9]"
1000        );
1001        assert_eq!(&b[9..11], &0xBCCDu16.to_be_bytes(), "flags @ [9..11]");
1002        assert_eq!(&b[11..13], &0x1122u16.to_be_bytes(), "stream_id @ [11..13]");
1003        assert_eq!(b[13], 0xEE, "epoch @ 13");
1004        assert_eq!(b[14], 0xFF, "path_id @ 14");
1005        // Round-trips the 14 masked fields; session_id is off-wire (placeholder).
1006        let rt = PacketHeader::from_wire(&b).expect("roundtrip");
1007        assert_eq!(rt.packet_number, header.packet_number);
1008        assert_eq!(rt.flags, header.flags);
1009        assert_eq!(rt.stream_id, header.stream_id);
1010        assert_eq!(rt.epoch, header.epoch);
1011        assert_eq!(rt.path_id, header.path_id);
1012        assert_eq!(
1013            rt.session_id,
1014            SessionId::from_bytes([0u8; 32]),
1015            "session_id off-wire (reconstructed from session context)"
1016        );
1017    }
1018
1019    /// T4.6 codec at the v5 layout: `to_wire_masked` → `RawPacket::from_wire` →
1020    /// `unmask_header` recovers the 14 masked fields for an arbitrary fixed mask,
1021    /// the masked wire region differs from the cleartext header (pn/flags
1022    /// hidden), and only the cleartext `version` byte stays readable without the
1023    /// mask (session_id is off-wire — reconstructed by `Session::parse_protected`).
1024    #[test]
1025    fn raw_packet_mask_unmask_round_trip() {
1026        let sid = SessionId::from_bytes([0x5Au8; 32]);
1027        let header = PacketHeader::new(
1028            sid,
1029            0x0203,             // stream_id
1030            0x1111222233334444, // packet_number
1031            PacketFlags::new(PacketFlags::ENCRYPTED | PacketFlags::PRIORITY),
1032        )
1033        .with_epoch(7)
1034        .with_path_id(9);
1035        let packet = PhantomPacket::new(header, vec![0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11]);
1036        // Arbitrary fixed 16-byte mask (production: HeaderProtector::mask_send).
1037        let mask: [u8; 16] = [
1038            0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, 0xA0, 0xB0, 0xC0, 0xD0, 0xE0,
1039            0xF0, 0x01,
1040        ];
1041
1042        let wire = packet.to_wire_masked(&mask);
1043        let cleartext = packet.to_wire();
1044
1045        // WIRE v6: the masked region is the WHOLE 15-byte header [0..15] — every
1046        // header byte, version INCLUDED, differs from the cleartext on the wire.
1047        assert_ne!(
1048            &wire[HP_PROTECTED_OFFSET..PacketHeader::SIZE],
1049            &cleartext[HP_PROTECTED_OFFSET..PacketHeader::SIZE],
1050            "version/pn/flags/stream_id/epoch/path_id must all be masked on the wire"
1051        );
1052        assert_eq!(
1053            HP_PROTECTED_OFFSET, 0,
1054            "v6 masks from byte 0 (version included)"
1055        );
1056        assert_ne!(
1057            wire[0], cleartext[0],
1058            "the version byte is masked in v6 (no constant cleartext byte)"
1059        );
1060
1061        // The envelope parses without the mask (payload located at the fixed
1062        // offset 15; the version is inside the masked region, not cleartext).
1063        let raw = RawPacket::from_wire(&wire).expect("raw parse");
1064        assert_eq!(raw.payload, packet.payload);
1065
1066        // Unmasking recovers all 15 masked fields incl. version; session_id stays
1067        // off-wire (the session layer sets it from `self.id()`), so compare field-wise.
1068        let recovered = raw.unmask_header(&mask).expect("unmask");
1069        assert_eq!(
1070            recovered.version, WIRE_VERSION,
1071            "the masked version byte round-trips through unmask"
1072        );
1073        assert_eq!(recovered.packet_number, header.packet_number);
1074        assert_eq!(recovered.flags, header.flags);
1075        assert_eq!(recovered.stream_id, header.stream_id);
1076        assert_eq!(recovered.epoch, header.epoch);
1077        assert_eq!(recovered.path_id, header.path_id);
1078        assert_eq!(
1079            recovered.session_id,
1080            SessionId::from_bytes([0u8; 32]),
1081            "session_id off-wire after unmask"
1082        );
1083    }
1084
1085    /// A too-short HP mask is rejected as a typed error, never a panic (the
1086    /// session always supplies a full 16-byte mask — this is defensive).
1087    #[test]
1088    fn unmask_header_rejects_short_mask() {
1089        let wire = PhantomPacket::new(
1090            PacketHeader::new(
1091                SessionId::from_bytes([1u8; 32]),
1092                1,
1093                1,
1094                PacketFlags::new(PacketFlags::ENCRYPTED),
1095            ),
1096            vec![0u8; 16],
1097        )
1098        .to_wire_masked(&[0u8; 16]);
1099        let raw = RawPacket::from_wire(&wire).expect("raw parse");
1100        assert!(
1101            raw.unmask_header(&[0u8; 8]).is_err(),
1102            "a mask shorter than HP_PROTECTED_LEN must error, not panic"
1103        );
1104    }
1105
1106    /// WIRE v6 drops `extensions` from the data-plane wire (D3): the field was
1107    /// always empty in practice and the AEAD AAD still binds an empty slice.
1108    /// `to_wire` emits only `header ‖ payload`; any `extensions` set on the struct
1109    /// are NOT serialised, and `from_wire` always yields empty extensions. (The
1110    /// forward-compat headroom can return later via a reserved flag + an encrypted
1111    /// TLV inside the padded plaintext — see the v6 anti-fingerprint design.)
1112    #[test]
1113    fn extensions_are_dropped_from_the_v6_wire() {
1114        let session_id = SessionId::random();
1115        let mut packet = PhantomPacket::new(
1116            PacketHeader::new(
1117                session_id,
1118                1,
1119                1,
1120                PacketFlags::new(PacketFlags::CONTROL | PacketFlags::RELIABLE),
1121            ),
1122            vec![1, 2, 3],
1123        );
1124        packet.extensions = vec![0xFF, 0x01, 0x00, 0x04, b't', b'e', b's', b't'];
1125
1126        let bytes = packet.to_wire();
1127        // The wire is exactly header ‖ payload — the extensions bytes are absent.
1128        assert_eq!(bytes.len(), PacketHeader::SIZE + 3, "header ‖ payload only");
1129        let deser = PhantomPacket::from_wire(&bytes).expect("deserialize failed");
1130        assert_eq!(deser.payload, vec![1, 2, 3]);
1131        assert!(
1132            deser.extensions.is_empty(),
1133            "extensions are off the v6 data-plane wire"
1134        );
1135    }
1136}