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