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