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}