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