Skip to main content

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}