phantom_protocol/transport/session.rs
1//! Phantom Protocol - Session Management
2//!
3//! The `Session` is the virtual association between two endpoints that persists
4//! across IP changes (single-path seamless connection migration — not multipath
5//! aggregation). It owns the per-direction AEAD `CryptoState` (hot-swapped on rekey
6//! via `ArcSwap`), the header-protection keys, the rotating connection-ID chain, the
7//! per-direction replay window, the stream table, the pacer + BBR bandwidth
8//! estimator, and the migration / liveness state. The `Scheduler` it holds is
9//! vestigial (see `SchedulerMode`).
10
11use crate::crypto::adaptive_crypto::{CryptoSession, AEAD_MAX_INVOCATIONS};
12use crate::crypto::cid_chain::{CidChain, CID_LEN, CID_WINDOW_LEADING, CID_WINDOW_TRAILING};
13use crate::crypto::header_protection::{HeaderProtector, HP_SAMPLE_LEN};
14use crate::errors::CoreError;
15use crate::security::ReplayWindow;
16use crate::transport::{
17 bandwidth_estimator::{BandwidthEstimator, DeliverySample},
18 fallback::FallbackStateMachine,
19 liveness::LivenessConfig,
20 pacer::Pacer,
21 path::{PathRegistry, PathStateKind, PATH_CHALLENGE_LEN},
22 scheduler::Scheduler,
23 shaping::PaddingPolicy,
24 stream::Stream,
25 types::{
26 PacketFlags, PacketHeader, PacketNumber, PhantomPacket, RawPacket, SchedulerMode,
27 SessionId, StreamId,
28 },
29};
30
31use arc_swap::ArcSwap;
32use parking_lot::{Mutex, RwLock};
33use std::collections::HashMap;
34use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicU8, Ordering};
35use std::sync::Arc;
36use std::time::{Duration, Instant};
37use zeroize::{Zeroize, ZeroizeOnDrop};
38
39/// Session state machine
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum SessionState {
42 /// Initial state, handshake in progress
43 Handshaking,
44 /// Fully established, data can flow
45 Connected,
46 /// Migrating to new IP address
47 Migrating,
48 /// Graceful shutdown in progress
49 Closing,
50 /// Session is closed
51 Closed,
52}
53
54/// Soft high-watermark for automatic mid-session rekey (C1). Once a direction's
55/// AEAD invocation count crosses this, the data pump rotates to a fresh key
56/// *before* the hard [`AEAD_MAX_INVOCATIONS`] ceiling (Invariant 8) so a
57/// long-lived session ratchets keys instead of failing with `NonceExhausted`.
58///
59/// Set to `2^32`, far below the hard [`AEAD_MAX_INVOCATIONS`] (`2^48`) ceiling — clean
60/// standards alignment (T5.3): the AES-256-GCM IND-CPA advantage at `2^32` records is
61/// ~`2^-33`, comfortably inside the CFRG / QUIC confidentiality margins (the prior `2^47`
62/// gave ~`2^-27`), so this is defense-in-depth, not a real exposure. It still dwarfs any
63/// realistic session's packet count — a session sending `2^32` packets re-keys roughly
64/// hourly even at 1 M pps — leaving ample headroom for in-flight old-epoch packets, so
65/// production sessions ratchet a fresh key rather than approaching the ceiling. Tests lower
66/// it via [`Session::set_rekey_threshold`] to exercise the path.
67pub const REKEY_SOFT_LIMIT: u64 = 1 << 32;
68
69// The soft rekey watermark must stay below the hard nonce-exhaustion ceiling so a session
70// always rotates keys before it would fail with `NonceExhausted` (Invariant 8).
71const _: () = assert!(REKEY_SOFT_LIMIT < AEAD_MAX_INVOCATIONS);
72
73/// How many epochs the receive path will catch up in one packet when accepting
74/// an authenticated forward rekey (C1). A small bound caps the HKDF work an
75/// attacker can force per spoofed packet (each step is a trial that commits
76/// nothing unless AEAD verifies) while comfortably absorbing the small epoch
77/// divergence that arises when both directions rekey at slightly different
78/// cadences. A gap larger than this is rejected; over a reliable transport the
79/// sender retransmits at the then-current epoch, so no data is lost. In
80/// practice (production `REKEY_SOFT_LIMIT` of `2^32`) the gap is essentially
81/// always 0 or 1.
82pub const MAX_REKEY_CATCHUP: u8 = 16;
83
84/// Reserved `path_id` for validating a **passive** NAT rebind (M-3).
85///
86/// A passive rebind (the peer's source address changes without it calling
87/// `migrate()`) keeps `path_id = 0` — the implicit, permanently-`Validated`
88/// handshake path. Because path 0 is always Validated, the path-id-gated
89/// challenge logic would skip it and the new source would never be validated,
90/// promoted, or used for the downstream direction (a stall). The server detects
91/// the rebind by *address* (an AEAD-authenticated frame whose source differs
92/// from the established peer) and challenges that candidate on this dedicated
93/// reserved id, which the registry can take through `Validating → Validated`
94/// independently of the always-Validated path 0.
95///
96/// This id is carved permanently out of the active-migration id space:
97/// [`Session::next_migration_path_id`] never returns it (it wraps `254 → 1`,
98/// skipping both `0` and `255`), so a client-driven migration and a passive
99/// rebind can never share this registry slot.
100pub const REBIND_VALIDATION_PATH_ID: u8 = 255;
101
102/// Crypto state for session encryption.
103///
104/// On drop, `session_key` is zeroed. The wrapped [`CryptoSession`] holds AEAD
105/// keys in ring's opaque `LessSafeKey` (which cannot be zeroed directly — we
106/// rely on the OS reclaiming memory and on the `Arc<CryptoSessionInner>` going
107/// out of scope alongside this struct).
108#[derive(ZeroizeOnDrop)]
109pub struct CryptoState {
110 /// Bidirectional crypto session
111 #[zeroize(skip)]
112 pub session: CryptoSession,
113 /// Shared session key (for additional derivations)
114 pub session_key: [u8; 32],
115}
116
117impl CryptoState {
118 /// Create new crypto state from shared secret
119 pub fn new(shared_secret: &[u8; 32], peer_side: bool) -> Result<Self, CoreError> {
120 let session = if peer_side {
121 CryptoSession::from_shared_secret_peer(shared_secret)?
122 } else {
123 CryptoSession::from_shared_secret(shared_secret)?
124 };
125
126 // Derive additional session keys using HKDF
127 let hk = hkdf::Hkdf::<sha2::Sha256>::from_prk(shared_secret)
128 .map_err(|_| CoreError::CryptoError("HKDF PRK failed".into()))?;
129
130 let mut key_bytes = [0u8; 32];
131 hk.expand(b"phantom-transport-key", &mut key_bytes)
132 .map_err(|_| CoreError::KeyDerivationError)?;
133
134 Ok(Self {
135 session,
136 session_key: key_bytes,
137 })
138 }
139
140 /// Encrypt with a caller-supplied 12-byte nonce. Used by
141 /// `Session::encrypt_packet`, which constructs the nonce as
142 /// `nonce_prefix(4) ‖ packet_number(8)` (① — Phase 4; see
143 /// `Session::build_packet_nonce`) from the authenticated header — the
144 /// per-direction `u64` packet number is used once, so the nonce never repeats
145 /// and the receiver survives failed decrypts without desyncing.
146 pub fn encrypt_with_nonce(
147 &self,
148 nonce: [u8; 12],
149 aad: &[u8],
150 plaintext: &[u8],
151 ) -> Result<Vec<u8>, CoreError> {
152 self.session
153 .encrypt_with_nonce(nonce, aad, plaintext)
154 .map_err(|e| CoreError::CryptoError(e.to_string()))
155 }
156
157 /// Decrypt with a caller-supplied 12-byte nonce (the recv counterpart of
158 /// [`encrypt_with_nonce`](Self::encrypt_with_nonce)).
159 pub fn decrypt_with_nonce(
160 &self,
161 nonce: [u8; 12],
162 aad: &[u8],
163 ciphertext: &[u8],
164 ) -> Result<Vec<u8>, CoreError> {
165 self.session
166 .decrypt_with_nonce(nonce, aad, ciphertext)
167 .map_err(|e| CoreError::CryptoError(e.to_string()))
168 }
169
170 /// Borrow the 4-byte nonce prefix derived at session establishment.
171 pub fn nonce_prefix(&self) -> [u8; 4] {
172 self.session.nonce_prefix()
173 }
174
175 /// Per-direction send-side AEAD invocation count for this epoch. Resets to
176 /// 0 on rekey (a fresh `CryptoState` is installed). Drives the C1
177 /// automatic-rekey trigger.
178 pub fn send_invocations(&self) -> u64 {
179 self.session.send_invocations()
180 }
181}
182
183/// A one-step slide of the inbound CID demux window (ε / WIRE v5, P4b), produced
184/// by [`Session::note_migration_path`] when the peer migrates. The demux applies
185/// it: `add` are the CIDs to register at the new leading edge, `remove` the CIDs
186/// that fell past the trailing edge, and `anchor` is a CID still routed for this
187/// session (the demux resolves the session's channel through it).
188#[derive(Clone, Debug)]
189pub struct CidSlide {
190 /// CIDs to register at the new leading edge.
191 pub add: Vec<[u8; CID_LEN]>,
192 /// CIDs to drop past the trailing edge.
193 pub remove: Vec<[u8; CID_LEN]>,
194 /// A CID currently routed for this session — the demux looks the channel up by it.
195 pub anchor: [u8; CID_LEN],
196}
197
198/// Session - virtual association between two endpoints
199pub struct Session {
200 /// Unique session identifier (256-bit)
201 id: SessionId,
202 /// Current state
203 state: RwLock<SessionState>,
204 /// Active `CryptoState` — wrapped in `ArcSwap` so `rekey()` can swap it
205 /// in lock-free (Phase 1.5 + Phase 2.7).
206 ///
207 /// Encrypt/decrypt callsites do `self.crypto.load()` which is an atomic
208 /// pointer load + deref to the inner `CryptoState`. No lock acquisition
209 /// per packet. `rekey()` is a single `store()` of a freshly-derived
210 /// `Arc<CryptoState>`.
211 crypto: ArcSwap<CryptoState>,
212 /// Per-session, per-direction header-protection keys (T4.6, QUIC RFC 9001
213 /// §5.4). Derived ONCE from the initial session secret + the negotiated
214 /// cipher suite and held stable for the session's lifetime — it does NOT
215 /// rotate with `crypto`/`epoch` (QUIC §6.1: the hp key must be constant
216 /// across key updates because `epoch` lives inside the masked header region;
217 /// see [`HeaderProtector`]). Masks the WHOLE 15-byte header span on the wire
218 /// (WIRE v6: `HP_PROTECTED_OFFSET = 0`, version byte included) via
219 /// [`Self::protect_packet`] / unmasks it via [`Self::parse_protected`].
220 header_protection: HeaderProtector,
221 /// Per-direction, **session-stable** rotating connection-ID chain (ε / WIRE
222 /// v5). Derived ONCE from the initial session secret (mirroring
223 /// `header_protection`, same `is_server` swap); it does NOT rotate with
224 /// `crypto`/`epoch`. The outbound chain stamps this peer's envelope `ConnId`
225 /// ([`Self::current_outbound_cid`]); the inbound chain is what the peer's
226 /// demux routes on ([`Self::inbound_window_cids`]). Zeroized on drop.
227 cid_chain: CidChain,
228 /// This peer's outbound CID migration index (ε / WIRE v5). Starts at 0;
229 /// advances on `migrate()` (P4) so the stamped `ConnId` rotates to an
230 /// independent-random value an observer cannot link across a migration.
231 outbound_cid_index: AtomicU64,
232 /// Highest inbound CID migration index observed (ε / WIRE v5). Centers the
233 /// inbound demux window [`Self::inbound_window_cids`]. Starts at 0; advances
234 /// post-AEAD when the peer migrates (P4). Tracked separately from
235 /// `outbound_cid_index` — the two directions migrate independently.
236 inbound_cid_highest_seen: AtomicU64,
237 /// The highest peer `path_id` observed (ε / WIRE v5, P4b). The peer bumps its
238 /// `path_id` in lock-step with its outbound CID index on each `migrate()`, so a
239 /// NEW (forward, mod-256) `path_id` signals a migration → the inbound CID demux
240 /// window slides one step. Tracked here so a reordered-old / duplicate
241 /// `path_id` — or a passive rebind, which never bumps `path_id` — does not
242 /// falsely slide.
243 last_seen_path_id: AtomicU8,
244 /// Per-direction traffic secret. Initial value is the hybrid handshake's
245 /// shared secret; each `rekey()` derives the next via
246 /// `HKDF-Expand(current, "phantom-rekey-v1", 32)` (Phase 1.5).
247 traffic_secret: RwLock<[u8; 32]>,
248 /// Rekey generation counter. Starts at 0 at session establishment; each
249 /// successful `rekey()` increments it. Wire-emitted in
250 /// `PacketHeader.epoch` so the peer can match the right key.
251 epoch: AtomicU8,
252 /// Send-side AEAD-invocation high-watermark that triggers an automatic
253 /// mid-session rekey (C1). Defaults to [`REKEY_SOFT_LIMIT`]; tests/embedders
254 /// lower it via [`set_rekey_threshold`](Self::set_rekey_threshold).
255 rekey_after: AtomicU64,
256 /// Serialises every epoch transition (C1). The data pump runs the send loop
257 /// and the receive task concurrently over one `Arc<Session>`, so a send-side
258 /// `rekey()` can race a receive-side ratchet. Both hold this mutex across
259 /// their derive→install→epoch-bump so the installed key depth and the epoch
260 /// counter never diverge (the bug would otherwise wedge the session).
261 rekey_lock: Mutex<()>,
262 /// T5.5(b) — "our locally-initiated rekey is not yet acknowledged by the
263 /// peer". SET by [`rekey`](Self::rekey) and CLEARED by
264 /// [`confirm_rekey_caught_up`](Self::confirm_rekey_caught_up) when an
265 /// authenticated inbound packet arrives at our current epoch (proof the peer
266 /// caught up). While set, the send path OR-s `PacketFlags::REKEY` into EVERY
267 /// outbound header (not just the trigger packet), so losing the first
268 /// new-epoch packet cannot strand a receiver behind the catch-up gate in
269 /// [`decrypt_packet_accepting_rekey`](Self::decrypt_packet_accepting_rekey).
270 /// A best-effort hint (Relaxed): over-advertising one extra packet or
271 /// stopping one packet early is harmless — the security gate is the
272 /// AEAD-authenticated REKEY flag + epoch check, not this bit.
273 rekey_unconfirmed: AtomicBool,
274 /// Anti-fingerprint size-padding policy (WIRE v6, deliverable (c)). `0` =
275 /// [`PaddingPolicy::None`](crate::transport::shaping::PaddingPolicy::None)
276 /// (default — shaping is opt-in), `1` =
277 /// [`PaddingPolicy::Padme`](crate::transport::shaping::PaddingPolicy::Padme).
278 /// Read lock-free by the send path to decide whether to pad a packet to a PADÉ
279 /// bucket before sealing; set via [`set_padding_policy`](Self::set_padding_policy).
280 padding_policy: AtomicU8,
281 /// Anti-fingerprint send-timing jitter ceiling, in milliseconds (WIRE v6,
282 /// deliverable (d)). `0` = off (default). When non-zero, the send path waits a
283 /// uniform random `[0, this]` ms before each packet so inter-packet timing no
284 /// longer tracks app writes. Read lock-free; set via
285 /// [`set_jitter_ms`](Self::set_jitter_ms).
286 jitter_max_ms: AtomicU32,
287 /// Anti-fingerprint cover-traffic floor interval, in milliseconds (WIRE v6,
288 /// deliverable (e)). `0` = off (default). When non-zero, the pump maintains a
289 /// minimum outbound packet rate: if no packet has gone out for this long, it
290 /// emits an `ENCRYPTED | COVER` dummy packet (idle-fill + a floor rate of
291 /// `1000 / this` packets/sec) so silence + volume no longer leak. Read
292 /// lock-free; set via [`set_cover_interval_ms`](Self::set_cover_interval_ms).
293 cover_min_interval_ms: AtomicU32,
294 /// Which side of the handshake we are. Carried into every
295 /// `CryptoState::new(...)` re-derivation so the per-direction keys are
296 /// laid out the same way they were at session establishment.
297 is_server: bool,
298 /// Active streams
299 streams: RwLock<HashMap<StreamId, Arc<Stream>>>,
300 /// Next stream ID counter
301 next_stream_id: AtomicU32,
302 /// Path scheduler — **vestigial** (single-path connection migration, not
303 /// multipath aggregation): constructed and reachable via `scheduler()`, but
304 /// `select_paths` is never called on the live data path. See `SchedulerMode`.
305 scheduler: Arc<Scheduler>,
306 /// Resumption secret for 0-RTT
307 resumption_secret: RwLock<Option<[u8; 32]>>,
308 /// Last activity timestamp. Refreshed on every authenticated inbound packet;
309 /// the liveness sweep (P4.3) measures inbound silence from here.
310 last_activity: RwLock<Instant>,
311 /// Path-liveness thresholds (Phase 4 / P4.3). Read each pump heartbeat to decide
312 /// path-down / recovery / death; lowerable via [`set_liveness_config`] for tests.
313 ///
314 /// [`set_liveness_config`]: Self::set_liveness_config
315 liveness_config: RwLock<LivenessConfig>,
316 /// Fallback state machine
317 #[allow(dead_code)]
318 fallback: Arc<FallbackStateMachine>,
319 /// Per-direction monotonic AEAD packet number (① — Phase 4). Every outbound
320 /// packet draws the next value here at send time, so the AEAD nonce is never
321 /// reused. Replaces the deleted per-stream `send_sequence` + the C1 watermark.
322 send_packet_number: AtomicU64,
323 /// Client-owned send-side `path_id` stamped on every outbound packet (D5 —
324 /// Phase 4). Defaults to 0 (the implicit handshake path, pre-validated on both
325 /// peers). [`next_migration_path_id`](Self::next_migration_path_id) bumps it on
326 /// each `migrate()` so the peer's source-change detector sees a new path label
327 /// and challenges it; reuse is nonce-safe because ① took `path_id` out of the
328 /// AEAD nonce. Never set to 0 by the bump — 0 is reserved for the
329 /// always-Validated handshake path. (Field name differs from the
330 /// [`current_send_path_id`](Self::current_send_path_id) accessor, mirroring the
331 /// `send_packet_number` / `next_send_pn` split.)
332 send_path_id: AtomicU8,
333 /// Per-direction sliding-window replay protection on the packet number
334 /// (① — Phase 4, Inv-4). One window per direction (the PN is unique across all
335 /// streams), replacing the per-`StreamId` `DashMap<…, ReplayWindow>`.
336 recv_replay: Mutex<ReplayWindow>,
337 /// Cumulative count of replay rejections (across all streams) — exposed
338 /// for metrics/telemetry.
339 replay_rejected_total: AtomicU64,
340 /// Per-path validation state (Phase 4.2). Each `path_id` referenced in a
341 /// `PacketHeader.path_id` must transit through `Unvalidated →
342 /// Validating → Validated` (via a challenge-response round trip)
343 /// before the data pump treats it as authoritative. Defaults to a
344 /// pre-populated entry for `path_id = 0` (the implicit single-path)
345 /// in the `Validated` state so legacy single-leg sessions keep
346 /// working without any explicit setup.
347 path_registry: Arc<PathRegistry>,
348 /// Outbound rate-limiter (Phase 2.6). Defaults to
349 /// [`Pacer::unlimited`] so the historical no-pacing behavior is
350 /// unchanged unless the caller explicitly sets a rate via
351 /// [`Session::pacer`]. The data pump consults this before every
352 /// outbound packet — the existing implementation just calls
353 /// `try_consume` and falls through if the pacer is disabled, so the
354 /// integration is zero-overhead in the default configuration.
355 pacer: Arc<Pacer>,
356 /// BBR-style bandwidth + RTT estimator (Phase 2.6 / Phase 4.4
357 /// foundation). The data pump feeds it via [`Session::on_packet_sent`]
358 /// and [`Session::on_packet_acked`]; the resulting `pacing_rate()`
359 /// feeds back into the `pacer` to close the loop.
360 bandwidth_estimator: parking_lot::Mutex<BandwidthEstimator>,
361 /// Outbound-ready signal (Phase 2.4). Streams or the application
362 /// can `notify_one()` this to wake the data pump immediately
363 /// instead of waiting for the next 10 ms `poll_interval` tick.
364 /// The pump keeps the tick as a retransmit-timer fallback.
365 send_notify: Arc<tokio::sync::Notify>,
366 /// Optional channel to the UDP demux for sliding this session's inbound CID
367 /// window (ε / WIRE v5, P4b). Set once post-handshake by the server's accept
368 /// path ([`Self::set_cid_slide_tx`]); `None` on the client and on socket-routed
369 /// transports (which have no CID demux). `handle_packet` signals a slide
370 /// through it when [`Self::note_migration_path`] reports the peer migrated.
371 cid_slide_tx: Mutex<Option<tokio::sync::mpsc::UnboundedSender<CidSlide>>>,
372 /// An idle keep-alive PING is in flight, awaiting the peer's PONG (Direction
373 /// #3 — download-only liveness). Set when the pump emits a `KEEPALIVE` ping on
374 /// an idle path; cleared when the peer's `KEEPALIVE | ACK` echo arrives (or any
375 /// authenticated inbound packet refreshes liveness). While set it makes the
376 /// liveness sweep treat the path as having an outstanding probe, so a
377 /// silently-dead downstream on a download-only path is detected exactly like an
378 /// active one. A plain `AtomicBool` — the ping is a single outstanding probe, so
379 /// a richer counter buys nothing.
380 keepalive_outstanding: AtomicBool,
381}
382
383impl Session {
384 /// Create a new session with given shared secret
385 pub fn new(
386 session_id: SessionId,
387 shared_secret: &[u8; 32],
388 peer_side: bool,
389 ) -> Result<Self, CoreError> {
390 let crypto = CryptoState::new(shared_secret, peer_side)?;
391 // HP keys derive from the INITIAL secret + the negotiated suite and stay
392 // stable for the session (QUIC §6.1) — see the `header_protection` field.
393 let header_protection =
394 HeaderProtector::derive(crypto.session.cipher_suite(), shared_secret, peer_side);
395 let path_registry = Arc::new(PathRegistry::new());
396 // Pre-register `path_id = 0` as the implicit default path — the
397 // handshake itself proved reachability over this path, so no
398 // additional PATH_CHALLENGE is needed (Phase 4.2).
399 path_registry.register_validated(0);
400
401 Ok(Self {
402 id: session_id,
403 state: RwLock::new(SessionState::Handshaking),
404 crypto: ArcSwap::new(Arc::new(crypto)),
405 header_protection,
406 cid_chain: CidChain::derive(shared_secret, peer_side),
407 outbound_cid_index: AtomicU64::new(0),
408 inbound_cid_highest_seen: AtomicU64::new(0),
409 last_seen_path_id: AtomicU8::new(0),
410 traffic_secret: RwLock::new(*shared_secret),
411 epoch: AtomicU8::new(0),
412 rekey_after: AtomicU64::new(REKEY_SOFT_LIMIT),
413 rekey_lock: Mutex::new(()),
414 rekey_unconfirmed: AtomicBool::new(false),
415 padding_policy: AtomicU8::new(0),
416 jitter_max_ms: AtomicU32::new(0),
417 cover_min_interval_ms: AtomicU32::new(0),
418 is_server: peer_side,
419 streams: RwLock::new(HashMap::new()),
420 next_stream_id: AtomicU32::new(1),
421 scheduler: Arc::new(Scheduler::new(SchedulerMode::LowLatency)),
422 resumption_secret: RwLock::new(None),
423 last_activity: RwLock::new(Instant::now()),
424 liveness_config: RwLock::new(LivenessConfig::default()),
425 fallback: Arc::new(FallbackStateMachine::with_defaults()),
426 send_packet_number: AtomicU64::new(0),
427 send_path_id: AtomicU8::new(0),
428 recv_replay: Mutex::new(ReplayWindow::new()),
429 replay_rejected_total: AtomicU64::new(0),
430 path_registry,
431 pacer: Arc::new(Pacer::unlimited()),
432 bandwidth_estimator: parking_lot::Mutex::new(BandwidthEstimator::new()),
433 send_notify: Arc::new(tokio::sync::Notify::new()),
434 cid_slide_tx: Mutex::new(None),
435 keepalive_outstanding: AtomicBool::new(false),
436 })
437 }
438
439 /// Create session from a pre-derived crypto state (e.g., after handshake).
440 ///
441 /// `traffic_secret` is the master from which the supplied `crypto` was
442 /// derived — it seeds the [`rekey`](Self::rekey) HKDF chain. `is_server`
443 /// records which side of the handshake we are; rekey re-derives keys
444 /// with the same side so per-direction layout is preserved.
445 pub fn from_derived(
446 session_id: SessionId,
447 crypto: CryptoState,
448 scheduler_mode: SchedulerMode,
449 traffic_secret: [u8; 32],
450 is_server: bool,
451 ) -> Self {
452 let header_protection =
453 HeaderProtector::derive(crypto.session.cipher_suite(), &traffic_secret, is_server);
454 let path_registry = Arc::new(PathRegistry::new());
455 path_registry.register_validated(0);
456 Self {
457 id: session_id,
458 state: RwLock::new(SessionState::Connected),
459 crypto: ArcSwap::new(Arc::new(crypto)),
460 header_protection,
461 cid_chain: CidChain::derive(&traffic_secret, is_server),
462 outbound_cid_index: AtomicU64::new(0),
463 inbound_cid_highest_seen: AtomicU64::new(0),
464 last_seen_path_id: AtomicU8::new(0),
465 traffic_secret: RwLock::new(traffic_secret),
466 epoch: AtomicU8::new(0),
467 rekey_after: AtomicU64::new(REKEY_SOFT_LIMIT),
468 rekey_lock: Mutex::new(()),
469 rekey_unconfirmed: AtomicBool::new(false),
470 padding_policy: AtomicU8::new(0),
471 jitter_max_ms: AtomicU32::new(0),
472 cover_min_interval_ms: AtomicU32::new(0),
473 is_server,
474 streams: RwLock::new(HashMap::new()),
475 next_stream_id: AtomicU32::new(1),
476 scheduler: Arc::new(Scheduler::new(scheduler_mode)),
477 resumption_secret: RwLock::new(None),
478 last_activity: RwLock::new(Instant::now()),
479 liveness_config: RwLock::new(LivenessConfig::default()),
480 fallback: Arc::new(FallbackStateMachine::with_defaults()),
481 send_packet_number: AtomicU64::new(0),
482 send_path_id: AtomicU8::new(0),
483 recv_replay: Mutex::new(ReplayWindow::new()),
484 replay_rejected_total: AtomicU64::new(0),
485 path_registry,
486 pacer: Arc::new(Pacer::unlimited()),
487 bandwidth_estimator: parking_lot::Mutex::new(BandwidthEstimator::new()),
488 send_notify: Arc::new(tokio::sync::Notify::new()),
489 cid_slide_tx: Mutex::new(None),
490 keepalive_outstanding: AtomicBool::new(false),
491 }
492 }
493
494 /// Resume a session using resumption secret (0-RTT)
495 pub fn resume(
496 session_id: SessionId,
497 resumption_secret: &[u8; 32],
498 peer_side: bool,
499 ) -> Result<Self, CoreError> {
500 let crypto = CryptoState::new(resumption_secret, peer_side)?;
501 let header_protection =
502 HeaderProtector::derive(crypto.session.cipher_suite(), resumption_secret, peer_side);
503 let path_registry = Arc::new(PathRegistry::new());
504 path_registry.register_validated(0);
505
506 Ok(Self {
507 id: session_id,
508 state: RwLock::new(SessionState::Connected),
509 crypto: ArcSwap::new(Arc::new(crypto)),
510 header_protection,
511 cid_chain: CidChain::derive(resumption_secret, peer_side),
512 outbound_cid_index: AtomicU64::new(0),
513 inbound_cid_highest_seen: AtomicU64::new(0),
514 last_seen_path_id: AtomicU8::new(0),
515 traffic_secret: RwLock::new(*resumption_secret),
516 epoch: AtomicU8::new(0),
517 rekey_after: AtomicU64::new(REKEY_SOFT_LIMIT),
518 rekey_lock: Mutex::new(()),
519 rekey_unconfirmed: AtomicBool::new(false),
520 padding_policy: AtomicU8::new(0),
521 jitter_max_ms: AtomicU32::new(0),
522 cover_min_interval_ms: AtomicU32::new(0),
523 is_server: peer_side,
524 streams: RwLock::new(HashMap::new()),
525 next_stream_id: AtomicU32::new(1),
526 scheduler: Arc::new(Scheduler::new(SchedulerMode::LowLatency)),
527 resumption_secret: RwLock::new(Some(*resumption_secret)),
528 last_activity: RwLock::new(Instant::now()),
529 liveness_config: RwLock::new(LivenessConfig::default()),
530 fallback: Arc::new(FallbackStateMachine::with_defaults()),
531 send_packet_number: AtomicU64::new(0),
532 send_path_id: AtomicU8::new(0),
533 recv_replay: Mutex::new(ReplayWindow::new()),
534 replay_rejected_total: AtomicU64::new(0),
535 path_registry,
536 pacer: Arc::new(Pacer::unlimited()),
537 bandwidth_estimator: parking_lot::Mutex::new(BandwidthEstimator::new()),
538 send_notify: Arc::new(tokio::sync::Notify::new()),
539 cid_slide_tx: Mutex::new(None),
540 keepalive_outstanding: AtomicBool::new(false),
541 })
542 }
543
544 /// The envelope `ConnId` this peer currently stamps on outbound UDP datagrams
545 /// (ε / WIRE v5). At the current outbound CID index (0 until the first
546 /// `migrate()`) this is `CID_0` of the outbound chain — and it is exactly
547 /// `inbound_window_cids()[0]` for the peer, which is how the demux routes it.
548 /// TCP/embedded transports are socket-routed and ignore this.
549 pub fn current_outbound_cid(&self) -> [u8; CID_LEN] {
550 self.cid_chain
551 .outbound_cid(self.outbound_cid_index.load(Ordering::Relaxed))
552 }
553
554 /// This peer's current outbound CID migration index (ε / WIRE v5). 0 until the
555 /// first `migrate()`; exposed for the migration wiring (P4) and diagnostics.
556 pub fn outbound_cid_index(&self) -> u64 {
557 self.outbound_cid_index.load(Ordering::Relaxed)
558 }
559
560 /// Rotate the outbound CID one step (ε / WIRE v5; called on `migrate()`):
561 /// advance the outbound index and return the new `CID_{i+1}` for the transport
562 /// to stamp. The new CID is independent-random vs the previous one, so an
563 /// observer cannot link the pre- and post-migration flows; it stays inside the
564 /// peer's pre-registered inbound window for up to K migrations (the demux
565 /// window slides post-AEAD beyond that).
566 pub fn advance_outbound_cid(&self) -> [u8; CID_LEN] {
567 let i = self.outbound_cid_index.fetch_add(1, Ordering::Relaxed) + 1;
568 self.cid_chain.outbound_cid(i)
569 }
570
571 /// Track the peer's migration via its `path_id` and, on a NEW (forward) path,
572 /// slide the inbound CID demux window one step (ε / WIRE v5, P4b). Returns the
573 /// [`CidSlide`] to apply at the demux, or `None` if `path_id` is not newer — a
574 /// reordered-old or duplicate packet, or a passive rebind that did not rotate
575 /// the CID, none of which advance the index. Call **post-AEAD only** (the
576 /// `path_id` is then authenticated). The single-step advance keeps the window
577 /// tracking the peer's outbound index (both bump once per `migrate()`); the
578 /// leading window K absorbs any transient lag from a multi-hop jump.
579 pub fn note_migration_path(&self, path_id: u8) -> Option<CidSlide> {
580 let last = self.last_seen_path_id.load(Ordering::Relaxed);
581 // "Newer" = a forward distance in (0, 128] mod 256 — a reordered-old
582 // path_id is > 128 behind, and the 255 -> 1 migrate wrap is distance 2.
583 let fwd = path_id.wrapping_sub(last);
584 if fwd == 0 || fwd > 128 {
585 return None;
586 }
587 // CAS so concurrent recv handling slides exactly once per migration.
588 if self
589 .last_seen_path_id
590 .compare_exchange(last, path_id, Ordering::Relaxed, Ordering::Relaxed)
591 .is_err()
592 {
593 return None;
594 }
595 // EPS-01 — advance by the FULL forward delta `d`, not +1. The peer bumps
596 // its path_id and CID index in lock-step on each `migrate()`, so a forward
597 // path_id distance of `d` means it migrated `d` times; sliding the window by
598 // `d` recenters it on the peer's *actual* migration index. The pre-fix +1
599 // step let lost intermediate migrations cumulatively erode the leading
600 // window until the peer's CID fell out of it and the session stranded. The
601 // triggering packet's CID (= `inbound_cid(new_high)`) was inside the pre-slide
602 // window (else it would have been dropped pre-AEAD and we would never run
603 // here), so `d <= CID_WINDOW_LEADING` and the widened K bounds the per-slide
604 // churn; `anchor` is therefore in the demux route table for `apply_slide`.
605 let d = fwd as u64;
606 let old_high = self
607 .inbound_cid_highest_seen
608 .fetch_add(d, Ordering::Relaxed);
609 let new_high = old_high + d;
610 let anchor = self.cid_chain.inbound_cid(new_high);
611 // Register the `d` new leading-edge CIDs `(old_high+K, new_high+K]`.
612 let add: Vec<[u8; CID_LEN]> = ((old_high + CID_WINDOW_LEADING + 1)
613 ..=(new_high + CID_WINDOW_LEADING))
614 .map(|i| self.cid_chain.inbound_cid(i))
615 .collect();
616 // Drop the trailing CIDs that fell past the window `[old_lo, new_lo)`
617 // (saturating at index 0 — indices below 0 were never registered).
618 let old_lo = old_high.saturating_sub(CID_WINDOW_TRAILING);
619 let new_lo = new_high.saturating_sub(CID_WINDOW_TRAILING);
620 let remove: Vec<[u8; CID_LEN]> = (old_lo..new_lo)
621 .map(|i| self.cid_chain.inbound_cid(i))
622 .collect();
623 Some(CidSlide {
624 add,
625 remove,
626 anchor,
627 })
628 }
629
630 /// Install the demux slide channel (ε / WIRE v5, P4b) — called once by the
631 /// server's accept path so `handle_packet` can signal inbound-window slides.
632 pub fn set_cid_slide_tx(&self, tx: tokio::sync::mpsc::UnboundedSender<CidSlide>) {
633 *self.cid_slide_tx.lock() = Some(tx);
634 }
635
636 /// Signal the demux to apply a [`CidSlide`] (ε / WIRE v5, P4b). A no-op when no
637 /// slide channel is installed (the client and socket-routed transports).
638 pub fn signal_cid_slide(&self, slide: CidSlide) {
639 if let Some(tx) = self.cid_slide_tx.lock().as_ref() {
640 let _ = tx.send(slide);
641 }
642 }
643
644 /// The inbound CIDs the demux should route to this session (ε / WIRE v5): the
645 /// window `[highest_seen − T, highest_seen + K]` over this peer's inbound
646 /// chain. At session establishment (`highest_seen = 0`, trailing saturates)
647 /// this is the leading lookahead `[CID_0 .. CID_K]` (K + 1 entries). The
648 /// server registers these in its `RouteTable`; a datagram whose `ConnId` is in
649 /// the set routes to this session. The window slide on `migrate()` is P4.
650 pub fn inbound_window_cids(&self) -> Vec<[u8; CID_LEN]> {
651 self.cid_chain
652 .inbound_window(
653 self.inbound_cid_highest_seen.load(Ordering::Relaxed),
654 CID_WINDOW_TRAILING,
655 CID_WINDOW_LEADING,
656 )
657 .map(|(_, cid)| cid)
658 .collect()
659 }
660
661 /// Get session ID
662 pub fn id(&self) -> &SessionId {
663 &self.id
664 }
665
666 /// Get current state
667 pub fn state(&self) -> SessionState {
668 *self.state.read()
669 }
670
671 /// Transition to a new state
672 pub fn set_state(&self, new_state: SessionState) {
673 *self.state.write() = new_state;
674 }
675
676 /// Open a new stream
677 pub fn open_stream(&self) -> Arc<Stream> {
678 let stream_id = self.next_stream_id.fetch_add(1, Ordering::SeqCst) as StreamId;
679 let stream = Arc::new(Stream::new(stream_id));
680
681 self.streams.write().insert(stream_id, stream.clone());
682 stream
683 }
684
685 /// Get an existing stream
686 pub fn get_stream(&self, stream_id: StreamId) -> Option<Arc<Stream>> {
687 self.streams.read().get(&stream_id).cloned()
688 }
689
690 /// Close a stream
691 pub fn close_stream(&self, stream_id: StreamId) -> bool {
692 self.streams.write().remove(&stream_id).is_some()
693 }
694
695 /// Get number of active streams
696 pub fn stream_count(&self) -> u32 {
697 self.streams.read().len() as u32
698 }
699
700 /// Total number of replayed packets rejected by the sliding-window check
701 /// across all streams in this session. Intended for the
702 /// `replay_rejected_total` metric.
703 pub fn replay_rejected_total(&self) -> u64 {
704 self.replay_rejected_total.load(Ordering::Relaxed)
705 }
706
707 /// Current rekey generation (Phase 1.5). Starts at 0; each successful
708 /// [`rekey`](Self::rekey) increments by one. Carried on the wire in
709 /// `PacketHeader.epoch` so the peer can match the right derived key.
710 pub fn current_epoch(&self) -> u8 {
711 self.epoch.load(Ordering::Relaxed)
712 }
713
714 /// Whether this session is acting as the server side. Determined at
715 /// construction; required for re-deriving per-direction keys on rekey.
716 pub fn is_server(&self) -> bool {
717 self.is_server
718 }
719
720 /// Mid-session key rotation (Phase 1.5).
721 ///
722 /// Derives the next traffic secret from the current one via
723 /// `HKDF-Expand(current, "phantom-rekey-v1", 32)` and builds a fresh
724 /// [`CryptoState`] under that secret. The new state is installed via
725 /// an atomic `ArcSwap::store`, so concurrent encrypt/decrypt calls
726 /// observe either the old or the new state — never a partially-written
727 /// in-between. The previous traffic secret is explicitly zeroed before
728 /// being overwritten.
729 ///
730 /// Returns the new epoch (1, 2, 3, ...). Wraps an error if the epoch
731 /// counter has saturated `u8::MAX` (after 255 successful rekeys —
732 /// equivalent to ~5 days at the default 30-minute cadence; long-lived
733 /// sessions are expected to reconnect rather than wrap).
734 ///
735 /// Wire signalling: callers that want the peer to follow this rekey
736 /// emit a packet whose header carries the new epoch (with the
737 /// `PacketFlags::REKEY` flag set). Receivers respond by calling
738 /// `rekey()` themselves once they see the bump — keeping both ends in
739 /// lockstep.
740 #[tracing::instrument(name = "phantom.session.rekey", skip_all)]
741 pub fn rekey(&self) -> Result<u8, CoreError> {
742 // Serialise the whole transition (C1): the send loop and the receive
743 // task share this `Session`, so derive+install+epoch-bump must be atomic
744 // w.r.t. a concurrent receive-side ratchet, or the installed key depth
745 // and the epoch counter diverge and wedge the session.
746 let _rekey = self.rekey_lock.lock();
747 let current_epoch = self.epoch.load(Ordering::Relaxed);
748 if current_epoch == u8::MAX {
749 return Err(CoreError::CryptoError(
750 "session epoch saturated (u8::MAX); reconnect required".into(),
751 ));
752 }
753 let (next_secret, new_crypto) = self.derive_forward_crypto(1)?;
754 self.commit_forward_crypto(1, next_secret, new_crypto);
755 // T5.5(b): mark this locally-initiated rekey "unconfirmed" so the send
756 // path re-advertises `PacketFlags::REKEY` on EVERY new-epoch packet until
757 // the peer is seen at the new epoch. A lost trigger packet then no longer
758 // strands the peer behind the catch-up gate. Cleared in
759 // `confirm_rekey_caught_up` from the recv path. NOTE: a receive-side
760 // forward catch-up (`decrypt_packet_accepting_rekey` → `commit_forward_crypto`)
761 // deliberately does NOT set this — following the peer's rekey is not OUR
762 // rekey, so we owe no re-advertise.
763 self.rekey_unconfirmed.store(true, Ordering::Relaxed);
764 Ok(current_epoch + 1)
765 }
766
767 /// Derive the next epoch's traffic secret + [`CryptoState`] from the current
768 /// secret WITHOUT installing them. The HKDF chain step is
769 /// `HKDF-Expand(current, "phantom-rekey-v1", 32)` (Invariant 5 — the label is
770 /// load-bearing; it must match the committing path in `rekey`). Returns the
771 /// derived secret and a fresh per-direction AEAD state under it.
772 ///
773 /// This is the non-committing half used by the receive path to verify a
774 /// claimed-next-epoch packet (trial decrypt) before trusting the epoch bump,
775 /// so a forged, unauthenticated `header.epoch` cannot desync the session.
776 ///
777 /// `steps` ≥ 1 applies the chain that many times (the receive path may need
778 /// to catch up several epochs when both directions rekey at slightly
779 /// different cadences). Intermediate secrets are zeroed as the walk
780 /// proceeds; only the final-epoch secret is returned for the caller to
781 /// commit.
782 fn derive_forward_crypto(&self, steps: u8) -> Result<([u8; 32], CryptoState), CoreError> {
783 use zeroize::Zeroizing;
784 debug_assert!(steps >= 1, "derive_forward_crypto needs at least one step");
785 // `Zeroizing` so every intermediate secret — and the working copy of the
786 // current secret — is wiped on *every* exit path, including the early
787 // `?` returns (an attacker can force this derivation merely by setting
788 // `header.epoch`, so the candidate is genuinely sensitive).
789 let mut secret: Zeroizing<[u8; 32]> = Zeroizing::new(*self.traffic_secret.read());
790 for _ in 0..steps {
791 let mut next: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]);
792 let hk = hkdf::Hkdf::<sha2::Sha256>::from_prk(&*secret)
793 .map_err(|_| CoreError::KeyDerivationError)?;
794 // Invariant 5 — the `phantom-rekey-v1` label is load-bearing and must
795 // match the committing path in `rekey`.
796 hk.expand(b"phantom-rekey-v1", &mut *next)
797 .map_err(|_| CoreError::KeyDerivationError)?;
798 secret = next; // previous-step secret drops → zeroed
799 }
800 let new_crypto = CryptoState::new(&secret, self.is_server)?;
801 // Copy the bytes out for the caller; the `Zeroizing` working copy is
802 // wiped when it drops here. The caller is responsible for the returned
803 // secret (committed into `traffic_secret`, or zeroed on a failed trial).
804 Ok((*secret, new_crypto))
805 }
806
807 /// Install a [`derive_forward_crypto`](Self::derive_forward_crypto)d epoch:
808 /// swap in the new `CryptoState` via the lock-free `ArcSwap`, zero+replace
809 /// the traffic secret under the write lock, and saturatingly advance the
810 /// epoch by `steps` (Invariant 5 — epoch never wraps). The caller MUST have
811 /// authenticated the transition (a successful trial decrypt, or its own
812 /// send-side rekey) — this routine verifies nothing itself.
813 fn commit_forward_crypto(&self, steps: u8, final_secret: [u8; 32], new_crypto: CryptoState) {
814 let mut current = self.traffic_secret.write();
815 // Install the new AEAD state, then the new epoch (SeqCst) so the wire
816 // header the send path stamps matches the key it encrypts under.
817 self.crypto.store(Arc::new(new_crypto));
818 // Zero the old secret before overwriting it so the previous-epoch key
819 // material does not survive in memory.
820 current.zeroize();
821 *current = final_secret;
822 let cur = self.epoch.load(Ordering::Relaxed);
823 self.epoch
824 .store(cur.saturating_add(steps), Ordering::SeqCst);
825 }
826
827 /// Send-side AEAD invocation count for the current epoch (resets to 0 on
828 /// each rekey). Drives [`send_needs_rekey`](Self::send_needs_rekey).
829 pub fn send_invocations(&self) -> u64 {
830 self.crypto.load().send_invocations()
831 }
832
833 /// The send-invocation high-watermark at which the pump auto-rekeys.
834 pub fn rekey_threshold(&self) -> u64 {
835 self.rekey_after.load(Ordering::Relaxed)
836 }
837
838 /// Override the auto-rekey high-watermark (default [`REKEY_SOFT_LIMIT`]).
839 /// Clamped to `>= 1`. Rust-only — primarily for tests/soak harnesses that
840 /// need to exercise mid-session rekey without sending `2^47` packets.
841 pub fn set_rekey_threshold(&self, n: u64) {
842 self.rekey_after.store(n.max(1), Ordering::Relaxed);
843 }
844
845 /// The active anti-fingerprint size-padding policy (WIRE v6, deliverable (c)).
846 /// Default [`PaddingPolicy::None`] (shaping is opt-in).
847 pub fn padding_policy(&self) -> PaddingPolicy {
848 match self.padding_policy.load(Ordering::Relaxed) {
849 1 => PaddingPolicy::Padme,
850 _ => PaddingPolicy::None,
851 }
852 }
853
854 /// Set the size-padding policy. When [`PaddingPolicy::Padme`], the send path
855 /// pads every packet up to a PADÉ bucket (inside the AEAD) before sealing, so
856 /// the datagram size no longer tracks the payload size — at a bounded
857 /// (≈ ≤12% worst-case) bandwidth cost.
858 pub fn set_padding_policy(&self, policy: PaddingPolicy) {
859 let v = match policy {
860 PaddingPolicy::None => 0,
861 PaddingPolicy::Padme => 1,
862 };
863 self.padding_policy.store(v, Ordering::Relaxed);
864 }
865
866 /// The send-timing jitter ceiling as a `Duration` (WIRE v6, deliverable (d)).
867 /// `Duration::ZERO` = jitter off (default).
868 pub fn send_jitter(&self) -> Duration {
869 Duration::from_millis(self.jitter_max_ms.load(Ordering::Relaxed) as u64)
870 }
871
872 /// Set the send-timing jitter ceiling in milliseconds (`0` = off). When set,
873 /// the send path waits a uniform random `[0, ms]` ms before each packet so
874 /// inter-packet timing no longer tracks the application's writes, at a cost of
875 /// up to `ms` of added latency per packet.
876 pub fn set_jitter_ms(&self, ms: u32) {
877 self.jitter_max_ms.store(ms, Ordering::Relaxed);
878 }
879
880 /// The cover-traffic floor interval as a `Duration` (WIRE v6, deliverable (e)).
881 /// `Duration::ZERO` = cover off (default).
882 pub fn cover_interval(&self) -> Duration {
883 Duration::from_millis(self.cover_min_interval_ms.load(Ordering::Relaxed) as u64)
884 }
885
886 /// Set the cover-traffic floor interval in milliseconds (`0` = off). When set,
887 /// the pump maintains a minimum outbound packet rate of `1000 / ms` packets/sec
888 /// by emitting an `ENCRYPTED | COVER` dummy packet whenever no packet has gone
889 /// out for `ms` — hiding idle/active patterns and volume, at a steady bandwidth
890 /// cost.
891 pub fn set_cover_interval_ms(&self, ms: u32) {
892 self.cover_min_interval_ms.store(ms, Ordering::Relaxed);
893 }
894
895 /// True once the send direction has crossed the rekey high-watermark and the
896 /// epoch has room to advance. The data pump checks this before each
897 /// application send and, when set, rekeys + flags the packet `REKEY` so the
898 /// peer follows via the authenticated epoch bump.
899 pub fn send_needs_rekey(&self) -> bool {
900 self.current_epoch() < u8::MAX
901 && self.send_invocations() >= self.rekey_after.load(Ordering::Relaxed)
902 }
903
904 /// T5.5(b) — true while a locally-initiated [`rekey`](Self::rekey) is still
905 /// unacknowledged by the peer. The send path OR-s `PacketFlags::REKEY` into
906 /// every outbound header while this holds, so the catch-up gate in
907 /// [`decrypt_packet_accepting_rekey`](Self::decrypt_packet_accepting_rekey)
908 /// can safely reject an unflagged forward epoch: an honest not-yet-confirmed
909 /// sender always re-advertises the flag. See the field doc for the relaxed
910 /// ordering rationale.
911 pub fn rekey_unconfirmed(&self) -> bool {
912 self.rekey_unconfirmed.load(Ordering::Relaxed)
913 }
914
915 /// Clear the [`rekey_unconfirmed`](Self::rekey_unconfirmed) re-advertise flag.
916 /// Called from the receive path on a successful authenticated decrypt of an
917 /// inbound packet at our current epoch (after any forward catch-up that packet
918 /// drove) — proof the peer has reached our epoch, so we stop re-advertising
919 /// `REKEY` (T5.5b). A no-op when the flag is already clear.
920 fn confirm_rekey_caught_up(&self) {
921 self.rekey_unconfirmed.store(false, Ordering::Relaxed);
922 }
923
924 /// Decrypt a packet, transparently following an **authenticated** forward
925 /// rekey of up to [`MAX_REKEY_CATCHUP`] epochs (C1).
926 ///
927 /// - `header.epoch == current`: ordinary [`decrypt_packet`](Self::decrypt_packet).
928 /// - `current < header.epoch <= current + MAX_REKEY_CATCHUP`: derive the
929 /// candidate key that many epochs ahead and *trial*-decrypt. Only on AEAD
930 /// success — i.e. once the epoch bump is proven authentic — is the rekey
931 /// committed and the replay window consulted (Invariant 4 ordering
932 /// preserved). A forged `header.epoch` fails the AEAD open, nothing is
933 /// committed, and the session does not desync. The bound caps an attacker
934 /// to at most `MAX_REKEY_CATCHUP` HKDF steps per spoofed packet.
935 /// - anything else (behind current, more than `MAX_REKEY_CATCHUP` ahead, or
936 /// epoch saturated): rejected. Over a reliable transport the sender
937 /// retransmits at the then-current epoch, so no data is lost.
938 pub fn decrypt_packet_accepting_rekey(
939 &self,
940 header: &PacketHeader,
941 ciphertext: &[u8],
942 extensions: &[u8],
943 ) -> Result<Vec<u8>, CoreError> {
944 // Fast paths that need no epoch transition (no lock).
945 let cur = self.current_epoch();
946 if header.epoch == cur {
947 let pt = self.decrypt_packet(header, ciphertext, extensions)?;
948 // T5.5(b): an authenticated peer packet at our current epoch proves
949 // the peer caught up to any rekey we initiated — stop re-advertising.
950 self.confirm_rekey_caught_up();
951 return Ok(pt);
952 }
953 if header.epoch < cur {
954 return Err(CoreError::CryptoError(format!(
955 "packet epoch {} is behind the current epoch {}",
956 header.epoch, cur
957 )));
958 }
959
960 // T5.5(b) catch-up GATE: a forward epoch must carry `PacketFlags::REKEY`.
961 // An honest sender that rekeyed re-advertises REKEY on EVERY new-epoch
962 // packet until we confirm (see `rekey_unconfirmed`), so an unflagged
963 // forward epoch is forged/corrupt — reject it cheaply HERE, before taking
964 // the rekey lock and running the HKDF catch-up walk. This tightens the DoS
965 // bound (a spoofed forward epoch with the flag cleared forces zero key
966 // derivation; with the flag set it still costs at most MAX_REKEY_CATCHUP
967 // HKDF steps + a failing trial decrypt). A genuine not-yet-caught-up sender
968 // is unaffected: it always sets the flag. NB this read is race-free against
969 // a concurrent send-side rekey — for `header.epoch` to be forward here our
970 // epoch is < header.epoch, and an honest sender only stops flagging once we
971 // have already reached its epoch (it saw our packet there), at which point
972 // header.epoch would not be forward. See the field doc.
973 if !header.flags.contains(PacketFlags::REKEY) {
974 return Err(CoreError::CryptoError(format!(
975 "forward epoch {} without the REKEY flag; rejected before catch-up (current {})",
976 header.epoch, cur
977 )));
978 }
979
980 // A forward ratchet mutates the epoch + key, so it must be serialised
981 // against a concurrent send-side `rekey()` (C1 — both tasks share this
982 // `Session`). Hold the rekey lock across the re-check, derive, trial
983 // decrypt, and commit so the installed key depth and the epoch stay in
984 // lockstep.
985 let _rekey = self.rekey_lock.lock();
986 // Re-read under the lock: a concurrent rekey may have already advanced us.
987 let cur = self.current_epoch();
988 if header.epoch == cur {
989 drop(_rekey);
990 let pt = self.decrypt_packet(header, ciphertext, extensions)?;
991 self.confirm_rekey_caught_up();
992 return Ok(pt);
993 }
994 if header.epoch < cur {
995 return Err(CoreError::CryptoError(format!(
996 "packet epoch {} is behind the current epoch {}",
997 header.epoch, cur
998 )));
999 }
1000 let steps = header.epoch - cur; // > 0, both u8 → no underflow
1001 if steps > MAX_REKEY_CATCHUP {
1002 return Err(CoreError::CryptoError(format!(
1003 "packet epoch {} is more than {} epochs ahead of current {}",
1004 header.epoch, MAX_REKEY_CATCHUP, cur
1005 )));
1006 }
1007
1008 // Candidate key `steps` epochs ahead — derived but NOT installed.
1009 let (mut final_secret, final_crypto) = self.derive_forward_crypto(steps)?;
1010 let nonce = Self::build_packet_nonce(final_crypto.nonce_prefix(), header);
1011 // AEAD gate: a forged epoch bump fails here and we return without
1012 // committing — the live epoch is untouched. Zero the (valid, sensitive)
1013 // candidate secret we are not going to install.
1014 let plaintext = match Self::with_packet_aad(header, extensions, |aad| {
1015 final_crypto.decrypt_with_nonce(nonce, aad, ciphertext)
1016 }) {
1017 Ok(pt) => pt,
1018 Err(e) => {
1019 final_secret.zeroize();
1020 return Err(e);
1021 }
1022 };
1023
1024 // Authentic forward rekey — commit (still under the rekey lock; `cur` was
1025 // read under it, so `cur + steps == header.epoch` is the absolute, race-
1026 // free target), then drop the lock and apply the replay window AFTER the
1027 // AEAD open (Invariant 4 — the single per-direction window keys on the
1028 // `u64` packet number and is epoch-independent, so it needs no rekey
1029 // serialisation).
1030 self.commit_forward_crypto(steps, final_secret, final_crypto);
1031 drop(_rekey);
1032 let accepted = self.recv_replay.lock().accept(header.packet_number);
1033 if !accepted {
1034 self.replay_rejected_total.fetch_add(1, Ordering::Relaxed);
1035 return Err(CoreError::ReplayDetected(format!(
1036 "packet_number {} already seen or beyond window",
1037 header.packet_number
1038 )));
1039 }
1040 // T5.5(b): we just followed the peer forward and committed — the peer is
1041 // now at our (new) current epoch, which is >= any epoch we rekeyed to, so
1042 // our own pending rekey (if any) is confirmed. Stop re-advertising.
1043 self.confirm_rekey_caught_up();
1044 Ok(plaintext)
1045 }
1046
1047 /// Advance to a specific target epoch by repeatedly applying the rekey
1048 /// HKDF chain. Used by the receive path to "catch up" when it sees a
1049 /// packet from a higher epoch than the locally known one. Refuses to go
1050 /// backwards (a lower target than current returns Ok without changes).
1051 pub fn ratchet_to_epoch(&self, target: u8) -> Result<(), CoreError> {
1052 let mut current = self.epoch.load(Ordering::Relaxed);
1053 while current < target {
1054 self.rekey()?;
1055 current = self.epoch.load(Ordering::Relaxed);
1056 }
1057 Ok(())
1058 }
1059
1060 // ── Multi-path / migration (Phase 4.2) ────────────────────────────
1061
1062 /// Snapshot of currently `Validated` path ids (those that completed a
1063 /// challenge/response round trip, plus the always-validated path 0).
1064 pub fn validated_paths(&self) -> Vec<u8> {
1065 self.path_registry.validated_paths()
1066 }
1067
1068 /// State of a specific path within this session. Returns `None` for
1069 /// path ids the session has never observed.
1070 pub fn path_state(&self, path_id: u8) -> Option<PathStateKind> {
1071 self.path_registry.state(path_id)
1072 }
1073
1074 /// Register a new path id and immediately issue a 32-byte
1075 /// PATH_CHALLENGE for it. Returns the challenge bytes; the caller
1076 /// must transmit them in a packet with `PacketFlags::PATH_VALIDATION`
1077 /// set on the new path. Subsequent calls on an already-Validating
1078 /// path re-issue a fresh challenge.
1079 ///
1080 /// Returns `None` if the path is in a terminal state (`Validated`
1081 /// or `Failed`).
1082 #[tracing::instrument(name = "phantom.path.begin_validation", skip_all, fields(path_id = path_id))]
1083 pub fn begin_path_validation(&self, path_id: u8) -> Option<[u8; PATH_CHALLENGE_LEN]> {
1084 self.path_registry.register(path_id);
1085 self.path_registry.issue_challenge(path_id)
1086 }
1087
1088 /// Register `path_id` as `Unvalidated` if the session has never observed it
1089 /// (PATH-001). Used by the receive-side gate to start tracking a fresh path
1090 /// id seen on inbound (AEAD-authenticated) data, so a later
1091 /// challenge/response can promote it to `Validated`. Idempotent — never
1092 /// resets an already-known path (e.g. the pre-validated path 0).
1093 pub(crate) fn register_unvalidated_path(&self, path_id: u8) {
1094 if self.path_registry.state(path_id).is_none() {
1095 self.path_registry.register(path_id);
1096 }
1097 }
1098
1099 /// Remove a path from the registry so its id becomes re-registerable (D5 —
1100 /// Phase 4). Used by the M-3 passive-rebind flow to retire the reserved
1101 /// validation id after a successful promotion, so a later rebind can re-issue a
1102 /// fresh challenge on it (a `Validated` path would otherwise refuse a new
1103 /// challenge). No-op for unknown ids. Reuse is nonce-safe because ① took
1104 /// `path_id` out of the AEAD nonce.
1105 pub(crate) fn retire_path(&self, path_id: u8) {
1106 self.path_registry.retire(path_id);
1107 }
1108
1109 /// Verify a peer's `PATH_VALIDATION` response. Returns `true` if
1110 /// the response matches the in-flight challenge (path is now
1111 /// `Validated`). Returns `false` otherwise — the path may have
1112 /// transitioned to `Failed`.
1113 #[tracing::instrument(name = "phantom.path.complete_validation", skip(response), fields(path_id = path_id))]
1114 pub fn complete_path_validation(&self, path_id: u8, response: &[u8]) -> bool {
1115 self.path_registry.verify_response(path_id, response)
1116 }
1117
1118 /// Record that a packet was observed on the path. Cheap to call
1119 /// per-packet — used by the data pump to keep `last_packet_seen`
1120 /// fresh for the timeout sweep.
1121 pub fn mark_path_seen(&self, path_id: u8) {
1122 self.path_registry.mark_seen(path_id);
1123 }
1124
1125 // ── Pacer / BandwidthEstimator (Phase 2.6) ─────────────────────────
1126
1127 /// Shared handle to this session's outbound rate-limiter. Cheap to
1128 /// clone (`Arc`). The data pump consults this before every outbound
1129 /// packet; idle by default ([`Pacer::unlimited`]).
1130 pub fn pacer(&self) -> Arc<Pacer> {
1131 self.pacer.clone()
1132 }
1133
1134 /// Record that a packet of `bytes` length is going on the wire.
1135 /// Feeds the BBR-style bandwidth estimator. Cheap (one mutex lock
1136 /// + a counter increment).
1137 pub fn on_packet_sent(&self, bytes: u64) {
1138 self.bandwidth_estimator.lock().on_send(bytes);
1139 }
1140
1141 /// Record that an ACK arrived with delivery sample `sample`. The
1142 /// returned `u64` is the updated bottleneck bandwidth estimate; we
1143 /// reflect it into the pacer so the outbound rate tracks the
1144 /// peer's actual receive throughput.
1145 pub fn on_packet_acked(&self, sample: DeliverySample) -> u64 {
1146 let bw = self.bandwidth_estimator.lock().on_ack(sample);
1147 // Mirror the estimator's pacing decision onto the pacer so the
1148 // two stay in lock-step.
1149 let rate = self.bandwidth_estimator.lock().pacing_rate();
1150 if rate > 0 {
1151 self.pacer.set_rate(rate);
1152 }
1153 bw
1154 }
1155
1156 /// Record that a packet of `bytes` length was lost (no ACK before
1157 /// retransmit timer fired). Drives BBR's loss-based feedback.
1158 pub fn on_packet_lost(&self, bytes: u64) {
1159 self.bandwidth_estimator.lock().on_loss(bytes);
1160 }
1161
1162 /// Reset the congestion controller + pacer to startup (Phase 4 / QUIC §9.4):
1163 /// a migration path switch lands on a different network, so the old
1164 /// bottleneck-bandwidth / cwnd estimate must not carry over — inheriting a low
1165 /// RTT/cwnd would trigger a spurious-retransmit storm on the first packets of
1166 /// the new path. Wired by the P4.2 migration switch.
1167 pub fn reset_congestion(&self) {
1168 let mut est = self.bandwidth_estimator.lock();
1169 *est = BandwidthEstimator::new();
1170 let rate = est.pacing_rate();
1171 drop(est);
1172 // Drop the dead path's stale pacing rate; BBR re-paces on the first ACK.
1173 self.pacer.set_rate(rate);
1174 }
1175
1176 /// Current BBR congestion-control state. Observability / test hook — lets
1177 /// callers confirm a loss drove the estimator into `FastRecovery`.
1178 pub fn bbr_state(&self) -> crate::transport::bandwidth_estimator::BbrState {
1179 self.bandwidth_estimator.lock().state()
1180 }
1181
1182 /// Read a snapshot of the bandwidth / pacing estimator. Cheap; held
1183 /// over a single mutex lock.
1184 pub fn bandwidth_snapshot(&self) -> BandwidthSnapshot {
1185 let est = self.bandwidth_estimator.lock();
1186 BandwidthSnapshot {
1187 bottleneck_bw_bps: est.bottleneck_bandwidth(),
1188 min_rtt: est.min_rtt(),
1189 pacing_rate_bps: est.pacing_rate(),
1190 cwnd_bytes: est.cwnd(),
1191 inflight_bytes: est.inflight_bytes(),
1192 }
1193 }
1194
1195 // ── Event-driven send-loop wake-up (Phase 2.4) ─────────────────────
1196
1197 /// Shared handle to the outbound-ready notify. The API-layer data
1198 /// pump awaits this via `Notify::notified()`; any task with the
1199 /// handle can wake it instantly via [`Self::notify_outbound_ready`].
1200 pub fn send_notifier(&self) -> Arc<tokio::sync::Notify> {
1201 self.send_notify.clone()
1202 }
1203
1204 /// Wake the data pump's send loop immediately so it can drain newly-
1205 /// queued packets instead of waiting for the next 10 ms tick. Cheap
1206 /// (a single `notify_one()`); duplicate calls collapse to one wake.
1207 pub fn notify_outbound_ready(&self) {
1208 self.send_notify.notify_one();
1209 }
1210
1211 /// Reserve the next outbound packet number for this direction (① — Phase 4).
1212 /// Strictly monotonic; never reused within a session. Drawn by the data pump
1213 /// for every outbound packet (data, control, path-validation, retransmit) at
1214 /// send time, so the AEAD nonce is never reused.
1215 pub fn next_send_pn(&self) -> PacketNumber {
1216 self.send_packet_number.fetch_add(1, Ordering::SeqCst)
1217 }
1218
1219 /// Read the next send packet number **without** consuming it. Used by the cover-
1220 /// traffic timer as a lock-free "did we send anything since last tick?" signal
1221 /// (every outbound packet draws a fresh PN via [`next_send_pn`](Self::next_send_pn)).
1222 pub fn peek_send_pn(&self) -> PacketNumber {
1223 self.send_packet_number.load(Ordering::SeqCst)
1224 }
1225
1226 /// The client-owned send-side `path_id` currently stamped on outbound packets
1227 /// (D5 — Phase 4). Defaults to 0 (the implicit handshake path). Bumped by
1228 /// [`next_migration_path_id`](Self::next_migration_path_id) on a migration.
1229 pub fn current_send_path_id(&self) -> u8 {
1230 self.send_path_id.load(Ordering::SeqCst)
1231 }
1232
1233 /// Bump the send-side `path_id` to a fresh value and return it (D5 — Phase 4).
1234 /// Called on each `migrate()`/rebind so the peer's source-change detector sees a
1235 /// new path label and issues a PATH_CHALLENGE. Never returns 0 — that id is
1236 /// permanently the always-Validated handshake path, so reusing it would make the
1237 /// peer skip the challenge and the switch could never fire. Also never returns
1238 /// [`REBIND_VALIDATION_PATH_ID`] (`255`), reserved for the M-3 passive-rebind
1239 /// challenge — sharing that slot would let an active-migration echo and a
1240 /// passive-rebind echo resolve each other's registry entry. Wraps `254 → 1`.
1241 /// Reuse of a retired id is nonce-safe because ① took `path_id` out of the AEAD
1242 /// nonce (`nonce = nonce_prefix ‖ packet_number`).
1243 pub fn next_migration_path_id(&self) -> u8 {
1244 // migrate() is embedder-driven and not concurrent, but a CAS loop keeps the
1245 // "never 0 / never reserved" invariant correct even under a racing caller.
1246 loop {
1247 let cur = self.send_path_id.load(Ordering::SeqCst);
1248 // Skip 0 (handshake path) and REBIND_VALIDATION_PATH_ID (reserved).
1249 let next = match cur.wrapping_add(1) {
1250 0 | REBIND_VALIDATION_PATH_ID => 1,
1251 n => n,
1252 };
1253 if self
1254 .send_path_id
1255 .compare_exchange(cur, next, Ordering::SeqCst, Ordering::SeqCst)
1256 .is_ok()
1257 {
1258 return next;
1259 }
1260 }
1261 }
1262
1263 /// Build the AEAD nonce (① — Phase 4): `prefix(4) ‖ packet_number(8)` = 12 B.
1264 ///
1265 /// `epoch` / `stream_id` / `path_id` are authenticated in the 47-byte AAD but
1266 /// NOT in the nonce — a `u64` packet number is unique per direction within an
1267 /// epoch (and the prefix is fresh per epoch), so the nonce is never reused.
1268 /// `epoch` is still read from the header to select the key. Audit anchor: "PN
1269 /// strictly monotonic, used once per direction → nonce never reused, full stop."
1270 fn build_packet_nonce(prefix: [u8; 4], header: &PacketHeader) -> [u8; 12] {
1271 let mut n = [0u8; 12];
1272 n[..4].copy_from_slice(&prefix);
1273 n[4..12].copy_from_slice(&header.packet_number.to_be_bytes());
1274 n
1275 }
1276
1277 /// Run `f` with the AEAD AAD for a packet: the 47-byte header AAD image
1278 /// (`PacketHeader::to_aad_image` — `version ‖ session_id ‖ packet_number ‖
1279 /// flags ‖ stream_id ‖ epoch ‖ path_id`) followed by the packet's
1280 /// `extensions` TLV.
1281 ///
1282 /// ε / WIRE v5 — `session_id` is off-wire (the on-wire header is 15 bytes),
1283 /// but the AAD stays the byte-identical 47-byte v4 image so the AEAD security
1284 /// argument is unchanged; the recv path reconstructs `header.session_id` from
1285 /// the session context before calling this (see [`Self::parse_protected`]).
1286 ///
1287 /// T4.1 — `extensions` (the forward-compat TLV headroom) is authenticated
1288 /// here, closing the prior malleability where it sat *outside* the AAD and an
1289 /// on-path attacker could rewrite the trailing bytes without breaking the tag.
1290 /// When `extensions` is empty — the data-plane default — the AAD is exactly
1291 /// the 47-byte header image and no allocation occurs, so the hot path stays
1292 /// alloc-free.
1293 #[inline]
1294 fn with_packet_aad<R>(
1295 header: &PacketHeader,
1296 extensions: &[u8],
1297 f: impl FnOnce(&[u8]) -> R,
1298 ) -> R {
1299 let header_bytes = header.to_aad_image();
1300 if extensions.is_empty() {
1301 f(&header_bytes)
1302 } else {
1303 let mut aad = Vec::with_capacity(header_bytes.len() + extensions.len());
1304 aad.extend_from_slice(&header_bytes);
1305 aad.extend_from_slice(extensions);
1306 f(&aad)
1307 }
1308 }
1309
1310 /// Encrypt a packet payload.
1311 ///
1312 /// The AEAD nonce is `prefix || packet_number` (① — Phase 4), derived from the
1313 /// authenticated header rather than an internal counter, so a failed peer
1314 /// decrypt never desyncs the receiver. The AAD is the 47-byte AAD image of the
1315 /// header ([`PacketHeader::to_aad_image`]) — which still binds `session_id` /
1316 /// `epoch` / `stream_id` / `path_id` even though the wire header is 15 bytes —
1317 /// followed by the packet's `extensions` TLV (T4.1), so any
1318 /// wire-level mutation of the header *or* the extensions invalidates the tag.
1319 pub fn encrypt_packet(
1320 &self,
1321 header: &PacketHeader,
1322 plaintext: &[u8],
1323 extensions: &[u8],
1324 ) -> Result<Vec<u8>, CoreError> {
1325 let crypto = self.crypto.load();
1326 let nonce = Self::build_packet_nonce(crypto.nonce_prefix(), header);
1327 Self::with_packet_aad(header, extensions, |aad| {
1328 crypto.encrypt_with_nonce(nonce, aad, plaintext)
1329 })
1330 }
1331
1332 /// Decrypt a packet payload. Performs AEAD verify + per-direction
1333 /// sliding-window replay rejection on the packet number (the window check runs
1334 /// **after** a successful AEAD open — Invariant 4 — so we never key off
1335 /// un-authenticated packet numbers).
1336 ///
1337 /// A failed decrypt does NOT desync future decrypts: the AEAD nonce is
1338 /// derived from this packet's authenticated header fields, so the receiver
1339 /// stays in lock-step with the sender regardless of intervening bad
1340 /// packets.
1341 pub fn decrypt_packet(
1342 &self,
1343 header: &PacketHeader,
1344 ciphertext: &[u8],
1345 extensions: &[u8],
1346 ) -> Result<Vec<u8>, CoreError> {
1347 let crypto = self.crypto.load();
1348 let nonce = Self::build_packet_nonce(crypto.nonce_prefix(), header);
1349 let plaintext = Self::with_packet_aad(header, extensions, |aad| {
1350 crypto.decrypt_with_nonce(nonce, aad, ciphertext)
1351 })?;
1352
1353 // Sliding-window guard (Invariant 4 — runs AFTER the AEAD open above).
1354 // The single per-direction `ReplayWindow` keys on the `u64` packet_number
1355 // only — the `epoch` / `path_id` fields do NOT contribute to the replay
1356 // identity because replay is a property of "is this packet number a
1357 // duplicate", independent of which path it arrived over or which rekey
1358 // generation produced it.
1359 let accepted = self.recv_replay.lock().accept(header.packet_number);
1360 if !accepted {
1361 self.replay_rejected_total.fetch_add(1, Ordering::Relaxed);
1362 return Err(CoreError::ReplayDetected(format!(
1363 "packet_number {} already seen or beyond window",
1364 header.packet_number
1365 )));
1366 }
1367 Ok(plaintext)
1368 }
1369
1370 // ── Header protection (T4.6, QUIC RFC 9001 §5.4) ──────────────────────────
1371
1372 /// Serialise a packet to its **header-protected** on-wire bytes: the WHOLE
1373 /// 15-byte header span (WIRE v6: `[0..15]`, version byte included) is XOR-masked
1374 /// with this session's send-direction HP key, keyed by the packet's ciphertext
1375 /// sample (the first 16 bytes of `packet.payload`). The data plane calls this
1376 /// instead of `to_wire` for every post-handshake packet. `packet.payload` must be
1377 /// AEAD ciphertext (>= the 16-byte tag) — always true for the output of
1378 /// [`encrypt_packet`](Self::encrypt_packet).
1379 pub fn protect_packet(&self, packet: &PhantomPacket) -> Result<Vec<u8>, CoreError> {
1380 let sample = Self::hp_sample(&packet.payload)?;
1381 let mask = self.header_protection.mask_send(&sample)?;
1382 Ok(packet.to_wire_masked(&mask))
1383 }
1384
1385 /// Parse a **header-protected** wire packet: recover the cleartext header by
1386 /// unmasking the WHOLE 15-byte span (WIRE v6: `[0..15]`, version byte included)
1387 /// with this session's recv-direction HP key (keyed by the cleartext ciphertext
1388 /// sample), reconstruct the off-wire `session_id` from this session's id (ε /
1389 /// WIRE v5), then reassemble the `PhantomPacket`. The caller still gates on
1390 /// `version` and runs AEAD on the result — a wire mutation of the masked region
1391 /// unmasks to a wrong header, so the subsequent AEAD open fails (no new oracle).
1392 /// A short / malformed frame returns `Err` and is dropped by the recv loop.
1393 pub fn parse_protected(&self, bytes: &[u8]) -> Result<PhantomPacket, CoreError> {
1394 let raw = RawPacket::from_wire(bytes)
1395 .map_err(|e| CoreError::CryptoError(format!("HP: malformed wire packet: {e}")))?;
1396 let sample = Self::hp_sample(&raw.payload)?;
1397 let mask = self.header_protection.mask_recv(&sample)?;
1398 let mut header = raw
1399 .unmask_header(&mask)
1400 .map_err(|e| CoreError::CryptoError(format!("HP: header unmask failed: {e}")))?;
1401 // ε / WIRE v5: session_id is off-wire — reconstruct it from the routed
1402 // session context so the AAD image matches what the sender authenticated
1403 // (the 47-byte `to_aad_image`). A wrong session here → wrong AAD → AEAD
1404 // fail; this is the off-wire analogue of the v4 cleartext-session_id bind.
1405 header.session_id = *self.id();
1406 Ok(raw.into_packet(header))
1407 }
1408
1409 /// The 16-byte HP sample = the first [`HP_SAMPLE_LEN`] bytes of the AEAD
1410 /// ciphertext. The tag is always present, so this exists even for an
1411 /// empty-plaintext packet (sample = tag). A payload shorter than the tag is a
1412 /// malformed / never-encrypted frame → typed error, never a panic.
1413 #[inline]
1414 fn hp_sample(payload: &[u8]) -> Result<[u8; HP_SAMPLE_LEN], CoreError> {
1415 payload
1416 .get(..HP_SAMPLE_LEN)
1417 .and_then(|s| <[u8; HP_SAMPLE_LEN]>::try_from(s).ok())
1418 .ok_or_else(|| {
1419 CoreError::CryptoError("HP: packet payload shorter than the AEAD tag".into())
1420 })
1421 }
1422
1423 /// The path scheduler. **Vestigial** — single-path connection migration means
1424 /// `select_paths` is never driven on the live data path; exposed for diagnostics
1425 /// / per-leg RTT-loss inspection only. See `SchedulerMode`.
1426 pub fn scheduler(&self) -> &Arc<Scheduler> {
1427 &self.scheduler
1428 }
1429
1430 /// Set resumption secret for 0-RTT.
1431 ///
1432 /// If a secret was already set, the previous bytes are explicitly zeroed
1433 /// before being replaced — defense in depth in case `set_resumption_secret`
1434 /// is called multiple times within a session.
1435 pub fn set_resumption_secret(&self, secret: [u8; 32]) {
1436 let mut guard = self.resumption_secret.write();
1437 if let Some(mut old) = guard.take() {
1438 old.zeroize();
1439 }
1440 *guard = Some(secret);
1441 }
1442
1443 /// The resumption secret for 0-RTT, if one has been installed. Rust-only —
1444 /// the FFI surface exposes this via `PhantomSession::resumption_hint()`.
1445 pub fn resumption_secret(&self) -> Option<[u8; 32]> {
1446 *self.resumption_secret.read()
1447 }
1448
1449 /// Check if session can be resumed (has resumption secret)
1450 pub fn can_resume(&self) -> bool {
1451 self.resumption_secret.read().is_some()
1452 }
1453
1454 /// Extract the resumption hint needed to attempt 0-RTT resume on
1455 /// a future connect (Phase 4.1). Returns `Some((session_id_bytes,
1456 /// resumption_secret))` only after a successful handshake — the
1457 /// resumption_secret is set by `process_client_hello` /
1458 /// `process_server_hello` once shared key material is in place.
1459 ///
1460 /// The caller is responsible for storing the tuple alongside the
1461 /// pinned `HybridVerifyingKey` of the server it was negotiated
1462 /// with. Mixing tickets across servers is a configuration bug —
1463 /// the resumption_secret is server-pinned.
1464 pub fn resumption_hint(&self) -> Option<([u8; 32], [u8; 32])> {
1465 let secret = (*self.resumption_secret.read())?;
1466 Some((self.id.0, secret))
1467 }
1468
1469 /// Update last activity timestamp.
1470 ///
1471 /// Called on every authenticated inbound packet. Any such packet — the
1472 /// keep-alive PONG, an ACK, or application data — proves the path is alive, so
1473 /// it also clears an outstanding idle keep-alive probe (Direction #3): the
1474 /// probe has been answered, so the liveness sweep no longer needs to treat the
1475 /// path as awaiting a response.
1476 pub fn update_activity(&self) {
1477 *self.last_activity.write() = Instant::now();
1478 self.keepalive_outstanding.store(false, Ordering::Relaxed);
1479 }
1480
1481 /// Mark that an idle keep-alive PING was just emitted and is awaiting the
1482 /// peer's PONG (Direction #3). While outstanding, the liveness sweep treats
1483 /// the path as having a probe in flight even with no reliable data queued, so a
1484 /// download-only path can detect a silently-dead downstream.
1485 pub fn mark_keepalive_outstanding(&self) {
1486 self.keepalive_outstanding.store(true, Ordering::Relaxed);
1487 }
1488
1489 /// Whether an idle keep-alive PING is currently awaiting a PONG (Direction #3).
1490 /// `false` once any authenticated inbound packet refreshes activity.
1491 pub fn keepalive_outstanding(&self) -> bool {
1492 self.keepalive_outstanding.load(Ordering::Relaxed)
1493 }
1494
1495 /// Check if session is expired
1496 pub fn is_expired(&self, timeout: Duration) -> bool {
1497 self.last_activity.read().elapsed() > timeout
1498 }
1499
1500 /// Elapsed time since the last authenticated inbound packet — the inbound-silence
1501 /// signal the liveness sweep evaluates (Phase 4 / P4.3).
1502 pub fn last_activity_elapsed(&self) -> Duration {
1503 self.last_activity.read().elapsed()
1504 }
1505
1506 /// Current path-liveness thresholds (P4.3).
1507 pub fn liveness_config(&self) -> LivenessConfig {
1508 *self.liveness_config.read()
1509 }
1510
1511 /// Override the path-liveness thresholds (P4.3). Rust-only test/advanced hook,
1512 /// mirroring [`set_rekey_threshold`](Self::set_rekey_threshold).
1513 pub fn set_liveness_config(&self, cfg: LivenessConfig) {
1514 *self.liveness_config.write() = cfg;
1515 }
1516}
1517
1518/// Read-only snapshot of the session's pacing / bandwidth state
1519/// (Phase 2.6). Returned by [`Session::bandwidth_snapshot`] for
1520/// telemetry / debugging without exposing the mutable estimator.
1521#[derive(Debug, Clone, Copy)]
1522pub struct BandwidthSnapshot {
1523 pub bottleneck_bw_bps: u64,
1524 pub min_rtt: Duration,
1525 pub pacing_rate_bps: u64,
1526 pub cwnd_bytes: u64,
1527 pub inflight_bytes: u64,
1528}
1529
1530impl std::fmt::Debug for Session {
1531 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1532 f.debug_struct("Session").field("id", &self.id).finish()
1533 }
1534}
1535
1536impl Drop for Session {
1537 /// On session drop, explicitly zero the master secrets. The
1538 /// `CryptoState` inside `crypto` is itself `ZeroizeOnDrop`, so its
1539 /// `session_key` is handled there.
1540 fn drop(&mut self) {
1541 // T5.1 — the rekey master (`traffic_secret`) seeds the HKDF ratchet for the
1542 // whole session; zero it on drop so it does not linger in freed memory
1543 // (rekey already zeroizes each *superseded* epoch secret; this covers the
1544 // final live one). The `CipherSuite`/`Instant`-free `[u8; 32]` is `Zeroize`.
1545 self.traffic_secret.write().zeroize();
1546 if let Some(mut secret) = self.resumption_secret.write().take() {
1547 secret.zeroize();
1548 }
1549 }
1550}