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}