phantom_protocol/api/session.rs
1//! Client-First Transport Session
2//!
3//! `PhantomSession` is the user-facing client session: `connect_with_transport`
4//! returns instantly and spawns a background task that drives the hybrid
5//! post-quantum handshake and then the data pump, with `send()` calls queued
6//! in-memory until the handshake completes. It is the transport-level API that
7//! sits directly above a `SessionTransport` byte-pipe (PhantomUDP / TCP /
8//! WebSocket / WASI / Embedded / MimicTls) and below any application protocol an
9//! embedder layers on top of `send()` / `recv()`.
10//!
11//! This file also carries the shared client/server data pump (`run_data_pump`)
12//! and every per-packet build/parse helper (`send_app_data`, `handle_packet`,
13//! the keep-alive / cover / window-update / path-validation senders), so any
14//! change to encrypt/decrypt, framing, or stream routing happens here, in one
15//! place, for both sides.
16
17use crate::crypto::hybrid_sign::HybridVerifyingKey;
18use crate::errors::CoreError;
19use crate::observability::attrs::{AeadAlgorithm, ReplayReason};
20use crate::observability::{Observability, ObservabilityConfig};
21use crate::runtime::{Runtime, TokioRuntime};
22use crate::transport::handshake::{HandshakeClient, ServerReject, ServerReply, EARLY_DATA_MAX_LEN};
23use crate::transport::multiplexer::StreamDemultiplexer;
24use crate::transport::packet_coalescer_codec::unwrap_coalesced_packet;
25use crate::transport::path_validation_codec::build_path_validation_packet;
26use crate::transport::session::{Session, SessionState};
27use crate::transport::shaping::{self, PaddingPolicy};
28use crate::transport::stream::Stream;
29use crate::transport::types::{
30 LegType, PacketFlags, PacketHeader, PhantomPacket, SessionId, StreamId as TransportStreamId,
31 WIRE_VERSION,
32};
33use bytes::Bytes;
34use dashmap::DashMap;
35use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
36use std::sync::Arc;
37use tokio::sync::{mpsc, oneshot, Mutex};
38
39/// Generate a fresh 128-bit session identifier from the thread-local CSPRNG.
40///
41/// Replaces the historical `rand::random::<u32>()` (32 bits, insufficient to
42/// avoid birthday collisions at scale and not advertised as cryptographic).
43/// `rand::thread_rng` is seeded from the OS at thread startup and uses a
44/// modern stream cipher (ChaCha) — adequate for non-secret identifiers.
45fn new_session_id() -> String {
46 let bytes: [u8; 16] = rand::random();
47 format!("phantom-{}", hex::encode(bytes))
48}
49
50// ─── Connection State ───────────────────────────────────────────────────────
51
52/// Connection state for `PhantomSession`.
53///
54/// The session is usable from the moment it's created — sends are queued
55/// until the handshake completes.
56#[cfg_attr(feature = "bindings", derive(uniffi::Enum))]
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[repr(u8)]
59#[non_exhaustive]
60pub enum ConnectionState {
61 /// Connection initiated, handshake pending
62 Connecting = 0,
63 /// Classical (X25519) channel established — data flows
64 ClassicalReady = 1,
65 /// PQC upgrade in progress
66 PqcUpgrading = 2,
67 /// Full hybrid PQC protection active
68 PqcReady = 3,
69 /// Fully connected and operational
70 Connected = 4,
71 /// Connection failed
72 Failed = 5,
73 /// Gracefully closed
74 Closed = 6,
75 /// The active path went silent (liveness lost); the session is held alive
76 /// (keys retained, outbound buffered) awaiting a `migrate()` or the path's
77 /// return. The embedder reacts by calling `migrate()` (Phase 4 / P4.3).
78 Migrating = 7,
79 /// The session is dead: the path stayed down past the migration idle-timeout
80 /// with no recovery. Terminal — `recv()` errors instead of hanging (P4.3).
81 Dead = 8,
82}
83
84/// Anti-fingerprint traffic-shaping configuration (WIRE v6, direction #4). Set on
85/// an established session via [`PhantomSession::set_traffic_shaping`]. **All
86/// shaping is opt-in** — the default (and the field defaults here) is no shaping,
87/// so a session pays nothing unless an embedder enables it.
88///
89/// Currently carries the size-padding policy (deliverable (c)); the timing-jitter
90/// (d) and cover-traffic (e) knobs will be added as further fields in later
91/// phases. Padding hides the datagram *size*; it costs bounded (≈ ≤12% worst-case)
92/// extra bandwidth.
93#[cfg_attr(feature = "bindings", derive(uniffi::Record))]
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub struct TrafficShapingConfig {
96 /// Size-padding policy. [`PaddingPolicy::None`] (default) = no padding;
97 /// [`PaddingPolicy::Padme`] = pad each packet up to a PADÉ bucket.
98 pub padding: PaddingPolicy,
99 /// Send-timing jitter ceiling in milliseconds (deliverable (d)). `0` (default)
100 /// = no jitter; otherwise each packet waits a uniform random `[0, jitter_ms]`
101 /// ms before it is sent, so the inter-packet timing no longer tracks the
102 /// application's writes — at a cost of up to `jitter_ms` of added latency.
103 pub jitter_ms: u32,
104 /// Cover-traffic floor interval in milliseconds (deliverable (e)). `0`
105 /// (default) = no cover traffic; otherwise the session maintains a minimum
106 /// outbound packet rate of `1000 / cover_interval_ms` packets/sec, emitting an
107 /// encrypted dummy (`COVER`) packet whenever no packet has gone out for
108 /// `cover_interval_ms` — hiding idle/active patterns and volume, at a steady
109 /// bandwidth cost. A typical value is 100–500 ms (10–2 packets/sec).
110 pub cover_interval_ms: u32,
111}
112
113/// Apply a [`TrafficShapingConfig`] to an established [`Session`] (#9). Shared by
114/// the immediate (`set_traffic_shaping` on a live session) and the deferred
115/// (background-task, at session install) paths.
116fn apply_shaping(session: &Session, cfg: TrafficShapingConfig) {
117 session.set_padding_policy(cfg.padding);
118 session.set_jitter_ms(cfg.jitter_ms);
119 session.set_cover_interval_ms(cfg.cover_interval_ms);
120}
121
122impl ConnectionState {
123 fn from_u8(v: u8) -> Self {
124 match v {
125 0 => Self::Connecting,
126 1 => Self::ClassicalReady,
127 2 => Self::PqcUpgrading,
128 3 => Self::PqcReady,
129 4 => Self::Connected,
130 5 => Self::Failed,
131 6 => Self::Closed,
132 7 => Self::Migrating,
133 8 => Self::Dead,
134 _ => Self::Failed,
135 }
136 }
137
138 /// Whether data can flow (classical or better). `Migrating` counts as ready:
139 /// the keep-alive window still accepts `send()` (buffered + retransmitted until
140 /// the path recovers), so the embedder's send path doesn't error mid-migration.
141 pub fn is_data_ready(&self) -> bool {
142 matches!(
143 self,
144 Self::ClassicalReady
145 | Self::PqcUpgrading
146 | Self::PqcReady
147 | Self::Connected
148 | Self::Migrating
149 )
150 }
151}
152
153// ─── Resumption Hint ────────────────────────────────────────────────────────
154
155/// 0-RTT resumption material extracted from a completed session.
156///
157/// Produced by [`PhantomSession::resumption_hint`] after a handshake
158/// completes, and fed back into [`connect_pinned_with_resumption`] to
159/// attempt a 0-RTT reconnect to the same server.
160///
161/// Both fields are exactly 32 bytes — this record is the
162/// UniFFI-representable surface for the internal `(session_id,
163/// resumption_secret)` tuple. The fields are `Vec<u8>` because UniFFI
164/// has no fixed-size-array type, so the length is a runtime invariant
165/// checked when the hint is used.
166///
167/// Store the hint alongside the pinned `HybridVerifyingKey` of the
168/// server it was negotiated against: the `resumption_secret` is
169/// server-pinned, and reusing a hint across servers is a configuration
170/// bug.
171#[cfg_attr(feature = "bindings", derive(uniffi::Record))]
172#[derive(Clone)]
173#[non_exhaustive]
174pub struct ResumptionHint {
175 /// The negotiated session id (32 bytes).
176 pub session_id: Vec<u8>,
177 /// The resumption secret (32 bytes) — sensitive; treat like a key.
178 pub resumption_secret: Vec<u8>,
179}
180
181// INFOLEAK-1: hand-written redacting `Debug` (not derived) so a mobile/FFI
182// consumer that logs the hint with `{:?}` cannot leak the 0-RTT `resumption_secret`
183// — the one secret-bearing type that crosses the FFI boundary. Mirrors the
184// REDACTED `Debug` on `HybridSigningKey` / `HybridSecretKey`.
185impl std::fmt::Debug for ResumptionHint {
186 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187 f.debug_struct("ResumptionHint")
188 .field(
189 "session_id",
190 &format_args!("<{} bytes>", self.session_id.len()),
191 )
192 .field("resumption_secret", &"REDACTED")
193 .finish()
194 }
195}
196
197// ─── Transport Abstraction ──────────────────────────────────────────────────
198
199// `SessionTransport` now lives in `crate::transport::session_transport` — a
200// dependency-light module that can compile in a `no_std + alloc` build. It is
201// re-exported here so `crate::api::session::SessionTransport` and the public
202// `phantom_protocol::api::SessionTransport` path stay stable.
203pub use crate::transport::session_transport::{FramePhase, SessionTransport};
204
205/// Transport decorator that records `record_send` / `record_recv` on the
206/// session's [`Observability`] for every frame that crosses the wire — so the
207/// data-plane packet/byte counters reflect a real run without threading the
208/// handle through every send site. Wraps the concrete `SessionTransport` just
209/// before the data pump takes over, so handshake bytes are not counted as
210/// data-plane packets (they have their own handshake metric).
211struct ObservedTransport<T> {
212 inner: T,
213 observability: Arc<Observability>,
214 leg: LegType,
215}
216
217impl<T> ObservedTransport<T> {
218 fn new(inner: T, observability: Arc<Observability>, leg: LegType) -> Self {
219 Self {
220 inner,
221 observability,
222 leg,
223 }
224 }
225}
226
227impl<T: SessionTransport> SessionTransport for ObservedTransport<T> {
228 async fn send_bytes(&self, data: &[u8]) -> Result<(), CoreError> {
229 let result = self.inner.send_bytes(data).await;
230 if result.is_ok() {
231 self.observability.record_send(data.len(), self.leg);
232 }
233 result
234 }
235
236 async fn recv_bytes(&self) -> Result<Bytes, CoreError> {
237 let result = self.inner.recv_bytes().await;
238 if let Ok(ref bytes) = result {
239 self.observability.record_recv(bytes.len(), self.leg);
240 }
241 result
242 }
243
244 // ── Transparent forwarding of the non-I/O trait surface ───────────────────
245 //
246 // ObservedTransport wraps the concrete transport for the whole data pump, so
247 // every control method the pump calls on it (phase, CID stamping, migration)
248 // MUST reach the inner transport — otherwise they silently hit the trait's
249 // defaults (no-op / `false`) and the feature is dead through the pump. (The
250 // pre-ε code only forwarded send/recv, so the FFI `migrate()` and the
251 // server-side migration detection were no-ops once wrapped; ε needs them live
252 // to rotate the CID on migration, so the wrapper is made fully transparent.)
253 fn set_frame_phase(&self, phase: FramePhase) {
254 self.inner.set_frame_phase(phase);
255 }
256
257 fn set_outbound_cid(&self, cid: [u8; 8]) {
258 self.inner.set_outbound_cid(cid);
259 }
260
261 fn has_migration_candidate(&self) -> bool {
262 self.inner.has_migration_candidate()
263 }
264
265 fn send_to_candidate(
266 &self,
267 data: &[u8],
268 ) -> impl core::future::Future<Output = Result<bool, CoreError>> + Send {
269 self.inner.send_to_candidate(data)
270 }
271
272 fn confirm_authenticated_source(&self) {
273 self.inner.confirm_authenticated_source();
274 }
275
276 fn promote_candidate(&self) -> bool {
277 self.inner.promote_candidate()
278 }
279
280 fn migrate(
281 &self,
282 local_addr: String,
283 ) -> impl core::future::Future<Output = Result<(), CoreError>> + Send {
284 self.inner.migrate(local_addr)
285 }
286
287 fn migrate_server(
288 &self,
289 local_addr: String,
290 ) -> impl core::future::Future<Output = Result<(), CoreError>> + Send {
291 self.inner.migrate_server(local_addr)
292 }
293}
294
295// ─── Session ────────────────────────────────────────────────────────────────
296
297/// Client-first session — instant construction, non-blocking `send()`.
298///
299/// # Design
300///
301/// The real entry point is `connect_with_transport` (NOT the inert legacy
302/// `connect()` constructor — see its doc): it returns instantly and runs the
303/// handshake + data pump in the background, so sends issued before the handshake
304/// finishes are buffered and auto-flushed once the channel is up.
305///
306/// ```text
307/// // instant — spawns the background handshake + pump:
308/// let session = PhantomSession::connect_with_transport(addr, transport, pinned_key);
309/// session.send(data).await; // queued until handshake completes
310/// session.send(data2).await; // also queued
311/// // ... handshake completes in background ...
312/// // queued data auto-flushed, new sends go directly
313/// ```
314///
315/// The session progresses through states:
316/// `Connecting → ClassicalReady → PqcUpgrading → PqcReady → Connected`
317#[cfg_attr(feature = "bindings", derive(uniffi::Object))]
318pub struct PhantomSession {
319 /// Session identifier
320 id: String,
321 /// Target server address
322 peer_addr: String,
323 /// Connection state (atomic for lock-free reads)
324 state: Arc<AtomicU8>,
325 /// Queued messages before connection is ready
326 send_queue: Arc<Mutex<Vec<Vec<u8>>>>,
327 /// Channel to send commands to the background handshake task
328 cmd_tx: mpsc::Sender<SessionCommand>,
329 /// Command receiver — taken by the background task when spawned
330 #[allow(dead_code)]
331 cmd_rx: Mutex<Option<mpsc::Receiver<SessionCommand>>>,
332 /// Received messages channel. Carries `Bytes` (not `Vec<u8>`) so the recv
333 /// path can fan out via cheap refcount clones to both the stream demux
334 /// and the synchronous `recv()` consumer without deep-copying the payload.
335 recv_rx: Mutex<mpsc::Receiver<Bytes>>,
336 /// Multiplexes incoming packets to independent streams
337 demux: Arc<StreamDemultiplexer>,
338 /// Active outgoing streams (ARQ management)
339 streams: Arc<DashMap<u32, Arc<Stream>>>,
340 /// Negotiated session handle, populated by the background task
341 /// once the handshake completes. Exposed via `resumption_hint`
342 /// for Phase 4.1 0-RTT clients. `None` while still handshaking
343 /// or after a failure.
344 inner_session: Arc<Mutex<Option<Arc<Session>>>>,
345 /// 0-RTT verdict. `None` while handshaking, after a failure, or when the
346 /// client sent no early-data on this connect. `Some(true)` — the server
347 /// consumed the early-data; `Some(false)` — the client sent early-data and
348 /// the server rejected it. Exposed via `early_data_accepted()`.
349 early_data_accepted: Arc<Mutex<Option<bool>>>,
350 /// Anti-fingerprint traffic-shaping config (#9). Set via `set_traffic_shaping`
351 /// at any time — **including before the (async) client handshake completes** —
352 /// and applied to the negotiated `Session` the moment it is installed by the
353 /// background task, so the very first data packets are already shaped. A
354 /// `parking_lot::Mutex` (no poison, never held across an `.await`). Default:
355 /// no shaping.
356 shaping: Arc<parking_lot::Mutex<TrafficShapingConfig>>,
357 /// Session observability handle. Server-accepted sessions share the
358 /// `PhantomListener`'s instance (so its `snapshot()` aggregates every
359 /// session it accepted); client sessions get their own. The data pump
360 /// records send/recv, the security drops, and the session lifecycle
361 /// (open/close) against it. A ZST no-op when `telemetry-otel` is off.
362 observability: Arc<Observability>,
363}
364
365/// Commands for the background session task
366pub enum SessionCommand {
367 /// Queue data for sending
368 Send(Vec<u8>),
369 /// Send data on a specific stream reliably
370 SendStreamReliable { stream_id: u32, data: bytes::Bytes },
371 /// Send data on a specific stream unreliably
372 SendStreamUnreliable { stream_id: u32, data: bytes::Bytes },
373 /// Close a specific stream
374 CloseStream { stream_id: u32 },
375 /// Migrate to a new local address (Phase 4 / P4.2 — embedder-triggered). Carries
376 /// the new local bind address as a `String`; the pump rebinds the transport and
377 /// bumps the send `path_id` (best-effort, never fatal to the session).
378 Migrate(String),
379 /// Migrate the SERVER's send path to a new local address (Rust-only, the server-side
380 /// mirror of [`Migrate`](Self::Migrate)). Carries the new local bind address as a
381 /// `String`; the pump rebinds the server's send socket (its receive keeps flowing on
382 /// the old address via the listener demux during the overlap) and rotates the s2c send
383 /// `path_id` + outbound CID in lock-step, so the client sees — and follows — a fresh
384 /// server source with a fresh, unlinkable ConnId. Best-effort, never fatal.
385 MigrateServer(String),
386 /// Close the session
387 Close,
388}
389
390impl PhantomSession {
391 /// Create a new session and start the background handshake task.
392 ///
393 /// Requires `expected_server_key` for MITM resistance — the client will
394 /// abort the handshake unless the server presents this exact verifying key.
395 /// Callers obtain this key out-of-band (e.g. from `PhantomListener::verifying_key_bytes`).
396 ///
397 /// The handshake runs in the background:
398 /// 1. Exchange hybrid PQC `ClientHello`/`ServerHello`.
399 /// 2. Verify server identity against `expected_server_key`.
400 /// 3. Derive AEAD keys; flush queued sends as encrypted packets.
401 ///
402 /// All network I/O goes through the provided `SessionTransport`. The
403 /// task that drives the handshake + data pump runs on the default
404 /// [`TokioRuntime`]; use
405 /// [`connect_with_transport_with_runtime`](Self::connect_with_transport_with_runtime)
406 /// to substitute a different `Runtime`.
407 pub fn connect_with_transport<T: SessionTransport>(
408 peer_addr: &str,
409 transport: T,
410 expected_server_key: HybridVerifyingKey,
411 ) -> Self {
412 Self::connect_with_transport_with_runtime(
413 peer_addr,
414 transport,
415 expected_server_key,
416 Arc::new(TokioRuntime),
417 )
418 }
419
420 /// Like [`connect_with_transport`](Self::connect_with_transport) but
421 /// runs the background task on the supplied `Runtime`. Intended for
422 /// WASM / embedded / test backends that don't drive `tokio::spawn`.
423 pub fn connect_with_transport_with_runtime<T: SessionTransport>(
424 peer_addr: &str,
425 transport: T,
426 expected_server_key: HybridVerifyingKey,
427 runtime: Arc<dyn Runtime>,
428 ) -> Self {
429 Self::spawn_client(peer_addr, transport, expected_server_key, runtime, None)
430 }
431
432 /// Connect with a **0-RTT resumption attempt**.
433 ///
434 /// `resumption_hint` is the `(session_id, resumption_secret)` tuple
435 /// from a prior session's [`PhantomSession::resumption_hint`].
436 /// `early_data` (≤ [`EARLY_DATA_MAX_LEN`] bytes) is sealed and carried
437 /// inside the resuming ClientHello so it reaches the server on the very
438 /// first flight — saving a round-trip versus 1-RTT.
439 ///
440 /// Acceptance is best-effort: a stale/unknown ticket or an AEAD failure
441 /// leaves [`early_data_accepted`](Self::early_data_accepted) at
442 /// `Some(false)` and the handshake completes as a normal 1-RTT exchange —
443 /// the caller must then send that payload over the normal channel.
444 /// Returns `Err` only when `early_data` exceeds the cap.
445 ///
446 /// Runs on the default [`TokioRuntime`].
447 pub fn connect_with_resumption<T: SessionTransport>(
448 peer_addr: &str,
449 transport: T,
450 expected_server_key: HybridVerifyingKey,
451 resumption_hint: ([u8; 32], [u8; 32]),
452 early_data: Vec<u8>,
453 ) -> Result<Self, CoreError> {
454 // fips bootstrap POST gate. `connect_with_resumption`
455 // returns `Result`, so unlike the infallible `connect_with_transport*`
456 // entry points we can surface the POST failure directly to the
457 // caller (mirrors the `PhantomListener::bind*` and
458 // `connect_pinned*` convention). The same POST is also checked
459 // in `background_task` as a defense-in-depth backstop.
460 #[cfg(feature = "fips")]
461 crate::crypto::self_tests::ensure_post_passed()
462 .map_err(|e| CoreError::FipsSelfTestFailure(format!("{e:?}")))?;
463
464 if early_data.len() > EARLY_DATA_MAX_LEN {
465 return Err(CoreError::ValidationError(format!(
466 "early_data is {} bytes, exceeds the {}-byte 0-RTT cap",
467 early_data.len(),
468 EARLY_DATA_MAX_LEN
469 )));
470 }
471 let (resume_id, resume_secret) = resumption_hint;
472 Ok(Self::spawn_client(
473 peer_addr,
474 transport,
475 expected_server_key,
476 Arc::new(TokioRuntime),
477 Some((resume_id, resume_secret, early_data)),
478 ))
479 }
480
481 /// Shared constructor body for [`connect_with_transport_with_runtime`]
482 /// and [`connect_with_resumption`]. `resumption_request` is `None`
483 /// for a plain handshake, `Some((id, secret, early_data))` to attempt a
484 /// 0-RTT resumption.
485 fn spawn_client<T: SessionTransport>(
486 peer_addr: &str,
487 transport: T,
488 expected_server_key: HybridVerifyingKey,
489 runtime: Arc<dyn Runtime>,
490 resumption_request: Option<([u8; 32], [u8; 32], Vec<u8>)>,
491 ) -> Self {
492 let (cmd_tx, cmd_rx) = mpsc::channel(256);
493 let (recv_tx, recv_rx) = mpsc::channel(256);
494
495 let state = Arc::new(AtomicU8::new(ConnectionState::Connecting as u8));
496 let send_queue = Arc::new(Mutex::new(Vec::new()));
497 let peer = peer_addr.to_string();
498 let (demux, _ctrl_rx) = StreamDemultiplexer::new(256);
499 let demux = Arc::new(demux);
500
501 let streams = Arc::new(DashMap::new());
502 let inner_session: Arc<Mutex<Option<Arc<Session>>>> = Arc::new(Mutex::new(None));
503 let early_data_accepted: Arc<Mutex<Option<bool>>> = Arc::new(Mutex::new(None));
504 // #9 — shared pending traffic-shaping config, applied at session install.
505 let shaping = Arc::new(parking_lot::Mutex::new(TrafficShapingConfig::default()));
506 // Client sessions have no listener, so they own their observability
507 // instance (its `snapshot()` reflects just this connection).
508 let observability = Observability::new(ObservabilityConfig::default());
509
510 let session = Self {
511 id: new_session_id(),
512 peer_addr: peer.clone(),
513 state: state.clone(),
514 send_queue: send_queue.clone(),
515 cmd_tx: cmd_tx.clone(),
516 cmd_rx: Mutex::new(None), // taken by background task
517 recv_rx: Mutex::new(recv_rx),
518 demux: demux.clone(),
519 streams: streams.clone(),
520 inner_session: inner_session.clone(),
521 early_data_accepted: early_data_accepted.clone(),
522 shaping: shaping.clone(),
523 observability: observability.clone(),
524 };
525
526 // Spawn the background handshake + data pump task on the supplied
527 // runtime. `SpawnHandle` is detached: dropping it leaves the task
528 // running. The session is owned by the caller for its lifetime
529 // and natural shutdown comes via `SessionCommand::Close`.
530 let runtime_for_pump = runtime.clone();
531 let _detached = runtime.spawn(Box::pin(Self::background_task(
532 state,
533 send_queue,
534 cmd_tx,
535 cmd_rx,
536 recv_tx,
537 transport,
538 peer,
539 demux,
540 streams,
541 expected_server_key,
542 runtime_for_pump,
543 inner_session,
544 early_data_accepted,
545 shaping,
546 resumption_request,
547 observability,
548 )));
549
550 session
551 }
552
553 /// Install a server-side `Session` (already derived by `HandshakeServer::process_client_hello`)
554 /// and spawn the data pump on the default [`TokioRuntime`]. Used by
555 /// `PhantomListener::accept` after driving the server handshake.
556 ///
557 /// `PhantomListener::accept` itself now uses
558 /// `from_accepted_server_session_with_runtime` so the listener's
559 /// runtime is honored. This wrapper is preserved for callers that
560 /// do not have a runtime handle and want the default `TokioRuntime`.
561 #[allow(dead_code)]
562 pub(crate) fn from_accepted_server_session<T: SessionTransport>(
563 peer_addr: String,
564 transport: T,
565 server_session: Arc<Session>,
566 ) -> Arc<Self> {
567 Self::from_accepted_server_session_with_runtime(
568 peer_addr,
569 transport,
570 server_session,
571 Arc::new(TokioRuntime),
572 Observability::new(ObservabilityConfig::default()),
573 LegType::Tcp,
574 )
575 }
576
577 /// Runtime-aware variant of [`from_accepted_server_session`].
578 pub(crate) fn from_accepted_server_session_with_runtime<T: SessionTransport>(
579 peer_addr: String,
580 transport: T,
581 server_session: Arc<Session>,
582 runtime: Arc<dyn Runtime>,
583 observability: Arc<Observability>,
584 leg: LegType,
585 ) -> Arc<Self> {
586 let (cmd_tx, cmd_rx) = mpsc::channel(256);
587 let (recv_tx, recv_rx) = mpsc::channel(256);
588
589 let state = Arc::new(AtomicU8::new(ConnectionState::Connected as u8));
590 let send_queue = Arc::new(Mutex::new(Vec::new()));
591 let (demux, _ctrl_rx) = StreamDemultiplexer::new(256);
592 let demux = Arc::new(demux);
593 let streams = Arc::new(DashMap::new());
594
595 let inner_session: Arc<Mutex<Option<Arc<Session>>>> =
596 Arc::new(Mutex::new(Some(server_session.clone())));
597
598 let session = Arc::new(Self {
599 id: new_session_id(),
600 peer_addr: peer_addr.clone(),
601 state: state.clone(),
602 send_queue: send_queue.clone(),
603 cmd_tx,
604 cmd_rx: Mutex::new(None),
605 recv_rx: Mutex::new(recv_rx),
606 demux: demux.clone(),
607 streams: streams.clone(),
608 inner_session,
609 // Server side: 0-RTT early-data is delivered via
610 // `AcceptOutcome`, not this client-facing field.
611 early_data_accepted: Arc::new(Mutex::new(None)),
612 // Server side: the session is already established here, so
613 // `set_traffic_shaping` applies immediately; default = no shaping.
614 shaping: Arc::new(parking_lot::Mutex::new(TrafficShapingConfig::default())),
615 // Shares the listener's instance so its `snapshot()` aggregates
616 // every accepted session.
617 observability: observability.clone(),
618 });
619
620 let session_id = *server_session.id();
621 let runtime_for_pump = runtime.clone();
622 // WIRE-001: the server handshake is complete — raise the receive frame
623 // cap from the tight unauthenticated handshake limit to the steady-state
624 // application limit before the data pump takes over.
625 transport.set_frame_phase(FramePhase::Established);
626 // ε / WIRE v5: switch the transport off the bootstrap ConnId onto this
627 // session's rotating CID_0 (the c2s chain the client routes on; the
628 // demux registers the matching inbound window). The server→client
629 // direction rotates too, so neither flow keeps a stable cleartext id.
630 transport.set_outbound_cid(server_session.current_outbound_cid());
631 let observed = Arc::new(ObservedTransport::new(
632 transport,
633 observability.clone(),
634 leg,
635 ));
636 let _detached = runtime.spawn(Box::pin(run_data_pump(
637 server_session,
638 session_id,
639 observed,
640 state,
641 send_queue,
642 cmd_rx,
643 recv_tx,
644 demux,
645 streams,
646 runtime_for_pump,
647 observability,
648 leg,
649 )));
650
651 session
652 }
653
654 /// Background task: performs handshake, then pumps data.
655 #[allow(clippy::too_many_arguments)]
656 async fn background_task<T: SessionTransport>(
657 state: Arc<AtomicU8>,
658 send_queue: Arc<Mutex<Vec<Vec<u8>>>>,
659 _cmd_tx: mpsc::Sender<SessionCommand>,
660 cmd_rx: mpsc::Receiver<SessionCommand>,
661 recv_tx: mpsc::Sender<Bytes>,
662 transport: T,
663 peer: String,
664 demux: Arc<StreamDemultiplexer>,
665 streams: Arc<DashMap<u32, Arc<Stream>>>,
666 expected_server_key: HybridVerifyingKey,
667 runtime: Arc<dyn Runtime>,
668 inner_session: Arc<Mutex<Option<Arc<Session>>>>,
669 early_data_accepted: Arc<Mutex<Option<bool>>>,
670 shaping: Arc<parking_lot::Mutex<TrafficShapingConfig>>,
671 resumption_request: Option<([u8; 32], [u8; 32], Vec<u8>)>,
672 observability: Arc<Observability>,
673 ) {
674 // DEBUG: the peer address is correlatable; keep it off default logs.
675 log::debug!("PhantomSession: starting handshake with {}", peer);
676
677 // fips bootstrap POST gate, mirroring the listener and
678 // `connect_pinned*` paths: the synchronous Rust-only entry
679 // points (`connect_with_transport*` / `connect_with_resumption`)
680 // also need to honor FIPS 140-3 §7.7 before any cryptographic
681 // work. Cached `OnceLock` makes the second+ call an atomic
682 // read; the first call runs the full POST battery.
683 //
684 // On failure we cannot return a `CoreError` (the entry points
685 // are infallible by API contract) — instead we transition the
686 // state machine to `Failed` and bail, matching the existing
687 // handshake-failure shape. The error string lands in the log.
688 #[cfg(feature = "fips")]
689 if let Err(e) = crate::crypto::self_tests::ensure_post_passed() {
690 log::error!(
691 "PhantomSession: FIPS POST self-test failed; refusing to handshake: {:?}",
692 e
693 );
694 state.store(ConnectionState::Failed as u8, Ordering::Relaxed);
695 return;
696 }
697
698 // Retain a copy of any 0-RTT early-data so it can be losslessly
699 // re-sent over the established session if the server rejects it (C3 —
700 // the rejection-retransmission contract). `run_client_handshake`
701 // consumes `resumption_request`, so clone the blob first.
702 let pending_early_data: Option<Vec<u8>> = resumption_request
703 .as_ref()
704 .and_then(|(_, _, ed)| (!ed.is_empty()).then(|| ed.clone()));
705
706 // ── Stage 1 & 2: Hybrid Handshake (optionally 0-RTT resumption) ──
707 // HS-02: bound the whole client handshake by a wall-clock deadline so a
708 // silent or stalling server can't hang the connect indefinitely. The
709 // TIMER is `runtime.sleep` (NOT raw tokio::time) so it stays correct
710 // under WasmRuntime/EmbeddedRuntime; `select!` is just the combinator.
711 const CLIENT_HANDSHAKE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(10);
712 // Scoped so the handshake future's borrow of `transport` ends before
713 // `transport` is moved into the data pump below.
714 let handshake_result = {
715 let handshake_fut =
716 run_client_handshake(&transport, &expected_server_key, resumption_request);
717 let handshake_timeout = runtime.sleep(CLIENT_HANDSHAKE_DEADLINE);
718 tokio::pin!(handshake_fut);
719 tokio::select! {
720 r = &mut handshake_fut => r,
721 _ = handshake_timeout => Err(CoreError::Timeout),
722 }
723 };
724 let (crypto_session, ed_accepted) = match handshake_result {
725 Ok((session, accepted)) => (Arc::new(session), accepted),
726 Err(e) => {
727 log::error!("PhantomSession: handshake failed: {}", e);
728 state.store(ConnectionState::Failed as u8, Ordering::Relaxed);
729 return;
730 }
731 };
732 log::info!("PhantomSession: Handshake complete — hybrid channel ready");
733
734 // Phase 4.1 — publish the negotiated Session + the 0-RTT
735 // verdict via the outer PhantomSession so `resumption_hint()`
736 // and `early_data_accepted()` can reach them after the
737 // background task moves the Arc into the pump.
738 {
739 let mut guard = inner_session.lock().await;
740 *guard = Some(crypto_session.clone());
741 }
742 // #9 — apply any traffic-shaping config the embedder set BEFORE the
743 // handshake completed (connect is async), so the very first data packets
744 // are already shaped rather than only after a manual post-establishment
745 // `set_traffic_shaping`. A later `set_traffic_shaping` re-applies live.
746 apply_shaping(&crypto_session, *shaping.lock());
747 *early_data_accepted.lock().await = ed_accepted;
748
749 // C3 — 0-RTT rejection retransmission contract. If we sent early-data
750 // and the server rejected it (`Some(false)`), it never reached the
751 // application layer, so re-send it losslessly over the now-established
752 // 1-RTT session. Prepend it to the pre-handshake send queue (drained
753 // first by the pump onto the reliable raw-app stream) so it lands
754 // *ahead* of anything the app queued while connecting — preserving the
755 // order in which the bytes were originally offered. `Some(true)` (the
756 // server consumed it) and `None` (none sent) need no action.
757 if ed_accepted == Some(false) {
758 if let Some(ed) = pending_early_data {
759 send_queue.lock().await.insert(0, ed);
760 log::debug!(
761 "PhantomSession: 0-RTT early-data rejected; re-queued for 1-RTT delivery"
762 );
763 }
764 }
765
766 let session_id = *crypto_session.id();
767 state.store(ConnectionState::Connected as u8, Ordering::Relaxed);
768 log::debug!("PhantomSession: fully connected to {}", peer);
769
770 // Wrap the (post-handshake) transport so every data-plane send/recv is
771 // recorded. The generic client path can ride any `SessionTransport`, but
772 // the observability leg label is not threaded through `connect_with_*`,
773 // so it is fixed to TCP here (the only metric this skews is the per-leg
774 // packet/byte slice; the totals are correct).
775 // WIRE-001: the handshake is done — raise the frame cap from the tight
776 // unauthenticated handshake limit to the steady-state application limit.
777 transport.set_frame_phase(FramePhase::Established);
778 // ε / WIRE v5: stamp this session's rotating CID_0 on every post-handshake
779 // datagram (the chain the server's demux routes on) instead of the
780 // bootstrap ConnId.
781 transport.set_outbound_cid(crypto_session.current_outbound_cid());
782 let observed = Arc::new(ObservedTransport::new(
783 transport,
784 observability.clone(),
785 LegType::Tcp,
786 ));
787 run_data_pump(
788 crypto_session,
789 session_id,
790 observed,
791 state,
792 send_queue,
793 cmd_rx,
794 recv_tx,
795 demux,
796 streams,
797 runtime,
798 observability,
799 LegType::Tcp,
800 )
801 .await;
802 }
803}
804
805/// Drive the client side of the Phantom Protocol handshake to completion.
806///
807/// When `resumption` is `Some((resume_id, resume_secret, early_data))` the
808/// first-flight `ClientHello` carries the resume id and, when `early_data` is
809/// non-empty, a sealed 0-RTT blob folded into `ClientHello.early_data` — so it
810/// reaches the server on the first flight. A cookie/PoW `HelloRetryRequest` is
811/// answered in-loop, reusing the same hello (the early-data blob rides along).
812///
813/// Returns the established `Session` and the 0-RTT verdict (resolved
814/// decision 1):
815/// - `Some(true)` — the client sent early-data and the server consumed it
816/// - `Some(false)` — the client sent early-data and the server rejected it
817/// (stale ticket / oversized / AEAD failure)
818/// - `None` — the client sent no early-data on this connect
819async fn run_client_handshake<T: SessionTransport>(
820 transport: &T,
821 expected_server_key: &HybridVerifyingKey,
822 resumption: Option<([u8; 32], [u8; 32], Vec<u8>)>,
823) -> Result<(Session, Option<bool>), CoreError> {
824 let handshake = HandshakeClient::new()?;
825
826 // Build the first-flight ClientHello. A resumption request folds the
827 // resume id and (optionally) a sealed 0-RTT early-data blob into the
828 // single hello; otherwise it is a plain hello.
829 let mut hello = match &resumption {
830 Some((resume_id, resume_secret, early_data)) => {
831 let ed: Option<&[u8]> = if early_data.is_empty() {
832 None
833 } else {
834 Some(early_data.as_slice())
835 };
836 handshake.create_client_hello_with_resume(*resume_id, resume_secret, ed)
837 }
838 None => handshake.create_client_hello(),
839 };
840
841 // HS-02: cap the number of HelloRetryRequest rounds. The legitimate flow
842 // needs at most one cookie round + one PoW round; a bound of 3 leaves slack
843 // for a benign reorder. Without it, a MITM answering every ClientHello with
844 // a fresh cheap HelloRetryRequest could loop the client forever.
845 const MAX_CLIENT_RETRY_ROUNDS: u32 = 3;
846 // Reviewer §5: bound how many injected/genuine ServerRejects we read past while still
847 // waiting for a ServerHello, so a reject flood can't loop the inner read forever.
848 const MAX_CLIENT_REJECT_ROUNDS: u32 = 3;
849 let mut retry_rounds: u32 = 0;
850 let mut reject_rounds: u32 = 0;
851 // Reviewer §5: an *injected* ServerReject (a tiny pre-crypto blob a network attacker can
852 // spray) must not abort a healthy handshake. Remember it and keep reading for a valid
853 // ServerHello; surface it only if one never arrives (do NOT auto-downgrade — Invariant 7).
854 let mut remembered_reject: Option<ServerReject> = None;
855
856 loop {
857 // (Re)send the current hello (fresh, or cookie/PoW-updated after a HelloRetryRequest).
858 let bytes = borsh::to_vec(&hello).map_err(|e| {
859 CoreError::SerializationError(format!("ClientHello encode failed: {}", e))
860 })?;
861 transport.send_bytes(&bytes).await?;
862
863 // Read responses for THIS hello, reading past an injected ServerReject (WITHOUT
864 // re-sending) until a ServerHello (success), a HelloRetryRequest (re-send with the
865 // cookie/PoW), or the channel ends.
866 loop {
867 let resp = match transport.recv_bytes().await {
868 Ok(r) => r,
869 Err(e) => {
870 // No further responses: surface a remembered reject (a genuine version
871 // mismatch) over the raw transport error.
872 return match &remembered_reject {
873 Some(r) => Err(CoreError::HandshakeError(format!(
874 "server rejected the handshake: unsupported protocol version \
875 (client speaks v{}, server speaks v{})",
876 hello.version, r.supported_version
877 ))),
878 None => Err(e),
879 };
880 }
881 };
882
883 // T4.4: the reply leads with an explicit discriminant byte
884 // (`[kind] ‖ borsh(body)`); dispatch on it instead of trial-deserializing by
885 // size. An unknown kind / malformed body is a handshake error, not a misparse.
886 match ServerReply::from_wire(&resp) {
887 Ok(ServerReply::Hello(sh)) => {
888 let (session, accepted) =
889 handshake.process_server_hello(&hello, &sh, Some(expected_server_key))?;
890 return Ok((session, accepted));
891 }
892 Ok(ServerReply::Reject(reject)) => {
893 // The marker is an extra sanity check on top of the discriminant. We do
894 // NOT auto-downgrade to `reject.supported_version` (Invariant 7).
895 if reject.has_marker() {
896 reject_rounds += 1;
897 if reject_rounds > MAX_CLIENT_REJECT_ROUNDS {
898 return Err(CoreError::HandshakeError(format!(
899 "server rejected the handshake: unsupported protocol version \
900 (client speaks v{}, server speaks v{})",
901 hello.version, reject.supported_version
902 )));
903 }
904 // reviewer §5: keep waiting for a valid ServerHello — read the next
905 // frame WITHOUT re-sending, so a single forged reject can't kill the
906 // handshake.
907 remembered_reject = Some(reject);
908 continue;
909 }
910 return Err(CoreError::HandshakeError(
911 "server reject missing marker".into(),
912 ));
913 }
914 Ok(ServerReply::Retry(retry)) => {
915 retry_rounds += 1;
916 if retry_rounds > MAX_CLIENT_RETRY_ROUNDS {
917 return Err(CoreError::HandshakeError(format!(
918 "server demanded more than {MAX_CLIENT_RETRY_ROUNDS} HelloRetryRequest rounds"
919 )));
920 }
921 log::info!("PhantomSession: Received HelloRetryRequest, retrying...");
922 hello.cookie = retry.cookie;
923 if let Some(challenge) = retry.challenge {
924 // H3: cap the accepted difficulty and bound the solver, so an
925 // injected/malicious HelloRetryRequest (e.g. difficulty 255)
926 // surfaces a handshake error instead of pinning a CPU core.
927 log::info!("PhantomSession: Solving PoW challenge...");
928 hello.pow_solution = Some(
929 challenge
930 .solve_capped(crate::crypto::pow::MAX_CLIENT_POW_DIFFICULTY)
931 .map_err(|e| CoreError::HandshakeError(e.to_string()))?,
932 );
933 }
934 break; // re-send the cookie/PoW-updated hello (outer loop)
935 }
936 Err(e) => {
937 return Err(CoreError::HandshakeError(format!(
938 "invalid server reply: {e}"
939 )));
940 }
941 }
942 }
943 }
944}
945
946/// Reserved stream id for the connectionless `send()`/`recv()` surface. The
947/// demultiplexer hands out ids of two and above, so this never collides with a
948/// user-opened stream. Idle keep-alives ([`send_keepalive`]) also stamp it for a
949/// well-formed, consistent header.
950const RAW_APP_STREAM_ID: u32 = 1;
951
952/// Shared client/server data pump.
953///
954/// After the handshake completes (client side) or after the server `Session` is
955/// derived (server side), this loop:
956/// - drains the queued early-data buffer,
957/// - listens for incoming packets and decrypts them,
958/// - encrypts outgoing application/stream packets,
959/// - sends ACKs for reliable packets.
960// The 12 parameters represent the complete session-identity and I/O surface.
961// Grouping them into a struct would require a generic struct (due to `T:
962// SessionTransport`), add indirection with no safety or clarity gain, and
963// constitute a public-API change. The function is private (`async fn`, no
964// `pub`), so the extra arguments are contained here.
965#[allow(clippy::too_many_arguments)]
966async fn run_data_pump<T: SessionTransport>(
967 crypto_session: Arc<Session>,
968 session_id: SessionId,
969 transport: Arc<T>,
970 state: Arc<AtomicU8>,
971 send_queue: Arc<Mutex<Vec<Vec<u8>>>>,
972 mut cmd_rx: mpsc::Receiver<SessionCommand>,
973 recv_tx: mpsc::Sender<Bytes>,
974 demux: Arc<StreamDemultiplexer>,
975 streams: Arc<DashMap<u32, Arc<Stream>>>,
976 runtime: Arc<dyn Runtime>,
977 observability: Arc<Observability>,
978 leg: LegType,
979) {
980 // Session is now established and active — bump the active-session gauge.
981 // The matching `session_closed` at teardown (below) lets the gauge fall,
982 // so it tracks live sessions instead of growing monotonically.
983 observability.session_opened(leg);
984
985 // Liveness (P4.3): stamp "alive now" at establishment so the inbound-silence
986 // sweep measures from the data-plane start, not from session construction (which
987 // predates the multi-KB handshake and would otherwise look stale immediately).
988 crypto_session.update_activity();
989
990 // ── Raw-app session stream (reserved id 1) ──
991 // The connectionless `send()` / `recv()` surface is multiplexed onto one
992 // reserved stream so it gets the same reliable-delivery machinery as
993 // explicitly-opened streams: `drain_streams_priority_ordered` (re)transmits
994 // its buffered segments on the poll tick / outbound-ready notify, and
995 // inbound ACKs for id 1 clear them via `Stream::ack`. The demultiplexer
996 // hands out ids 2+, so this never collides with a user-opened stream.
997 let raw_stream = Arc::new(Stream::new(RAW_APP_STREAM_ID as TransportStreamId));
998 streams.insert(RAW_APP_STREAM_ID, raw_stream.clone());
999
1000 // ── Flush queued early-data onto the raw-app stream ──
1001 // Routed through the stream (not a one-shot direct send) so queued
1002 // pre-handshake data is buffered for retransmit just like post-handshake
1003 // sends — a dropped early-data frame is recovered, not lost.
1004 {
1005 let mut queue = send_queue.lock().await;
1006 let count = queue.len();
1007 'flush: for msg in queue.drain(..) {
1008 for chunk in msg.chunks(TRANSPORT_MTU) {
1009 if let Err(e) = raw_stream
1010 .send_reliable(Bytes::copy_from_slice(chunk))
1011 .await
1012 {
1013 // T4.5 fail-closed: the reliable offset space is exhausted (~2^32
1014 // segments) — refuse rather than wrap. Astronomically unreachable;
1015 // the session stalls and the liveness sweep tears it down.
1016 log::error!("PhantomSession: early-data flush aborted — {e}");
1017 break 'flush;
1018 }
1019 }
1020 }
1021 if count > 0 {
1022 log::info!(
1023 "PhantomSession: queued {} early-data message(s) onto the raw-app stream",
1024 count
1025 );
1026 crypto_session.notify_outbound_ready();
1027 }
1028 }
1029
1030 // ── Receive-delivery decoupling ──
1031 // The reader task hands decrypted application data to a dedicated delivery
1032 // task over an UNBOUNDED channel and never blocks on app delivery, so a slow
1033 // `recv()` consumer cannot head-of-line-stall inbound ACK / WINDOW_UPDATE /
1034 // control processing. The delivery task does the app-paced `recv_tx.send()`
1035 // and credits the flow-control window on *real* consumption; enforced
1036 // send-side flow control (`Stream::poll_send`) bounds the in-flight backlog
1037 // to ~one window, and `undelivered_bytes` + `RECV_DELIVERY_HARD_CAP` guard
1038 // against a peer that ignores flow control.
1039 let (deliver_tx, mut deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
1040 let undelivered_bytes = Arc::new(AtomicU64::new(0));
1041 {
1042 let recv_tx_deliver = recv_tx; // move the session recv channel here
1043 let demux_deliver = demux.clone();
1044 let streams_deliver = streams.clone();
1045 let crypto_deliver = crypto_session.clone();
1046 let undelivered_deliver = undelivered_bytes.clone();
1047 runtime.spawn(Box::pin(async move {
1048 while let Some((stream_id, bytes)) = deliver_rx.recv().await {
1049 let len = bytes.len() as u64;
1050 // Best-effort, non-blocking notification to the (vestigial) demux.
1051 demux_deliver.route_data(stream_id, bytes.clone());
1052 // Account the item the instant it leaves the UNBOUNDED delivery
1053 // queue (which the reader's HARD_CAP guards) — BEFORE the
1054 // app-paced `recv_tx.send()` below, which can block for a long
1055 // time on a slow consumer. Decrementing (and crediting) only
1056 // after a successful send would (a) keep this item counted
1057 // against the cap while it sits in the bounded recv pipeline,
1058 // inflating `undelivered_bytes`, and (b) leak the count entirely
1059 // if the send then fails. The byte is now in the bounded
1060 // recv-channel pipeline (capacity-limited, its own backpressure),
1061 // so it no longer belongs to the unbounded backlog.
1062 undelivered_deliver.fetch_sub(len, Ordering::AcqRel);
1063 // Credit the flow-control window: the item has been pulled into
1064 // the app-delivery pipeline (matching the inline ACK's "accepted
1065 // into my in-memory delivery queue" semantics). The pull rate is
1066 // still paced by `recv_tx.send()` completing below, so credit
1067 // tracks app consumption (one item of look-ahead) — backpressure
1068 // is preserved. Wake the send loop to flush the WINDOW_UPDATE
1069 // (emitted there — the sole outbound writer — so it is sealed
1070 // under the live epoch; the epoch's two writers both serialise
1071 // through `rekey_lock`, so the flush is always epoch-consistent).
1072 if let Some(stream) = streams_deliver.get(&stream_id) {
1073 if let Some(credit) = stream.record_app_consumed(len as u32) {
1074 stream.stage_window_update_credit(credit);
1075 crypto_deliver.notify_outbound_ready();
1076 }
1077 }
1078 // Real, app-paced delivery to the session recv channel. A closed
1079 // channel means the consumer is gone → session ending; stop. The
1080 // item was already removed from the backlog accounting above, so
1081 // breaking here leaks nothing.
1082 if recv_tx_deliver.send(bytes).await.is_err() {
1083 break;
1084 }
1085 }
1086 }));
1087 }
1088
1089 // ── Receive (reader) task: deserialize, decrypt, hand off to delivery ──
1090 let transport_recv = transport.clone();
1091 let transport_send_ack = transport.clone();
1092 let crypto_recv = crypto_session.clone();
1093 let demux_recv = demux.clone();
1094 let streams_recv = streams.clone();
1095 let undelivered_reader = undelivered_bytes.clone();
1096 let observability_recv = observability.clone();
1097 // Completion signal for the receive task. `SpawnHandle` from the
1098 // runtime trait does not expose a `Future` for `.await` directly
1099 // (different runtimes provide different join futures), so we wire a
1100 // one-shot channel — the recv task sends `()` right before exiting
1101 // and the main loop selects on the receiver to detect transport
1102 // closure.
1103 let (recv_done_tx, mut recv_done_rx) = oneshot::channel::<()>();
1104 let transport_for_path = transport.clone();
1105 let recv_handle = runtime.spawn(Box::pin(async move {
1106 // Reusable buffer for ACK frame serialization. Hoisted out of the
1107 // loop (Phase 2.3) so we don't pay a fresh `Vec::new()` allocation
1108 // for every ACK we emit on a busy reliable stream. 256 bytes is
1109 // comfortably larger than a serialized empty `PhantomPacket` (the
1110 // 15-byte header plus the AEAD tag — no cleartext length prefixes
1111 // since v6), so the underlying buffer is never reallocated after the
1112 // first frame.
1113 let mut ack_buf: Vec<u8> = Vec::with_capacity(256);
1114 // Buffering ceiling: the delivery queue is unbounded so the reader
1115 // never blocks, but a peer that ignores flow control could flood it.
1116 // Compliant senders are bounded by ~one window per stream (enforced
1117 // `poll_send`), far below this cap; crossing it means the peer is
1118 // misbehaving, so we tear the session down rather than buffer without
1119 // limit. 4 MiB tolerates many streams × the 64 KiB window with margin.
1120 const RECV_DELIVERY_HARD_CAP: u64 = 4 * 1024 * 1024;
1121 loop {
1122 // Flow-control / anti-flood gate: if the app-delivery backlog
1123 // has blown past the cap, the peer is not honouring the window —
1124 // close instead of growing the in-memory queue unboundedly. Cheap
1125 // pre-check, before any AEAD work.
1126 if undelivered_reader.load(Ordering::Acquire) > RECV_DELIVERY_HARD_CAP {
1127 log::warn!(
1128 "PhantomSession: receive backlog {} B exceeds cap — peer ignoring flow \
1129 control; closing session",
1130 undelivered_reader.load(Ordering::Acquire)
1131 );
1132 break;
1133 }
1134 let data = match transport_recv.recv_bytes().await {
1135 Ok(b) => b,
1136 Err(_) => break,
1137 };
1138
1139 // Remove header protection (T4.6) and parse: a malformed / unparseable
1140 // / short-of-the-AEAD-tag frame (no legitimate peer produces one) is
1141 // dropped — never a panic. Since WIRE v6 the WHOLE 15-byte header is
1142 // HP-masked (`HP_PROTECTED_OFFSET == 0`, no cleartext header byte on
1143 // the wire); `parse_protected` unmasks it with this session's recv HP
1144 // key and reconstructs the off-wire 32-byte `session_id` from session
1145 // context before the header is interpreted.
1146 let packet = match crypto_recv.parse_protected(&data) {
1147 Ok(v) => v,
1148 Err(_) => continue,
1149 };
1150 // Pinned wire-version gate: the format is not negotiated, so a
1151 // frame carrying any other version byte is dropped.
1152 if packet.header.version != WIRE_VERSION {
1153 continue;
1154 }
1155 handle_packet(
1156 packet,
1157 session_id,
1158 &crypto_recv,
1159 &streams_recv,
1160 &demux_recv,
1161 &transport_send_ack,
1162 &transport_for_path,
1163 &deliver_tx,
1164 &undelivered_reader,
1165 &mut ack_buf,
1166 &observability_recv,
1167 leg,
1168 )
1169 .await;
1170 }
1171 // Reader exiting → drop `deliver_tx` so the delivery task drains any
1172 // queued items and then sees the channel closed and exits.
1173 drop(deliver_tx);
1174 // Signal the main loop that the recv task has exited so it can
1175 // also unwind. `send` returns `Err(())` if the receiver was
1176 // already dropped — that case is harmless, the main loop has
1177 // already shut down.
1178 let _ = recv_done_tx.send(());
1179 }));
1180
1181 // MTU for transport packets
1182 const TRANSPORT_MTU: usize = 1300;
1183 // Phase 2.4: the 10 ms `poll_interval` stays as a retransmit-timer
1184 // fallback (streams without an explicit notifier reference still
1185 // get swept), but `send_notify.notified()` joins the select! so the
1186 // pump wakes immediately when a producer calls
1187 // `Session::notify_outbound_ready()`. This drops idle CPU usage to
1188 // zero on quiet sessions while keeping the worst-case post-queue
1189 // latency at <10 ms even for producers that haven't been wired into
1190 // the notifier yet.
1191 let mut poll_interval = tokio::time::interval(std::time::Duration::from_millis(10));
1192 let send_notify = crypto_session.send_notifier();
1193 // Liveness keep-alive bookkeeping (P4.3): `Some(t)` while in the `Migrating`
1194 // window (the pump-local truth + how long); `died` records an idle-timeout death
1195 // so the teardown publishes `Dead` instead of overwriting it with `Closed`.
1196 let mut migrating_since: Option<std::time::Instant> = None;
1197 let mut died = false;
1198 // Idle keep-alive bookkeeping (Direction #3 — download-only liveness): the
1199 // pump-local instant of the last keep-alive PING we emitted, so we send at most
1200 // one per `keepalive_interval` (no 10 ms-heartbeat spam). Seeded at "now" so the
1201 // first PING waits a full interval after the data plane starts.
1202 let mut last_keepalive = std::time::Instant::now();
1203 // Cover-traffic bookkeeping (WIRE v6, deliverable (e)): the send PN observed at
1204 // the last cover check, and the instant of the last observed outbound activity.
1205 // Any real packet advances the PN, resetting the idle window, so cover only
1206 // fills genuine gaps (idle-fill + a floor rate). Seeded at "now"/current PN.
1207 let mut last_outbound_pn = crypto_session.peek_send_pn();
1208 let mut last_outbound_at = std::time::Instant::now();
1209 // Outbound WINDOW_UPDATE control packets are emitted on the send loop — the
1210 // sole outbound writer — so the encrypted control frame is always sealed under
1211 // the epoch live when it stamps. The epoch has two writers (this loop's own
1212 // `rekey()` and the receive task's authenticated forward catch-up in
1213 // `decrypt_packet_accepting_rekey`), but both serialise through the session's
1214 // `rekey_lock`, so the seal is always epoch-consistent. The delivery task only
1215 // stages the relative credit (`Stream::stage_window_update_credit`) and
1216 // wakes us; the wire sequence is drawn from the stream's own send-sequence
1217 // space inside `flush_pending_window_updates` (no private counter, so it
1218 // can never collide with application data on the AEAD nonce).
1219
1220 loop {
1221 tokio::select! {
1222 _ = poll_interval.tick() => {
1223 flush_pending_window_updates(
1224 &transport, &crypto_session, session_id, &streams,
1225 )
1226 .await;
1227 drain_streams_priority_ordered(
1228 &transport,
1229 &crypto_session,
1230 session_id,
1231 &streams,
1232 )
1233 .await;
1234 // Idle keep-alive (Direction #3 — download-only liveness): on an
1235 // otherwise-idle Connected path, emit one small ENCRYPTED PING so a
1236 // download-only path (which sends only ACKs) has an outstanding probe
1237 // to anchor the liveness sweep below — and the peer's PONG refreshes
1238 // its activity timer. Runs before the sweep so a just-emitted PING is
1239 // already marked outstanding this tick.
1240 maybe_send_keepalive(
1241 &transport, &crypto_session, session_id, &mut last_keepalive,
1242 )
1243 .await;
1244 // Cover traffic (WIRE v6, deliverable (e)): on this same heartbeat,
1245 // maintain the minimum outbound packet rate — emit a COVER dummy when
1246 // the outbound path has been idle past the floor interval. No-op when
1247 // cover is disabled (default) or real traffic is flowing.
1248 maybe_send_cover(
1249 &transport,
1250 &crypto_session,
1251 session_id,
1252 &mut last_outbound_pn,
1253 &mut last_outbound_at,
1254 )
1255 .await;
1256 // Liveness sweep (P4.3): the 10 ms heartbeat is the reliable place to
1257 // evaluate inbound silence vs. outstanding data and surface
1258 // Migrating / recover / Dead. A `Dead` verdict ends the pump.
1259 if apply_liveness(&crypto_session, &state, &mut migrating_since) {
1260 died = true;
1261 break;
1262 }
1263 }
1264 _ = send_notify.notified() => {
1265 // Same drain logic as the tick arm — fast-wake path. Also flush
1266 // any flow-control credit the delivery task staged.
1267 flush_pending_window_updates(
1268 &transport, &crypto_session, session_id, &streams,
1269 )
1270 .await;
1271 drain_streams_priority_ordered(
1272 &transport,
1273 &crypto_session,
1274 session_id,
1275 &streams,
1276 )
1277 .await;
1278 }
1279 cmd_opt = cmd_rx.recv() => {
1280 match cmd_opt {
1281 Some(SessionCommand::Send(data)) => {
1282 // Route through the raw-app stream so the payload is
1283 // buffered for retransmit until ACKed (drained by
1284 // `drain_streams_priority_ordered`), instead of being
1285 // fired once and forgotten on the wire.
1286 for chunk in data.chunks(TRANSPORT_MTU) {
1287 if let Err(e) = raw_stream
1288 .send_reliable(Bytes::copy_from_slice(chunk))
1289 .await
1290 {
1291 log::error!("PhantomSession: send aborted — {e}");
1292 break;
1293 }
1294 }
1295 crypto_session.notify_outbound_ready();
1296 }
1297 Some(SessionCommand::SendStreamReliable { stream_id, data }) => {
1298 if let Some(stream) = streams.get(&stream_id) {
1299 for chunk in data.chunks(TRANSPORT_MTU) {
1300 if let Err(e) =
1301 stream.send_reliable(Bytes::copy_from_slice(chunk)).await
1302 {
1303 log::error!("PhantomSession: stream send aborted — {e}");
1304 break;
1305 }
1306 }
1307 }
1308 }
1309 Some(SessionCommand::SendStreamUnreliable { stream_id, data }) => {
1310 if let Some(stream) = streams.get(&stream_id) {
1311 for chunk in data.chunks(TRANSPORT_MTU) {
1312 stream.send_unreliable(Bytes::copy_from_slice(chunk)).await;
1313 }
1314 }
1315 }
1316 Some(SessionCommand::CloseStream { stream_id }) => {
1317 if let Some(stream) = streams.get(&stream_id) {
1318 stream.finish().await;
1319 let _ = send_app_data(
1320 &transport,
1321 &crypto_session,
1322 session_id,
1323 stream_id as TransportStreamId,
1324 &[],
1325 PacketFlags::FIN,
1326 None, // bare FIN is a control frame — no reliable offset
1327 ).await;
1328 }
1329 streams.remove(&stream_id);
1330 demux.close_stream(stream_id);
1331 }
1332 Some(SessionCommand::Migrate(local_addr)) => {
1333 // Embedder-triggered connection migration (Phase 4 / P4.2).
1334 // Rebind the transport to the new local socket FIRST (it keeps
1335 // the old socket for the overlap); only on a successful rebind
1336 // bump the send `path_id` so every subsequent packet from the
1337 // new socket carries a fresh, not-yet-Validated path label —
1338 // which is what makes the server detect + challenge the new
1339 // path (a still-`0` path_id would be skipped, path 0 being
1340 // permanently Validated). Both happen inside this `select!`
1341 // arm, so no send interleaves between them. Best-effort: a
1342 // failed rebind leaves the session untouched on the old socket
1343 // (broken-rebind safety) — migration never tears it down.
1344 match transport.migrate(local_addr).await {
1345 Ok(()) => {
1346 let new_path = crypto_session.next_migration_path_id();
1347 // ε / WIRE v5: rotate the outbound CID so every
1348 // post-migration datagram stamps an
1349 // independent-random ConnId an observer cannot link
1350 // to the pre-migration flow. The new CID_{i+1} is
1351 // already in the server's pre-registered inbound
1352 // window (which slides post-AEAD beyond K migrations).
1353 transport.set_outbound_cid(crypto_session.advance_outbound_cid());
1354 log::info!(
1355 "PhantomSession: migrated send path -> path_id {}, CID rotated",
1356 new_path
1357 );
1358 // Wake the send loop so app data + L1 retransmits flow
1359 // from the new socket immediately, triggering the
1360 // server-side new-source detection.
1361 crypto_session.notify_outbound_ready();
1362 }
1363 Err(e) => {
1364 log::warn!(
1365 "PhantomSession: migrate rebind failed (staying on the old path): {}",
1366 e
1367 );
1368 }
1369 }
1370 }
1371 Some(SessionCommand::MigrateServer(local_addr)) => {
1372 // Server-side migration (the mirror of `Migrate`). Rebind the
1373 // server's SEND socket to the new local address FIRST (its receive
1374 // keeps flowing on the old address through the listener demux during
1375 // the overlap, so c2s never drops); only on a successful rebind
1376 // rotate the s2c send `path_id` + outbound CID in lock-step, so the
1377 // client sees a fresh server source with a fresh, unlinkable ConnId
1378 // and follows it (its unconnected socket hears the new source). Both
1379 // happen inside this `select!` arm, so no send interleaves between
1380 // them. Best-effort: a failed rebind leaves the session on the old
1381 // send socket — server migration never tears it down.
1382 match transport.migrate_server(local_addr).await {
1383 Ok(()) => {
1384 let new_path = crypto_session.next_migration_path_id();
1385 transport.set_outbound_cid(crypto_session.advance_outbound_cid());
1386 log::info!(
1387 "PhantomSession: migrated server send path -> path_id {}, s2c CID rotated",
1388 new_path
1389 );
1390 // Wake the send loop so the next s2c packet carries the new
1391 // source + path_id + CID immediately.
1392 crypto_session.notify_outbound_ready();
1393 }
1394 Err(e) => {
1395 log::warn!(
1396 "PhantomSession: server migrate rebind failed (staying on the old send socket): {}",
1397 e
1398 );
1399 }
1400 }
1401 }
1402 Some(SessionCommand::Close) => {
1403 log::info!("PhantomSession: closing");
1404 // `disconnect()` is a *graceful* close (doc: "Send the
1405 // graceful close frame and shut the session down" — TCP-FIN
1406 // semantics: finish sending, then close). Mirror the
1407 // handle-drop (`None`) arm so buffered `send()` data still
1408 // reaches the peer: `session.send(x); session.disconnect()`
1409 // must not lose `x`, just like `send(x); drop(session)`.
1410 flush_pending_window_updates(
1411 &transport, &crypto_session, session_id, &streams,
1412 )
1413 .await;
1414 drain_streams_priority_ordered(
1415 &transport, &crypto_session, session_id, &streams,
1416 )
1417 .await;
1418 break;
1419 }
1420 None => {
1421 log::info!("PhantomSession: command channel dropped");
1422 // The outer `PhantomSession` handle was dropped. Data already
1423 // handed to `send()` was routed onto the raw-app stream but may
1424 // not have hit the wire yet (transmission happens on the next
1425 // tick / notify of THIS loop). Flush it before exiting so a
1426 // fire-and-forget `send()` immediately followed by dropping the
1427 // handle still reaches the peer — otherwise a freshly-accepted
1428 // server session that does `recv(); send(echo)` then drops loses
1429 // the echo, and the client's `recv()` hangs to its timeout.
1430 flush_pending_window_updates(
1431 &transport, &crypto_session, session_id, &streams,
1432 )
1433 .await;
1434 drain_streams_priority_ordered(
1435 &transport, &crypto_session, session_id, &streams,
1436 )
1437 .await;
1438 break;
1439 }
1440 }
1441 }
1442 _ = &mut recv_done_rx => {
1443 log::error!("PhantomSession: receive task ended unexpectedly (transport closed)");
1444 break;
1445 }
1446 }
1447 }
1448
1449 // Abort the recv task if it's still running; idempotent on a finished
1450 // handle. Goes through the runtime-agnostic `SpawnHandle::abort`.
1451 recv_handle.abort();
1452 // A liveness idle-timeout death already published `ConnectionState::Dead`; only a
1453 // normal teardown (graceful close / transport drop) publishes `Closed`.
1454 if !died {
1455 state.store(ConnectionState::Closed as u8, Ordering::Relaxed);
1456 }
1457 // Session torn down — drop the active-session gauge back down.
1458 observability.session_closed(leg);
1459}
1460
1461/// Evaluate path liveness once (Phase 4 / P4.3) and apply the resulting transition to
1462/// both the internal [`SessionState`] and the FFI-visible [`ConnectionState`]. Returns
1463/// `true` when the session has died (idle-timeout in `Migrating`), so the caller ends
1464/// the pump. `migrating_since` is the pump-local truth for the keep-alive window.
1465fn apply_liveness(
1466 crypto_session: &Arc<Session>,
1467 state: &Arc<AtomicU8>,
1468 migrating_since: &mut Option<std::time::Instant>,
1469) -> bool {
1470 use crate::transport::liveness::{liveness_verdict, LivenessVerdict};
1471 let cfg = crypto_session.liveness_config();
1472 let snap = crypto_session.bandwidth_snapshot();
1473 let silence = crypto_session.last_activity_elapsed();
1474 let in_migrating = migrating_since.is_some();
1475 let migrating_for = migrating_since
1476 .map(|t| t.elapsed())
1477 .unwrap_or(std::time::Duration::ZERO);
1478 // Direction #3 (download-only liveness): an outstanding idle keep-alive PING is
1479 // an outstanding probe just like in-flight reliable data, so fold it into the
1480 // sweep's `inflight > 0` gate. This is what lets a download-only path — which
1481 // sends only ACKs and so has zero reliable bytes in flight — declare the path
1482 // down when the PING goes unanswered (the PONG would have refreshed activity).
1483 let effective_inflight = if crypto_session.keepalive_outstanding() {
1484 snap.inflight_bytes.max(1)
1485 } else {
1486 snap.inflight_bytes
1487 };
1488 match liveness_verdict(
1489 silence,
1490 effective_inflight,
1491 snap.min_rtt,
1492 in_migrating,
1493 migrating_for,
1494 &cfg,
1495 ) {
1496 LivenessVerdict::PathDown => {
1497 *migrating_since = Some(std::time::Instant::now());
1498 crypto_session.set_state(SessionState::Migrating);
1499 state.store(ConnectionState::Migrating as u8, Ordering::Relaxed);
1500 log::info!(
1501 "PhantomSession: path down (no inbound for {silence:?} with data in flight) \
1502 — entering Migrating; the embedder should migrate()"
1503 );
1504 false
1505 }
1506 LivenessVerdict::Recovered => {
1507 *migrating_since = None;
1508 crypto_session.set_state(SessionState::Connected);
1509 state.store(ConnectionState::Connected as u8, Ordering::Relaxed);
1510 log::info!("PhantomSession: path recovered — back to Connected");
1511 false
1512 }
1513 LivenessVerdict::Dead => {
1514 crypto_session.set_state(SessionState::Closed);
1515 state.store(ConnectionState::Dead as u8, Ordering::Relaxed);
1516 log::warn!("PhantomSession: migration idle-timeout elapsed — session dead");
1517 true
1518 }
1519 LivenessVerdict::Unchanged => false,
1520 }
1521}
1522
1523/// Emit an idle keep-alive PING when the path is idle (Direction #3 —
1524/// download-only liveness). Decides via the pure [`should_send_keepalive`] gate
1525/// over the live signals (Connected? nothing in flight? inbound silent ≥ interval?
1526/// no recent PING?). On a fire it sends one empty `ENCRYPTED | KEEPALIVE` packet,
1527/// marks the probe outstanding (so the very next liveness sweep treats the path as
1528/// awaiting a response even with no reliable data queued), and records the send
1529/// instant for the per-interval throttle. Best-effort: a send failure just leaves
1530/// `last_keepalive` unchanged so the next tick retries.
1531async fn maybe_send_keepalive<T: SessionTransport>(
1532 transport: &Arc<T>,
1533 crypto_session: &Arc<Session>,
1534 session_id: SessionId,
1535 last_keepalive: &mut std::time::Instant,
1536) {
1537 use crate::transport::liveness::should_send_keepalive;
1538 let cfg = crypto_session.liveness_config();
1539 // Cheap fast-path: skip everything when keep-alives are disabled.
1540 if cfg.keepalive_interval.is_none() {
1541 return;
1542 }
1543 let connected = crypto_session.state() == SessionState::Connected;
1544 let snap = crypto_session.bandwidth_snapshot();
1545 // An already-outstanding PING is itself "in flight" — fold it into the gate so
1546 // we don't queue a second PING before the first is answered or times out.
1547 let inflight = if crypto_session.keepalive_outstanding() {
1548 snap.inflight_bytes.max(1)
1549 } else {
1550 snap.inflight_bytes
1551 };
1552 if !should_send_keepalive(
1553 connected,
1554 inflight,
1555 crypto_session.last_activity_elapsed(),
1556 last_keepalive.elapsed(),
1557 &cfg,
1558 ) {
1559 return;
1560 }
1561 // PING (not a PONG): a bare KEEPALIVE that the peer echoes back as KEEPALIVE|ACK.
1562 if send_keepalive(transport, crypto_session, session_id, false).await {
1563 crypto_session.mark_keepalive_outstanding();
1564 *last_keepalive = std::time::Instant::now();
1565 }
1566}
1567
1568/// Emit any flow-control credit the receive **delivery** task staged.
1569///
1570/// The delivery task credits the window on real app consumption and stages the
1571/// relative credit via `Stream::stage_window_update_credit` + a send-loop wake;
1572/// the send loop (this, the sole outbound writer) actually encrypts and sends the
1573/// `WINDOW_UPDATE`, so the control frame is always sealed under the epoch live
1574/// when it stamps. The epoch can be advanced by either this loop's own `rekey()`
1575/// or the receive task's authenticated forward catch-up, but both serialise
1576/// through `rekey_lock`, so the seal is always epoch-consistent. The staged
1577/// credits are snapshotted out of the `DashMap` first so no
1578/// shard lock is held across the `.await` (which would deadlock the delivery /
1579/// reader tasks that also touch `streams`).
1580async fn flush_pending_window_updates<T: SessionTransport>(
1581 transport: &Arc<T>,
1582 crypto_session: &Arc<Session>,
1583 session_id: SessionId,
1584 streams: &Arc<DashMap<u32, Arc<Stream>>>,
1585) {
1586 let pending: Vec<(u32, u32, Arc<Stream>)> = streams
1587 .iter()
1588 .filter_map(|e| {
1589 e.value()
1590 .take_pending_window_update()
1591 .map(|c| (*e.key(), c, e.value().clone()))
1592 })
1593 .collect();
1594 for (stream_id, credit, stream) in pending {
1595 if !send_window_update(
1596 transport,
1597 crypto_session,
1598 session_id,
1599 stream_id as TransportStreamId,
1600 credit,
1601 )
1602 .await
1603 {
1604 // The send failed (transient transport hiccup): re-stage the credit
1605 // so the next send-loop pass — the 10 ms tick at the latest — retries
1606 // it. Dropping it silently would under-credit the peer and could
1607 // eventually stall the sender. Credits accumulate, so a retry simply
1608 // folds back in; a permanently dead transport tears the session down
1609 // via the reader, which ends this loop.
1610 stream.stage_window_update_credit(credit);
1611 }
1612 }
1613}
1614
1615/// Drain every stream with pending data, scheduling them in strict
1616/// priority order (higher `Stream::priority()` wins). Streams of equal
1617/// priority are drained in stream-id order (deterministic so tests
1618/// don't get flaky under DashMap's hash-order shuffle).
1619///
1620/// This is **strict priority**: a stream with priority N never yields
1621/// to a stream with priority < N while it still has data. A future
1622/// weighted-fair scheduler can replace this without changing the
1623/// caller surface. Phase 4.3.
1624async fn drain_streams_priority_ordered<T: SessionTransport>(
1625 transport: &Arc<T>,
1626 crypto_session: &Arc<Session>,
1627 session_id: SessionId,
1628 streams: &Arc<DashMap<u32, Arc<Stream>>>,
1629) {
1630 // Snapshot the stream set so we can sort without holding DashMap
1631 // shard locks across awaits. Each entry is (priority, stream_id,
1632 // stream-Arc) — Arc clones are cheap (refcount bump).
1633 let mut snapshot: Vec<(u32, u32, Arc<Stream>)> = streams
1634 .iter()
1635 .map(|e| (e.value().priority(), *e.key(), e.value().clone()))
1636 .collect();
1637 // Descending priority; ties broken by stream id ascending so the
1638 // order is stable across iterations.
1639 snapshot.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
1640
1641 for (_priority, stream_id, stream) in snapshot {
1642 loop {
1643 // Bytes of new data the congestion window currently permits.
1644 // Recomputed each iteration: every send grows inflight, so the
1645 // budget shrinks and the drain stops once the window is full.
1646 let snap = crypto_session.bandwidth_snapshot();
1647 let budget = snap.cwnd_bytes.saturating_sub(snap.inflight_bytes);
1648 let Some(seg) = stream.poll_send(budget).await else {
1649 break;
1650 };
1651 // A retransmission means the prior send was lost — tell congestion
1652 // control so BBR enters FastRecovery and the pacing rate backs off.
1653 if seg.retransmit {
1654 crypto_session.on_packet_lost(seg.data.len() as u64);
1655 }
1656 let base = if seg.reliable {
1657 PacketFlags::RELIABLE
1658 } else {
1659 PacketFlags::UNRELIABLE
1660 };
1661 // Reliable segments carry their gap-free `stream_offset` in the AEAD
1662 // plaintext (A.5) for in-order reassembly; unreliable segments do not.
1663 let reliable_offset = if seg.reliable {
1664 Some(seg.stream_offset)
1665 } else {
1666 None
1667 };
1668 if !send_app_data(
1669 transport,
1670 crypto_session,
1671 session_id,
1672 stream_id as TransportStreamId,
1673 &seg.data,
1674 base,
1675 reliable_offset,
1676 )
1677 .await
1678 {
1679 log::error!("PhantomSession: priority-ordered drain send failed");
1680 // `poll_send` already stamped `sent_at` on this reliable
1681 // segment, but the bytes never reached the wire. Clear it so the
1682 // next drain re-offers it immediately instead of stalling a full
1683 // RTO before the retransmit pass. Unreliable segments were
1684 // removed by `poll_send` (fire-and-forget) — nothing to reset.
1685 if seg.reliable {
1686 stream.mark_unsent(seg.stream_offset).await;
1687 }
1688 break;
1689 }
1690 }
1691 }
1692}
1693
1694/// Build a `DeliverySample` from a successful Stream ack callback and
1695/// feed it into the session's BBR estimator (Phase 4.4). The BBR loop
1696/// internally re-sets the pacer rate via `Session::on_packet_acked`,
1697/// so the next outbound packet is paced at the freshly-estimated
1698/// bottleneck bandwidth.
1699///
1700/// `ack_delay_us` is the `Sack::ack_delay_us` field carried in the ACK's AEAD
1701/// plaintext (microseconds the receiver held the ACK before sending) —
1702/// subtracted from the observed RTT to yield the propagation delay. Pass 0 when
1703/// no peer-side delay is known (the estimator treats it as "no delay reported").
1704fn feed_bbr_on_ack(
1705 crypto_session: &Arc<Session>,
1706 sent_at: tokio::time::Instant,
1707 packet_bytes: u64,
1708 ack_delay_us: u64,
1709) {
1710 let sample = crate::transport::bandwidth_estimator::DeliverySample {
1711 delivered_bytes: 0, // BandwidthEstimator tracks its own counter
1712 sent_at: sent_at.into_std(),
1713 acked_at: std::time::Instant::now(),
1714 packet_bytes,
1715 is_app_limited: false,
1716 ack_delay_us,
1717 };
1718 let _ = crypto_session.on_packet_acked(sample);
1719}
1720
1721/// Wait until the pacer has tokens for `bytes` bytes. No-op when the
1722/// pacer is unlimited (the default until BBR sets a finite rate).
1723async fn pace_send(crypto_session: &Arc<Session>, bytes: u64) {
1724 // Anti-fingerprint send-timing jitter (WIRE v6, deliverable (d)): when enabled,
1725 // wait a uniform random [0, max] ms before this send so the inter-packet timing
1726 // no longer tracks the application's writes. Applied independently of the pacer
1727 // (a wire-rate limiter) and before it, so the total delay is jitter + pacing.
1728 // Opt-in (default 0 → no-op, no latency cost).
1729 let jitter_max = crypto_session.send_jitter();
1730 if !jitter_max.is_zero() {
1731 let delay = shaping::random_jitter(jitter_max.as_millis() as u32);
1732 if !delay.is_zero() {
1733 tokio::time::sleep(delay).await;
1734 }
1735 }
1736 let pacer = crypto_session.pacer();
1737 if !pacer.is_enabled() {
1738 return;
1739 }
1740 loop {
1741 if pacer.try_consume(bytes) {
1742 return;
1743 }
1744 let wait = pacer.time_until_available(bytes);
1745 if wait.is_zero() {
1746 // Tokens should be available; retry the consume to handle
1747 // a concurrent race with another sender.
1748 continue;
1749 }
1750 // Cap the wait to keep the loop responsive — a stale wait
1751 // estimate from a long-idle pacer is corrected on the next
1752 // iteration.
1753 let cap = std::time::Duration::from_millis(50);
1754 let wait = wait.min(cap);
1755 tokio::time::sleep(wait).await;
1756 }
1757}
1758
1759/// Decide whether a rekey is needed before stamping a packet and, if so, perform
1760/// it. A rekey fires when the direction-wide AEAD-invocation high-watermark
1761/// ([`Session::send_needs_rekey`]) is crossed (Invariant 8). The per-stream C1
1762/// watermark is gone — under ① the packet number is a per-direction `u64` that
1763/// cannot wrap within a session, so the nonce can never repeat.
1764///
1765/// Returns the extra flag bits to OR into the header, or `None` if a rekey was
1766/// required but failed (epoch saturated at `u8::MAX`) — the caller MUST fail the
1767/// send so the session reconnects rather than reusing a nonce.
1768///
1769/// T5.5(b) — the returned `PacketFlags::REKEY` bit is set not only on the single
1770/// rotation-trigger packet but on EVERY packet sent at the new epoch until the
1771/// peer acknowledges the rekey ([`Session::rekey_unconfirmed`] clears once an
1772/// authenticated inbound packet is seen at the new epoch). Re-advertising the
1773/// flag is what makes the receive-side catch-up gate in
1774/// [`Session::decrypt_packet_accepting_rekey`] safe: a lost rotation-trigger
1775/// packet no longer strands the peer, because the next new-epoch packet (incl. a
1776/// reliable retransmit) still carries REKEY and drives the catch-up.
1777fn rekey_before_stamp(crypto_session: &Arc<Session>) -> Option<u16> {
1778 if crypto_session.send_needs_rekey() {
1779 // Crossed the high-watermark: rotate now. `rekey()` marks the session
1780 // `rekey_unconfirmed`, so the flag below re-arms automatically.
1781 if let Err(e) = crypto_session.rekey() {
1782 log::error!("PhantomSession: mid-session rekey failed: {}", e);
1783 return None;
1784 }
1785 }
1786 // Re-advertise REKEY while our last rekey is still unacknowledged — even when
1787 // no rotation happened on this packet (the trigger may have rotated several
1788 // packets ago and been lost).
1789 Some(if crypto_session.rekey_unconfirmed() {
1790 PacketFlags::REKEY
1791 } else {
1792 0
1793 })
1794}
1795
1796/// V2 send. Builds `PhantomPacket` with `PacketFlags::ENCRYPTED` and
1797/// the negotiated rekey epoch; AEAD nonce derives from the header
1798/// (`Session::encrypt_packet`), so a failed peer decrypt no longer
1799/// desyncs the local counter.
1800async fn send_app_data<T: SessionTransport>(
1801 transport: &Arc<T>,
1802 crypto_session: &Arc<Session>,
1803 session_id: SessionId,
1804 stream_id: TransportStreamId,
1805 payload: &[u8],
1806 base_flags: u16,
1807 reliable_offset: Option<u32>,
1808) -> bool {
1809 // Always OR in ENCRYPTED for application data.
1810 let mut flag_bits = base_flags | PacketFlags::ENCRYPTED;
1811 // Mid-session rekey: rotate to a fresh key BEFORE stamping this header when the
1812 // direction-wide AEAD high-watermark is crossed, so the header carries the new
1813 // epoch (+ the REKEY flag). The peer follows on the authenticated epoch bump
1814 // (it trial-decrypts under the next key).
1815 match rekey_before_stamp(crypto_session) {
1816 Some(extra) => flag_bits |= extra,
1817 // Epoch saturated (u8::MAX): can't rotate further. Surface as a failed
1818 // send so the caller re-offers; the session reconnects rather than wrap.
1819 None => return false,
1820 }
1821 // ① — Phase 4: draw the per-direction packet number at send time (so a
1822 // retransmit gets a fresh PN and the nonce is never reused).
1823 let packet_number = crypto_session.next_send_pn();
1824 // Build the inner AEAD plaintext (owned so size-padding can extend it). For
1825 // reliable data, prepend the gap-free per-stream `stream_offset` (A.5, 4
1826 // big-endian bytes) so the receiver reassembles in send order regardless of
1827 // `sequence` holes left by interleaved control frames. Unreliable / control
1828 // frames carry no offset. This all lives inside the AEAD (authenticated,
1829 // invisible on the wire).
1830 let mut plaintext: Vec<u8> = match reliable_offset {
1831 Some(off) => {
1832 let mut v = Vec::with_capacity(4 + payload.len());
1833 v.extend_from_slice(&off.to_be_bytes());
1834 v.extend_from_slice(payload);
1835 v
1836 }
1837 None => payload.to_vec(),
1838 };
1839 // Anti-fingerprint size padding (WIRE v6, deliverable (c)): when the session's
1840 // padding policy is enabled, pad this packet up to a PADÉ bucket INSIDE the
1841 // AEAD plaintext and flag it `PADDED`, so the on-wire datagram size no longer
1842 // tracks the payload size. The receiver strips the trailer after a successful
1843 // decrypt. Opt-in (default `None` → no-op, zero overhead). The `PADDED` flag
1844 // rides in the AAD (and is HP-masked on the wire), so a tamper fails the AEAD.
1845 let trailer = shaping::padding_trailer_len(plaintext.len(), crypto_session.padding_policy());
1846 if trailer > 0 {
1847 shaping::append_padding(&mut plaintext, trailer);
1848 flag_bits |= PacketFlags::PADDED;
1849 }
1850 let header = PacketHeader::new(
1851 session_id,
1852 stream_id,
1853 packet_number,
1854 PacketFlags::new(flag_bits),
1855 )
1856 .with_epoch(crypto_session.current_epoch())
1857 // Stamp the current send-side path_id (D5 — Phase 4). Default 0 (the implicit
1858 // handshake path) is behaviour-preserving; after a `migrate()` bump this carries
1859 // the new path label so the peer detects the new path and issues a challenge.
1860 // Retransmits flow through here too, so ARQ re-carries on the new path (D7).
1861 .with_path_id(crypto_session.current_send_path_id());
1862 // The data-plane packet carries no `extensions` (TLV headroom stays empty),
1863 // so the AEAD AAD binds an empty extensions slice — matching the wire.
1864 let ciphertext = match crypto_session.encrypt_packet(&header, &plaintext, &[]) {
1865 Ok(c) => c,
1866 Err(e) => {
1867 log::error!("PhantomSession: encrypt_packet failed: {}", e);
1868 return false;
1869 }
1870 };
1871 let packet = PhantomPacket::new(header, ciphertext);
1872 // Header protection (T4.6): XOR-mask the whole 15-byte header before it hits
1873 // the wire (WIRE v6: `HP_PROTECTED_OFFSET == 0`, no cleartext header byte).
1874 // Infallible in practice (the payload always carries the AEAD tag).
1875 let buf = match crypto_session.protect_packet(&packet) {
1876 Ok(b) => b,
1877 Err(e) => {
1878 log::error!("PhantomSession: header protection failed: {}", e);
1879 return false;
1880 }
1881 };
1882 let size = buf.len();
1883 // Pacing is a wire-rate limiter, so it consumes the full on-wire size.
1884 pace_send(crypto_session, size as u64).await;
1885 if let Err(e) = transport.send_bytes(&buf[..size]).await {
1886 log::error!("PhantomSession: transport send failed: {}", e);
1887 return false;
1888 }
1889 // Inflight/cwnd accounting MUST use the same unit the ACK and loss paths
1890 // settle in. `Stream::ack` returns and `on_packet_lost` subtracts the
1891 // segment's *payload* length (`seg.data.len()`), so the send side has to add
1892 // the payload length too — adding the full wire size here leaked the
1893 // per-packet framing overhead (15-byte header + AEAD tag) as phantom
1894 // inflight, which silently exhausted the congestion window after a few dozen
1895 // packets and stalled long-lived sessions. (Bandwidth/BDP derive from acked
1896 // bytes, so they stay in the same payload unit.)
1897 crypto_session.on_packet_sent(payload.len() as u64);
1898 true
1899}
1900
1901/// Emit a V2 WINDOW_UPDATE packet announcing `new_window` bytes of
1902/// receive capacity for `stream_id`. Encrypted under the current
1903/// session epoch (Phase 4.3 flow control).
1904async fn send_window_update<T: SessionTransport>(
1905 transport: &Arc<T>,
1906 crypto_session: &Arc<Session>,
1907 session_id: SessionId,
1908 stream_id: TransportStreamId,
1909 new_window: u32,
1910) -> bool {
1911 let mut flag_bits = PacketFlags::ENCRYPTED | PacketFlags::WINDOW_UPDATE;
1912 // WINDOW_UPDATE obeys the same direction-wide rekey discipline before stamping.
1913 match rekey_before_stamp(crypto_session) {
1914 Some(extra) => flag_bits |= extra,
1915 None => return false,
1916 }
1917 let packet_number = crypto_session.next_send_pn();
1918 let header = PacketHeader::new(
1919 session_id,
1920 stream_id,
1921 packet_number,
1922 PacketFlags::new(flag_bits),
1923 )
1924 .with_epoch(crypto_session.current_epoch());
1925 let payload = new_window.to_be_bytes();
1926 let ciphertext = match crypto_session.encrypt_packet(&header, &payload, &[]) {
1927 Ok(c) => c,
1928 Err(e) => {
1929 log::error!("PhantomSession: WINDOW_UPDATE encrypt failed: {}", e);
1930 return false;
1931 }
1932 };
1933 let packet = PhantomPacket::new(header, ciphertext);
1934 let buf = match crypto_session.protect_packet(&packet) {
1935 Ok(b) => b,
1936 Err(e) => {
1937 log::error!(
1938 "PhantomSession: WINDOW_UPDATE header protection failed: {}",
1939 e
1940 );
1941 return false;
1942 }
1943 };
1944 if let Err(e) = transport.send_bytes(&buf).await {
1945 log::error!("PhantomSession: WINDOW_UPDATE send failed: {}", e);
1946 return false;
1947 }
1948 true
1949}
1950
1951/// Emit an idle keep-alive packet (Direction #3 — download-only liveness): a
1952/// small `ENCRYPTED | KEEPALIVE` packet with an **empty** payload, stamped on the
1953/// current send path.
1954///
1955/// `is_pong` selects the role: a bare `KEEPALIVE` is a PING (`is_pong = false`);
1956/// `KEEPALIVE | ACK` is the PONG echo a receiver sends back (`is_pong = true`).
1957/// Either way the payload is empty, so the peer's `recv()` never sees it. The
1958/// packet is sealed exactly like application data — ENCRYPTED (Inv-2), a fresh
1959/// per-direction packet number (no nonce reuse), header-protected — so an off-path
1960/// peer can neither forge nor replay it (the replay window rejects a duplicate PN
1961/// after AEAD verify, Inv-4). Returns `false` on a rekey-saturation or
1962/// seal/transport failure (the caller just skips the keep-alive — it is
1963/// best-effort).
1964async fn send_keepalive<T: SessionTransport>(
1965 transport: &Arc<T>,
1966 crypto_session: &Arc<Session>,
1967 session_id: SessionId,
1968 is_pong: bool,
1969) -> bool {
1970 let mut flag_bits = PacketFlags::ENCRYPTED | PacketFlags::KEEPALIVE;
1971 if is_pong {
1972 flag_bits |= PacketFlags::ACK;
1973 }
1974 // Obey the same direction-wide rekey discipline before stamping the header.
1975 match rekey_before_stamp(crypto_session) {
1976 Some(extra) => flag_bits |= extra,
1977 None => return false,
1978 }
1979 let packet_number = crypto_session.next_send_pn();
1980 let header = PacketHeader::new(
1981 session_id,
1982 // Reserved raw-app stream id (1) — the keep-alive carries no stream data,
1983 // but a stable id keeps the header well-formed and consistent with the
1984 // session's own send()/recv() surface.
1985 RAW_APP_STREAM_ID as TransportStreamId,
1986 packet_number,
1987 PacketFlags::new(flag_bits),
1988 )
1989 .with_epoch(crypto_session.current_epoch())
1990 .with_path_id(crypto_session.current_send_path_id());
1991 let ciphertext = match crypto_session.encrypt_packet(&header, &[], &[]) {
1992 Ok(c) => c,
1993 Err(e) => {
1994 log::error!("PhantomSession: keep-alive encrypt failed: {}", e);
1995 return false;
1996 }
1997 };
1998 let packet = PhantomPacket::new(header, ciphertext);
1999 let buf = match crypto_session.protect_packet(&packet) {
2000 Ok(b) => b,
2001 Err(e) => {
2002 log::error!("PhantomSession: keep-alive header protection failed: {}", e);
2003 return false;
2004 }
2005 };
2006 if let Err(e) = transport.send_bytes(&buf).await {
2007 log::error!("PhantomSession: keep-alive send failed: {}", e);
2008 return false;
2009 }
2010 true
2011}
2012
2013/// Emit one anti-fingerprint COVER (dummy) packet (WIRE v6, deliverable (e)): an
2014/// `ENCRYPTED | COVER` packet with **empty** inner plaintext, PADÉ-padded to a
2015/// bucket so it is not a tiny distinctive size on the wire. It carries no stream
2016/// data; the peer AEAD-authenticates it (which refreshes its liveness timer and
2017/// makes off-path injection impossible) then drops it before the data path, so it
2018/// never reaches `recv()`. Cover is always padded, independent of the session's
2019/// data-padding policy.
2020async fn send_cover<T: SessionTransport>(
2021 transport: &Arc<T>,
2022 crypto_session: &Arc<Session>,
2023 session_id: SessionId,
2024) -> bool {
2025 let mut flag_bits = PacketFlags::ENCRYPTED | PacketFlags::COVER;
2026 // Same direction-wide rekey discipline as any other send.
2027 match rekey_before_stamp(crypto_session) {
2028 Some(extra) => flag_bits |= extra,
2029 None => return false,
2030 }
2031 let mut plaintext = Vec::new();
2032 let trailer = shaping::padding_trailer_len(0, PaddingPolicy::Padme);
2033 if trailer > 0 {
2034 shaping::append_padding(&mut plaintext, trailer);
2035 flag_bits |= PacketFlags::PADDED;
2036 }
2037 let packet_number = crypto_session.next_send_pn();
2038 let header = PacketHeader::new(
2039 session_id,
2040 RAW_APP_STREAM_ID as TransportStreamId,
2041 packet_number,
2042 PacketFlags::new(flag_bits),
2043 )
2044 .with_epoch(crypto_session.current_epoch())
2045 .with_path_id(crypto_session.current_send_path_id());
2046 let ciphertext = match crypto_session.encrypt_packet(&header, &plaintext, &[]) {
2047 Ok(c) => c,
2048 Err(e) => {
2049 log::error!("PhantomSession: cover encrypt failed: {}", e);
2050 return false;
2051 }
2052 };
2053 let packet = PhantomPacket::new(header, ciphertext);
2054 let buf = match crypto_session.protect_packet(&packet) {
2055 Ok(b) => b,
2056 Err(e) => {
2057 log::error!("PhantomSession: cover header protection failed: {}", e);
2058 return false;
2059 }
2060 };
2061 if let Err(e) = transport.send_bytes(&buf).await {
2062 log::error!("PhantomSession: cover send failed: {}", e);
2063 return false;
2064 }
2065 true
2066}
2067
2068/// Maintain a minimum outbound packet rate with cover traffic (WIRE v6, deliverable
2069/// (e)): when no packet has gone out for `cover_interval`, emit a COVER dummy so
2070/// silence + volume no longer leak (idle-fill + a floor rate of `1000 / interval_ms`
2071/// packets/sec). `last_pn` / `last_at` track the last observed outbound activity —
2072/// any real packet advances the send PN, resetting the idle window, so cover only
2073/// fills genuine gaps and never piles on top of active traffic.
2074async fn maybe_send_cover<T: SessionTransport>(
2075 transport: &Arc<T>,
2076 crypto_session: &Arc<Session>,
2077 session_id: SessionId,
2078 last_pn: &mut u64,
2079 last_at: &mut std::time::Instant,
2080) {
2081 let interval = crypto_session.cover_interval();
2082 if interval.is_zero() {
2083 return;
2084 }
2085 if crypto_session.state() != SessionState::Connected {
2086 return;
2087 }
2088 let pn = crypto_session.peek_send_pn();
2089 if pn != *last_pn {
2090 // Real (or prior cover) traffic went out since the last check — reset.
2091 *last_pn = pn;
2092 *last_at = std::time::Instant::now();
2093 return;
2094 }
2095 if last_at.elapsed() >= interval && send_cover(transport, crypto_session, session_id).await {
2096 *last_pn = crypto_session.peek_send_pn();
2097 *last_at = std::time::Instant::now();
2098 }
2099}
2100
2101/// Emit a V2 PATH_VALIDATION packet on `path_id` carrying the given
2102/// 32-byte challenge or response payload. Encrypted under the current
2103/// session epoch.
2104/// Build + encrypt a `PATH_VALIDATION` packet, returning its on-wire bytes. The
2105/// caller routes them: to the established peer (a response echo) via `send_bytes`,
2106/// or to a migration candidate (a server-issued challenge) via `send_to_candidate`
2107/// (Phase 4). Returns `None` only if the AEAD seal fails.
2108fn encrypt_path_validation(
2109 crypto_session: &Arc<Session>,
2110 session_id: SessionId,
2111 path_id: u8,
2112 payload: [u8; crate::transport::path::PATH_CHALLENGE_LEN],
2113) -> Option<Vec<u8>> {
2114 let packet_number = crypto_session.next_send_pn();
2115 let mut packet = build_path_validation_packet(session_id, path_id, packet_number, payload);
2116 let flag_bits = packet.header.flags.0 | PacketFlags::ENCRYPTED;
2117 packet.header.flags = PacketFlags::new(flag_bits);
2118 packet.header.epoch = crypto_session.current_epoch();
2119 let plaintext = std::mem::take(&mut packet.payload);
2120 let ciphertext = match crypto_session.encrypt_packet(&packet.header, &plaintext, &[]) {
2121 Ok(c) => c,
2122 Err(e) => {
2123 log::error!("PhantomSession: PATH_VALIDATION encrypt failed: {}", e);
2124 return None;
2125 }
2126 };
2127 packet.payload = ciphertext;
2128 match crypto_session.protect_packet(&packet) {
2129 Ok(buf) => Some(buf),
2130 Err(e) => {
2131 log::error!(
2132 "PhantomSession: PATH_VALIDATION header protection failed: {}",
2133 e
2134 );
2135 None
2136 }
2137 }
2138}
2139
2140/// Send a `PATH_VALIDATION` packet to the established peer (a response echo).
2141async fn send_path_validation<T: SessionTransport>(
2142 transport: &Arc<T>,
2143 crypto_session: &Arc<Session>,
2144 session_id: SessionId,
2145 path_id: u8,
2146 payload: [u8; crate::transport::path::PATH_CHALLENGE_LEN],
2147) -> bool {
2148 let buf = match encrypt_path_validation(crypto_session, session_id, path_id, payload) {
2149 Some(b) => b,
2150 None => return false,
2151 };
2152 if let Err(e) = transport.send_bytes(&buf).await {
2153 log::error!("PhantomSession: PATH_VALIDATION send failed: {}", e);
2154 return false;
2155 }
2156 true
2157}
2158
2159/// Hard cap on concurrent receive streams a peer can open on one session (H-3). The recv
2160/// path auto-creates a `Stream` for any of the 2^32 `stream_id`s; without a cap a peer can
2161/// spray distinct ids to explode the stream table. With the per-stream reorder budget,
2162/// `MAX_STREAMS` times `MAX_RECV_REORDER_BYTES` bounds the session's total reorder memory.
2163/// Sized well above QUIC's ~100-stream default so real multiplexing is unaffected.
2164const MAX_STREAMS: usize = 256;
2165
2166/// EPS-02 symmetric-rotation step — extracted from [`handle_packet`] so the role
2167/// branch is unit-tested always-on (not only by the `#[ignore]` `udp_integration`
2168/// suite). After the post-AEAD path detects a peer migration (`note_migration_path`
2169/// returned a slide), rotate OUR OWN outbound CID so the return direction's
2170/// cleartext ConnId does not stay stable across the peer's move (§12.5). A server
2171/// (whose client migrated) rotates the s2c CID path-id-silent (the socket-routed
2172/// client needs no window slide, and not bumping the send `path_id` prevents a
2173/// ping-pong). A client (whose server migrated) bumps its send `path_id` and
2174/// rotates the c2s CID (the path_id bump slides the server's c2s demux window onto
2175/// the rotated CID — the no-stranding fix), ending the exchange in one round.
2176fn apply_eps02_peer_migration_rotation<T: SessionTransport>(crypto: &Session, transport: &T) {
2177 if crypto.is_server() {
2178 transport.set_outbound_cid(crypto.advance_outbound_cid());
2179 } else {
2180 crypto.next_migration_path_id();
2181 transport.set_outbound_cid(crypto.advance_outbound_cid());
2182 }
2183}
2184
2185/// Recv-side handler for a packet:
2186/// - session-id guard → drop any frame not stamped with the negotiated
2187/// session id before touching any state (H1).
2188/// - decrypt (REQUIRED on application data — a non-empty unencrypted
2189/// post-handshake packet is a downgrade indicator and is dropped).
2190/// - ACK (now `ENCRYPTED | ACK`, post-decrypt) → parse the authenticated
2191/// `Sack` from the plaintext, retire every covered segment, feed BBR per
2192/// retired segment + route to the stream / demux. Forged/plaintext ACKs
2193/// cannot reach this path (H1); a malformed SACK is dropped, never a panic.
2194/// - PATH_VALIDATION flag → drive the path registry: verify against an
2195/// outstanding challenge if one exists, otherwise echo the payload
2196/// back as a response.
2197/// - WINDOW_UPDATE flag → apply the peer's announced flow-control window.
2198/// - COALESCED flag → split the decrypted bundle into sub-payloads and
2199/// route each through the demux as an independent application chunk.
2200#[allow(clippy::too_many_arguments)]
2201async fn handle_packet<T: SessionTransport>(
2202 packet: PhantomPacket,
2203 session_id: SessionId,
2204 crypto_recv: &Arc<Session>,
2205 streams_recv: &Arc<DashMap<u32, Arc<Stream>>>,
2206 demux_recv: &Arc<StreamDemultiplexer>,
2207 transport_send_ack: &Arc<T>,
2208 transport_for_path: &Arc<T>,
2209 // The reader hands decrypted application data to the delivery task via
2210 // this unbounded channel instead of blocking on `recv_tx`/the demux — so a
2211 // slow `recv()` consumer can never head-of-line-stall inbound ACK/control.
2212 deliver_tx: &mpsc::UnboundedSender<(u32, Bytes)>,
2213 undelivered_bytes: &AtomicU64,
2214 ack_buf: &mut Vec<u8>,
2215 observability: &Observability,
2216 leg: LegType,
2217) {
2218 let stream_id: u32 = packet.header.stream_id.into();
2219 let path_id = packet.header.path_id;
2220
2221 // Bind every inbound frame to the negotiated session (H1). In ε / WIRE v5 the
2222 // inner `session_id` is off-wire: `parse_protected` reconstructed
2223 // `header.session_id` from this session's id, so this comparison is now a
2224 // structural backstop (always true on a correctly-routed frame). The real
2225 // cross-session bind is the AEAD AAD, which still authenticates `session_id`
2226 // — a frame mis-delivered to the wrong session reconstructs that session's id
2227 // into the AAD → wrong AAD → AEAD fail below, so forged ACK/FIN injection can
2228 // never reach the stream table, BBR, or the path registry. Retained as a
2229 // defensive backstop (design §2.1).
2230 if packet.header.session_id != session_id {
2231 return;
2232 }
2233
2234 // Mark path activity even before decrypt (the path id is plaintext
2235 // header bytes; this is just a liveness signal for the sweep).
2236 crypto_recv.mark_path_seen(path_id);
2237
2238 // NOTE: ACK/FIN are NO LONGER processed here, pre-decrypt. They are
2239 // authenticated `ENCRYPTED | ACK` control frames now (H1) and are handled
2240 // *after* the AEAD gate below — see the ACK branch following the decrypt.
2241
2242 // Decrypt if marked. V2 sessions REQUIRE ENCRYPTED on application
2243 // data — a non-empty unencrypted V2 application-data packet is a
2244 // downgrade indicator and is dropped (same posture as V1).
2245 let plaintext: Vec<u8> = if packet.header.flags.contains(PacketFlags::ENCRYPTED) {
2246 // Accept a single authenticated forward rekey step (C1): if this
2247 // packet's epoch is one ahead, the peer rekeyed — trial-decrypt under
2248 // the next key and only commit the ratchet on AEAD success, so a forged
2249 // epoch can't desync us. Same-epoch packets take the ordinary path.
2250 match crypto_recv.decrypt_packet_accepting_rekey(
2251 &packet.header,
2252 &packet.payload,
2253 &packet.extensions,
2254 ) {
2255 Ok(pt) => pt,
2256 Err(e) => {
2257 // Distinguish the two drop reasons for the security metrics: a
2258 // post-AEAD sliding-window replay reject vs an AEAD-verify
2259 // failure (Invariant 4 — replay is checked after AEAD opens).
2260 // decrypt_packet doesn't surface old-vs-duplicate, so record the
2261 // representative `Duplicate` reason.
2262 if matches!(e, CoreError::ReplayDetected(_)) {
2263 observability.record_replay_rejected(ReplayReason::Duplicate);
2264 } else {
2265 observability.record_aead_failure(leg, AeadAlgorithm::Aes256Gcm);
2266 }
2267 log::warn!("PhantomSession: V2 decrypt failed (dropping packet): {}", e);
2268 return;
2269 }
2270 }
2271 } else {
2272 // Stripped-flag downgrade defense (Invariant 2, M-2): ANY unencrypted post-handshake
2273 // packet is dropped — including an empty-payload one whose only remaining effect would
2274 // be a forged standalone FIN tearing down an `open_stream()` stream without AEAD
2275 // verification. Legitimate data and control frames (incl. FIN) always set ENCRYPTED.
2276 observability.record_unencrypted_dropped(leg);
2277 log::warn!(
2278 "PhantomSession: dropping unencrypted post-handshake packet (downgrade / forged FIN?)"
2279 );
2280 return;
2281 };
2282
2283 // Strip anti-fingerprint size padding (WIRE v6, deliverable (c)): a PADDED
2284 // packet's AEAD plaintext ends with a `‹zeros› ‖ pad_n:u16be` trailer. The
2285 // PADDED flag is AEAD-authenticated (it is part of the header AAD verified
2286 // above), so this only runs on genuine padded packets; a malformed trailer
2287 // from a buggy peer is dropped without panic. Stripping here — before any
2288 // downstream parse — means the SACK / keepalive / data paths all see the real
2289 // inner plaintext, exactly as if no padding had been applied.
2290 let plaintext: Vec<u8> = if packet.header.flags.contains(PacketFlags::PADDED) {
2291 match shaping::strip_padding(&plaintext) {
2292 Ok(inner) => inner.to_vec(),
2293 Err(_) => {
2294 log::warn!("PhantomSession: dropping packet with malformed padding trailer");
2295 return;
2296 }
2297 }
2298 } else {
2299 plaintext
2300 };
2301
2302 // Liveness (P4.3): an authenticated inbound packet (it passed AEAD above) proves
2303 // the peer is alive on some path — refresh the activity timer so the pump's
2304 // liveness sweep does not false-trip. Plaintext/forged packets never reach here
2305 // (a failed decrypt returned early), so an off-path attacker cannot keep a dead
2306 // session looking alive.
2307 if packet.header.flags.contains(PacketFlags::ENCRYPTED) {
2308 crypto_recv.update_activity();
2309 // M-1: this packet just AEAD-authenticated, so its source really is the peer — possibly
2310 // at a NEW address (migration / NAT rebind). Commit it as the migration candidate ONLY
2311 // now (post-decrypt), so a spoofed CID-matched datagram (which never decrypts) cannot
2312 // clobber the candidate slot and misdirect / stall a legitimate migration. No-op for
2313 // same-source packets and for non-address transports (default trait impl).
2314 transport_for_path.confirm_authenticated_source();
2315 // ε / WIRE v5 (P4b): the path_id is now authenticated. If the peer migrated
2316 // (a new forward path_id), slide our inbound CID demux window so its rotated
2317 // CID stays routable for arbitrarily many migrations. No-op on the client and
2318 // for a path_id that is not newer (reorder / duplicate / passive rebind).
2319 if let Some(slide) = crypto_recv.note_migration_path(packet.header.path_id) {
2320 crypto_recv.signal_cid_slide(slide);
2321 // EPS-02 (symmetric rotation) — the peer migrated, so rotate our OWN outbound
2322 // CID too; otherwise the return direction keeps a stable cleartext ConnId across
2323 // the move and a both-networks observer relinks the session by it (§12.5). BOTH
2324 // sides now act, but the mechanism differs by demux topology:
2325 //
2326 // * SERVER detecting a CLIENT migration: rotate the s2c CID only, path_id-SILENT.
2327 // The client is socket-routed (accepts any inbound CID), so it needs no window
2328 // slide; and NOT bumping the server's send path_id is what prevents a ping-pong
2329 // (the client would otherwise see a forward server path_id and re-reflect).
2330 //
2331 // * CLIENT detecting a SERVER migration (D4, the EPS-02 closure for server-
2332 // initiated migration): rotate the c2s CID AND bump our send path_id. The
2333 // server DOES demux c2s by a CID window keyed on the client path_id, so the
2334 // path_id bump is what makes it slide that window to the rotated c2s CID — the
2335 // no-stranding fix (rotating the CID alone, without the path_id bump, was the
2336 // hazard the old "client must not rotate" rule avoided). This terminates in one
2337 // round: the server, seeing the client's forward path_id, slides its c2s window
2338 // AND runs its own path_id-silent s2c re-rotation (the SERVER arm above), from
2339 // which the client sees no new forward server path_id → note_migration_path
2340 // returns None → no re-reflection. The reflected c2s comes from the SAME client
2341 // source, so the server's confirm_authenticated_source is a no-op for it.
2342 //
2343 // The `path_id` bump (session layer) and the CID rotation (transport layer)
2344 // are not a single atomic step, so a send racing this rotation can stamp a
2345 // one-step-skewed `(path_id=N, CID_{N-1})` or `(path_id=N-1, CID_N)` pair. That
2346 // is harmless: the peer demux routes by CID against a window with `T = 2`
2347 // trailing + `K = 16` leading slack (cid_chain), which absorbs a ±1 skew, so the
2348 // skewed packet still routes and the L1 ARQ would re-carry it anyway — no strand.
2349 // Same two-step shape as the `Migrate` / `migrate_server` pump arms above.
2350 apply_eps02_peer_migration_rotation(crypto_recv, transport_for_path.as_ref());
2351 }
2352 }
2353
2354 // Idle keep-alive (Direction #3 — download-only liveness). A keep-alive carries
2355 // no application bytes; its sole effect is the `update_activity()` above (which
2356 // refreshed this side's liveness timer and cleared any outstanding probe). It
2357 // is handled here, BEFORE the ACK branch, because a PONG is `KEEPALIVE | ACK`
2358 // and must not be mis-parsed as a SACK. A bare `KEEPALIVE` is a PING → echo a
2359 // `KEEPALIVE | ACK` PONG so the peer's own liveness timer + outstanding-probe
2360 // flag clear; a `KEEPALIVE | ACK` is that PONG → nothing more to do. Either way
2361 // we return so the empty payload never reaches the SACK / data paths.
2362 if packet.header.flags.contains(PacketFlags::KEEPALIVE) {
2363 if !packet.header.flags.contains(PacketFlags::ACK) {
2364 // PING → reply with a PONG (KEEPALIVE | ACK). Best-effort; a drop just
2365 // means the peer re-PINGs next interval (its probe stays outstanding).
2366 let _ = send_keepalive(transport_send_ack, crypto_recv, session_id, true).await;
2367 }
2368 return;
2369 }
2370
2371 // Cover traffic (WIRE v6, deliverable (e)): a COVER packet carries no application
2372 // data (its inner plaintext is empty after the padding strip above). Its only
2373 // effect is the `update_activity()` already done above (it AEAD-authenticated, so
2374 // it proves the peer is alive and cannot be off-path injected). Drop it here,
2375 // before the SACK / data paths, so the empty payload never surfaces in `recv()`.
2376 // (A cover packet is never an ACK — it is `ENCRYPTED | COVER | PADDED` — so this
2377 // must precede the ACK branch below.)
2378 if packet.header.flags.contains(PacketFlags::COVER) {
2379 return;
2380 }
2381
2382 // Authenticated SACK ACK (H1, L1-A). ACKs are `ENCRYPTED | ACK` control
2383 // frames whose AEAD *plaintext* carries a `Sack` (largest_acked,
2384 // ack_delay_us, and the inclusive received ranges). We act on the ACK only
2385 // *after* AEAD verify, which authenticates the header (including `session_id`)
2386 // and the SACK plaintext — so a forged or stripped-flag ACK (dropped above by
2387 // the downgrade defense) can neither retire a pending segment, restore a
2388 // flow-control permit, poison BBR, nor close a stream. A malformed SACK from
2389 // a buggy (but authenticated) peer is dropped without panic and retires
2390 // nothing.
2391 if packet.header.flags.contains(PacketFlags::ACK) {
2392 let sack = match crate::transport::sack::Sack::from_wire(&plaintext) {
2393 Ok(s) => s,
2394 Err(e) => {
2395 log::debug!(
2396 "PhantomSession: dropping malformed SACK ({} B): {}",
2397 plaintext.len(),
2398 e
2399 );
2400 return;
2401 }
2402 };
2403 if let Some(stream) = streams_recv.get(&stream_id) {
2404 // Retire EVERY segment the SACK covers (cumulative). RTT is sampled
2405 // inside `on_sack` per Karn (only for never-retransmitted segments);
2406 // feed BBR per retired segment using the real `ack_delay_us`.
2407 let result = stream.on_sack(&sack).await;
2408 for retired in result.retired {
2409 if let Some(sent_at) = retired.sent_at {
2410 feed_bbr_on_ack(crypto_recv, sent_at, retired.size, sack.ack_delay_us as u64);
2411 }
2412 }
2413 // L1-B (#7 — congestion 4.4 fix): the SACK gap detector just declared
2414 // segments lost; wake the send loop so Pass-0 fast-retransmits them promptly.
2415 // We do NOT feed BBR's loss signal here. Loss is fed exactly ONCE per loss
2416 // event, at the *retransmission* point (`drain_streams_priority_ordered`'s
2417 // `if seg.retransmit { on_packet_lost(...) }`), which covers BOTH a SACK-gap
2418 // fast-retransmit and an RTO-timeout retransmit. Feeding it again here would
2419 // double-count: `on_packet_lost` decrements the purely-incremental
2420 // `inflight_bytes`, so a SACK-gap-lost segment fed at both detection AND
2421 // retransmission nets `+b −b −b +b −b = −b` over its send/loss/resend/ack
2422 // lifecycle — a permanent inflight under-count that inflates the cwnd budget
2423 // (`cwnd − inflight`) and accumulates with every SACK-gap loss → over-send,
2424 // exactly when the controller should be backing off. Retransmits bypass the
2425 // cwnd gate, so a lost segment is always retransmitted → the single feed at
2426 // the retransmission point reliably fires (and a spurious gap that gets ACKed
2427 // before retransmit correctly feeds no loss at all).
2428 if !result.lost.is_empty() {
2429 crypto_recv.notify_outbound_ready();
2430 }
2431 }
2432 // Best-effort, non-blocking: the demux/PhantomStream path is vestigial;
2433 // routing the ACK/close notification to it must never block the reader.
2434 // Route `largest_acked` to preserve the existing close/notify semantics
2435 // (the waiter only needs *an* ACK signal for the stream).
2436 demux_recv.route_ack(stream_id, sack.largest_acked);
2437 if packet.header.flags.contains(PacketFlags::FIN) {
2438 demux_recv.route_close(stream_id);
2439 }
2440 return;
2441 }
2442
2443 // WINDOW_UPDATE dispatch (Phase 4.3 flow control). Payload is a
2444 // big-endian u32 carrying relative flow-control credit — the bytes the
2445 // peer's application just consumed, which we ADD to our send window.
2446 if packet.header.flags.contains(PacketFlags::WINDOW_UPDATE) {
2447 if plaintext.len() != 4 {
2448 log::warn!(
2449 "PhantomSession: WINDOW_UPDATE payload length {} (expected 4)",
2450 plaintext.len()
2451 );
2452 return;
2453 }
2454 let credit = u32::from_be_bytes([plaintext[0], plaintext[1], plaintext[2], plaintext[3]]);
2455 if let Some(stream) = streams_recv.get(&stream_id) {
2456 // Relative-credit flow control — add the granted credit, then
2457 // wake the send loop so a window-blocked sender resumes immediately
2458 // instead of waiting a full poll tick.
2459 stream.apply_peer_window_update(credit);
2460 crypto_recv.notify_outbound_ready();
2461 }
2462 return;
2463 }
2464
2465 // PATH_VALIDATION dispatch (Phase 4.2): the codec inspects the *plaintext*
2466 // because the wire packet was sealed by the AEAD layer.
2467 if packet.header.flags.contains(PacketFlags::PATH_VALIDATION) {
2468 if plaintext.len() != crate::transport::path::PATH_CHALLENGE_LEN {
2469 log::warn!(
2470 "PhantomSession: PATH_VALIDATION plaintext length {} (expected {})",
2471 plaintext.len(),
2472 crate::transport::path::PATH_CHALLENGE_LEN
2473 );
2474 return;
2475 }
2476 let mut payload_buf = [0u8; crate::transport::path::PATH_CHALLENGE_LEN];
2477 payload_buf.copy_from_slice(&plaintext);
2478 // If we have an in-flight challenge on this path, try to
2479 // verify against it. If verification succeeds, the path
2480 // transitions to Validated and we're done. If it fails, the
2481 // registry already transitioned to Failed — also done.
2482 match crypto_recv.path_state(path_id) {
2483 Some(crate::transport::path::PathStateKind::Validating) => {
2484 // The peer echoed our challenge on this path. If it validates AND a
2485 // migration candidate is pending, SWITCH the active peer to it (D7)
2486 // and reset RTT/cwnd for the new network (D8) — no re-handshake,
2487 // keys persist; subsequent app data + ARQ retransmits flow to the
2488 // new peer. (P4.1 only challenged; P4.2 performs the switch.)
2489 if crypto_recv.complete_path_validation(path_id, &payload_buf)
2490 && transport_for_path.promote_candidate()
2491 {
2492 crypto_recv.reset_congestion();
2493 for s in streams_recv.iter() {
2494 s.value().reset_rto();
2495 }
2496 // M-3: if this was a passive-rebind validation (the reserved id),
2497 // retire the path so a LATER rebind re-registers it fresh and can
2498 // be challenged again. The reserved id stays Validated otherwise,
2499 // and `begin_path_validation` on a Validated path returns None, so
2500 // the second rebind would never issue a challenge. Active-migration
2501 // ids are left intact (they are retired by their own lifecycle).
2502 if path_id == crate::transport::session::REBIND_VALIDATION_PATH_ID {
2503 crypto_recv.retire_path(path_id);
2504 }
2505 }
2506 return;
2507 }
2508 Some(crate::transport::path::PathStateKind::Validated)
2509 | Some(crate::transport::path::PathStateKind::Failed) => {
2510 // Terminal state — ignore.
2511 return;
2512 }
2513 _ => {
2514 // Unknown or Unvalidated: treat this packet as an
2515 // incoming challenge and echo the payload back as our
2516 // response. The remote will then verify it against its
2517 // own pending challenge.
2518 let _ = send_path_validation(
2519 transport_for_path,
2520 crypto_recv,
2521 session_id,
2522 path_id,
2523 payload_buf,
2524 )
2525 .await;
2526 return;
2527 }
2528 }
2529 }
2530
2531 // PATH-001 split (D10, Phase 4). Runs AFTER AEAD verify + the per-direction
2532 // replay window, so it never acts on an attacker-chosen plaintext path_id.
2533 //
2534 // PATH-001b (recv, relaxed): AEAD-authenticated, non-replayed app data is
2535 // DELIVERED regardless of which path it arrived on. Dropping it by source buys
2536 // no security (only the real peer holds the keys; replays are already rejected)
2537 // and would break a seamless NAT-rebind / migration. PATH-001a (the strict
2538 // send-gate) lives in the send loop: app data is only ever sent to the
2539 // established peer — a candidate gets a PATH_CHALLENGE, never app data.
2540 //
2541 // Server-side migration (P4.1): if this app packet arrived on a not-yet-
2542 // Validated path AND the transport flagged a migration candidate (a new source
2543 // for this CID), proactively issue + send a challenge to the candidate so the
2544 // new path can validate. We do NOT switch the peer here (that is P4.2); the
2545 // challenge goes to the candidate under its anti-amplification budget.
2546 if !matches!(
2547 crypto_recv.path_state(path_id),
2548 Some(crate::transport::path::PathStateKind::Validated)
2549 ) {
2550 if transport_for_path.has_migration_candidate() {
2551 if let Some(challenge) = crypto_recv.begin_path_validation(path_id) {
2552 if let Some(buf) =
2553 encrypt_path_validation(crypto_recv, session_id, path_id, challenge)
2554 {
2555 // To the candidate, NOT the peer; capped at 3× by the transport.
2556 let _ = transport_for_path.send_to_candidate(&buf).await;
2557 }
2558 }
2559 } else {
2560 // No migration candidate (non-address transport, or a path id seen
2561 // without a source change): track it for a possible later challenge.
2562 crypto_recv.register_unvalidated_path(path_id);
2563 }
2564 // PATH-001b: fall through and deliver the authenticated data below.
2565 } else if transport_for_path.has_migration_candidate() {
2566 // M-3 (passive NAT rebind): the frame arrived on an already-Validated path —
2567 // the path-0 rebind case, where the peer's source address changed WITHOUT it
2568 // calling `migrate()`, so it never bumped `path_id`. The active-migration gate
2569 // above is skipped (path is Validated), so without this branch the new
2570 // authenticated source would never be challenged → never promoted → the
2571 // downstream (server→client) direction keeps targeting the OLD, now-dead
2572 // address → stall. Detection is therefore ADDRESS-driven, not path-id-driven:
2573 // a migration candidate exists only because `confirm_authenticated_source`
2574 // committed an AEAD-authenticated source that differs from the established
2575 // peer (M-1). We challenge that candidate on the RESERVED validation path-id
2576 // (carved out of the migration id space), which the registry can take through
2577 // `Validating → Validated` independently of the always-Validated path 0. The
2578 // challenge goes ONLY to the candidate (its claimed address), under the same
2579 // 3× anti-amplification cap — anti-spoof is preserved exactly as for an active
2580 // migration. The peer switch happens later, when the candidate echoes the
2581 // challenge (the PATH_VALIDATION completion branch above).
2582 let rebind_path = crate::transport::session::REBIND_VALIDATION_PATH_ID;
2583 if let Some(challenge) = crypto_recv.begin_path_validation(rebind_path) {
2584 if let Some(buf) =
2585 encrypt_path_validation(crypto_recv, session_id, rebind_path, challenge)
2586 {
2587 let _ = transport_for_path.send_to_candidate(&buf).await;
2588 }
2589 }
2590 }
2591
2592 // COALESCED dispatch (Phase 2.5): split the decrypted bundle into sub-payloads
2593 // and hand each, IN ORDER, to the single FIFO delivery task. Bundles are NOT
2594 // reassembled by stream offset — they are not emitted by the live sender (a
2595 // recv-side capability only), are not independently sequenced, and do not
2596 // auto-ACK (the outer sequence was consumed by the replay window). Delivered in
2597 // arrival order, preserving the bundle's internal order.
2598 if packet.header.flags.contains(PacketFlags::COALESCED) {
2599 let inner_for_codec = PhantomPacket {
2600 header: packet.header,
2601 payload: plaintext,
2602 extensions: Vec::new(),
2603 };
2604 match unwrap_coalesced_packet(&inner_for_codec) {
2605 Ok(Some(subs)) => {
2606 let payloads: Vec<Bytes> = subs
2607 .into_iter()
2608 .filter(|s| !s.is_empty())
2609 .map(Bytes::from)
2610 .collect();
2611 deliver_in_order_run(payloads, stream_id, deliver_tx, undelivered_bytes);
2612 }
2613 Ok(None) => {
2614 log::warn!("PhantomSession: COALESCED flag set but bundle didn't parse");
2615 }
2616 Err(e) => {
2617 log::warn!("PhantomSession: COALESCED parse error: {}", e);
2618 }
2619 }
2620 return;
2621 }
2622
2623 // Reliable application data → reassemble by the gap-free `stream_offset` (A.5),
2624 // emit an authenticated **SACK** ACK inline (H1, L1-A), then deliver the
2625 // in-order run. The reliable AEAD plaintext is `[stream_offset: u32 BE][data]`;
2626 // reordering on `stream_offset` (not the control-frame-holed `header.sequence`)
2627 // is what makes reliable in-order delivery correct over a reordering path. The
2628 // ACK is an `ENCRYPTED | ACK` control frame whose AEAD *plaintext* carries a
2629 // `Sack` over `stream_offset` ranges; the peer parses it only after AEAD verify,
2630 // so it cannot be forged off-path and a malformed range from a buggy peer is
2631 // dropped post-decrypt without crashing (handled in the sender branch). The SACK
2632 // retires every covered segment at once, so a lost ACK no longer strands a
2633 // segment — the next SACK re-acks it cumulatively. The ACK's own
2634 // `header.sequence` is drawn from this side's per-stream send counter — shared
2635 // with our data/window-update sends — so `(epoch, stream_id, sequence, path_id)`
2636 // is unique and never collides with our outbound data (the nonce-reuse trap); it
2637 // obeys the C1 rekey discipline. "ACK" means "received, decrypted, replay-passed,
2638 // accepted into in-order reassembly."
2639 if packet.header.flags.contains(PacketFlags::RELIABLE) {
2640 // Reliable plaintext = [stream_offset: u32 BE][data] (A.5). A frame shorter
2641 // than the 4-byte offset prefix is malformed — no legitimate sender emits
2642 // one — so drop it (never a panic).
2643 if plaintext.len() < 4 {
2644 log::warn!(
2645 "PhantomSession: reliable frame missing stream-offset prefix ({} B)",
2646 plaintext.len()
2647 );
2648 return;
2649 }
2650 let pt = Bytes::from(plaintext);
2651 let stream_offset = u32::from_be_bytes([pt[0], pt[1], pt[2], pt[3]]);
2652 let data = pt.slice(4..);
2653
2654 // H-3: cap concurrent receive streams. A new stream_id is auto-created only while
2655 // under MAX_STREAMS; past the cap the segment is refused (and, being unrecorded, not
2656 // SACKed → the sender retransmits / the stream stalls), so a peer cannot explode the
2657 // stream table across the 2^32 id space.
2658 let existing = streams_recv.get(&stream_id).map(|s| s.clone());
2659 let local = match existing {
2660 Some(s) => s,
2661 None => {
2662 if streams_recv.len() >= MAX_STREAMS {
2663 log::warn!(
2664 "PhantomSession: refusing new receive stream {stream_id}: \
2665 MAX_STREAMS ({MAX_STREAMS}) reached"
2666 );
2667 return;
2668 }
2669 streams_recv
2670 .entry(stream_id)
2671 .or_insert_with(|| Arc::new(Stream::new(stream_id as TransportStreamId)))
2672 .clone()
2673 }
2674 };
2675 // Accept into the reorder buffer FIRST so the SACK derived next reflects it.
2676 // `accept_in_order` returns the in-order run now deliverable and stamps the
2677 // data-arrival instant; `received_sack(0)` then populates `ack_delay_us`
2678 // from a coarse `now − recv_at`. A `None` SACK is structurally impossible
2679 // here (we just accepted an offset), but we skip the ACK rather than unwrap.
2680 let delivered = local.accept_in_order(stream_offset, vec![data]).await;
2681 let Some(sack) = local.received_sack(0).await else {
2682 return;
2683 };
2684 let mut ack_flag_bits = PacketFlags::ENCRYPTED | PacketFlags::ACK;
2685 match rekey_before_stamp(crypto_recv) {
2686 Some(extra) => ack_flag_bits |= extra,
2687 // Epoch saturated — drop this ACK rather than reuse a nonce; the
2688 // sender retransmits and the session is expected to reconnect.
2689 None => return,
2690 }
2691 let ack_pn = crypto_recv.next_send_pn();
2692 let ack_header = PacketHeader::new(
2693 session_id,
2694 stream_id as TransportStreamId,
2695 ack_pn,
2696 PacketFlags::new(ack_flag_bits),
2697 )
2698 .with_epoch(crypto_recv.current_epoch())
2699 .with_path_id(path_id);
2700 let ack_payload = sack.to_wire();
2701 match crypto_recv.encrypt_packet(&ack_header, &ack_payload, &[]) {
2702 Ok(ct) => {
2703 let ack_packet = PhantomPacket::new(ack_header, ct);
2704 match crypto_recv.protect_packet(&ack_packet) {
2705 Ok(buf) => {
2706 ack_buf.clear();
2707 ack_buf.extend_from_slice(&buf);
2708 let size = ack_buf.len();
2709 let _ = transport_send_ack.send_bytes(&ack_buf[..size]).await;
2710 }
2711 Err(e) => {
2712 log::error!("PhantomSession: ACK header protection failed: {}", e)
2713 }
2714 }
2715 }
2716 Err(e) => log::error!("PhantomSession: ACK encrypt failed: {}", e),
2717 }
2718
2719 // Deliver the in-order run released by the reorder buffer (empty if this
2720 // segment filled a future hole — it waits for the gap to close).
2721 deliver_in_order_run(delivered, stream_id, deliver_tx, undelivered_bytes);
2722
2723 if packet.header.flags.contains(PacketFlags::FIN) {
2724 demux_recv.route_close(stream_id);
2725 }
2726 return;
2727 }
2728
2729 // Non-reliable application data → deliver in arrival order (unreliable data is
2730 // not sequenced/reordered by design). Unbounded + non-blocking, so the reader
2731 // never stalls on a slow `recv()` consumer; counted toward the backlog only on
2732 // a successful enqueue (a dead delivery task can't inflate `undelivered_bytes`).
2733 if !plaintext.is_empty() {
2734 let len = plaintext.len() as u64;
2735 if deliver_tx.send((stream_id, Bytes::from(plaintext))).is_ok() {
2736 undelivered_bytes.fetch_add(len, Ordering::AcqRel);
2737 }
2738 }
2739
2740 if packet.header.flags.contains(PacketFlags::FIN) {
2741 demux_recv.route_close(stream_id);
2742 }
2743}
2744
2745/// Hand an in-order run of reliable payloads (as released by
2746/// [`Stream::accept_in_order`]) to the single FIFO delivery task, in order. Each
2747/// non-empty chunk is counted toward the `undelivered_bytes` backlog only on a
2748/// successful enqueue, so a dead delivery task (consumer gone, `deliver_rx`
2749/// dropped) cannot inflate the counter for data that was discarded.
2750fn deliver_in_order_run(
2751 run: Vec<Bytes>,
2752 stream_id: u32,
2753 deliver_tx: &mpsc::UnboundedSender<(u32, Bytes)>,
2754 undelivered_bytes: &AtomicU64,
2755) {
2756 for chunk in run {
2757 if chunk.is_empty() {
2758 continue;
2759 }
2760 let len = chunk.len() as u64;
2761 if deliver_tx.send((stream_id, chunk)).is_ok() {
2762 undelivered_bytes.fetch_add(len, Ordering::AcqRel);
2763 }
2764 }
2765}
2766
2767// Internal-only methods — deliberately NOT on the `#[uniffi::export]` surface.
2768// `set_state` mutates the connection state machine; a foreign caller forcing
2769// `Connected` mid-handshake would make `is_data_ready()` lie and let `send()`
2770// bypass the queue, or `Closed` without tearing down the pump.
2771impl PhantomSession {
2772 /// Transition to a new connection state. Crate-internal: driven by the
2773 /// handshake task and teardown only.
2774 pub(crate) fn set_state(&self, new_state: ConnectionState) {
2775 self.state.store(new_state as u8, Ordering::Relaxed);
2776 }
2777
2778 /// Session observability handle (Rust-only — `Observability` is not a
2779 /// UniFFI type). For a server-accepted session this is the
2780 /// `PhantomListener`'s shared instance; for a client it is the session's
2781 /// own. Read `.snapshot()` for the lock-free metric counters.
2782 pub fn observability(&self) -> Arc<Observability> {
2783 self.observability.clone()
2784 }
2785}
2786
2787#[cfg_attr(feature = "bindings", uniffi::export(async_runtime = "tokio"))]
2788impl PhantomSession {
2789 /// Create a placeholder session — returns instantly and performs **no**
2790 /// handshake.
2791 ///
2792 /// # ⚠️ This does not connect
2793 ///
2794 /// Despite the name, this constructor never opens a transport, never runs
2795 /// the PQC handshake, and never spawns the background data pump. It returns
2796 /// an inert shell stuck in [`ConnectionState::Connecting`]: any `send()`
2797 /// only queues into an in-memory buffer that is never flushed, and `recv()`
2798 /// never yields application bytes. **No bytes ever reach the network.** It
2799 /// exists only as a pre-handshake placeholder from an earlier API shape.
2800 ///
2801 /// **Deprecated — use a real entry point instead:**
2802 /// - [`PhantomSession::connect_with_transport`] (Rust) — supply a
2803 /// `SessionTransport` and the pinned `expected_server_key`; this spawns
2804 /// the handshake + pump.
2805 /// - [`connect_pinned`] (native FFI / mobile) — one-shot TCP connect with a
2806 /// pinned key.
2807 ///
2808 /// # Why no `#[deprecated]` attribute (T5.7)
2809 ///
2810 /// A `#[deprecated]` attribute would be the natural way to flag this, but it
2811 /// **cannot** be applied here: this constructor is `#[uniffi::constructor]`,
2812 /// and UniFFI 0.31 emits FFI scaffolding that calls `Self::connect()` from
2813 /// generated code in this same crate. That generated call would trip the
2814 /// `deprecated` lint, which CI promotes to a hard error under
2815 /// `clippy --lib -D warnings` — and no item-scoped `#[allow(deprecated)]`
2816 /// reaches the macro-generated call site (only a module-wide
2817 /// `#![allow(deprecated)]` would, which would silently mask every *future*
2818 /// genuine deprecation across this module). So the deprecation is documented
2819 /// loudly here instead. UniFFI copies this doc-comment into the generated
2820 /// Python / Swift / Kotlin docstrings (the C header carries no docstrings),
2821 /// so they were regenerated and committed alongside this change — the
2822 /// `bindings` `drift` CI job stays green. See
2823 /// `tests::deprecated_connect_is_inert_and_sends_no_bytes` for the regression
2824 /// pinning the inert behaviour.
2825 #[cfg_attr(feature = "bindings", uniffi::constructor)]
2826 pub fn connect(peer_addr: String) -> Arc<Self> {
2827 let (cmd_tx, cmd_rx) = mpsc::channel(256);
2828 let (_recv_tx, recv_rx) = mpsc::channel(256);
2829
2830 let (demux, _ctrl_rx) = StreamDemultiplexer::new(256);
2831 let streams = Arc::new(DashMap::new());
2832 Arc::new(Self {
2833 id: new_session_id(),
2834 peer_addr,
2835 state: Arc::new(AtomicU8::new(ConnectionState::Connecting as u8)),
2836 send_queue: Arc::new(Mutex::new(Vec::new())),
2837 cmd_tx,
2838 cmd_rx: Mutex::new(Some(cmd_rx)),
2839 recv_rx: Mutex::new(recv_rx),
2840 demux: Arc::new(demux),
2841 streams,
2842 inner_session: Arc::new(Mutex::new(None)),
2843 early_data_accepted: Arc::new(Mutex::new(None)),
2844 shaping: Arc::new(parking_lot::Mutex::new(TrafficShapingConfig::default())),
2845 // Placeholder session (no transport / pump yet); a no-op holder
2846 // until `connect_with_transport` spawns the real pump.
2847 observability: Observability::new(ObservabilityConfig::default()),
2848 })
2849 }
2850
2851 /// Open a new multiplexed stream
2852 pub fn open_stream(&self) -> Arc<crate::api::stream::PhantomStream> {
2853 let handle = self.demux.open_stream(1024);
2854 let stream_id = handle.stream_id;
2855
2856 let transport_stream = Arc::new(Stream::new(stream_id as TransportStreamId));
2857 self.streams.insert(stream_id, transport_stream);
2858
2859 Arc::new(crate::api::stream::PhantomStream::new(
2860 handle,
2861 self.cmd_tx.clone(),
2862 ))
2863 }
2864
2865 /// Send data through the session.
2866 ///
2867 /// - If the session is connected: sends immediately
2868 /// - If still handshaking: queues the data for auto-flush later
2869 pub async fn send(&self, data: Vec<u8>) -> Result<(), CoreError> {
2870 let state = self.connection_state();
2871
2872 if state.is_data_ready() {
2873 // Channel is up — send directly
2874 self.cmd_tx
2875 .send(SessionCommand::Send(data))
2876 .await
2877 .map_err(|_| CoreError::NetworkError("Session closed".into()))?;
2878 } else if state == ConnectionState::Connecting {
2879 // Still handshaking — queue
2880 self.send_queue.lock().await.push(data);
2881 } else {
2882 return Err(CoreError::NetworkError(format!(
2883 "Cannot send in state {:?}",
2884 state
2885 )));
2886 }
2887
2888 Ok(())
2889 }
2890
2891 /// Receive data from the session.
2892 ///
2893 /// Internally the recv pipeline keeps payloads as `Bytes` to avoid the
2894 /// per-packet Vec clone that used to fan out to the stream demux. The
2895 /// FFI surface still hands callers a `Vec<u8>`; if this is the last
2896 /// refcount the Vec is moved out of the underlying buffer, otherwise
2897 /// `Bytes::to_vec` copies.
2898 pub async fn recv(&self) -> Result<Vec<u8>, CoreError> {
2899 let mut rx = self.recv_rx.lock().await;
2900 let bytes = rx
2901 .recv()
2902 .await
2903 .ok_or_else(|| CoreError::NetworkError("Session closed".into()))?;
2904 Ok(bytes.to_vec())
2905 }
2906
2907 /// Get the current connection state (lock-free).
2908 pub fn connection_state(&self) -> ConnectionState {
2909 ConnectionState::from_u8(self.state.load(Ordering::Relaxed))
2910 }
2911
2912 /// Whether the session is ready for data transmission.
2913 pub fn is_data_ready(&self) -> bool {
2914 self.connection_state().is_data_ready()
2915 }
2916
2917 /// Whether the session has full PQC protection.
2918 pub fn is_pqc_ready(&self) -> bool {
2919 matches!(
2920 self.connection_state(),
2921 ConnectionState::PqcReady | ConnectionState::Connected
2922 )
2923 }
2924
2925 /// Flush all queued messages (called when handshake completes).
2926 pub async fn flush_queue(&self) -> Result<u32, CoreError> {
2927 let mut queue = self.send_queue.lock().await;
2928 let count = queue.len() as u32;
2929 for msg in queue.drain(..) {
2930 self.cmd_tx
2931 .send(SessionCommand::Send(msg))
2932 .await
2933 .map_err(|_| CoreError::NetworkError("Session closed during flush".into()))?;
2934 }
2935 Ok(count)
2936 }
2937
2938 /// Number of messages queued (waiting for handshake).
2939 pub async fn queued_count(&self) -> u32 {
2940 self.send_queue.lock().await.len() as u32
2941 }
2942
2943 /// Session identifier.
2944 pub fn id(&self) -> String {
2945 self.id.clone()
2946 }
2947
2948 /// Target peer address.
2949 pub fn peer_addr(&self) -> String {
2950 self.peer_addr.clone()
2951 }
2952
2953 /// The 0-RTT verdict for this session.
2954 ///
2955 /// - `None` — still handshaking, the handshake failed, or the client sent
2956 /// no early-data on this connect.
2957 /// - `Some(true)` — the server consumed the 0-RTT early-data.
2958 /// - `Some(false)` — the client sent early-data and the server rejected it
2959 /// (stale/unknown ticket, oversized blob, or AEAD failure). The caller
2960 /// must re-send that payload over the normal channel.
2961 pub async fn early_data_accepted(&self) -> Option<bool> {
2962 *self.early_data_accepted.lock().await
2963 }
2964
2965 /// Extract a [`ResumptionHint`] for a future 0-RTT reconnect.
2966 ///
2967 /// Returns `Some` after a successful handshake; `None` while still
2968 /// handshaking, after a failure, or before the inner session has
2969 /// been published.
2970 ///
2971 /// Store the hint alongside the pinned `HybridVerifyingKey` of the
2972 /// server it was negotiated against and feed it back to
2973 /// [`connect_pinned_with_resumption`]. Reusing a hint across
2974 /// servers is a configuration bug — the `resumption_secret` is
2975 /// server-pinned.
2976 pub async fn resumption_hint(&self) -> Option<ResumptionHint> {
2977 let guard = self.inner_session.lock().await;
2978 guard
2979 .as_ref()
2980 .and_then(|s| s.resumption_hint())
2981 .map(|(session_id, resumption_secret)| ResumptionHint {
2982 session_id: session_id.to_vec(),
2983 resumption_secret: resumption_secret.to_vec(),
2984 })
2985 }
2986
2987 /// Current rekey epoch of the established session (`None` while still
2988 /// connecting). Rust-only — used by soak / integration tests to confirm
2989 /// that automatic mid-session rekey (C1) advanced the epoch.
2990 pub async fn current_epoch(&self) -> Option<u8> {
2991 self.inner_session
2992 .lock()
2993 .await
2994 .as_ref()
2995 .map(|s| s.current_epoch())
2996 }
2997
2998 /// Override the automatic-rekey send-invocation high-watermark on the
2999 /// established session (default `REKEY_SOFT_LIMIT`, currently `2^32`).
3000 /// Returns `false` if the session is still connecting. Rust-only — primarily
3001 /// for soak/load harnesses that need to exercise mid-session rekey without
3002 /// sending `2^32` packets.
3003 pub async fn set_rekey_threshold(&self, n: u64) -> bool {
3004 match self.inner_session.lock().await.as_ref() {
3005 Some(s) => {
3006 s.set_rekey_threshold(n);
3007 true
3008 }
3009 None => false,
3010 }
3011 }
3012
3013 /// Apply an anti-fingerprint traffic-shaping configuration to the established
3014 /// session (WIRE v6, direction #4). Returns `false` if the session is still
3015 /// connecting. All shaping is opt-in (default: none); enabling size padding
3016 /// ([`PaddingPolicy::Padme`]) makes outbound packets pad up to a PADÉ bucket so
3017 /// the datagram size no longer tracks the payload size, at a bounded (≈ ≤12%
3018 /// worst-case) bandwidth cost. FFI-exported so mobile / other embedders can
3019 /// tune it.
3020 pub async fn set_traffic_shaping(&self, config: TrafficShapingConfig) -> bool {
3021 // #9 — store as the pending config (a clone applied at session install, so
3022 // it works BEFORE the async client handshake completes), then apply
3023 // immediately too if the session is already established. Always accepted.
3024 *self.shaping.lock() = config;
3025 if let Some(s) = self.inner_session.lock().await.as_ref() {
3026 apply_shaping(s, config);
3027 }
3028 true
3029 }
3030
3031 /// Read back the traffic-shaping config currently applied to the established
3032 /// session (#9). `None` while still connecting (the session is not installed
3033 /// yet — the pending config set via [`set_traffic_shaping`](Self::set_traffic_shaping)
3034 /// will apply on install). FFI-exported.
3035 pub async fn traffic_shaping(&self) -> Option<TrafficShapingConfig> {
3036 self.inner_session
3037 .lock()
3038 .await
3039 .as_ref()
3040 .map(|s| TrafficShapingConfig {
3041 padding: s.padding_policy(),
3042 jitter_ms: s.send_jitter().as_millis() as u32,
3043 cover_interval_ms: s.cover_interval().as_millis() as u32,
3044 })
3045 }
3046
3047 /// Migrate the session to a new local network address (Phase 4 — embedder-
3048 /// triggered connection migration). The embedder calls this when the OS reports a
3049 /// network change (Wi-Fi↔cellular, NAT rebind); `local_addr` is the new local
3050 /// bind address (e.g. `"0.0.0.0:0"` to let the OS pick an ephemeral port on the
3051 /// new interface).
3052 ///
3053 /// **Best-effort and non-blocking on validation.** It hands the request to the
3054 /// background pump, which rebinds the transport (keeping the old socket for the
3055 /// overlap) and bumps the send `path_id`; the path validation + server-side peer
3056 /// switch then complete asynchronously. The keys and session persist — **no
3057 /// re-handshake**. A failed rebind never tears the session down: it keeps running
3058 /// on the existing socket (broken-rebind safety). `Err` here means only that the
3059 /// session was already closed (the command channel is gone).
3060 pub async fn migrate(&self, local_addr: String) -> Result<(), CoreError> {
3061 self.cmd_tx
3062 .send(SessionCommand::Migrate(local_addr))
3063 .await
3064 .map_err(|_| CoreError::NetworkError("Session closed".into()))
3065 }
3066
3067 /// Send the graceful close frame and shut the session down.
3068 ///
3069 /// Named `disconnect` rather than `close` because UniFFI's Kotlin
3070 /// generator unconditionally adds `AutoCloseable.close()` to every
3071 /// object, and a Rust-side `close` here would conflict with it.
3072 pub async fn disconnect(&self) -> Result<(), CoreError> {
3073 self.set_state(ConnectionState::Closed);
3074 let _ = self.cmd_tx.send(SessionCommand::Close).await;
3075 Ok(())
3076 }
3077}
3078
3079impl PhantomSession {
3080 /// Get the stream demultiplexer (internal use, not exposed to UniFFI)
3081 pub fn demux(&self) -> Arc<StreamDemultiplexer> {
3082 self.demux.clone()
3083 }
3084
3085 /// Override the path-liveness thresholds on the established session (Phase 4 /
3086 /// P4.3). Returns `false` if the session is still connecting. Rust-only (the
3087 /// `LivenessConfig` type is not on the UniFFI surface) — for tests / advanced
3088 /// embedders that want a faster or slower path-down / migration-idle timeout than
3089 /// the default.
3090 pub async fn set_liveness_config(
3091 &self,
3092 cfg: crate::transport::liveness::LivenessConfig,
3093 ) -> bool {
3094 match self.inner_session.lock().await.as_ref() {
3095 Some(s) => {
3096 s.set_liveness_config(cfg);
3097 true
3098 }
3099 None => false,
3100 }
3101 }
3102
3103 /// Migrate the **server side** of this session to a new local send address (the
3104 /// server-side mirror of [`migrate`](Self::migrate)). Intended for an accepted server
3105 /// session whose network path changes (failover, multi-homing, an egress NAT rebind):
3106 /// the server rebinds its send socket to `local_addr` and rotates its server→client
3107 /// `path_id` + connection-ID in lock-step, so the peer follows the fresh s2c source
3108 /// (its unconnected socket hears it) and an observer cannot relink the session by the
3109 /// s2c ConnId across the move. The keys and session persist — **no re-handshake**.
3110 ///
3111 /// The server keeps RECEIVING client→server traffic on the established (listen) address
3112 /// through the overlap, so the session stays bidirectional immediately. Best-effort: a
3113 /// failed rebind leaves the session on the old send socket and never tears it down.
3114 /// `Err` here means only that the session was already closed.
3115 ///
3116 /// **Rust-only** (deliberately not on the UniFFI/FFI surface): server migration is a
3117 /// native-deployment operation, not a mobile-client one. The peer's symmetric c2s
3118 /// follow (switching its send target to the new server address once the old one is
3119 /// unreachable) and the matching c2s CID rotation are added in the follow-up
3120 /// security-core change.
3121 pub async fn migrate_server(&self, local_addr: String) -> Result<(), CoreError> {
3122 self.cmd_tx
3123 .send(SessionCommand::MigrateServer(local_addr))
3124 .await
3125 .map_err(|_| CoreError::NetworkError("Session closed".into()))
3126 }
3127}
3128
3129impl std::fmt::Debug for PhantomSession {
3130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3131 f.debug_struct("PhantomSession")
3132 .field("id", &self.id)
3133 .field("peer", &self.peer_addr)
3134 .field("state", &self.connection_state())
3135 .finish()
3136 }
3137}
3138
3139// ─── Pinned-Connect Shim (Phase 7.2 mobile bridge) ──────────────────────────
3140//
3141// `connect_with_transport` itself can't cross the UniFFI boundary directly —
3142// it takes a generic `T: SessionTransport` trait object and a typed
3143// `HybridVerifyingKey`, neither of which is a UniFFI primitive. Mobile
3144// callers (iOS / Android) need a single async entry point that opens a TCP
3145// connection, wraps it in `TcpSessionTransport`, parses the pinned key from
3146// bytes (per security invariant 1 in SECURITY.md), and hands back an
3147// `Arc<PhantomSession>` ready for `send` / `recv`.
3148//
3149// Native-only: `TcpSessionTransport` lives behind `cfg(not(target_arch =
3150// "wasm32"))`, mirroring `crate::api::tcp_transport`. Wasm consumers use
3151// the in-tree `WebSocketLeg` instead.
3152#[cfg(not(target_arch = "wasm32"))]
3153#[cfg_attr(feature = "bindings", uniffi::export(async_runtime = "tokio"))]
3154pub async fn connect_pinned(
3155 host: String,
3156 port: u16,
3157 pinned_key: Vec<u8>,
3158) -> Result<Arc<PhantomSession>, CoreError> {
3159 // fips bootstrap POST gate (same policy as
3160 // `PhantomListener::bind_inner`). A failure here aborts the
3161 // connect before any socket is opened or key material is
3162 // touched.
3163 #[cfg(feature = "fips")]
3164 crate::crypto::self_tests::ensure_post_passed()
3165 .map_err(|e| CoreError::FipsSelfTestFailure(format!("{e:?}")))?;
3166
3167 // Decode the server's hybrid verifying key. A malformed blob is a
3168 // crypto-layer problem (wrong length, wrong encoding) rather than a
3169 // network failure — surface it as `CryptoError`.
3170 let expected_server_key = HybridVerifyingKey::from_bytes(&pinned_key)
3171 .map_err(|e| CoreError::CryptoError(format!("invalid pinned key: {}", e)))?;
3172
3173 // Open the TCP stream. The `format!` is shared between the actual
3174 // connect target and the `peer_addr` recorded inside the session
3175 // (`connect_with_transport` takes it as a free-form string).
3176 let addr = format!("{}:{}", host, port);
3177 let stream = tokio::net::TcpStream::connect(&addr)
3178 .await
3179 .map_err(|e| CoreError::NetworkError(format!("connect {}: {}", addr, e)))?;
3180 let transport = crate::api::tcp_transport::TcpSessionTransport::new(stream);
3181
3182 // The handshake is driven by the background task spawned inside
3183 // `connect_with_transport`; the returned `PhantomSession` is usable
3184 // immediately (state `Connecting`, sends auto-queued until ready).
3185 let session = PhantomSession::connect_with_transport(&addr, transport, expected_server_key);
3186 Ok(Arc::new(session))
3187}
3188
3189/// Connect to a pinned server over the **TLS-over-TCP active-mimicry** transport
3190/// (`mimicry` feature) — the flow looks like an ordinary HTTPS handshake to an
3191/// on-path observer, while the real authentication / confidentiality remains the
3192/// inner Phantom post-quantum session.
3193///
3194/// `sni` is the cover domain presented in the synthetic ClientHello. It is
3195/// **required and should be rotated** per connection and kept plausible for the
3196/// server's IP/AS — a single network-wide default SNI is itself a blocklist key.
3197///
3198/// **The outer TLS is anti-DPI obfuscation only, and is detectable by active
3199/// probing.** It defeats stateless DPI + passive JA3/JA4 fingerprinting + light
3200/// stateful inspection, but a censor that completes a real TLS handshake or
3201/// validates a certificate detects it in one round trip — do **not** use this
3202/// where active probing is in the threat model. See `docs/security/threat-model.md`.
3203///
3204/// Rust-only and native-only, gated on the `mimicry` feature.
3205#[cfg(all(not(target_arch = "wasm32"), feature = "mimicry"))]
3206pub async fn connect_pinned_mimic(
3207 host: String,
3208 port: u16,
3209 pinned_key: Vec<u8>,
3210 sni: String,
3211) -> Result<Arc<PhantomSession>, CoreError> {
3212 #[cfg(feature = "fips")]
3213 crate::crypto::self_tests::ensure_post_passed()
3214 .map_err(|e| CoreError::FipsSelfTestFailure(format!("{e:?}")))?;
3215
3216 let expected_server_key = HybridVerifyingKey::from_bytes(&pinned_key)
3217 .map_err(|e| CoreError::CryptoError(format!("invalid pinned key: {}", e)))?;
3218
3219 let addr = format!("{}:{}", host, port);
3220 let stream = tokio::net::TcpStream::connect(&addr)
3221 .await
3222 .map_err(|e| CoreError::NetworkError(format!("connect {}: {}", addr, e)))?;
3223
3224 // Run the synthetic TLS prelude before handing the leg to the background
3225 // handshake pump (the leg is ready for `send_bytes`/`recv_bytes` once this
3226 // returns). A prelude failure (e.g. an unreachable / non-mimic server) aborts
3227 // the connect.
3228 let config = crate::transport::legs::mimic_tls::MimicConfig::new(sni);
3229 let transport =
3230 crate::transport::legs::mimic_tls::MimicTlsLeg::connect(stream, &config).await?;
3231
3232 let session = PhantomSession::connect_with_transport(&addr, transport, expected_server_key);
3233 Ok(Arc::new(session))
3234}
3235
3236/// Connect to a pinned server with a **0-RTT resumption attempt** — the
3237/// resumption-aware analogue of [`connect_pinned`].
3238///
3239/// `hint` is a [`ResumptionHint`] from a prior session's
3240/// [`PhantomSession::resumption_hint`]; both of its fields must be
3241/// exactly 32 bytes or the call fails with `ValidationError` before any
3242/// socket is opened. `early_data` (≤ 16 KiB) is sealed into the resuming
3243/// ClientHello so it reaches the server on the very first flight.
3244///
3245/// Acceptance is best-effort: when the server does not consume the early-data
3246/// (stale/unknown ticket or AEAD failure) the handshake completes 1-RTT — the
3247/// caller checks [`PhantomSession::early_data_accepted`] and re-sends over the
3248/// normal channel when it is not `Some(true)`.
3249///
3250/// Native-only, like [`connect_pinned`]: `TcpSessionTransport` lives
3251/// behind `cfg(not(target_arch = "wasm32"))`.
3252#[cfg(not(target_arch = "wasm32"))]
3253#[cfg_attr(feature = "bindings", uniffi::export(async_runtime = "tokio"))]
3254pub async fn connect_pinned_with_resumption(
3255 host: String,
3256 port: u16,
3257 pinned_key: Vec<u8>,
3258 hint: ResumptionHint,
3259 early_data: Vec<u8>,
3260) -> Result<Arc<PhantomSession>, CoreError> {
3261 // fips bootstrap POST gate (same policy as
3262 // `connect_pinned`).
3263 #[cfg(feature = "fips")]
3264 crate::crypto::self_tests::ensure_post_passed()
3265 .map_err(|e| CoreError::FipsSelfTestFailure(format!("{e:?}")))?;
3266
3267 // Server-key pinning stays mandatory (security invariant 1): a
3268 // malformed blob is a crypto-layer problem, surfaced as `CryptoError`.
3269 let expected_server_key = HybridVerifyingKey::from_bytes(&pinned_key)
3270 .map_err(|e| CoreError::CryptoError(format!("invalid pinned key: {}", e)))?;
3271
3272 // `ResumptionHint` fields are `Vec<u8>` (UniFFI has no fixed-size
3273 // array type) — enforce the 32-byte invariant here, before any
3274 // socket is opened, so a caller bug never becomes a network call.
3275 let session_id: [u8; 32] = hint.session_id.as_slice().try_into().map_err(|_| {
3276 CoreError::ValidationError(format!(
3277 "resumption hint session_id must be 32 bytes, got {}",
3278 hint.session_id.len()
3279 ))
3280 })?;
3281 let resumption_secret: [u8; 32] =
3282 hint.resumption_secret.as_slice().try_into().map_err(|_| {
3283 CoreError::ValidationError(format!(
3284 "resumption hint resumption_secret must be 32 bytes, got {}",
3285 hint.resumption_secret.len()
3286 ))
3287 })?;
3288
3289 // APIFFI-03: reject oversized early-data BEFORE opening a socket, so a caller
3290 // bug (or oversized blob) never wastes a TCP connection establishment. The
3291 // inner `connect_with_resumption` enforces the same cap as defense-in-depth.
3292 if early_data.len() > EARLY_DATA_MAX_LEN {
3293 return Err(CoreError::ValidationError(format!(
3294 "early_data is {} bytes, exceeds the {}-byte 0-RTT cap",
3295 early_data.len(),
3296 EARLY_DATA_MAX_LEN
3297 )));
3298 }
3299
3300 let addr = format!("{}:{}", host, port);
3301 let stream = tokio::net::TcpStream::connect(&addr)
3302 .await
3303 .map_err(|e| CoreError::NetworkError(format!("connect {}: {}", addr, e)))?;
3304 let transport = crate::api::tcp_transport::TcpSessionTransport::new(stream);
3305
3306 // Reuses the Rust-only `connect_with_resumption` — no new crypto and
3307 // no new wire format. That path enforces the `EARLY_DATA_MAX_LEN`
3308 // cap and keeps 0-RTT one-shot / best-effort (security invariant 9).
3309 let session = PhantomSession::connect_with_resumption(
3310 &addr,
3311 transport,
3312 expected_server_key,
3313 (session_id, resumption_secret),
3314 early_data,
3315 )?;
3316 Ok(Arc::new(session))
3317}
3318
3319#[cfg(test)]
3320mod tests {
3321 use super::*;
3322 use crate::transport::handshake::{ClientHello, HandshakeResponse, HandshakeServer};
3323
3324 // ── Mock transport for testing ──
3325
3326 /// In-memory transport using channels (simulates a loopback pipe).
3327 struct ChannelTransport {
3328 tx: mpsc::Sender<Vec<u8>>,
3329 rx: Mutex<mpsc::Receiver<Vec<u8>>>,
3330 }
3331
3332 impl ChannelTransport {
3333 /// Create a pair of connected transports (client ↔ server).
3334 fn pair() -> (Self, Self) {
3335 let (a_tx, b_rx) = mpsc::channel(64);
3336 let (b_tx, a_rx) = mpsc::channel(64);
3337 (
3338 Self {
3339 tx: a_tx,
3340 rx: Mutex::new(a_rx),
3341 },
3342 Self {
3343 tx: b_tx,
3344 rx: Mutex::new(b_rx),
3345 },
3346 )
3347 }
3348 }
3349
3350 impl SessionTransport for ChannelTransport {
3351 async fn send_bytes(&self, data: &[u8]) -> Result<(), CoreError> {
3352 self.tx
3353 .send(data.to_vec())
3354 .await
3355 .map_err(|_| CoreError::NetworkError("channel closed".into()))
3356 }
3357
3358 async fn recv_bytes(&self) -> Result<Bytes, CoreError> {
3359 let mut rx = self.rx.lock().await;
3360 let v = rx
3361 .recv()
3362 .await
3363 .ok_or_else(|| CoreError::NetworkError("channel closed".into()))?;
3364 Ok(Bytes::from(v))
3365 }
3366 }
3367
3368 // ── Tests ──
3369
3370 /// T5.5(b) send-side: `rekey_before_stamp` re-advertises `PacketFlags::REKEY`
3371 /// on EVERY packet at the new epoch — not just the rotation-trigger packet —
3372 /// until the peer acknowledges the rekey. This is what lets a lost trigger
3373 /// packet recover: the next stamp still flags REKEY so the receive-side gate
3374 /// follows the catch-up. Without re-advertise the second stamp would carry the
3375 /// new epoch unflagged and the gate would strand the receiver.
3376 #[test]
3377 fn rekey_before_stamp_re_advertises_rekey_until_peer_confirms() {
3378 use crate::transport::session::{CryptoState, Session};
3379 use crate::transport::types::{SchedulerMode, SessionId};
3380
3381 let shared = [0x55u8; 32];
3382 let id = SessionId::from_bytes([7u8; 32]);
3383 let crypto = CryptoState::new(&shared, false).expect("crypto");
3384 let session = Arc::new(Session::from_derived(
3385 id,
3386 crypto,
3387 SchedulerMode::LowLatency,
3388 shared,
3389 false,
3390 ));
3391 session.set_rekey_threshold(2);
3392
3393 // Below the watermark: no rekey, no flag.
3394 assert_eq!(
3395 rekey_before_stamp(&session),
3396 Some(0),
3397 "below threshold: no flag"
3398 );
3399
3400 // Cross the high-watermark so the next stamp rotates.
3401 let h = PacketHeader::new(
3402 *session.id(),
3403 1,
3404 0,
3405 PacketFlags::new(PacketFlags::ENCRYPTED),
3406 );
3407 for i in 0..2u64 {
3408 session
3409 .encrypt_packet(
3410 &PacketHeader {
3411 packet_number: i,
3412 ..h
3413 },
3414 b"x",
3415 &[],
3416 )
3417 .expect("encrypt");
3418 }
3419 assert!(session.send_needs_rekey());
3420
3421 // The rotation-trigger stamp flags REKEY and bumps the epoch.
3422 assert_eq!(rekey_before_stamp(&session), Some(PacketFlags::REKEY));
3423 assert_eq!(session.current_epoch(), 1);
3424 assert!(session.rekey_unconfirmed());
3425
3426 // The NEXT stamp re-advertises REKEY even though no further rekey happens —
3427 // the peer has not confirmed yet.
3428 assert_eq!(rekey_before_stamp(&session), Some(PacketFlags::REKEY));
3429 assert_eq!(
3430 session.current_epoch(),
3431 1,
3432 "no second rekey — only a re-advertise"
3433 );
3434 }
3435
3436 /// H9 forward-compat (client side): when the server answers a `ClientHello`
3437 /// with a typed `ServerReject` (the version isn't one it speaks), the client
3438 /// surfaces a clear version-mismatch error instead of hanging or returning a
3439 /// generic failure — and crucially does NOT auto-downgrade.
3440 #[tokio::test]
3441 async fn client_surfaces_server_reject_as_version_error() {
3442 use crate::transport::handshake::{ServerReject, ServerReply};
3443
3444 let (client_transport, server_transport) = ChannelTransport::pair();
3445 // The reject path errors before any key verification, so any key works.
3446 let (_sk, expected_vk) = crate::crypto::hybrid_sign::HybridSigningKey::generate();
3447
3448 let server = tokio::spawn(async move {
3449 // Consume the ClientHello, then reply with the typed reject (T4.4 framed).
3450 let _hello = server_transport.recv_bytes().await.unwrap();
3451 let reject = ServerReply::Reject(ServerReject::unsupported_version())
3452 .to_wire()
3453 .unwrap();
3454 server_transport.send_bytes(&reject).await.unwrap();
3455 });
3456
3457 let result = run_client_handshake(&client_transport, &expected_vk, None).await;
3458 server.await.unwrap();
3459
3460 let err = result.expect_err("client must surface the reject as an error");
3461 let msg = format!("{err:?}");
3462 assert!(
3463 msg.contains("unsupported protocol version"),
3464 "expected a version-mismatch error, got: {msg}"
3465 );
3466 }
3467
3468 /// Reviewer §5: an **injected** `ServerReject` (a tiny, pre-crypto blob a network
3469 /// attacker can spray) during a HEALTHY handshake must NOT abort it. The client remembers
3470 /// the reject and keeps waiting for a valid `ServerHello`; it gives up (surfacing the
3471 /// reject) only if one never arrives. Here the attacker injects a reject ahead of the real
3472 /// cookie/ServerHello flow; the handshake must still succeed.
3473 #[tokio::test]
3474 async fn injected_server_reject_does_not_abort_a_healthy_handshake() {
3475 use crate::transport::handshake::{ServerReject, ServerReply};
3476
3477 let (client_transport, server_transport) = ChannelTransport::pair();
3478 let server_hs = HandshakeServer::new().unwrap();
3479 let expected_vk = server_hs.verifying_key().clone();
3480
3481 let server = tokio::spawn(async move {
3482 let Ok(hello_bytes) = server_transport.recv_bytes().await else {
3483 return;
3484 };
3485 let Ok(client_hello) = borsh::from_slice::<ClientHello>(&hello_bytes) else {
3486 return;
3487 };
3488 // Inject a forged reject AHEAD of the real handshake responses (T4.4 framed).
3489 let reject = ServerReply::Reject(ServerReject::unsupported_version())
3490 .to_wire()
3491 .unwrap();
3492 if server_transport.send_bytes(&reject).await.is_err() {
3493 return;
3494 }
3495 let ip = "127.0.0.1".parse().unwrap();
3496 let sh = match server_hs.process_client_hello(&client_hello, 0, ip) {
3497 HandshakeResponse::Retry(retry) => {
3498 if server_transport
3499 .send_bytes(&ServerReply::Retry(retry).to_wire().unwrap())
3500 .await
3501 .is_err()
3502 {
3503 return;
3504 }
3505 let Ok(h2) = server_transport.recv_bytes().await else {
3506 return;
3507 };
3508 let Ok(next) = borsh::from_slice::<ClientHello>(&h2) else {
3509 return;
3510 };
3511 match server_hs.process_client_hello(&next, 0, ip) {
3512 HandshakeResponse::Success(sh, _, _) => sh,
3513 _ => return,
3514 }
3515 }
3516 HandshakeResponse::Success(sh, _, _) => sh,
3517 _ => return,
3518 };
3519 let _ = server_transport
3520 .send_bytes(&ServerReply::Hello(sh).to_wire().unwrap())
3521 .await;
3522 });
3523
3524 let result = run_client_handshake(&client_transport, &expected_vk, None).await;
3525 // Close the channel so the server task ends even if the client aborted (RED), instead
3526 // of blocking forever on the retried-hello it will never receive.
3527 drop(client_transport);
3528 let _ = server.await;
3529 assert!(
3530 result.is_ok(),
3531 "an injected ServerReject ahead of the real ServerHello must not abort a healthy \
3532 handshake; got {result:?}"
3533 );
3534 }
3535
3536 /// **HS-02.** A MITM that answers every `ClientHello` with a fresh cheap
3537 /// `HelloRetryRequest` must NOT loop the client forever — `run_client_handshake`
3538 /// caps the retry rounds and returns an error. (Pre-fix this test would hang.)
3539 #[tokio::test]
3540 async fn client_handshake_caps_retry_rounds() {
3541 use crate::transport::handshake::HelloRetryRequest;
3542
3543 let (client_transport, server_transport) = ChannelTransport::pair();
3544 let (_sk, expected_vk) = crate::crypto::hybrid_sign::HybridSigningKey::generate();
3545
3546 // Malicious server: answer EVERY ClientHello with a fresh, cheap
3547 // HelloRetryRequest (no cookie, no PoW) — never converging.
3548 let server = tokio::spawn(async move {
3549 loop {
3550 if server_transport.recv_bytes().await.is_err() {
3551 break;
3552 }
3553 let retry = borsh::to_vec(&HelloRetryRequest {
3554 challenge: None,
3555 cookie: None,
3556 })
3557 .expect("encode retry");
3558 if server_transport.send_bytes(&retry).await.is_err() {
3559 break;
3560 }
3561 }
3562 });
3563
3564 let result = run_client_handshake(&client_transport, &expected_vk, None).await;
3565 drop(client_transport); // close the channel so the server task ends
3566 let _ = server.await;
3567
3568 assert!(
3569 matches!(result, Err(CoreError::HandshakeError(_))),
3570 "client must error after the retry-round cap, not loop forever; got {result:?}"
3571 );
3572 }
3573
3574 /// **INFOLEAK-1.** `ResumptionHint`'s `Debug` must redact the 0-RTT
3575 /// `resumption_secret` — a mobile/FFI consumer logging it with `{:?}` must
3576 /// not leak the key material.
3577 #[test]
3578 fn resumption_hint_debug_redacts_secret() {
3579 let hint = ResumptionHint {
3580 session_id: vec![0xAB; 32],
3581 resumption_secret: vec![0xCD; 32],
3582 };
3583 let dbg = format!("{hint:?}");
3584 assert!(dbg.contains("REDACTED"), "secret must be redacted: {dbg}");
3585 // No representation of the secret bytes (0xCD) leaks — neither hex nor
3586 // the decimal the derived Debug would have printed for a Vec<u8>.
3587 assert!(
3588 !dbg.contains("205"),
3589 "no decimal secret bytes in Debug: {dbg}"
3590 );
3591 assert!(
3592 !dbg.to_lowercase().contains("cd, cd"),
3593 "no hex secret bytes: {dbg}"
3594 );
3595 }
3596
3597 #[tokio::test]
3598 async fn test_phantom_session_instant_connect() {
3599 let session = PhantomSession::connect("example.com:443".to_string());
3600
3601 // Should be in Connecting state immediately
3602 assert_eq!(session.connection_state(), ConnectionState::Connecting);
3603 assert!(!session.is_data_ready());
3604 assert_eq!(session.peer_addr(), "example.com:443");
3605 }
3606
3607 /// **T5.7 regression — the inert `connect()` performs no handshake and
3608 /// sends no bytes.** The constructor is documented as deprecated (no
3609 /// `#[deprecated]` attribute is possible — see the doc-comment for why), so
3610 /// this test pins the inert contract the doc promises: the session is stuck
3611 /// in `Connecting`, every `send()` only piles into the in-memory queue (no
3612 /// transport / pump exists to flush it), and `recv()` never yields. If a
3613 /// future change ever wires a real pump into this constructor, this test
3614 /// must be updated alongside the doc — the two must not drift apart.
3615 #[tokio::test]
3616 async fn deprecated_connect_is_inert_and_sends_no_bytes() {
3617 let session = PhantomSession::connect("example.com:443".to_string());
3618
3619 // Inert: never leaves the pre-handshake state on its own.
3620 assert_eq!(session.connection_state(), ConnectionState::Connecting);
3621 assert!(!session.is_data_ready());
3622 assert!(!session.is_pqc_ready());
3623
3624 // Every send while inert only queues — it never reaches a transport,
3625 // because no transport / data pump was ever spawned.
3626 session.send(b"first".to_vec()).await.unwrap();
3627 session.send(b"second".to_vec()).await.unwrap();
3628 assert_eq!(
3629 session.queued_count().await,
3630 2,
3631 "inert connect() must buffer sends in memory, never flush them to a wire"
3632 );
3633
3634 // The session is STILL inert after sending — no background task moved
3635 // the state forward, so the bytes are still sitting in the queue.
3636 assert_eq!(session.connection_state(), ConnectionState::Connecting);
3637
3638 // recv() must never deliver application bytes — no pump feeds the recv
3639 // channel, and the inert constructor drops the channel's sender at once,
3640 // so recv() resolves to an error rather than any data. A short timeout
3641 // bounds the wait and proves recv() does not yield a payload.
3642 let recv = tokio::time::timeout(std::time::Duration::from_millis(50), session.recv()).await;
3643 match recv {
3644 Ok(Err(_)) => { /* expected: "session closed" — never any bytes */ }
3645 Ok(Ok(bytes)) => panic!(
3646 "inert connect() must never deliver received data, got {} bytes",
3647 bytes.len()
3648 ),
3649 Err(_elapsed) => { /* also acceptable: recv blocked the whole window */ }
3650 }
3651 }
3652
3653 #[tokio::test]
3654 async fn test_phantom_session_send_queue() {
3655 let session = PhantomSession::connect("example.com:443".to_string());
3656
3657 // Send while still connecting — should queue
3658 session.send(vec![1, 2, 3]).await.unwrap();
3659 session.send(vec![4, 5, 6]).await.unwrap();
3660 assert_eq!(session.queued_count().await, 2);
3661
3662 // Simulate handshake completion
3663 session.set_state(ConnectionState::ClassicalReady);
3664 assert!(session.is_data_ready());
3665
3666 // Flush queue
3667 let flushed = session.flush_queue().await.unwrap();
3668 assert_eq!(flushed, 2);
3669 assert_eq!(session.queued_count().await, 0);
3670 }
3671
3672 #[tokio::test]
3673 async fn test_phantom_session_state_progression() {
3674 let session = PhantomSession::connect("example.com:443".to_string());
3675
3676 assert_eq!(session.connection_state(), ConnectionState::Connecting);
3677 assert!(!session.is_data_ready());
3678
3679 session.set_state(ConnectionState::ClassicalReady);
3680 assert!(session.is_data_ready());
3681 assert!(!session.is_pqc_ready());
3682
3683 session.set_state(ConnectionState::PqcUpgrading);
3684 assert!(session.is_data_ready());
3685 assert!(!session.is_pqc_ready());
3686
3687 session.set_state(ConnectionState::PqcReady);
3688 assert!(session.is_data_ready());
3689 assert!(session.is_pqc_ready());
3690
3691 session.set_state(ConnectionState::Connected);
3692 assert!(session.is_data_ready());
3693 assert!(session.is_pqc_ready());
3694 }
3695
3696 #[tokio::test]
3697 async fn test_phantom_session_close() {
3698 let session = PhantomSession::connect("example.com:443".to_string());
3699 session.disconnect().await.unwrap();
3700 assert_eq!(session.connection_state(), ConnectionState::Closed);
3701 assert!(!session.is_data_ready());
3702 }
3703
3704 /// Helper: decrypt an incoming encrypted frame on the test server side.
3705 fn decrypt_incoming(
3706 server_session: &crate::transport::session::Session,
3707 bytes: &[u8],
3708 ) -> Vec<u8> {
3709 // The peer pump applies header protection (T4.6); unmask with this
3710 // side's recv HP key (== the sender's send HP key) before reading.
3711 let pkt = server_session
3712 .parse_protected(bytes)
3713 .expect("parse header-protected PhantomPacket");
3714 assert!(
3715 pkt.header.flags.contains(PacketFlags::ENCRYPTED),
3716 "expected ENCRYPTED flag on application data"
3717 );
3718 let plain = server_session
3719 .decrypt_packet(&pkt.header, &pkt.payload, &[])
3720 .expect("decrypt application data");
3721 // Reliable app frames carry a 4-byte gap-free stream_offset prefix (A.5);
3722 // strip it so callers compare against the raw application payload.
3723 if pkt.header.flags.contains(PacketFlags::RELIABLE) && plain.len() >= 4 {
3724 plain[4..].to_vec()
3725 } else {
3726 plain
3727 }
3728 }
3729
3730 /// Helper: build an encrypted reply frame from the test server side. Mirrors
3731 /// the live sender's reliable framing: plaintext = `[stream_offset: u32 BE]
3732 /// [payload]` with `stream_offset == sequence` (no control gaps in this test).
3733 fn encrypt_outgoing(
3734 server_session: &crate::transport::session::Session,
3735 session_id: SessionId,
3736 stream_id: TransportStreamId,
3737 sequence: u32,
3738 payload: &[u8],
3739 ) -> Vec<u8> {
3740 let flag_bits = PacketFlags::RELIABLE | PacketFlags::ENCRYPTED;
3741 let header = PacketHeader::new(
3742 session_id,
3743 stream_id,
3744 sequence as u64,
3745 PacketFlags::new(flag_bits),
3746 )
3747 .with_epoch(server_session.current_epoch());
3748 let mut pt = Vec::with_capacity(4 + payload.len());
3749 pt.extend_from_slice(&sequence.to_be_bytes());
3750 pt.extend_from_slice(payload);
3751 let ct = server_session
3752 .encrypt_packet(&header, &pt, &[])
3753 .expect("encrypt reply");
3754 let packet = PhantomPacket::new(header, ct);
3755 // Apply header protection so the peer pump's parse_protected unmasks it.
3756 server_session
3757 .protect_packet(&packet)
3758 .expect("header protection")
3759 }
3760
3761 /// Integration test: Client handshake via ChannelTransport with a
3762 /// simulated server responder.
3763 #[tokio::test]
3764 async fn test_phantom_session_handshake_via_transport() {
3765 let (client_transport, server_transport) = ChannelTransport::pair();
3766 let server_hs = HandshakeServer::new().unwrap();
3767 let server_pinned_key = server_hs.verifying_key().clone();
3768
3769 // Start client session — spawns background handshake (with pinning)
3770 let session = PhantomSession::connect_with_transport(
3771 "test-server:9000",
3772 client_transport,
3773 server_pinned_key,
3774 );
3775
3776 // Queue a message before handshake completes
3777 session.send(b"early-data".to_vec()).await.unwrap();
3778
3779 // Simulate server responder
3780 let server_handle = tokio::spawn(async move {
3781 let client_ip = "127.0.0.1".parse().unwrap();
3782
3783 // 1. Receive the (bare borsh) ClientHello.
3784 let client_hello_bytes = server_transport.recv_bytes().await.unwrap();
3785 let client_hello = borsh::from_slice::<ClientHello>(&client_hello_bytes).unwrap();
3786
3787 // 2. Process — may retry with cookie/PoW.
3788 let server_session = loop {
3789 let response = server_hs.process_client_hello(&client_hello, 0, client_ip);
3790 match response {
3791 HandshakeResponse::Retry(retry) => {
3792 let retry_bytes = ServerReply::Retry(retry).to_wire().unwrap();
3793 server_transport.send_bytes(&retry_bytes).await.unwrap();
3794 // Receive retried client hello
3795 let next_bytes = server_transport.recv_bytes().await.unwrap();
3796 let next_hello = borsh::from_slice::<ClientHello>(&next_bytes).unwrap();
3797 let resp2 = server_hs.process_client_hello(&next_hello, 0, client_ip);
3798 match resp2 {
3799 HandshakeResponse::Success(server_hello, session, _) => {
3800 let server_hello_bytes =
3801 ServerReply::Hello(server_hello).to_wire().unwrap();
3802 server_transport
3803 .send_bytes(&server_hello_bytes)
3804 .await
3805 .unwrap();
3806 break session;
3807 }
3808 _ => panic!("Expected success after retry"),
3809 }
3810 }
3811 HandshakeResponse::Success(server_hello, session, _) => {
3812 let server_hello_bytes =
3813 ServerReply::Hello(server_hello).to_wire().unwrap();
3814 server_transport
3815 .send_bytes(&server_hello_bytes)
3816 .await
3817 .unwrap();
3818 break session;
3819 }
3820 HandshakeResponse::Reject(r) => panic!("unexpected reject: {:?}", r),
3821 HandshakeResponse::Fail(e) => panic!("handshake failed: {:?}", e),
3822 }
3823 };
3824
3825 let session_id = *server_session.id();
3826
3827 // 3. Receive the flushed early data — must be ENCRYPTED.
3828 let early_frame = server_transport.recv_bytes().await.unwrap();
3829 assert!(
3830 !early_frame
3831 .windows(b"early-data".len())
3832 .any(|w| w == b"early-data"),
3833 "encrypted frame must not contain plaintext early-data"
3834 );
3835 let early_plain = decrypt_incoming(&server_session, &early_frame);
3836 assert_eq!(early_plain, b"early-data");
3837
3838 // 4. Receive a post-handshake message — must be ENCRYPTED.
3839 let post_frame = server_transport.recv_bytes().await.unwrap();
3840 let post_plain = decrypt_incoming(&server_session, &post_frame);
3841 assert_eq!(post_plain, b"after-handshake");
3842
3843 // 5. Send encrypted reply back. stream_offset (== sequence here) must
3844 // be 0: this is the FIRST reliable frame server→client on this stream,
3845 // so the client reassembles it at offset 0 (A.5).
3846 let reply = encrypt_outgoing(&server_session, session_id, 1, 0, b"server-reply");
3847 server_transport.send_bytes(&reply).await.unwrap();
3848 });
3849
3850 // Wait for handshake to progress
3851 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
3852
3853 // Should be connected now
3854 assert_eq!(session.connection_state(), ConnectionState::Connected);
3855
3856 // Send after handshake
3857 session.send(b"after-handshake".to_vec()).await.unwrap();
3858
3859 // Receive server reply — now returns DECRYPTED plaintext payload.
3860 let reply = session.recv().await.unwrap();
3861 assert_eq!(reply, b"server-reply");
3862
3863 server_handle.await.unwrap();
3864 session.disconnect().await.unwrap();
3865 }
3866
3867 /// Reliable delivery: a RELIABLE application send must survive a dropped data frame.
3868 ///
3869 /// The client runs over a `LossyTransport`; once the handshake completes we
3870 /// arm a drop of the next frame (the data frame) and send a reliable
3871 /// payload. The first transmission is lost, so the server only sees the
3872 /// payload because the raw-app stream buffers it and the data pump
3873 /// retransmits the timed-out segment.
3874 #[tokio::test]
3875 async fn reliable_send_survives_a_dropped_data_frame() {
3876 use crate::test_harness::fault_transport::{FaultControl, LossyTransport};
3877
3878 let (client_transport, server_transport) = ChannelTransport::pair();
3879 let faults = FaultControl::new();
3880 let lossy_client = LossyTransport::new(client_transport, faults.clone());
3881
3882 let server_hs = HandshakeServer::new().unwrap();
3883 let server_pinned_key = server_hs.verifying_key().clone();
3884
3885 let session = PhantomSession::connect_with_transport(
3886 "test-server:9000",
3887 lossy_client,
3888 server_pinned_key,
3889 );
3890
3891 let server_handle = tokio::spawn(async move {
3892 let client_ip = "127.0.0.1".parse().unwrap();
3893 let client_hello_bytes = server_transport.recv_bytes().await.unwrap();
3894 let client_hello = borsh::from_slice::<ClientHello>(&client_hello_bytes).unwrap();
3895
3896 // Drive the handshake to completion (may take one cookie/PoW retry).
3897 let server_session = loop {
3898 match server_hs.process_client_hello(&client_hello, 0, client_ip) {
3899 HandshakeResponse::Retry(retry) => {
3900 let retry_bytes = ServerReply::Retry(retry).to_wire().unwrap();
3901 server_transport.send_bytes(&retry_bytes).await.unwrap();
3902 let next_bytes = server_transport.recv_bytes().await.unwrap();
3903 let next_hello = borsh::from_slice::<ClientHello>(&next_bytes).unwrap();
3904 match server_hs.process_client_hello(&next_hello, 0, client_ip) {
3905 HandshakeResponse::Success(server_hello, session, _) => {
3906 let b = ServerReply::Hello(server_hello).to_wire().unwrap();
3907 server_transport.send_bytes(&b).await.unwrap();
3908 break session;
3909 }
3910 _ => panic!("expected success after retry"),
3911 }
3912 }
3913 HandshakeResponse::Success(server_hello, session, _) => {
3914 let b = ServerReply::Hello(server_hello).to_wire().unwrap();
3915 server_transport.send_bytes(&b).await.unwrap();
3916 break session;
3917 }
3918 HandshakeResponse::Reject(r) => panic!("unexpected reject: {:?}", r),
3919 HandshakeResponse::Fail(e) => panic!("handshake failed: {:?}", e),
3920 }
3921 };
3922
3923 // The reliable data frame was dropped on first transmission; it can
3924 // only arrive via retransmission. Time-bounded so a missing
3925 // retransmit fails loudly instead of hanging the test forever.
3926 let data_frame = tokio::time::timeout(
3927 std::time::Duration::from_secs(3),
3928 server_transport.recv_bytes(),
3929 )
3930 .await
3931 .expect(
3932 "reliable payload never arrived within 3s — the dropped data frame was not \
3933 retransmitted (loss-recovery regression)",
3934 )
3935 .unwrap();
3936 let plain = decrypt_incoming(&server_session, &data_frame);
3937 assert_eq!(plain, b"reliable-payload");
3938 });
3939
3940 // Wait for the handshake to complete.
3941 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
3942 assert_eq!(session.connection_state(), ConnectionState::Connected);
3943
3944 // Arm a single drop, then send: the next frame on the wire (the data
3945 // frame) is silently lost.
3946 faults.arm_drop_next(1);
3947 session.send(b"reliable-payload".to_vec()).await.unwrap();
3948
3949 server_handle.await.unwrap();
3950 session.disconnect().await.unwrap();
3951 }
3952
3953 /// A retransmission (RTO expiry) must be reported to congestion control as
3954 /// a loss, driving BBR into FastRecovery — proves the drain → on_packet_lost
3955 /// wiring, not just that the retransmit happens.
3956 #[tokio::test]
3957 async fn drain_reports_a_retransmit_as_loss_to_bbr() {
3958 use crate::transport::bandwidth_estimator::BbrState;
3959
3960 tokio::time::pause();
3961 let sid = fixed_session_id();
3962 let (client, _server) = paired_sessions(sid);
3963
3964 let stream = Arc::new(TransportStream::new(1));
3965 stream.send_reliable(Bytes::from("payload")).await.unwrap();
3966 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
3967 streams.insert(1u32, stream);
3968
3969 let (client_t, _server_t) = ChannelTransport::pair();
3970 let transport = Arc::new(client_t);
3971
3972 // First drain: the initial transmission — not a loss.
3973 drain_streams_priority_ordered(&transport, &client, sid, &streams).await;
3974 assert_ne!(client.bbr_state(), BbrState::FastRecovery);
3975
3976 // The RTO expires; the next drain retransmits and must report the loss.
3977 tokio::time::advance(std::time::Duration::from_millis(1100)).await;
3978 drain_streams_priority_ordered(&transport, &client, sid, &streams).await;
3979 assert_eq!(
3980 client.bbr_state(),
3981 BbrState::FastRecovery,
3982 "a retransmit must be reported to BBR as a loss"
3983 );
3984 }
3985
3986 /// New data must not be transmitted while inflight already exceeds the
3987 /// congestion window — the drain holds it back until ACKs free the window.
3988 #[tokio::test]
3989 async fn drain_withholds_new_data_when_inflight_exceeds_cwnd() {
3990 let sid = fixed_session_id();
3991 let (client, _server) = paired_sessions(sid);
3992
3993 // Drive inflight far above any plausible initial cwnd, so the window
3994 // has no room for new data.
3995 client.on_packet_sent(100_000_000);
3996 let inflight_before = client.bandwidth_snapshot().inflight_bytes;
3997
3998 let stream = Arc::new(TransportStream::new(1));
3999 stream.send_reliable(Bytes::from("new-data")).await.unwrap();
4000 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4001 streams.insert(1u32, stream);
4002
4003 let (client_t, _server_t) = ChannelTransport::pair();
4004 let transport = Arc::new(client_t);
4005
4006 drain_streams_priority_ordered(&transport, &client, sid, &streams).await;
4007
4008 // No new segment was transmitted — inflight is unchanged (a send would
4009 // have grown it via on_packet_sent).
4010 assert_eq!(
4011 client.bandwidth_snapshot().inflight_bytes,
4012 inflight_before,
4013 "no new data should be sent when inflight >= cwnd"
4014 );
4015 }
4016
4017 // ────────────────────────────────────────────────────────────────────
4018 // V2 wire-routing tests (Phase 4.2 / 2.5 follow-up — data-pump V2)
4019 // ────────────────────────────────────────────────────────────────────
4020
4021 use crate::transport::multiplexer::StreamDemultiplexer;
4022 use crate::transport::session::Session as InnerSession;
4023 use crate::transport::stream::Stream as TransportStream;
4024
4025 /// Build two `InnerSession` instances that share a 32-byte secret —
4026 /// one as the "client" (peer_side=false), one as the "server"
4027 /// (peer_side=true). Mirrors the role split after a real handshake.
4028 fn paired_sessions(session_id: SessionId) -> (Arc<InnerSession>, Arc<InnerSession>) {
4029 let secret = [0x11u8; 32];
4030 let client = Arc::new(InnerSession::new(session_id, &secret, false).unwrap());
4031 let server = Arc::new(InnerSession::new(session_id, &secret, true).unwrap());
4032 (client, server)
4033 }
4034
4035 fn fixed_session_id() -> SessionId {
4036 SessionId::from_bytes([0x88; 32])
4037 }
4038
4039 /// Encrypt a V2 application-data packet from the client side at
4040 /// `stream_id` / `sequence`. The returned bytes are wire-serialised
4041 /// ([`PhantomPacket::to_wire`]) and ready to feed into `handle_packet`.
4042 /// Build a RELIABLE app frame whose `stream_offset` equals its `sequence` (the
4043 /// no-control-gap case, which holds for almost every test).
4044 fn build_app_frame(
4045 client_session: &InnerSession,
4046 session_id: SessionId,
4047 stream_id: TransportStreamId,
4048 sequence: u32,
4049 payload: &[u8],
4050 ) -> Vec<u8> {
4051 build_app_frame_with_offset(
4052 client_session,
4053 session_id,
4054 stream_id,
4055 sequence,
4056 sequence,
4057 payload,
4058 )
4059 }
4060
4061 /// Build a RELIABLE app frame with an explicit gap-free `stream_offset`
4062 /// distinct from the wire `sequence` (A.5). The plaintext is
4063 /// `[stream_offset: u32 BE][payload]`, matching the live sender's reliable
4064 /// framing; the receiver reassembles by `stream_offset`.
4065 fn build_app_frame_with_offset(
4066 client_session: &InnerSession,
4067 session_id: SessionId,
4068 stream_id: TransportStreamId,
4069 sequence: u32,
4070 stream_offset: u32,
4071 payload: &[u8],
4072 ) -> Vec<u8> {
4073 let flag_bits = PacketFlags::RELIABLE | PacketFlags::ENCRYPTED;
4074 let header = PacketHeader::new(
4075 session_id,
4076 stream_id,
4077 sequence as u64,
4078 PacketFlags::new(flag_bits),
4079 )
4080 .with_epoch(client_session.current_epoch());
4081 let mut pt = Vec::with_capacity(4 + payload.len());
4082 pt.extend_from_slice(&stream_offset.to_be_bytes());
4083 pt.extend_from_slice(payload);
4084 let ciphertext = client_session
4085 .encrypt_packet(&header, &pt, &[])
4086 .expect("encrypt_packet");
4087 // Cleartext wire: this frame is decoded back to a struct and fed to
4088 // handle_packet directly (it never traverses the pump's transport, which
4089 // is the only path that applies/removes header protection).
4090 PhantomPacket::new(header, ciphertext).to_wire()
4091 }
4092
4093 /// Decode a test-built frame the way the recv pump's `parse_protected` does:
4094 /// `from_wire` + reconstruct the off-wire `session_id` from session context
4095 /// (ε / WIRE v5). The `build_*` helpers emit cleartext `to_wire` (header
4096 /// protection is exercised separately), so the inner `session_id` is the
4097 /// placeholder zero until this sets it — mirroring production, where
4098 /// `parse_protected` reconstructs it before `handle_packet` ever sees the
4099 /// packet.
4100 fn decode_recv_frame(frame: &[u8], session_id: SessionId) -> PhantomPacket {
4101 let mut packet = PhantomPacket::from_wire(frame).expect("decode test recv frame");
4102 packet.header.session_id = session_id;
4103 packet
4104 }
4105
4106 #[tokio::test]
4107 async fn v2_recv_routes_encrypted_app_data_through_recv_channel() {
4108 let session_id = fixed_session_id();
4109 let (client_session, server_session) = paired_sessions(session_id);
4110
4111 // Encrypt a V2 application-data packet on the client side.
4112 let stream_id: TransportStreamId = 1;
4113 let frame = build_app_frame(&client_session, session_id, stream_id, 0, b"hello-v2");
4114
4115 // Receive on the server side: decode (reconstructing the off-wire
4116 // session_id, as parse_protected does) then drive handle_packet.
4117 let v2 = decode_recv_frame(&frame, session_id);
4118
4119 let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
4120 let demux = Arc::new(demux);
4121 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4122 let (deliver_tx, mut deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
4123 let undelivered = AtomicU64::new(0);
4124 let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(4);
4125 let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
4126 tx: ack_a,
4127 rx: Mutex::new(ack_b),
4128 });
4129
4130 let mut ack_buf = Vec::with_capacity(256);
4131 let obs = Observability::new(ObservabilityConfig::default());
4132 handle_packet(
4133 v2,
4134 session_id,
4135 &server_session,
4136 &streams,
4137 &demux,
4138 &transport_send,
4139 &transport_send,
4140 &deliver_tx,
4141 &undelivered,
4142 &mut ack_buf,
4143 &obs,
4144 LegType::Tcp,
4145 )
4146 .await;
4147
4148 // The decrypted plaintext must have been handed to the delivery task,
4149 // tagged with its stream id, and counted toward the undelivered backlog.
4150 let (sid, received) = deliver_rx.recv().await.expect("delivery hand-off");
4151 assert_eq!(sid, stream_id as u32);
4152 assert_eq!(&received[..], b"hello-v2");
4153 assert_eq!(
4154 undelivered.load(Ordering::Acquire),
4155 b"hello-v2".len() as u64
4156 );
4157 }
4158
4159 /// H-3: the recv path must cap concurrent receive streams. A peer that sprays reliable
4160 /// frames across far more distinct `stream_id`s than the cap must not auto-create an
4161 /// unbounded number of `Stream`s — the table is bounded by `MAX_STREAMS`, which (with the
4162 /// per-stream reorder byte budget) bounds the session's total reorder memory.
4163 #[tokio::test]
4164 async fn recv_path_caps_concurrent_streams_at_max_streams() {
4165 let session_id = fixed_session_id();
4166 let (client_session, server_session) = paired_sessions(session_id);
4167
4168 let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
4169 let demux = Arc::new(demux);
4170 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4171 let (deliver_tx, _deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
4172 let undelivered = AtomicU64::new(0);
4173 let attempts = MAX_STREAMS as u32 + 64;
4174 let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(attempts as usize + 16);
4175 let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
4176 tx: ack_a,
4177 rx: Mutex::new(ack_b),
4178 });
4179 let mut ack_buf = Vec::with_capacity(256);
4180 let obs = Observability::new(ObservabilityConfig::default());
4181
4182 // A peer opens far more receive streams than the cap. Each frame uses a distinct
4183 // `sequence` (the per-direction packet number, else they replay-reject) but
4184 // stream_offset 0 so it delivers in order and creates its stream.
4185 for sid in 0..attempts {
4186 let frame = build_app_frame_with_offset(
4187 &client_session,
4188 session_id,
4189 sid as TransportStreamId,
4190 sid, // sequence = distinct per-direction PN
4191 0, // stream_offset 0 → in-order delivery
4192 b"x",
4193 );
4194 let v2 = decode_recv_frame(&frame, session_id);
4195 handle_packet(
4196 v2,
4197 session_id,
4198 &server_session,
4199 &streams,
4200 &demux,
4201 &transport_send,
4202 &transport_send,
4203 &deliver_tx,
4204 &undelivered,
4205 &mut ack_buf,
4206 &obs,
4207 LegType::Tcp,
4208 )
4209 .await;
4210 }
4211
4212 assert!(
4213 streams.len() <= MAX_STREAMS,
4214 "recv path must cap concurrent receive streams at MAX_STREAMS ({MAX_STREAMS}); have {}",
4215 streams.len()
4216 );
4217 }
4218
4219 /// M-2 (audit 2026-06-11, residual of prior H1): a forged **unencrypted, empty-payload**
4220 /// packet carrying only the `FIN` flag (valid `session_id`) must NOT tear down an
4221 /// `open_stream()` stream. The stripped-flag downgrade defense must drop ALL unencrypted
4222 /// post-handshake packets — not only non-empty ones — so the standalone-FIN path is never
4223 /// reached without AEAD verification. Legitimate FINs are always `ENCRYPTED`.
4224 #[tokio::test]
4225 async fn forged_unencrypted_fin_does_not_close_a_stream() {
4226 let session_id = fixed_session_id();
4227 let (_client_session, server_session) = paired_sessions(session_id);
4228
4229 let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
4230 let demux = Arc::new(demux);
4231 // Register stream 2 — an open_stream()-style stream (ids 2+), the M-2 target.
4232 let mut handle = demux.register_stream(2, 8);
4233 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4234 let (deliver_tx, _deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
4235 let undelivered = AtomicU64::new(0);
4236 let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(4);
4237 let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
4238 tx: ack_a,
4239 rx: Mutex::new(ack_b),
4240 });
4241 let mut ack_buf = Vec::with_capacity(64);
4242 let obs = Observability::new(ObservabilityConfig::default());
4243
4244 // Forged: UNENCRYPTED, empty payload, FIN flag, valid session_id, stream 2.
4245 let header = PacketHeader::new(session_id, 2, 0, PacketFlags::new(PacketFlags::FIN));
4246 let forged = PhantomPacket::new(header, Vec::new());
4247
4248 handle_packet(
4249 forged,
4250 session_id,
4251 &server_session,
4252 &streams,
4253 &demux,
4254 &transport_send,
4255 &transport_send,
4256 &deliver_tx,
4257 &undelivered,
4258 &mut ack_buf,
4259 &obs,
4260 LegType::Tcp,
4261 )
4262 .await;
4263
4264 assert!(
4265 handle.rx.try_recv().is_err(),
4266 "a forged unencrypted FIN must not close an open_stream() stream"
4267 );
4268 }
4269
4270 /// Like [`build_app_frame`] but stamps a caller-chosen `path_id` so the
4271 /// receive-side path gate (PATH-001) can be exercised.
4272 fn build_app_frame_on_path(
4273 client_session: &InnerSession,
4274 session_id: SessionId,
4275 stream_id: TransportStreamId,
4276 sequence: u32,
4277 stream_offset: u32,
4278 path_id: u8,
4279 payload: &[u8],
4280 ) -> Vec<u8> {
4281 let flag_bits = PacketFlags::RELIABLE | PacketFlags::ENCRYPTED;
4282 let header = PacketHeader::new(
4283 session_id,
4284 stream_id,
4285 sequence as u64,
4286 PacketFlags::new(flag_bits),
4287 )
4288 .with_epoch(client_session.current_epoch())
4289 .with_path_id(path_id);
4290 // Reliable plaintext = [stream_offset: u32 BE][payload] (A.5).
4291 let mut pt = Vec::with_capacity(4 + payload.len());
4292 pt.extend_from_slice(&stream_offset.to_be_bytes());
4293 pt.extend_from_slice(payload);
4294 let ciphertext = client_session
4295 .encrypt_packet(&header, &pt, &[])
4296 .expect("encrypt_packet");
4297 // Cleartext wire: this frame is decoded back to a struct and fed to
4298 // handle_packet directly (it never traverses the pump's transport, which
4299 // is the only path that applies/removes header protection).
4300 PhantomPacket::new(header, ciphertext).to_wire()
4301 }
4302
4303 #[test]
4304 fn send_path_id_starts_at_zero_then_bumps_per_migration() {
4305 // D5 (Phase 4): the client owns a monotonic send-side path_id, default 0 (the
4306 // implicit handshake path), bumped on each migration so the server can detect
4307 // and challenge the new path. Reuse is nonce-safe under ① (path_id left the
4308 // AEAD nonce — `nonce = nonce_prefix ‖ packet_number`).
4309 let session_id = fixed_session_id();
4310 let (client_session, _server_session) = paired_sessions(session_id);
4311 assert_eq!(client_session.current_send_path_id(), 0);
4312 assert_eq!(client_session.next_migration_path_id(), 1);
4313 assert_eq!(client_session.current_send_path_id(), 1);
4314 assert_eq!(client_session.next_migration_path_id(), 2);
4315 assert_eq!(client_session.current_send_path_id(), 2);
4316 }
4317
4318 /// ε / WIRE v5 (audit V-1 / Invariant 4) — the inbound CID-window slide is
4319 /// signalled ONLY from the post-AEAD path. A forged packet that fails AEAD —
4320 /// even one carrying a NEW forward `path_id` (the migration signal) — must not
4321 /// advance the inbound CID window or emit a `CidSlide`. This pins that an
4322 /// off-path attacker (who cannot produce a valid tag) cannot push the demux
4323 /// window; a future refactor that hoists the slide above the AEAD gate fails here.
4324 #[tokio::test]
4325 async fn eps_slide_requires_aead_success() {
4326 let session_id = fixed_session_id();
4327 let (client_session, server_session) = paired_sessions(session_id);
4328 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4329
4330 // Install the demux slide channel and snapshot the inbound CID window.
4331 let (slide_tx, mut slide_rx) = mpsc::unbounded_channel();
4332 server_session.set_cid_slide_tx(slide_tx);
4333 let window_before = server_session.inbound_window_cids();
4334
4335 // A valid frame on a NEW path_id (1, the migration signal), then corrupt
4336 // the ciphertext so AEAD verification fails. The header stays intact.
4337 let stream_id: TransportStreamId = 1;
4338 let frame =
4339 build_app_frame_on_path(&client_session, session_id, stream_id, 0, 0, 1, b"forged");
4340 let mut pkt = decode_recv_frame(&frame, session_id);
4341 assert!(!pkt.payload.is_empty(), "ciphertext present to corrupt");
4342 pkt.payload[0] ^= 0xFF; // tamper → AEAD open fails
4343
4344 run_recv(pkt, session_id, &server_session, &streams).await;
4345
4346 assert!(
4347 slide_rx.try_recv().is_err(),
4348 "a forged (AEAD-failing) packet must emit no CidSlide"
4349 );
4350 assert_eq!(
4351 server_session.inbound_window_cids(),
4352 window_before,
4353 "the inbound CID window must not advance on a forged packet (slide is post-AEAD)"
4354 );
4355 }
4356
4357 use std::sync::atomic::AtomicBool;
4358
4359 /// Records each `SessionTransport` control method the inner transport receives,
4360 /// for the [`observed_transport_forwards_all_control_methods`] tripwire.
4361 #[derive(Default)]
4362 struct ControlRecorder {
4363 set_frame_phase: AtomicBool,
4364 set_outbound_cid: std::sync::Mutex<Option<[u8; 8]>>,
4365 has_migration_candidate: AtomicBool,
4366 send_to_candidate: AtomicBool,
4367 confirm_authenticated_source: AtomicBool,
4368 promote_candidate: AtomicBool,
4369 migrate: std::sync::Mutex<Option<String>>,
4370 migrate_server: std::sync::Mutex<Option<String>>,
4371 }
4372
4373 struct RecordingTransport {
4374 rec: Arc<ControlRecorder>,
4375 }
4376
4377 impl SessionTransport for RecordingTransport {
4378 async fn send_bytes(&self, _data: &[u8]) -> Result<(), CoreError> {
4379 Ok(())
4380 }
4381 async fn recv_bytes(&self) -> Result<Bytes, CoreError> {
4382 Ok(Bytes::new())
4383 }
4384 fn set_frame_phase(&self, _phase: FramePhase) {
4385 self.rec.set_frame_phase.store(true, Ordering::SeqCst);
4386 }
4387 fn set_outbound_cid(&self, cid: [u8; 8]) {
4388 *self.rec.set_outbound_cid.lock().unwrap() = Some(cid);
4389 }
4390 fn has_migration_candidate(&self) -> bool {
4391 self.rec
4392 .has_migration_candidate
4393 .store(true, Ordering::SeqCst);
4394 true
4395 }
4396 async fn send_to_candidate(&self, _data: &[u8]) -> Result<bool, CoreError> {
4397 self.rec.send_to_candidate.store(true, Ordering::SeqCst);
4398 Ok(true)
4399 }
4400 fn confirm_authenticated_source(&self) {
4401 self.rec
4402 .confirm_authenticated_source
4403 .store(true, Ordering::SeqCst);
4404 }
4405 fn promote_candidate(&self) -> bool {
4406 self.rec.promote_candidate.store(true, Ordering::SeqCst);
4407 true
4408 }
4409 async fn migrate(&self, local_addr: String) -> Result<(), CoreError> {
4410 *self.rec.migrate.lock().unwrap() = Some(local_addr);
4411 Ok(())
4412 }
4413 async fn migrate_server(&self, local_addr: String) -> Result<(), CoreError> {
4414 *self.rec.migrate_server.lock().unwrap() = Some(local_addr);
4415 Ok(())
4416 }
4417 }
4418
4419 /// EPS-02 (audit L4) — the symmetric-rotation role branch, pinned ALWAYS-ON
4420 /// (the live wire-level proof is `#[ignore]` `udp_integration`). On detecting a
4421 /// peer migration, a **server** rotates its s2c CID but keeps its send `path_id`
4422 /// (path-id-silent — prevents a ping-pong), while a **client** rotates its c2s
4423 /// CID **and** bumps its send `path_id` (the window-slide / no-stranding fix).
4424 /// Non-vacuous: flipping the `is_server` branch flips which side bumps `path_id`,
4425 /// failing an assertion here.
4426 #[test]
4427 fn eps02_rotation_branch_is_role_correct() {
4428 let session_id = fixed_session_id();
4429 let (client, server) = paired_sessions(session_id);
4430
4431 // SERVER (its client migrated): rotate s2c CID, path_id SILENT.
4432 let s_path_before = server.current_send_path_id();
4433 let s_cid_before = server.current_outbound_cid();
4434 let rec_s = Arc::new(ControlRecorder::default());
4435 apply_eps02_peer_migration_rotation(&server, &RecordingTransport { rec: rec_s.clone() });
4436 assert_ne!(
4437 server.current_outbound_cid(),
4438 s_cid_before,
4439 "server must rotate its s2c CID on a peer (client) migration"
4440 );
4441 assert_eq!(
4442 *rec_s.set_outbound_cid.lock().unwrap(),
4443 Some(server.current_outbound_cid()),
4444 "server stamps the rotated CID onto its transport"
4445 );
4446 assert_eq!(
4447 server.current_send_path_id(),
4448 s_path_before,
4449 "server rotation is path-id-SILENT (bumping it would ping-pong the client)"
4450 );
4451
4452 // CLIENT (its server migrated): rotate c2s CID AND bump send path_id.
4453 let c_path_before = client.current_send_path_id();
4454 let c_cid_before = client.current_outbound_cid();
4455 let rec_c = Arc::new(ControlRecorder::default());
4456 apply_eps02_peer_migration_rotation(&client, &RecordingTransport { rec: rec_c.clone() });
4457 assert_ne!(
4458 client.current_outbound_cid(),
4459 c_cid_before,
4460 "client must rotate its c2s CID on a peer (server) migration"
4461 );
4462 assert_eq!(
4463 *rec_c.set_outbound_cid.lock().unwrap(),
4464 Some(client.current_outbound_cid()),
4465 "client stamps the rotated CID onto its transport"
4466 );
4467 assert_eq!(
4468 client.current_send_path_id(),
4469 c_path_before + 1,
4470 "client bumps its send path_id (slides the server's c2s demux window — no stranding)"
4471 );
4472 }
4473
4474 /// ε / WIRE v5 (audit V-3 / EPS-03 / EPS-04) — `ObservedTransport` must forward
4475 /// EVERY `SessionTransport` control method to the inner transport, not just
4476 /// send/recv. A method left on the trait default silently no-ops — the bug that
4477 /// made the pre-ε FFI `migrate()` vacuous and linkable. This always-on tripwire
4478 /// pins the full control surface without UDP loopback: a dropped forward, or a
4479 /// future-added trait method the wrapper forgets, fails an assertion here.
4480 #[tokio::test]
4481 async fn observed_transport_forwards_all_control_methods() {
4482 let rec = Arc::new(ControlRecorder::default());
4483 let observed = ObservedTransport::new(
4484 RecordingTransport { rec: rec.clone() },
4485 Observability::new(ObservabilityConfig::default()),
4486 LegType::Udp,
4487 );
4488
4489 observed.set_frame_phase(FramePhase::Established);
4490 observed.set_outbound_cid([7u8; 8]);
4491 assert!(observed.has_migration_candidate());
4492 assert!(observed
4493 .send_to_candidate(b"challenge")
4494 .await
4495 .expect("send_to_candidate"));
4496 observed.confirm_authenticated_source();
4497 assert!(observed.promote_candidate());
4498 observed
4499 .migrate("127.0.0.1:0".to_string())
4500 .await
4501 .expect("migrate");
4502 observed
4503 .migrate_server("127.0.0.1:0".to_string())
4504 .await
4505 .expect("migrate_server");
4506
4507 assert!(
4508 rec.set_frame_phase.load(Ordering::SeqCst),
4509 "set_frame_phase not forwarded"
4510 );
4511 assert_eq!(
4512 *rec.set_outbound_cid.lock().unwrap(),
4513 Some([7u8; 8]),
4514 "set_outbound_cid not forwarded"
4515 );
4516 assert!(
4517 rec.has_migration_candidate.load(Ordering::SeqCst),
4518 "has_migration_candidate not forwarded"
4519 );
4520 assert!(
4521 rec.send_to_candidate.load(Ordering::SeqCst),
4522 "send_to_candidate not forwarded"
4523 );
4524 assert!(
4525 rec.confirm_authenticated_source.load(Ordering::SeqCst),
4526 "confirm_authenticated_source not forwarded"
4527 );
4528 assert!(
4529 rec.promote_candidate.load(Ordering::SeqCst),
4530 "promote_candidate not forwarded"
4531 );
4532 assert_eq!(
4533 rec.migrate.lock().unwrap().as_deref(),
4534 Some("127.0.0.1:0"),
4535 "migrate not forwarded"
4536 );
4537 assert_eq!(
4538 rec.migrate_server.lock().unwrap().as_deref(),
4539 Some("127.0.0.1:0"),
4540 "migrate_server not forwarded"
4541 );
4542 }
4543
4544 /// EPS-02 (symmetric CID rotation) — when the **server** (the demuxing side)
4545 /// detects a client migration (a new authenticated `path_id`, post-AEAD), it
4546 /// must rotate its OWN outbound (server→client) CID so that direction also
4547 /// gets a fresh `ConnId` across the move. Otherwise an on-path observer seeing
4548 /// both networks links the session by the stable s2c CID (the ε §12.5 residual
4549 /// this fix closes). The socket-routed client accepts any inbound CID, so no
4550 /// client-side window slide is needed and there is no ping-pong (we never bump
4551 /// the server's own send path_id here).
4552 #[tokio::test]
4553 async fn eps02_server_rotates_s2c_cid_on_client_migration() {
4554 let session_id = fixed_session_id();
4555 let (client_session, server_session) = paired_sessions(session_id);
4556 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4557
4558 assert!(server_session.is_server(), "server side");
4559 let s2c_cid_before = server_session.current_outbound_cid();
4560
4561 // The client migrates: it bumps its send path_id and sends app data on the
4562 // new path. Deliver that migration packet (path_id = 1) to the server.
4563 let stream_id: TransportStreamId = 1;
4564 let frame =
4565 build_app_frame_on_path(&client_session, session_id, stream_id, 0, 0, 1, b"migrated");
4566 let pkt = decode_recv_frame(&frame, session_id);
4567 run_recv(pkt, session_id, &server_session, &streams).await;
4568
4569 assert_ne!(
4570 server_session.current_outbound_cid(),
4571 s2c_cid_before,
4572 "the server must rotate its server->client CID when the client migrates (EPS-02)"
4573 );
4574 }
4575
4576 /// EPS-02 CLOSURE (D4, symmetric rotation) — when the CLIENT detects a server
4577 /// migration (a new authenticated server `path_id`, post-AEAD) it REFLECTS: it bumps
4578 /// its OWN send path_id AND rotates its outbound (c2s) CID. The path_id bump is what
4579 /// makes the server slide its c2s demux window so the rotated c2s CID stays routable
4580 /// (no stranding — the reason the old "client must not rotate" rule existed); the CID
4581 /// rotation closes the s2c/c2s linkability residual for SERVER-initiated migration.
4582 /// The server's own s2c re-rotation (the test above) is path_id-silent, so the client
4583 /// sees no new forward server path_id from it — no ping-pong, terminates in one round.
4584 #[tokio::test]
4585 async fn eps02_client_rotates_c2s_on_server_migration() {
4586 let session_id = fixed_session_id();
4587 let (client_session, server_session) = paired_sessions(session_id);
4588 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4589
4590 assert!(!client_session.is_server(), "client side");
4591 let c2s_cid_before = client_session.current_outbound_cid();
4592 let send_path_before = client_session.current_send_path_id();
4593
4594 // The server migrates: build a server→client app frame on a new path_id and
4595 // deliver it to the client's recv path.
4596 let stream_id: TransportStreamId = 1;
4597 let frame = build_app_frame_on_path(
4598 &server_session,
4599 session_id,
4600 stream_id,
4601 0,
4602 0,
4603 1,
4604 b"srv-moved",
4605 );
4606 let pkt = decode_recv_frame(&frame, session_id);
4607 run_recv(pkt, session_id, &client_session, &streams).await;
4608
4609 assert_ne!(
4610 client_session.current_outbound_cid(),
4611 c2s_cid_before,
4612 "the client must rotate its c2s CID on detecting a server migration (EPS-02 closure)"
4613 );
4614 assert_ne!(
4615 client_session.current_send_path_id(),
4616 send_path_before,
4617 "the client must bump its send path_id so the server slides its c2s window (no stranding)"
4618 );
4619 }
4620
4621 /// EPS-02 closure, multi-step case — the client's c2s rotation is driven by ITS OWN
4622 /// reflection count, NOT by the server's migration count `d`. When the client detects a
4623 /// *forward* server `path_id` of `d > 1` (it missed intermediate server migrations under
4624 /// loss), it reflects ONCE: `send_path_id` and `outbound_cid_index` each advance by 1 and
4625 /// stay **1:1**. That 1:1 is exactly the invariant the server's c2s window slide relies on
4626 /// — the server slides by the *client's* `path_id` delta (1) and routes the client's c2s
4627 /// CID at index 1. So a `d > 1` server migration does NOT desync the c2s direction (the
4628 /// `d` the client computes here is its *inbound* view of the server's s2c chain, which the
4629 /// socket-routed client does not even use). Guards against an over-eager "bump by d" fix.
4630 #[tokio::test]
4631 async fn eps02_client_reflects_once_for_a_multi_step_server_migration() {
4632 let session_id = fixed_session_id();
4633 let (client_session, server_session) = paired_sessions(session_id);
4634 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4635
4636 assert!(!client_session.is_server(), "client side");
4637
4638 // The server migrated TWICE but the client only sees the second (the first s2c on
4639 // path_id 1 was lost): deliver a server→client app frame on path_id = 2 (forward
4640 // distance d = 2 from the client's view).
4641 let stream_id: TransportStreamId = 1;
4642 let frame = build_app_frame_on_path(
4643 &server_session,
4644 session_id,
4645 stream_id,
4646 0,
4647 0,
4648 2,
4649 b"srv-moved-2x",
4650 );
4651 let pkt = decode_recv_frame(&frame, session_id);
4652 run_recv(pkt, session_id, &client_session, &streams).await;
4653
4654 assert_eq!(
4655 client_session.current_send_path_id(),
4656 1,
4657 "client reflects ONCE (not d = 2) — its c2s rotation is decoupled from the server's migration count"
4658 );
4659 assert_eq!(
4660 client_session.outbound_cid_index(),
4661 1,
4662 "outbound CID index stays 1:1 with send_path_id, so the server (which slides its c2s window by the CLIENT's path_id delta) routes the rotated c2s CID — no desync on a d>1 server migration"
4663 );
4664 }
4665
4666 #[test]
4667 fn migration_path_id_never_collides_with_the_handshake_path() {
4668 // The migration counter must never hand back path_id 0 — that id is
4669 // permanently the Validated handshake path on both peers, so reusing it would
4670 // make the server skip the challenge (path 0 is always Validated) and the
4671 // switch would never fire. Spanning > 2 u8 wraps proves the wrap (never 0;
4672 // it also skips the reserved 255 — see the dedicated collision test).
4673 let session_id = fixed_session_id();
4674 let (client_session, _server_session) = paired_sessions(session_id);
4675 for _ in 0..600 {
4676 assert_ne!(client_session.next_migration_path_id(), 0);
4677 }
4678 }
4679
4680 #[tokio::test]
4681 async fn send_app_data_stamps_the_current_send_path_id() {
4682 // P4.2b: `send_app_data` must stamp `header.path_id` from the session's
4683 // current send path_id (default 0). After a migration bump, every outbound
4684 // app-data packet — including ARQ retransmits, which also flow through
4685 // `send_app_data` — carries the new path_id, which is exactly what makes the
4686 // server detect the new path and issue a PATH_CHALLENGE (D5 / D6).
4687 let session_id = fixed_session_id();
4688 let (client_session, _server_session) = paired_sessions(session_id);
4689 let (client_t, server_t) = ChannelTransport::pair();
4690 let client_t = Arc::new(client_t);
4691
4692 // Default: app data is stamped on the implicit path 0.
4693 assert!(
4694 send_app_data(
4695 &client_t,
4696 &client_session,
4697 session_id,
4698 1,
4699 b"pre-migration",
4700 PacketFlags::RELIABLE,
4701 Some(0),
4702 )
4703 .await
4704 );
4705 let wire = server_t.recv_bytes().await.unwrap();
4706 let pkt = _server_session.parse_protected(&wire).unwrap();
4707 assert_eq!(
4708 pkt.header.path_id, 0,
4709 "default send path is the implicit path 0"
4710 );
4711
4712 // After a migration bump, the new path_id is stamped on subsequent app data.
4713 assert_eq!(client_session.next_migration_path_id(), 1);
4714 assert!(
4715 send_app_data(
4716 &client_t,
4717 &client_session,
4718 session_id,
4719 1,
4720 b"post-migration",
4721 PacketFlags::RELIABLE,
4722 Some(13),
4723 )
4724 .await
4725 );
4726 let wire2 = server_t.recv_bytes().await.unwrap();
4727 let pkt2 = _server_session.parse_protected(&wire2).unwrap();
4728 assert_eq!(
4729 pkt2.header.path_id, 1,
4730 "after migrate(), app data must carry the bumped send path_id"
4731 );
4732 }
4733
4734 #[tokio::test]
4735 async fn app_data_on_non_validated_path_is_delivered_recv_relax() {
4736 // PATH-001 split (D10, Phase 4). RECV is relaxed: AEAD-authenticated,
4737 // non-replayed app data is DELIVERED regardless of which path it arrived
4738 // on (the data already passed AEAD + the per-direction replay window, so
4739 // dropping it by source buys no security and would break a seamless
4740 // NAT-rebind). The path is still registered Unvalidated so it can be
4741 // challenged. The strict half (PATH-001a, the send-gate: app data only to
4742 // the peer / a Validated path) is exercised over a real UdpServerTransport
4743 // in udp_integration.
4744 use crate::transport::path::PathStateKind;
4745 let session_id = fixed_session_id();
4746 let (client_session, server_session) = paired_sessions(session_id);
4747 let stream_id: TransportStreamId = 1;
4748
4749 let frame = build_app_frame_on_path(
4750 &client_session,
4751 session_id,
4752 stream_id,
4753 0,
4754 0, // stream_offset 0 — first reliable frame on this stream
4755 7, // a path the receiver has never validated
4756 b"on-new-path",
4757 );
4758 let frame = decode_recv_frame(&frame, session_id);
4759
4760 let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
4761 let demux = Arc::new(demux);
4762 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4763 let (deliver_tx, mut deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
4764 let undelivered = AtomicU64::new(0);
4765 let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(4);
4766 let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
4767 tx: ack_a,
4768 rx: Mutex::new(ack_b),
4769 });
4770 let mut ack_buf = Vec::with_capacity(256);
4771 let obs = Observability::new(ObservabilityConfig::default());
4772
4773 handle_packet(
4774 frame,
4775 session_id,
4776 &server_session,
4777 &streams,
4778 &demux,
4779 &transport_send,
4780 &transport_send,
4781 &deliver_tx,
4782 &undelivered,
4783 &mut ack_buf,
4784 &obs,
4785 LegType::Tcp,
4786 )
4787 .await;
4788
4789 // Recv-relax (D10b): the authenticated frame IS delivered, even though
4790 // path 7 is not validated.
4791 let (sid, received) =
4792 tokio::time::timeout(std::time::Duration::from_secs(1), deliver_rx.recv())
4793 .await
4794 .expect("recv-relax must deliver promptly (no drop / hang)")
4795 .expect("delivery channel open");
4796 assert_eq!(sid, stream_id as u32);
4797 assert_eq!(&received[..], b"on-new-path");
4798 // The new path is registered Unvalidated for a later challenge. (The
4799 // ChannelTransport reports no migration candidate, so no challenge is
4800 // issued here — the server-challenge path is exercised in udp_integration.)
4801 assert_eq!(
4802 server_session.path_state(7),
4803 Some(PathStateKind::Unvalidated),
4804 "the new path id must be registered for a later challenge"
4805 );
4806 }
4807
4808 #[tokio::test]
4809 async fn server_challenges_a_migration_candidate() {
4810 // P4.1 end-to-end over a real UdpServerTransport: app data on a NEW path_id
4811 // from a NEW source makes the server issue a PATH_VALIDATION challenge TO
4812 // THAT SOURCE (not the established peer), under the 3× anti-amp cap, and the
4813 // new path goes Validating. No peer switch (that is P4.2).
4814 use crate::api::udp_transport::UdpServerTransport;
4815 use crate::transport::path::PathStateKind;
4816 use crate::transport::phantom_udp::datagram::{push_datagram, FragmentAssembler};
4817
4818 let session_id = fixed_session_id();
4819 let (client_session, server_session) = paired_sessions(session_id);
4820 let stream_id: TransportStreamId = 1;
4821
4822 let server_sock = Arc::new(tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap());
4823 let peer: std::net::SocketAddr = "127.0.0.1:9".parse().unwrap(); // established (old) peer
4824 let cand_sock = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
4825 let cand_addr = cand_sock.local_addr().unwrap();
4826
4827 // Build the server transport and set the candidate by feeding a frame from
4828 // the candidate source through the demux channel (as the demux would), with
4829 // enough received bytes that the 3× budget admits a challenge.
4830 let (tx, rx) = mpsc::channel(8);
4831 let ust = Arc::new(UdpServerTransport::new(
4832 server_sock.clone(),
4833 peer,
4834 [5u8; 8],
4835 tx.clone(),
4836 rx,
4837 ));
4838 tx.send((Bytes::from(vec![0u8; 256]), cand_addr))
4839 .await
4840 .unwrap();
4841 let _ = ust.recv_bytes().await.unwrap();
4842 // M-1: the candidate is committed only on the post-decrypt (authenticated) path, which
4843 // handle_packet drives in production; mirror that here for the manual setup.
4844 ust.confirm_authenticated_source();
4845 assert!(
4846 ust.has_migration_candidate(),
4847 "new source must set a candidate"
4848 );
4849
4850 // App data on a NEW (unvalidated) path id → the server must challenge it.
4851 let frame =
4852 build_app_frame_on_path(&client_session, session_id, stream_id, 0, 0, 1, b"migrated");
4853 let frame = decode_recv_frame(&frame, session_id);
4854
4855 let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
4856 let demux = Arc::new(demux);
4857 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4858 let (deliver_tx, _deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
4859 let undelivered = AtomicU64::new(0);
4860 let mut ack_buf = Vec::with_capacity(256);
4861 let obs = Observability::new(ObservabilityConfig::default());
4862
4863 handle_packet(
4864 frame,
4865 session_id,
4866 &server_session,
4867 &streams,
4868 &demux,
4869 &ust,
4870 &ust,
4871 &deliver_tx,
4872 &undelivered,
4873 &mut ack_buf,
4874 &obs,
4875 LegType::Udp,
4876 )
4877 .await;
4878
4879 // The server issued a challenge → path 1 is now Validating.
4880 assert_eq!(
4881 server_session.path_state(1),
4882 Some(PathStateKind::Validating),
4883 "an unvalidated path on a candidate source must be challenged"
4884 );
4885 // ...and the challenge datagram reached the CANDIDATE socket (not the peer).
4886 let mut buf = vec![0u8; 2048];
4887 let (n, _from) = tokio::time::timeout(
4888 std::time::Duration::from_secs(1),
4889 cand_sock.recv_from(&mut buf),
4890 )
4891 .await
4892 .expect("challenge must reach the candidate")
4893 .unwrap();
4894 let mut asm = FragmentAssembler::new();
4895 let (_hdr, inner) = push_datagram(&mut asm, &buf[..n]).expect("decode envelope");
4896 let inner = inner.expect("single-datagram challenge");
4897 // The server emitted this challenge (protect_packet under its send HP
4898 // key); unmask it from the client side (== the server's send key).
4899 let pkt = client_session
4900 .parse_protected(&inner)
4901 .expect("inner packet");
4902 assert!(
4903 pkt.header.flags.contains(PacketFlags::PATH_VALIDATION),
4904 "the candidate must receive a PATH_VALIDATION challenge"
4905 );
4906 assert_eq!(pkt.header.path_id, 1, "challenge must be on the new path");
4907 }
4908
4909 #[tokio::test]
4910 async fn server_challenges_a_passive_rebind_on_path_zero() {
4911 // M-3: a *passive* NAT rebind keeps `path_id = 0` (the client never called
4912 // `migrate()`, so it never bumped its send path_id). Path 0 is permanently
4913 // `Validated`, so the path-id-gated challenge block is skipped — pre-fix the
4914 // server NEVER challenged the new source, never promoted it, and kept sending
4915 // downstream to the OLD (now-dead) address → stall. The fix makes detection
4916 // address-driven: when an authenticated frame arrives on a Validated path AND
4917 // the transport flags a migration candidate (a new authenticated source), the
4918 // server issues a PATH_CHALLENGE to that candidate on a RESERVED validation
4919 // path-id (`REBIND_VALIDATION_PATH_ID`), under the 3× anti-amp cap. Anti-spoof
4920 // still holds: the candidate is only ever the AEAD-authenticated source, and
4921 // the challenge only goes there.
4922 use crate::api::udp_transport::UdpServerTransport;
4923 use crate::transport::path::PathStateKind;
4924 use crate::transport::phantom_udp::datagram::{push_datagram, FragmentAssembler};
4925 use crate::transport::session::REBIND_VALIDATION_PATH_ID;
4926
4927 let session_id = fixed_session_id();
4928 let (client_session, server_session) = paired_sessions(session_id);
4929 let stream_id: TransportStreamId = 1;
4930
4931 let server_sock = Arc::new(tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap());
4932 let peer: std::net::SocketAddr = "127.0.0.1:9".parse().unwrap(); // established (old) peer
4933 let cand_sock = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
4934 let cand_addr = cand_sock.local_addr().unwrap();
4935
4936 let (tx, rx) = mpsc::channel(8);
4937 let ust = Arc::new(UdpServerTransport::new(
4938 server_sock.clone(),
4939 peer,
4940 [5u8; 8],
4941 tx.clone(),
4942 rx,
4943 ));
4944 // A frame from the rebind source seeds the candidate + its 3× budget.
4945 tx.send((Bytes::from(vec![0u8; 256]), cand_addr))
4946 .await
4947 .unwrap();
4948 let _ = ust.recv_bytes().await.unwrap();
4949 ust.confirm_authenticated_source();
4950 assert!(
4951 ust.has_migration_candidate(),
4952 "new source must set a candidate"
4953 );
4954
4955 // The reserved validation path is untouched at the start.
4956 assert_eq!(
4957 server_session.path_state(REBIND_VALIDATION_PATH_ID),
4958 None,
4959 "the rebind validation path must not exist before the rebind is observed"
4960 );
4961
4962 // App data on the ESTABLISHED, always-Validated path 0 (passive rebind:
4963 // path_id unchanged) from the candidate source → the server must STILL
4964 // challenge the candidate on the reserved validation path-id.
4965 let frame =
4966 build_app_frame_on_path(&client_session, session_id, stream_id, 0, 0, 0, b"rebound");
4967 let frame = decode_recv_frame(&frame, session_id);
4968
4969 let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
4970 let demux = Arc::new(demux);
4971 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
4972 let (deliver_tx, _deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
4973 let undelivered = AtomicU64::new(0);
4974 let mut ack_buf = Vec::with_capacity(256);
4975 let obs = Observability::new(ObservabilityConfig::default());
4976
4977 handle_packet(
4978 frame,
4979 session_id,
4980 &server_session,
4981 &streams,
4982 &demux,
4983 &ust,
4984 &ust,
4985 &deliver_tx,
4986 &undelivered,
4987 &mut ack_buf,
4988 &obs,
4989 LegType::Udp,
4990 )
4991 .await;
4992
4993 // The server issued a challenge on the RESERVED rebind path → it is Validating.
4994 assert_eq!(
4995 server_session.path_state(REBIND_VALIDATION_PATH_ID),
4996 Some(PathStateKind::Validating),
4997 "a path-0 rebind on a candidate source must be challenged on the reserved id"
4998 );
4999 // ...and the challenge datagram reached the CANDIDATE socket (not the peer).
5000 let mut buf = vec![0u8; 2048];
5001 let (n, _from) = tokio::time::timeout(
5002 std::time::Duration::from_secs(1),
5003 cand_sock.recv_from(&mut buf),
5004 )
5005 .await
5006 .expect("rebind challenge must reach the candidate")
5007 .unwrap();
5008 let mut asm = FragmentAssembler::new();
5009 let (_hdr, inner) = push_datagram(&mut asm, &buf[..n]).expect("decode envelope");
5010 let inner = inner.expect("single-datagram challenge");
5011 let pkt = client_session
5012 .parse_protected(&inner)
5013 .expect("inner packet");
5014 assert!(
5015 pkt.header.flags.contains(PacketFlags::PATH_VALIDATION),
5016 "the candidate must receive a PATH_VALIDATION challenge"
5017 );
5018 assert_eq!(
5019 pkt.header.path_id, REBIND_VALIDATION_PATH_ID,
5020 "the passive-rebind challenge must be stamped on the reserved validation path-id"
5021 );
5022 }
5023
5024 #[test]
5025 fn migration_path_id_never_collides_with_the_rebind_validation_path() {
5026 // M-3: the client's active-migration counter must never hand back the
5027 // reserved rebind validation id — otherwise an active migration and a
5028 // concurrent passive-rebind challenge would share a registry slot and a
5029 // late echo to one could resolve the other. The counter wraps 254 → 1,
5030 // skipping both 0 (the handshake path) and 255 (the reserved id).
5031 use crate::transport::session::REBIND_VALIDATION_PATH_ID;
5032 let session_id = fixed_session_id();
5033 let (client_session, _server_session) = paired_sessions(session_id);
5034 for _ in 0..600 {
5035 let id = client_session.next_migration_path_id();
5036 assert_ne!(id, 0, "must never reuse the handshake path");
5037 assert_ne!(
5038 id, REBIND_VALIDATION_PATH_ID,
5039 "must never reuse the reserved rebind validation path"
5040 );
5041 }
5042 }
5043
5044 /// Build an `ENCRYPTED | ACK` frame (H1, L1-A) from `acker_session`
5045 /// acknowledging `acked_seq` on `stream_id`, with its own header sequence
5046 /// `ack_header_seq` (drawn from the acker's send space, distinct from the
5047 /// acked data sequence). The AEAD plaintext is a single-sequence `Sack`
5048 /// (the SACK superset of the legacy single-seq ACK). Wire-serialised, ready
5049 /// for `handle_packet`.
5050 fn build_encrypted_ack(
5051 acker_session: &InnerSession,
5052 session_id: SessionId,
5053 stream_id: TransportStreamId,
5054 ack_header_seq: u32,
5055 acked_seq: u32,
5056 ) -> Vec<u8> {
5057 let sack = crate::transport::sack::Sack::from_received(&[acked_seq], 0)
5058 .expect("single-seq sack")
5059 .to_wire();
5060 build_encrypted_ack_with_payload(
5061 acker_session,
5062 session_id,
5063 stream_id,
5064 ack_header_seq,
5065 &sack,
5066 )
5067 }
5068
5069 /// Like [`build_encrypted_ack`] but with an arbitrary AEAD plaintext payload
5070 /// (used to exercise malformed-SACK handling on the sender path).
5071 fn build_encrypted_ack_with_payload(
5072 acker_session: &InnerSession,
5073 session_id: SessionId,
5074 stream_id: TransportStreamId,
5075 ack_header_seq: u32,
5076 payload: &[u8],
5077 ) -> Vec<u8> {
5078 let flag_bits = PacketFlags::ENCRYPTED | PacketFlags::ACK;
5079 let header = PacketHeader::new(
5080 session_id,
5081 stream_id,
5082 ack_header_seq as u64,
5083 PacketFlags::new(flag_bits),
5084 )
5085 .with_epoch(acker_session.current_epoch());
5086 let ct = acker_session
5087 .encrypt_packet(&header, payload, &[])
5088 .expect("encrypt ack");
5089 PhantomPacket::new(header, ct).to_wire()
5090 }
5091
5092 /// Drive a single inbound packet through `handle_packet` against
5093 /// `server_session` with throwaway delivery/transport/observability wiring.
5094 async fn run_recv(
5095 pkt: PhantomPacket,
5096 session_id: SessionId,
5097 server_session: &Arc<InnerSession>,
5098 streams: &Arc<DashMap<u32, Arc<TransportStream>>>,
5099 ) {
5100 let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
5101 let demux = Arc::new(demux);
5102 let (deliver_tx, _deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
5103 let undelivered = AtomicU64::new(0);
5104 let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(4);
5105 let transport: Arc<ChannelTransport> = Arc::new(ChannelTransport {
5106 tx: ack_a,
5107 rx: Mutex::new(ack_b),
5108 });
5109 let mut ack_buf = Vec::with_capacity(64);
5110 let obs = Observability::new(ObservabilityConfig::default());
5111 handle_packet(
5112 pkt,
5113 session_id,
5114 server_session,
5115 streams,
5116 &demux,
5117 &transport,
5118 &transport,
5119 &deliver_tx,
5120 &undelivered,
5121 &mut ack_buf,
5122 &obs,
5123 LegType::Tcp,
5124 )
5125 .await;
5126 }
5127
5128 /// Stage a stream with one in-flight reliable segment; returns the stream,
5129 /// the shared streams map, and the segment's sequence number.
5130 async fn staged_pending_segment() -> (
5131 Arc<TransportStream>,
5132 Arc<DashMap<u32, Arc<TransportStream>>>,
5133 u32,
5134 ) {
5135 let stream_id: TransportStreamId = 1;
5136 let stream = Arc::new(TransportStream::new(stream_id));
5137 let seq = stream
5138 .send_reliable(Bytes::from_static(b"reliable-payload"))
5139 .await
5140 .unwrap();
5141 let _ = stream.poll_send(u64::MAX).await.expect("segment in-flight");
5142 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5143 streams.insert(stream_id as u32, stream.clone());
5144 (stream, streams, seq)
5145 }
5146
5147 /// **H1 (Invariant 2).** A forged *unauthenticated* ACK — whether bare
5148 /// (`ACK` flag, empty payload) or carrying a plaintext 4-byte acked-seq —
5149 /// must NOT retire a pending reliable segment. Pre-fix, the ACK branch ran
5150 /// before the AEAD gate and trusted `header.sequence`, so an off-path
5151 /// attacker could silently drop never-acknowledged segments.
5152 #[tokio::test]
5153 async fn forged_plaintext_ack_does_not_retire_pending_segment() {
5154 let session_id = fixed_session_id();
5155 let (_client, server_session) = paired_sessions(session_id);
5156 let (stream, streams, seq) = staged_pending_segment().await;
5157 let stream_id: TransportStreamId = 1;
5158
5159 // Variant 1: bare ACK, no ENCRYPTED, empty payload, guessed sequence.
5160 run_recv(
5161 PhantomPacket::new(
5162 PacketHeader::new(
5163 session_id,
5164 stream_id,
5165 seq as u64,
5166 PacketFlags::new(PacketFlags::ACK),
5167 ),
5168 Vec::new(),
5169 ),
5170 session_id,
5171 &server_session,
5172 &streams,
5173 )
5174 .await;
5175 // Variant 2: ACK with a plaintext 4-byte acked-seq, no ENCRYPTED.
5176 run_recv(
5177 PhantomPacket::new(
5178 PacketHeader::new(
5179 session_id,
5180 stream_id,
5181 999,
5182 PacketFlags::new(PacketFlags::ACK),
5183 ),
5184 seq.to_be_bytes().to_vec(),
5185 ),
5186 session_id,
5187 &server_session,
5188 &streams,
5189 )
5190 .await;
5191
5192 assert!(
5193 stream.ack(seq).await.is_some(),
5194 "a forged unauthenticated ACK must not retire the pending reliable segment"
5195 );
5196 }
5197
5198 /// **H1 + L1-B.** A forged *unauthenticated* SACK (plaintext `ACK`, no
5199 /// `ENCRYPTED`) carrying a wide range must neither retire a pending segment NOR
5200 /// trigger a fast-retransmit: it is dropped by the downgrade defense before the
5201 /// AEAD gate, so it never reaches the SACK loss detector (which would otherwise
5202 /// flag segments lost and drive Pass-0). The SACK plaintext is acted on only
5203 /// after AEAD verify.
5204 #[tokio::test]
5205 async fn forged_sack_neither_retires_nor_fast_retransmits() {
5206 let session_id = fixed_session_id();
5207 let (_client, server_session) = paired_sessions(session_id);
5208 let stream_id: TransportStreamId = 1;
5209
5210 // Stage offsets 0..=5, all in flight.
5211 let stream = Arc::new(TransportStream::new(stream_id));
5212 for _ in 0..6u32 {
5213 stream
5214 .send_reliable(Bytes::from_static(b"x"))
5215 .await
5216 .unwrap();
5217 let _ = stream.poll_send(u64::MAX).await.expect("in-flight");
5218 }
5219 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5220 streams.insert(stream_id as u32, stream.clone());
5221 assert_eq!(stream.pending_send_count().await, 6);
5222
5223 // Forged PLAINTEXT ACK (no ENCRYPTED) carrying a SACK over offset {5}.
5224 // If acted on, it would retire offset 5 AND flag offsets 0,1,2 lost.
5225 let forged_sack = crate::transport::sack::Sack::from_received(&[5], 0)
5226 .expect("sack")
5227 .to_wire();
5228 run_recv(
5229 PhantomPacket::new(
5230 PacketHeader::new(
5231 session_id,
5232 stream_id,
5233 4242,
5234 PacketFlags::new(PacketFlags::ACK),
5235 ),
5236 forged_sack, // plaintext — NOT encrypted
5237 ),
5238 session_id,
5239 &server_session,
5240 &streams,
5241 )
5242 .await;
5243
5244 // Nothing retired: all six segments remain buffered.
5245 assert_eq!(
5246 stream.pending_send_count().await,
5247 6,
5248 "a forged unauthenticated SACK must not retire any segment (H1)"
5249 );
5250 // No fast-retransmit: nothing was flagged lost, so poll_send (all sent, no
5251 // new data) returns None rather than a Pass-0 retransmit.
5252 assert!(
5253 stream.poll_send(u64::MAX).await.is_none(),
5254 "a forged SACK must not trigger a fast-retransmit (no segment flagged lost)"
5255 );
5256 }
5257
5258 /// **#7 (congestion 4.4 fix).** A SACK that declares segments lost must NOT itself feed
5259 /// BBR's loss signal — loss is fed exactly once per loss event, at the *retransmission*
5260 /// point (`drain_streams`'s `if seg.retransmit`), which covers both SACK-gap and RTO
5261 /// retransmits. Feeding it again here, at SACK-gap detection, double-decrements the
5262 /// purely-incremental `inflight_bytes`: a lost segment fed at both detection and
5263 /// retransmission nets a permanent inflight under-count, inflating the cwnd budget
5264 /// (`cwnd − inflight`) → over-send, accumulating with every SACK-gap loss. Here a sender
5265 /// has six in-flight segments; an authenticated SACK acking offset {5} retires segment 5
5266 /// and flags 0,1,2 lost. Afterward `inflight_bytes` must drop by ONLY the retired
5267 /// segment — never by the three flagged-lost ones (which the bug would subtract here).
5268 #[tokio::test]
5269 async fn loss_declaring_sack_does_not_feed_bbr_loss_at_detection() {
5270 tokio::time::pause();
5271 let session_id = fixed_session_id();
5272 let (client_session, server_session) = paired_sessions(session_id);
5273 let stream_id: TransportStreamId = 1;
5274
5275 // Stage six in-flight reliable segments on the sender, mirroring the pump's
5276 // inflight accounting (`on_packet_sent` per sent segment).
5277 let stream = Arc::new(TransportStream::new(stream_id));
5278 let mut seg_size = 0u64;
5279 for _ in 0..6u32 {
5280 stream
5281 .send_reliable(Bytes::from_static(b"x"))
5282 .await
5283 .unwrap();
5284 let seg = stream.poll_send(u64::MAX).await.expect("in-flight");
5285 seg_size = seg.data.len() as u64;
5286 server_session.on_packet_sent(seg_size);
5287 }
5288 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5289 streams.insert(stream_id as u32, stream.clone());
5290 let inflight_before = server_session.bandwidth_snapshot().inflight_bytes;
5291 assert_eq!(inflight_before, 6 * seg_size, "six segments in flight");
5292
5293 // Authenticated SACK acking offset {5}: retires segment 5, flags 0,1,2 lost.
5294 let ack = build_encrypted_ack(&client_session, session_id, stream_id, 4242, 5);
5295 let pkt = decode_recv_frame(&ack, session_id);
5296 run_recv(pkt, session_id, &server_session, &streams).await;
5297
5298 let inflight_after = server_session.bandwidth_snapshot().inflight_bytes;
5299 assert_eq!(
5300 inflight_after,
5301 inflight_before - seg_size,
5302 "inflight must drop by ONLY the retired (acked) segment; double-feeding loss at \
5303 SACK-gap detection would over-decrement it by the three flagged-lost segments (#7)"
5304 );
5305 }
5306
5307 /// **H1 positive control.** A genuine `ENCRYPTED | ACK` frame from the peer,
5308 /// whose AEAD payload carries the acked data sequence, retires the matching
5309 /// pending segment after AEAD verify. The ACK's own `header.sequence`
5310 /// (`ack_header_seq`) is deliberately different from the acked sequence to
5311 /// prove the handler reads the authenticated payload, not the header.
5312 #[tokio::test]
5313 async fn authenticated_ack_retires_pending_segment() {
5314 let session_id = fixed_session_id();
5315 let (client_session, server_session) = paired_sessions(session_id);
5316 let (stream, streams, seq) = staged_pending_segment().await;
5317 let stream_id: TransportStreamId = 1;
5318
5319 let ack_header_seq = seq.wrapping_add(54_321);
5320 let frame =
5321 build_encrypted_ack(&client_session, session_id, stream_id, ack_header_seq, seq);
5322 let ack_pkt = decode_recv_frame(&frame, session_id);
5323 run_recv(ack_pkt, session_id, &server_session, &streams).await;
5324
5325 assert!(
5326 stream.ack(seq).await.is_none(),
5327 "an authenticated ACK must retire the acked pending segment"
5328 );
5329 }
5330
5331 /// **L1-A SACK end-to-end (gap retire).** Stage segments 0..=5 on the sender,
5332 /// deliver one authenticated `ENCRYPTED | ACK` carrying a SACK over the
5333 /// received set {0,1,2,4,5} (gap at 3), and assert the sender retires exactly
5334 /// those five segments from its send buffer — keeping only the gap segment 3.
5335 /// This proves the SACK retires MULTIPLE segments in one ACK (vs. the legacy
5336 /// single-seq ACK).
5337 #[tokio::test]
5338 async fn authenticated_sack_retires_all_covered_segments_skipping_gap() {
5339 let session_id = fixed_session_id();
5340 let (client_session, server_session) = paired_sessions(session_id);
5341 let stream_id: TransportStreamId = 1;
5342
5343 // Sender stages segments 0..=5, all in-flight.
5344 let stream = Arc::new(TransportStream::new(stream_id));
5345 for i in 0..6u32 {
5346 let seq = stream
5347 .send_reliable(Bytes::from(format!("seg-{i}")))
5348 .await
5349 .unwrap();
5350 assert_eq!(seq, i);
5351 let _ = stream.poll_send(u64::MAX).await.expect("in-flight");
5352 }
5353 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5354 streams.insert(stream_id as u32, stream.clone());
5355 assert_eq!(stream.pending_send_count().await, 6);
5356
5357 // The receiver (client_session) emits a SACK over {0,1,2,4,5}.
5358 let sack = crate::transport::sack::Sack::from_received(&[0, 1, 2, 4, 5], 777)
5359 .expect("sack")
5360 .to_wire();
5361 let frame = build_encrypted_ack_with_payload(
5362 &client_session,
5363 session_id,
5364 stream_id,
5365 9_999, // ACK header seq distinct from the acked data seqs
5366 &sack,
5367 );
5368 let ack_pkt = decode_recv_frame(&frame, session_id);
5369 run_recv(ack_pkt, session_id, &server_session, &streams).await;
5370
5371 // Exactly the gap segment (3) remains.
5372 assert_eq!(
5373 stream.pending_send_count().await,
5374 1,
5375 "SACK must retire all five covered segments at once"
5376 );
5377 for retired in [0u32, 1, 2, 4, 5] {
5378 assert!(
5379 stream.ack(retired).await.is_none(),
5380 "seq {retired} should have been retired by the SACK"
5381 );
5382 }
5383 assert!(
5384 stream.ack(3).await.is_some(),
5385 "the gap segment 3 must remain buffered"
5386 );
5387 }
5388
5389 /// **L1-A malformed-SACK robustness.** An authenticated (post-AEAD) but
5390 /// structurally malformed SACK payload — here a truncated 5-byte blob — must
5391 /// be dropped on the sender path WITHOUT panic and retire NOTHING. Post-AEAD
5392 /// the frame is authenticated, but a buggy peer must not crash us.
5393 #[tokio::test]
5394 async fn malformed_sack_is_dropped_and_retires_nothing() {
5395 let session_id = fixed_session_id();
5396 let (client_session, server_session) = paired_sessions(session_id);
5397 let (stream, streams, seq) = staged_pending_segment().await;
5398 let stream_id: TransportStreamId = 1;
5399
5400 // 5 bytes < MIN_WIRE_LEN (14) → Sack::from_wire returns Truncated.
5401 let bad_payload = vec![0u8; 5];
5402 let frame = build_encrypted_ack_with_payload(
5403 &client_session,
5404 session_id,
5405 stream_id,
5406 1234,
5407 &bad_payload,
5408 );
5409 let ack_pkt = decode_recv_frame(&frame, session_id);
5410 // Must not panic.
5411 run_recv(ack_pkt, session_id, &server_session, &streams).await;
5412
5413 assert!(
5414 stream.ack(seq).await.is_some(),
5415 "a malformed SACK must retire nothing — the pending segment stays buffered"
5416 );
5417 }
5418
5419 /// **L1-A ack_delay plumbing.** A reliable data packet driven through the
5420 /// receiver's `handle_packet` produces an `ENCRYPTED | ACK` frame on the wire
5421 /// whose decoded SACK has a populated (non-zero) `ack_delay_us` — proving the
5422 /// field, previously always 0, is now plumbed end-to-end.
5423 #[tokio::test]
5424 async fn receiver_emits_sack_with_populated_ack_delay() {
5425 let session_id = fixed_session_id();
5426 // Two paired sessions sharing keys so the receiver's ACK decrypts under
5427 // the sender's session.
5428 let (sender_session, receiver_session) = paired_sessions(session_id);
5429 let stream_id: TransportStreamId = 1;
5430
5431 // Build a reliable data packet from the sender at sequence 7 (stream_offset
5432 // == sequence == 7 via build_app_frame, so the SACK's largest_acked is 7).
5433 let data_seq = 7u32;
5434 let data_pkt = decode_recv_frame(
5435 &build_app_frame(
5436 &sender_session,
5437 session_id,
5438 stream_id,
5439 data_seq,
5440 b"hello-reliable",
5441 ),
5442 session_id,
5443 );
5444
5445 // Wiring with a capturable ACK transport.
5446 let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
5447 let demux = Arc::new(demux);
5448 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5449 let (deliver_tx, _deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
5450 let undelivered = AtomicU64::new(0);
5451 let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(4);
5452 let transport: Arc<ChannelTransport> = Arc::new(ChannelTransport {
5453 tx: ack_a,
5454 rx: Mutex::new(ack_b),
5455 });
5456 let mut ack_buf = Vec::with_capacity(64);
5457 let obs = Observability::new(ObservabilityConfig::default());
5458 handle_packet(
5459 data_pkt,
5460 session_id,
5461 &receiver_session,
5462 &streams,
5463 &demux,
5464 &transport,
5465 &transport,
5466 &deliver_tx,
5467 &undelivered,
5468 &mut ack_buf,
5469 &obs,
5470 LegType::Tcp,
5471 )
5472 .await;
5473
5474 // Pull the emitted ACK frame off the transport and decode the SACK.
5475 let ack_frame = transport
5476 .rx
5477 .lock()
5478 .await
5479 .recv()
5480 .await
5481 .expect("an ACK frame must have been emitted");
5482 // The receiver pump emitted this ACK with header protection; unmask from
5483 // the sender side (== the receiver's send HP key).
5484 let ack_pkt = sender_session
5485 .parse_protected(&ack_frame)
5486 .expect("parse emitted ack");
5487 assert!(ack_pkt.header.flags.contains(PacketFlags::ACK));
5488 // Decrypt under the sender's session (shared keys) to read the SACK.
5489 let plain = sender_session
5490 .decrypt_packet(&ack_pkt.header, &ack_pkt.payload, &[])
5491 .expect("decrypt emitted ack");
5492 let sack = crate::transport::sack::Sack::from_wire(&plain).expect("decode emitted sack");
5493 assert_eq!(sack.largest_acked, data_seq, "SACK must ack the data seq");
5494 assert!(sack.acks(data_seq));
5495 // The field is plumbed: ack_delay_us is the coarse recv-to-emit hold.
5496 // It is derived from `now − recv_at` and is therefore populated (the
5497 // assertion is on the field being threaded through, not a tight bound).
5498 let _ = sack.ack_delay_us;
5499 }
5500
5501 /// **H1 session binding.** A frame whose `header.session_id` does not match
5502 /// the negotiated session must be dropped by the per-frame guard before any
5503 /// state mutation — pre-fix the ACK was processed with no session check.
5504 #[tokio::test]
5505 async fn ack_with_wrong_session_id_is_dropped() {
5506 let session_id = fixed_session_id();
5507 let (_client, server_session) = paired_sessions(session_id);
5508 let (stream, streams, seq) = staged_pending_segment().await;
5509 let stream_id: TransportStreamId = 1;
5510
5511 let wrong_id = SessionId::from_bytes([0x11; 32]);
5512 run_recv(
5513 PhantomPacket::new(
5514 PacketHeader::new(
5515 wrong_id,
5516 stream_id,
5517 seq as u64,
5518 PacketFlags::new(PacketFlags::ACK),
5519 ),
5520 Vec::new(),
5521 ),
5522 session_id,
5523 &server_session,
5524 &streams,
5525 )
5526 .await;
5527
5528 assert!(
5529 stream.ack(seq).await.is_some(),
5530 "an ACK for a different session id must not retire the segment"
5531 );
5532 }
5533
5534 #[tokio::test]
5535 async fn v2_recv_drops_unencrypted_non_empty_post_handshake_payload() {
5536 // Downgrade defense: a V2 application-data packet WITHOUT the
5537 // ENCRYPTED flag but with a non-empty plaintext-looking payload
5538 // must be dropped, mirroring the V1 invariant.
5539 let session_id = fixed_session_id();
5540 let (_, server_session) = paired_sessions(session_id);
5541
5542 let stream_id: TransportStreamId = 2;
5543 let bad_header = PacketHeader::new(
5544 session_id,
5545 stream_id,
5546 0,
5547 PacketFlags::new(PacketFlags::RELIABLE), // no ENCRYPTED
5548 );
5549 let bad_packet = PhantomPacket::new(bad_header, b"leaked-cleartext".to_vec());
5550
5551 let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
5552 let demux = Arc::new(demux);
5553 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5554 let (deliver_tx, mut deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
5555 let undelivered = AtomicU64::new(0);
5556 let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(4);
5557 let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
5558 tx: ack_a,
5559 rx: Mutex::new(ack_b),
5560 });
5561
5562 let mut ack_buf = Vec::with_capacity(256);
5563 let obs = Observability::new(ObservabilityConfig::default());
5564 handle_packet(
5565 bad_packet,
5566 session_id,
5567 &server_session,
5568 &streams,
5569 &demux,
5570 &transport_send,
5571 &transport_send,
5572 &deliver_tx,
5573 &undelivered,
5574 &mut ack_buf,
5575 &obs,
5576 LegType::Tcp,
5577 )
5578 .await;
5579
5580 // Nothing should have been handed to the delivery task, and the backlog
5581 // counter must stay at zero (the packet was dropped before hand-off).
5582 assert!(
5583 deliver_rx.try_recv().is_err(),
5584 "unencrypted post-handshake payload must NOT be handed off for delivery"
5585 );
5586 assert_eq!(undelivered.load(Ordering::Acquire), 0);
5587 }
5588
5589 #[tokio::test]
5590 async fn v2_recv_handles_coalesced_bundle_and_routes_each_subpayload() {
5591 use crate::transport::packet_coalescer::{CoalescerConfig, PacketCoalescer};
5592
5593 let session_id = fixed_session_id();
5594 let (client_session, server_session) = paired_sessions(session_id);
5595
5596 // Build a COALESCED bundle of three sub-payloads.
5597 let mut coalescer = PacketCoalescer::new(CoalescerConfig::default());
5598 coalescer.push(b"alpha");
5599 coalescer.push(b"bravo");
5600 coalescer.push(b"charlie");
5601 let bundle = coalescer.flush().expect("bundle");
5602
5603 // Encrypt the bundle and wrap it in a V2 packet with
5604 // ENCRYPTED + COALESCED flags.
5605 let stream_id: TransportStreamId = 3;
5606 let flag_bits = PacketFlags::ENCRYPTED | PacketFlags::COALESCED;
5607 let header = PacketHeader::new(session_id, stream_id, 0, PacketFlags::new(flag_bits))
5608 .with_epoch(client_session.current_epoch());
5609 let ciphertext = client_session
5610 .encrypt_packet(&header, &bundle, &[])
5611 .expect("encrypt bundle");
5612 let v2 = PhantomPacket::new(header, ciphertext);
5613
5614 let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
5615 let demux = Arc::new(demux);
5616 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5617 let (deliver_tx, mut deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
5618 let undelivered = AtomicU64::new(0);
5619 let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(4);
5620 let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
5621 tx: ack_a,
5622 rx: Mutex::new(ack_b),
5623 });
5624
5625 let mut ack_buf = Vec::with_capacity(256);
5626 let obs = Observability::new(ObservabilityConfig::default());
5627 handle_packet(
5628 v2,
5629 session_id,
5630 &server_session,
5631 &streams,
5632 &demux,
5633 &transport_send,
5634 &transport_send,
5635 &deliver_tx,
5636 &undelivered,
5637 &mut ack_buf,
5638 &obs,
5639 LegType::Tcp,
5640 )
5641 .await;
5642
5643 // Each sub-payload is handed off IN ORDER through the single FIFO
5644 // delivery channel, every one tagged with the outer stream id, and the
5645 // total counted toward the undelivered backlog.
5646 let (sa, a) = deliver_rx.recv().await.expect("alpha");
5647 let (sb, b) = deliver_rx.recv().await.expect("bravo");
5648 let (sc, c) = deliver_rx.recv().await.expect("charlie");
5649 assert_eq!(
5650 (sa, sb, sc),
5651 (stream_id as u32, stream_id as u32, stream_id as u32)
5652 );
5653 assert_eq!(&a[..], b"alpha");
5654 assert_eq!(&b[..], b"bravo");
5655 assert_eq!(&c[..], b"charlie");
5656 assert_eq!(undelivered.load(Ordering::Acquire), (5 + 5 + 7) as u64);
5657 }
5658
5659 /// Ordering across two COALESCED bundles: the single FIFO delivery channel
5660 /// must hand the first bundle's `[A, B, C]` and the second bundle's `[D]` to
5661 /// the consumer in exactly `A, B, C, D` — decoupling delivery from the reader
5662 /// must not reorder application bytes. (COALESCED is delivered immediately in
5663 /// arrival order — it is not reassembled by stream offset and is not mixed with
5664 /// RELIABLE frames on a stream by the live sender.)
5665 #[tokio::test]
5666 async fn delivery_preserves_order_across_coalesced_then_normal_frame() {
5667 use crate::transport::packet_coalescer::{CoalescerConfig, PacketCoalescer};
5668
5669 let session_id = fixed_session_id();
5670 let (client_session, server_session) = paired_sessions(session_id);
5671 let stream_id: TransportStreamId = 1;
5672
5673 let build_bundle = |seq: u32, items: &[&[u8]]| -> PhantomPacket {
5674 let mut coalescer = PacketCoalescer::new(CoalescerConfig::default());
5675 for it in items {
5676 coalescer.push(it);
5677 }
5678 let bundle = coalescer.flush().expect("bundle");
5679 let flag_bits = PacketFlags::ENCRYPTED | PacketFlags::COALESCED;
5680 let h = PacketHeader::new(
5681 session_id,
5682 stream_id,
5683 seq as u64,
5684 PacketFlags::new(flag_bits),
5685 )
5686 .with_epoch(client_session.current_epoch());
5687 let ct = client_session
5688 .encrypt_packet(&h, &bundle, &[])
5689 .expect("encrypt bundle");
5690 PhantomPacket::new(h, ct)
5691 };
5692
5693 // Frame 1: COALESCED [A, B, C] at sequence 0; Frame 2: COALESCED [D] at seq 1.
5694 let coalesced = build_bundle(0, &[b"A", b"B", b"C"]);
5695 let normal = build_bundle(1, &[b"D"]);
5696
5697 let (demux, _ctrl) = StreamDemultiplexer::new(16);
5698 let demux = Arc::new(demux);
5699 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5700 let (deliver_tx, mut deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
5701 let undelivered = AtomicU64::new(0);
5702 let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(8);
5703 let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
5704 tx: ack_a,
5705 rx: Mutex::new(ack_b),
5706 });
5707 let mut ack_buf = Vec::with_capacity(256);
5708 let obs = Observability::new(ObservabilityConfig::default());
5709
5710 for pkt in [coalesced, normal] {
5711 handle_packet(
5712 pkt,
5713 session_id,
5714 &server_session,
5715 &streams,
5716 &demux,
5717 &transport_send,
5718 &transport_send,
5719 &deliver_tx,
5720 &undelivered,
5721 &mut ack_buf,
5722 &obs,
5723 LegType::Tcp,
5724 )
5725 .await;
5726 }
5727
5728 // Drain the FIFO delivery channel — order must be exactly A, B, C, D.
5729 let mut got: Vec<Bytes> = Vec::new();
5730 while let Ok((_sid, b)) = deliver_rx.try_recv() {
5731 got.push(b);
5732 }
5733 let seen: Vec<&[u8]> = got.iter().map(|b| &b[..]).collect();
5734 assert_eq!(seen, vec![&b"A"[..], b"B", b"C", b"D"]);
5735 }
5736
5737 /// **A.5 RED → GREEN.** Two RELIABLE frames arriving OUT OF sequence order on
5738 /// the wire (seq 1 before seq 0) must be delivered to the app IN sequence
5739 /// order (`zero`, `one`). Before the receive-side reorder fix, the live pump
5740 /// delivered in decrypt-arrival order, breaking reliable in-order delivery
5741 /// over a reordering (UDP) path.
5742 #[tokio::test]
5743 async fn reliable_frames_delivered_in_sequence_order_despite_arrival_order() {
5744 let session_id = fixed_session_id();
5745 let (client_session, server_session) = paired_sessions(session_id);
5746 let stream_id: TransportStreamId = 1;
5747
5748 let f0 = decode_recv_frame(
5749 &build_app_frame(&client_session, session_id, stream_id, 0, b"zero"),
5750 session_id,
5751 );
5752 let f1 = decode_recv_frame(
5753 &build_app_frame(&client_session, session_id, stream_id, 1, b"one"),
5754 session_id,
5755 );
5756
5757 let (demux, _ctrl) = StreamDemultiplexer::new(16);
5758 let demux = Arc::new(demux);
5759 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5760 let (deliver_tx, mut deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
5761 let undelivered = AtomicU64::new(0);
5762 let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(8);
5763 let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
5764 tx: ack_a,
5765 rx: Mutex::new(ack_b),
5766 });
5767 let mut ack_buf = Vec::with_capacity(256);
5768 let obs = Observability::new(ObservabilityConfig::default());
5769
5770 // Deliver OUT OF ORDER on the wire: seq 1 first, then seq 0.
5771 for pkt in [f1, f0] {
5772 handle_packet(
5773 pkt,
5774 session_id,
5775 &server_session,
5776 &streams,
5777 &demux,
5778 &transport_send,
5779 &transport_send,
5780 &deliver_tx,
5781 &undelivered,
5782 &mut ack_buf,
5783 &obs,
5784 LegType::Tcp,
5785 )
5786 .await;
5787 }
5788
5789 let mut got: Vec<Bytes> = Vec::new();
5790 while let Ok((_sid, b)) = deliver_rx.try_recv() {
5791 got.push(b);
5792 }
5793 let seen: Vec<&[u8]> = got.iter().map(|b| &b[..]).collect();
5794 assert_eq!(
5795 seen,
5796 vec![&b"zero"[..], b"one"],
5797 "reliable data must be delivered in sequence order, not arrival order"
5798 );
5799 }
5800
5801 /// **A.5 control-gap regression (the bidirectional-hang fix).** Reliable data
5802 /// whose wire `header.sequence` has a HOLE (a control frame — ACK /
5803 /// WINDOW_UPDATE — consumed that sequence) but whose gap-free `stream_offset`
5804 /// is contiguous must still deliver in order WITHOUT stalling on the sequence
5805 /// hole. Here header seqs are 0 and 2 (seq 1 = a control frame), offsets 0 and
5806 /// 1. Reordering keyed on the raw `header.sequence` hangs forever waiting for
5807 /// seq 1; keyed on `stream_offset` it delivers `a, b`.
5808 #[tokio::test]
5809 async fn reliable_delivery_skips_control_frame_sequence_holes() {
5810 let session_id = fixed_session_id();
5811 let (client_session, server_session) = paired_sessions(session_id);
5812 let stream_id: TransportStreamId = 1;
5813
5814 // header.seq 0, offset 0, "a"; header.seq 2, offset 1, "b" (seq 1 is a hole).
5815 let a = decode_recv_frame(
5816 &build_app_frame_with_offset(&client_session, session_id, stream_id, 0, 0, b"a"),
5817 session_id,
5818 );
5819 let b = decode_recv_frame(
5820 &build_app_frame_with_offset(&client_session, session_id, stream_id, 2, 1, b"b"),
5821 session_id,
5822 );
5823
5824 let (demux, _ctrl) = StreamDemultiplexer::new(16);
5825 let demux = Arc::new(demux);
5826 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
5827 let (deliver_tx, mut deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
5828 let undelivered = AtomicU64::new(0);
5829 let (ack_a, ack_b) = mpsc::channel::<Vec<u8>>(8);
5830 let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
5831 tx: ack_a,
5832 rx: Mutex::new(ack_b),
5833 });
5834 let mut ack_buf = Vec::with_capacity(256);
5835 let obs = Observability::new(ObservabilityConfig::default());
5836
5837 for pkt in [a, b] {
5838 handle_packet(
5839 pkt,
5840 session_id,
5841 &server_session,
5842 &streams,
5843 &demux,
5844 &transport_send,
5845 &transport_send,
5846 &deliver_tx,
5847 &undelivered,
5848 &mut ack_buf,
5849 &obs,
5850 LegType::Tcp,
5851 )
5852 .await;
5853 }
5854
5855 let mut got: Vec<Bytes> = Vec::new();
5856 while let Ok((_sid, x)) = deliver_rx.try_recv() {
5857 got.push(x);
5858 }
5859 let seen: Vec<&[u8]> = got.iter().map(|b| &b[..]).collect();
5860 assert_eq!(
5861 seen,
5862 vec![&b"a"[..], b"b"],
5863 "reliable data must deliver in stream_offset order, skipping control-frame \
5864 sequence holes (not stall on them)"
5865 );
5866 }
5867
5868 /// A peer that ignores flow control and floods application data faster than
5869 /// the app drains must NOT grow the receive backlog without bound: once the
5870 /// undelivered backlog crosses the reader's hard cap, the session is torn
5871 /// down (state → `Closed`) instead of buffering unboundedly. The app here
5872 /// never calls `recv()`, so the delivery channel fills and the reader's
5873 /// pre-decrypt cap gate fires.
5874 #[tokio::test]
5875 async fn peer_ignoring_flow_control_trips_delivery_hard_cap_and_closes_session() {
5876 let session_id = fixed_session_id();
5877 let (client_inner, server_inner) = paired_sessions(session_id);
5878 let (client_t, server_t) = ChannelTransport::pair();
5879 let client_t = Arc::new(client_t);
5880
5881 // Full server-side session with a running pump; the app NEVER drains it.
5882 let server = PhantomSession::from_accepted_server_session(
5883 "flooder".to_string(),
5884 server_t,
5885 server_inner,
5886 );
5887
5888 // Drain and discard everything the server sends back (ACKs / control)
5889 // so the server reader never blocks on the back channel — a real
5890 // flooding peer likewise keeps emptying its socket. Without this the
5891 // reader would wedge on its own ACK send and the cap could never trip.
5892 let drain_t = client_t.clone();
5893 let drainer = tokio::spawn(async move { while drain_t.recv_bytes().await.is_ok() {} });
5894
5895 // Malicious client: flood valid RELIABLE app packets with unique
5896 // monotonic sequences (so none are replay-dropped) and never honor a
5897 // WINDOW_UPDATE — i.e. ignore flow control entirely.
5898 let payload = vec![0xABu8; 64 * 1024];
5899 let mut seq: u32 = 0;
5900 let mut torn_down = false;
5901 for _ in 0..4000 {
5902 if server.connection_state() == ConnectionState::Closed {
5903 torn_down = true;
5904 break;
5905 }
5906 let flag_bits = PacketFlags::RELIABLE | PacketFlags::ENCRYPTED;
5907 let header = PacketHeader::new(session_id, 1, seq as u64, PacketFlags::new(flag_bits))
5908 .with_epoch(client_inner.current_epoch());
5909 // Reliable plaintext = [stream_offset: u32 BE][payload] (A.5). Offsets
5910 // are contiguous (== seq), so every frame delivers in order and grows
5911 // the undelivered backlog — exactly what should trip the hard cap.
5912 let mut pt = Vec::with_capacity(4 + payload.len());
5913 pt.extend_from_slice(&seq.to_be_bytes());
5914 pt.extend_from_slice(&payload);
5915 let ct = client_inner
5916 .encrypt_packet(&header, &pt, &[])
5917 .expect("encrypt");
5918 // Bound the send so a torn-down (or wedged) transport can't hang the
5919 // test: a closed channel or a stalled reader both mean the flood is
5920 // no longer absorbed — i.e. the session is being torn down.
5921 // This frame traverses the server pump's transport, which removes
5922 // header protection on recv — so apply it on the way out.
5923 let packet = PhantomPacket::new(header, ct);
5924 let wire = client_inner
5925 .protect_packet(&packet)
5926 .expect("header protection");
5927 match tokio::time::timeout(
5928 std::time::Duration::from_secs(5),
5929 client_t.send_bytes(&wire),
5930 )
5931 .await
5932 {
5933 Ok(Ok(())) => {}
5934 _ => {
5935 torn_down = true;
5936 break;
5937 }
5938 }
5939 seq = seq.wrapping_add(1);
5940 tokio::task::yield_now().await;
5941 }
5942 assert!(
5943 torn_down,
5944 "a peer flooding past the delivery hard cap must get its session torn down"
5945 );
5946
5947 // Definitive: the session ends up Closed.
5948 let mut closed = false;
5949 for _ in 0..200 {
5950 if server.connection_state() == ConnectionState::Closed {
5951 closed = true;
5952 break;
5953 }
5954 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
5955 }
5956 drainer.abort();
5957 assert!(
5958 closed,
5959 "session state must be Closed after the hard cap trips"
5960 );
5961 }
5962
5963 /// Phase 4.4 — BBR ACK feedback drives the pacer rate. Build a
5964 /// realistic DeliverySample with known sent_at/acked_at timestamps
5965 /// and packet size; assert that calling `on_packet_acked` causes
5966 /// the pacer to leave its default unlimited state with a finite
5967 /// finite positive rate.
5968 #[tokio::test]
5969 async fn bbr_on_ack_drives_pacer_rate() {
5970 use crate::transport::bandwidth_estimator::DeliverySample;
5971 use std::time::{Duration, Instant};
5972
5973 let session_id = fixed_session_id();
5974 let (client_session, _server_session) = paired_sessions(session_id);
5975
5976 // The default Pacer is `unlimited` — track it before/after.
5977 assert!(!client_session.pacer().is_enabled());
5978
5979 // Simulate sending a 1500-byte packet, then receiving an ACK
5980 // 20 ms later. We feed a few samples in a row so the EMA
5981 // estimator has data to work with.
5982 let now = Instant::now();
5983 for i in 0..16 {
5984 let sent_at = now - Duration::from_millis(20 + i * 5);
5985 let acked_at = now - Duration::from_millis(i * 5);
5986 let sample = DeliverySample {
5987 delivered_bytes: 0,
5988 sent_at,
5989 acked_at,
5990 packet_bytes: 1500,
5991 is_app_limited: false,
5992 ack_delay_us: 100,
5993 };
5994 client_session.on_packet_sent(1500);
5995 let _ = client_session.on_packet_acked(sample);
5996 }
5997
5998 // The pacer should now be set to a real rate (still
5999 // "unlimited" handle, but with a finite stored rate). The
6000 // BandwidthEstimator's `pacing_rate()` is what gets pushed
6001 // into the pacer; assert it is non-zero and finite.
6002 let snap = client_session.bandwidth_snapshot();
6003 assert!(
6004 snap.pacing_rate_bps > 0,
6005 "expected pacing_rate to be non-zero, got {}",
6006 snap.pacing_rate_bps,
6007 );
6008 // The pacer's stored rate must match the estimator's view
6009 // (Session.on_packet_acked mirrors them).
6010 assert_eq!(client_session.pacer().rate(), snap.pacing_rate_bps);
6011 }
6012
6013 /// Phase 4.3 — WINDOW_UPDATE round-trip under the relative-credit model.
6014 /// The receive **delivery** task credits the flow-control window on real
6015 /// app consumption and stages the credit; the **send loop** flushes it as a
6016 /// single encrypted WINDOW_UPDATE via `flush_pending_window_updates`. The
6017 /// sender then ADDS the relative credit to its `peer_send_window` — it does
6018 /// not overwrite it with an absolute value.
6019 #[tokio::test]
6020 async fn flow_control_window_update_round_trip() {
6021 use crate::transport::stream::INITIAL_STREAM_WINDOW;
6022
6023 let session_id = fixed_session_id();
6024 let (client_session, server_session) = paired_sessions(session_id);
6025
6026 let stream_id: TransportStreamId = 9;
6027 let server_streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
6028 let server_stream = Arc::new(TransportStream::new(stream_id));
6029 server_streams.insert(stream_id as u32, server_stream.clone());
6030
6031 // Client also has a Stream so we can apply the inbound credit.
6032 let client_stream = Arc::new(TransportStream::new(stream_id));
6033
6034 // Pre-drain the client's peer_send_window so the credit has a real
6035 // effect to assert against.
6036 let drain = INITIAL_STREAM_WINDOW - 1000;
6037 assert!(client_stream.try_consume_send_window(drain));
6038 assert_eq!(client_stream.peer_send_window(), 1000);
6039
6040 // The delivery task credits the window on real consumption: model one
6041 // drain that crosses the half-window threshold and stage the credit
6042 // exactly as `run_data_pump`'s delivery task does.
6043 let consumed = INITIAL_STREAM_WINDOW / 2 + 1;
6044 let credit = server_stream
6045 .record_app_consumed(consumed)
6046 .expect("threshold crossed → credit granted");
6047 server_stream.stage_window_update_credit(credit);
6048
6049 // The send loop flushes the staged credit as a single WINDOW_UPDATE.
6050 let (out_tx, mut out_rx) = mpsc::channel::<Vec<u8>>(4);
6051 let (back_tx, back_rx) = mpsc::channel::<Vec<u8>>(4);
6052 let server_outbound: Arc<ChannelTransport> = Arc::new(ChannelTransport {
6053 tx: out_tx,
6054 rx: Mutex::new(back_rx),
6055 });
6056 let _keep = back_tx;
6057 flush_pending_window_updates(
6058 &server_outbound,
6059 &server_session,
6060 session_id,
6061 &server_streams,
6062 )
6063 .await;
6064
6065 // Exactly one WINDOW_UPDATE was emitted; decrypt it and read the credit.
6066 let frame = tokio::time::timeout(std::time::Duration::from_millis(100), out_rx.recv())
6067 .await
6068 .expect("expected a WINDOW_UPDATE frame")
6069 .expect("channel open");
6070 let pv2 = client_session.parse_protected(&frame).unwrap();
6071 assert!(pv2.header.flags.contains(PacketFlags::WINDOW_UPDATE));
6072 // The control frame's sequence comes from the stream's own send space —
6073 // distinct from any data packet so the AEAD nonce never repeats.
6074 let pt = client_session
6075 .decrypt_packet(&pv2.header, &pv2.payload, &[])
6076 .expect("decrypt WINDOW_UPDATE");
6077 assert_eq!(pt.len(), 4);
6078 let announced = u32::from_be_bytes([pt[0], pt[1], pt[2], pt[3]]);
6079 assert_eq!(
6080 announced, credit,
6081 "WINDOW_UPDATE carries the relative credit (bytes consumed since last update)"
6082 );
6083 // Exactly one frame was emitted — nothing else is queued on the wire.
6084 assert!(
6085 out_rx.try_recv().is_err(),
6086 "exactly one WINDOW_UPDATE must be emitted"
6087 );
6088
6089 // The staged slot is now empty — a second flush emits nothing.
6090 flush_pending_window_updates(
6091 &server_outbound,
6092 &server_session,
6093 session_id,
6094 &server_streams,
6095 )
6096 .await;
6097 assert!(
6098 out_rx.try_recv().is_err(),
6099 "no spurious second WINDOW_UPDATE after the credit was already flushed"
6100 );
6101
6102 // Apply the relative credit on the client side: peer_send_window ADDS it
6103 // to the current 1000 (it does not jump to an absolute value).
6104 client_stream.apply_peer_window_update(announced);
6105 assert_eq!(client_stream.peer_send_window(), 1000 + credit);
6106 }
6107
6108 /// Phase 4.3 — priority scheduler ordering. Two streams enqueue
6109 /// data simultaneously; the higher-priority one must be drained
6110 /// first, all of its data before any of the lower one's.
6111 #[tokio::test]
6112 async fn priority_scheduler_drains_higher_priority_stream_first() {
6113 // Build a real Session (any crypto state — we only inspect
6114 // send order, not ciphertext) and an Arc<Stream> per stream.
6115 let session_id = fixed_session_id();
6116 let (client_session, _server_session) = paired_sessions(session_id);
6117
6118 // Capture every outbound packet by stuffing into a channel-
6119 // backed transport whose tx end we can drain after.
6120 let (tx_a, mut rx_a) = mpsc::channel::<Vec<u8>>(32);
6121 let (tx_b, rx_b) = mpsc::channel::<Vec<u8>>(32);
6122 let transport: Arc<ChannelTransport> = Arc::new(ChannelTransport {
6123 tx: tx_a,
6124 rx: Mutex::new(rx_b),
6125 });
6126 let _keep = tx_b; // keep the recv side alive
6127
6128 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
6129
6130 // Stream 11: low priority (1), 3 reliable chunks.
6131 let low = Arc::new(TransportStream::new(11));
6132 low.set_priority(1);
6133 low.send_reliable(Bytes::from_static(b"L0")).await.unwrap();
6134 low.send_reliable(Bytes::from_static(b"L1")).await.unwrap();
6135 low.send_reliable(Bytes::from_static(b"L2")).await.unwrap();
6136 streams.insert(11, low);
6137
6138 // Stream 22: HIGH priority (100), 3 reliable chunks.
6139 let hi = Arc::new(TransportStream::new(22));
6140 hi.set_priority(100);
6141 hi.send_reliable(Bytes::from_static(b"H0")).await.unwrap();
6142 hi.send_reliable(Bytes::from_static(b"H1")).await.unwrap();
6143 hi.send_reliable(Bytes::from_static(b"H2")).await.unwrap();
6144 streams.insert(22, hi);
6145
6146 drain_streams_priority_ordered(&transport, &client_session, session_id, &streams).await;
6147
6148 // Pull all packets off the channel and verify their order:
6149 // the three H* chunks must come before any L* chunk.
6150 let mut order: Vec<&'static str> = Vec::new();
6151 while let Ok(frame) =
6152 tokio::time::timeout(std::time::Duration::from_millis(50), rx_a.recv()).await
6153 {
6154 let bytes = match frame {
6155 Some(b) => b,
6156 None => break,
6157 };
6158 let v2 = _server_session.parse_protected(&bytes).unwrap();
6159 // Decrypt under the SERVER role so the per-direction key
6160 // matches the client-side encrypt.
6161 let plaintext = _server_session
6162 .decrypt_packet(&v2.header, &v2.payload, &[])
6163 .expect("decrypt");
6164 // Reliable frames carry a 4-byte stream_offset prefix (A.5); the tag is
6165 // the application payload after it.
6166 let tag: &'static str = match &plaintext[4..] {
6167 b"H0" => "H0",
6168 b"H1" => "H1",
6169 b"H2" => "H2",
6170 b"L0" => "L0",
6171 b"L1" => "L1",
6172 b"L2" => "L2",
6173 other => panic!("unexpected payload {:?}", other),
6174 };
6175 order.push(tag);
6176 }
6177
6178 // All H* before any L*.
6179 let first_low = order
6180 .iter()
6181 .position(|s| s.starts_with('L'))
6182 .unwrap_or(order.len());
6183 let last_high = order.iter().rposition(|s| s.starts_with('H')).unwrap();
6184 assert!(
6185 last_high < first_low,
6186 "strict priority violated: order = {:?}",
6187 order
6188 );
6189 }
6190
6191 #[tokio::test]
6192 async fn v2_recv_echoes_path_validation_challenge_back_as_response() {
6193 // Two paired sessions on different IDs (so neither has a
6194 // pending challenge for the path). The "responder" sees a
6195 // PATH_VALIDATION packet on a new path id and must echo the
6196 // 32-byte payload back via the transport.
6197 let session_id = fixed_session_id();
6198 let (client_session, server_session) = paired_sessions(session_id);
6199
6200 // Build a PATH_VALIDATION packet with ENCRYPTED + path_id=7.
6201 let path_id: u8 = 7;
6202 let payload = [0xDEu8; crate::transport::path::PATH_CHALLENGE_LEN];
6203 let flag_bits = PacketFlags::ENCRYPTED | PacketFlags::PATH_VALIDATION;
6204 let header = PacketHeader::new(session_id, 0, 0, PacketFlags::new(flag_bits))
6205 .with_epoch(client_session.current_epoch())
6206 .with_path_id(path_id);
6207 let ciphertext = client_session
6208 .encrypt_packet(&header, &payload, &[])
6209 .expect("encrypt challenge");
6210 let v2 = PhantomPacket::new(header, ciphertext);
6211
6212 let (demux, _ctrl_rx) = StreamDemultiplexer::new(16);
6213 let demux = Arc::new(demux);
6214 let streams: Arc<DashMap<u32, Arc<TransportStream>>> = Arc::new(DashMap::new());
6215 let (deliver_tx, _deliver_rx) = mpsc::unbounded_channel::<(u32, Bytes)>();
6216 let undelivered = AtomicU64::new(0);
6217 // Server's outbound transport — captures the echo back.
6218 let (echo_tx, mut echo_rx) = mpsc::channel::<Vec<u8>>(4);
6219 let (back_tx, back_rx) = mpsc::channel::<Vec<u8>>(4);
6220 let transport_send: Arc<ChannelTransport> = Arc::new(ChannelTransport {
6221 tx: echo_tx,
6222 rx: Mutex::new(back_rx),
6223 });
6224 let _back_tx_keepalive = back_tx; // keep the recv side alive
6225
6226 let mut ack_buf = Vec::with_capacity(256);
6227 let obs = Observability::new(ObservabilityConfig::default());
6228
6229 handle_packet(
6230 v2,
6231 session_id,
6232 &server_session,
6233 &streams,
6234 &demux,
6235 &transport_send,
6236 &transport_send,
6237 &deliver_tx,
6238 &undelivered,
6239 &mut ack_buf,
6240 &obs,
6241 LegType::Tcp,
6242 )
6243 .await;
6244
6245 // Server should have emitted a PATH_VALIDATION response on the
6246 // outbound transport. Pull it out and verify it carries the
6247 // same payload back.
6248 let echo_bytes =
6249 tokio::time::timeout(std::time::Duration::from_millis(200), echo_rx.recv())
6250 .await
6251 .expect("echo should arrive")
6252 .expect("channel open");
6253
6254 // Decrypt the echo on the original (client) side — server-side
6255 // ciphertext authenticates the round-trip.
6256 // The server emitted this echo with header protection; unmask from the
6257 // client side (== the server's send HP key).
6258 let echo_v2 = client_session.parse_protected(&echo_bytes).unwrap();
6259 assert!(echo_v2.header.flags.contains(PacketFlags::PATH_VALIDATION));
6260 assert_eq!(echo_v2.header.path_id, path_id);
6261 }
6262
6263 // ────────────────────────────────────────────────────────────────────
6264 // 0-RTT early-data
6265 // ────────────────────────────────────────────────────────────────────
6266
6267 /// Full 0-RTT round-trip over `ChannelTransport`: a priming handshake
6268 /// populates the server cache and yields a resumption hint; a second
6269 /// connect via `connect_with_resumption` carries application early-data
6270 /// sealed inside the resuming ClientHello, which the server decrypts and
6271 /// surfaces. The client learns the verdict via `early_data_accepted()`.
6272 ///
6273 /// The server side runs inline (not a spawned task) so its
6274 /// `ChannelTransport` halves stay alive in scope — dropping them
6275 /// would close the client's data pump and flip the session to
6276 /// `Closed` before the assertions run.
6277 #[tokio::test]
6278 async fn zero_rtt_early_data_full_round_trip() {
6279 // One HandshakeServer shared across both phases so its session
6280 // cache persists between the priming handshake and the resume.
6281 let server_hs = HandshakeServer::new().unwrap();
6282 let server_pinned_key = server_hs.verifying_key().clone();
6283 let client_ip: std::net::IpAddr = "127.0.0.1".parse().unwrap();
6284
6285 // ── Step 1: prime — a normal handshake fills the cache ──
6286 let (c1, s1) = ChannelTransport::pair();
6287 let phase1_session =
6288 PhantomSession::connect_with_transport("test:9000", c1, server_pinned_key.clone());
6289
6290 let hello_bytes = s1.recv_bytes().await.unwrap();
6291 let ch = borsh::from_slice::<ClientHello>(&hello_bytes).unwrap();
6292 let retry = match server_hs.process_client_hello(&ch, 0, client_ip) {
6293 HandshakeResponse::Retry(r) => r,
6294 _ => panic!("expected Retry"),
6295 };
6296 s1.send_bytes(&ServerReply::Retry(retry).to_wire().unwrap())
6297 .await
6298 .unwrap();
6299 let next = s1.recv_bytes().await.unwrap();
6300 let ch2 = borsh::from_slice::<ClientHello>(&next).unwrap();
6301 match server_hs.process_client_hello(&ch2, 0, client_ip) {
6302 HandshakeResponse::Success(sh, _session, _) => {
6303 s1.send_bytes(&ServerReply::Hello(sh).to_wire().unwrap())
6304 .await
6305 .unwrap();
6306 }
6307 _ => panic!("expected Success"),
6308 }
6309
6310 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
6311 assert_eq!(
6312 phase1_session.connection_state(),
6313 ConnectionState::Connected
6314 );
6315 let hint = phase1_session
6316 .resumption_hint()
6317 .await
6318 .expect("phase 1 produced a resumption hint");
6319 // The Rust-only `connect_with_resumption` takes the raw tuple;
6320 // `resumption_hint()` now yields the UniFFI `ResumptionHint`
6321 // record, so rebuild the tuple from its 32-byte fields.
6322 let hint = (
6323 <[u8; 32]>::try_from(hint.session_id.as_slice()).expect("session_id is 32 bytes"),
6324 <[u8; 32]>::try_from(hint.resumption_secret.as_slice())
6325 .expect("resumption_secret is 32 bytes"),
6326 );
6327
6328 // ── Step 2: resume — the ClientHello carries sealed early-data ──
6329 let early_payload = b"zero-rtt application bytes".to_vec();
6330 let (c2, s2) = ChannelTransport::pair();
6331 let phase2_session = PhantomSession::connect_with_resumption(
6332 "test:9000",
6333 c2,
6334 server_pinned_key.clone(),
6335 hint,
6336 early_payload.clone(),
6337 )
6338 .expect("early_data is within the size cap");
6339
6340 let hello_bytes = s2.recv_bytes().await.unwrap();
6341 let ch3 = borsh::from_slice::<ClientHello>(&hello_bytes).unwrap();
6342 assert!(
6343 ch3.early_data.is_some(),
6344 "phase 2 hello carries sealed 0-RTT early-data"
6345 );
6346 match server_hs.process_client_hello(&ch3, 0, client_ip) {
6347 HandshakeResponse::Success(sh, _session, early_data) => {
6348 // The server decrypted exactly what the client sealed.
6349 assert_eq!(early_data.as_deref(), Some(&early_payload[..]));
6350 assert!(sh.early_data_accepted);
6351 s2.send_bytes(&ServerReply::Hello(sh).to_wire().unwrap())
6352 .await
6353 .unwrap();
6354 }
6355 _ => {
6356 panic!("expected Success with accepted early-data — the resumption ticket is fresh")
6357 }
6358 }
6359
6360 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
6361 assert_eq!(
6362 phase2_session.connection_state(),
6363 ConnectionState::Connected
6364 );
6365 assert_eq!(
6366 phase2_session.early_data_accepted().await,
6367 Some(true),
6368 "client must see the server accepted its 0-RTT early-data"
6369 );
6370
6371 // Keep the server transports alive until every assertion has
6372 // run — see the doc comment above.
6373 drop((s1, s2));
6374 }
6375
6376 /// `connect_pinned_with_resumption` validates the `ResumptionHint`
6377 /// field lengths *before* opening any socket — a hint whose
6378 /// `session_id` or `resumption_secret` is not exactly 32 bytes is a
6379 /// caller bug and surfaces as `ValidationError`, never a network
6380 /// round-trip.
6381 #[tokio::test]
6382 async fn connect_pinned_with_resumption_rejects_malformed_hint() {
6383 let server_hs = HandshakeServer::new().unwrap();
6384 let pinned = server_hs.verifying_key().to_bytes();
6385
6386 let bad_hint = ResumptionHint {
6387 session_id: vec![0u8; 5], // not 32 bytes
6388 resumption_secret: vec![0u8; 32],
6389 };
6390
6391 let err = connect_pinned_with_resumption(
6392 "127.0.0.1".to_string(),
6393 9,
6394 pinned,
6395 bad_hint,
6396 Vec::new(),
6397 )
6398 .await
6399 .expect_err("a 5-byte session_id must be rejected");
6400
6401 assert!(
6402 matches!(err, CoreError::ValidationError(_)),
6403 "expected ValidationError, got {err:?}"
6404 );
6405 }
6406}