Skip to main content

phantom_protocol/transport/
path.rs

1//! Connection-migration path-validation state (Phase 4.2).
2//!
3//! Tracks the per-path lifecycle from "newly observed" through
4//! "validated" so the session can refuse to send application data over
5//! an unverified path. Each path is identified by the 1-byte
6//! `path_id` field in `PacketHeader` (Phase 3.3 / Phase 4.2 wire
7//! addition). The framework does **single-path** connection migration
8//! (one live path at a time) — not multipath aggregation — but the
9//! registry can hold several `PathState`s at once during a migration
10//! switch (old path retiring, new path validating).
11//!
12//! ## Validation protocol
13//!
14//! When a peer arrives on a new (session_id, path_id) tuple — a fresh
15//! UDP source IP, a different transport leg, whatever — the receiver
16//! MUST NOT trust the path for application data until it has proven
17//! reachability by completing a challenge-response round-trip:
18//!
19//! 1. Receiver registers the new `path_id` (state: `Unvalidated`).
20//! 2. Receiver calls [`PathRegistry::issue_challenge`] to allocate a
21//!    fresh 32-byte random challenge, stored under the `path_id`. The
22//!    state transitions to `Validating`.
23//! 3. Receiver sends a `PATH_VALIDATION` flagged packet on the new
24//!    path carrying the challenge bytes as its payload.
25//! 4. The legitimate peer echoes the same bytes back in a
26//!    `PATH_VALIDATION` packet (the AEAD authentication guarantees
27//!    only the legitimate peer who holds the session key can do this).
28//! 5. Receiver calls [`PathRegistry::verify_response`]. If the bytes
29//!    match the stored challenge, the path transitions to `Validated`
30//!    and may carry application data. A mismatch transitions to
31//!    `Failed`.
32//!
33//! The cryptographic protection comes from the AEAD layer: a network
34//! attacker observing the wire cannot forge a `PATH_VALIDATION` packet
35//! with the right payload because they don't hold the session AEAD key.
36//! The challenge bytes themselves don't need to be secret — they exist
37//! to bind a specific path-validation attempt to a specific response.
38//!
39//! ## Use against migration
40//!
41//! When a peer's source IP changes mid-session (mobile handoff,
42//! LTE↔Wi-Fi switch), the session must NOT silently accept packets on
43//! the new path — that would let an attacker hijack by spoofing the
44//! source IP. Issuing a challenge on the new path before accepting
45//! traffic forces the attacker to also hold the AEAD key, which they
46//! don't.
47
48use std::sync::atomic::{AtomicU32, AtomicU8, Ordering};
49use std::time::Instant;
50
51use dashmap::DashMap;
52use parking_lot::{Mutex, RwLock};
53use subtle::ConstantTimeEq;
54
55use crate::crypto::rng::{OsRng, RngProvider};
56
57/// Width of a path-validation challenge / response, in bytes.
58pub const PATH_CHALLENGE_LEN: usize = 32;
59
60/// Lifecycle state of a single path within a session.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum PathStateKind {
63    /// First seen but never sent / received a validation challenge.
64    /// Application data MUST NOT be sent on or accepted from this
65    /// path while in this state.
66    Unvalidated,
67    /// Validation challenge has been issued; awaiting a matching
68    /// response. Application data MUST NOT cross until `Validated`.
69    Validating,
70    /// Path has completed challenge-response. Application data is
71    /// allowed.
72    Validated,
73    /// Path validation failed (wrong response, timeout, etc.). Path
74    /// is permanently disabled within this session — the peer must
75    /// re-register from `Unvalidated`.
76    Failed,
77}
78
79/// Per-path bookkeeping. Lives inside [`PathRegistry`].
80pub struct PathState {
81    pub path_id: u8,
82    state: AtomicU8, // PathStateKind as u8
83    /// EMA-smoothed RTT estimate for this path, in milliseconds. Reserved
84    /// per-path telemetry slot — currently unwired: live RTT/loss for the
85    /// active path is tracked by the BBR `BandwidthEstimator`, not here (the
86    /// multipath `scheduler` that would have fed these is vestigial; the
87    /// project does single-path connection migration, not aggregation).
88    pub rtt_ms: AtomicU32,
89    /// Smoothed loss percentage (0-100) for this path. Reserved, currently
90    /// unwired — see `rtt_ms` above.
91    pub loss_pct: AtomicU8,
92    /// Wall-clock instant of the most recent packet observed on this
93    /// path. Used by the timeout sweep.
94    pub last_packet_seen: RwLock<Option<Instant>>,
95    /// 32-byte challenge associated with the in-flight validation
96    /// attempt. `None` outside `Validating`.
97    pending_challenge: Mutex<Option<[u8; PATH_CHALLENGE_LEN]>>,
98}
99
100impl PathState {
101    fn new(path_id: u8) -> Self {
102        Self {
103            path_id,
104            state: AtomicU8::new(PathStateKind::Unvalidated as u8),
105            rtt_ms: AtomicU32::new(0),
106            loss_pct: AtomicU8::new(0),
107            last_packet_seen: RwLock::new(None),
108            pending_challenge: Mutex::new(None),
109        }
110    }
111
112    pub fn state(&self) -> PathStateKind {
113        match self.state.load(Ordering::Acquire) {
114            0 => PathStateKind::Unvalidated,
115            1 => PathStateKind::Validating,
116            2 => PathStateKind::Validated,
117            3 => PathStateKind::Failed,
118            // Bit-rot insurance: never trust a malformed state byte.
119            _ => PathStateKind::Failed,
120        }
121    }
122
123    fn set_state(&self, new: PathStateKind) {
124        self.state.store(new as u8, Ordering::Release);
125    }
126
127    /// Mark this path as having just observed a packet. Updates the
128    /// `last_packet_seen` timestamp; cheap enough to call per-packet.
129    pub fn mark_seen(&self) {
130        *self.last_packet_seen.write() = Some(Instant::now());
131    }
132}
133
134/// Per-session collection of [`PathState`]s indexed by `path_id`.
135///
136/// Lock-free in the steady state (DashMap is lock-free for reads);
137/// per-path validation operations take only the per-path `Mutex` on
138/// the pending challenge, which is uncontended outside of the brief
139/// window of an active challenge round-trip.
140pub struct PathRegistry {
141    paths: DashMap<u8, PathState>,
142}
143
144/// Outcome of a [`PathRegistry::register`] call.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum RegistrationResult {
147    /// Path was newly created — caller should issue a challenge.
148    Created,
149    /// Path was already present. No state change.
150    AlreadyKnown,
151}
152
153impl Default for PathRegistry {
154    fn default() -> Self {
155        Self::new()
156    }
157}
158
159impl PathRegistry {
160    pub fn new() -> Self {
161        Self {
162            paths: DashMap::new(),
163        }
164    }
165
166    /// Register a new path id if it doesn't already exist. Returns
167    /// `Created` if this call created the entry — caller is the one
168    /// that should now issue a validation challenge.
169    pub fn register(&self, path_id: u8) -> RegistrationResult {
170        // DashMap::insert returns the previous value. We use entry
171        // semantics so we don't overwrite an existing PathState.
172        let mut created = false;
173        self.paths.entry(path_id).or_insert_with(|| {
174            created = true;
175            PathState::new(path_id)
176        });
177        if created {
178            RegistrationResult::Created
179        } else {
180            RegistrationResult::AlreadyKnown
181        }
182    }
183
184    /// Register a new path id directly in the `Validated` state, skipping
185    /// the challenge-response round trip. Used for the implicit
186    /// `path_id = 0` initialised at session establishment — that path
187    /// is the one the handshake itself traversed, so the AEAD setup
188    /// itself was already a stronger proof of reachability than any
189    /// PATH_CHALLENGE would be.
190    ///
191    /// Returns `Created` if this call created the entry. If the entry
192    /// already existed, its state is NOT modified — the caller must
193    /// explicitly drive a challenge-response if they want to change it.
194    pub fn register_validated(&self, path_id: u8) -> RegistrationResult {
195        let mut created = false;
196        self.paths.entry(path_id).or_insert_with(|| {
197            created = true;
198            let p = PathState::new(path_id);
199            p.set_state(PathStateKind::Validated);
200            p
201        });
202        if created {
203            RegistrationResult::Created
204        } else {
205            RegistrationResult::AlreadyKnown
206        }
207    }
208
209    /// Remove a path so its `path_id` becomes re-registerable (D5 — Phase 4).
210    /// Called when a path is retired after a migration switch. Reuse is
211    /// nonce-safe because ① took `path_id` out of the AEAD nonce
212    /// (`nonce = nonce_prefix ‖ packet_number`). No-op for unknown paths.
213    pub fn retire(&self, path_id: u8) {
214        self.paths.remove(&path_id);
215    }
216
217    /// Update `last_packet_seen` on the path. No-op for unknown paths.
218    pub fn mark_seen(&self, path_id: u8) {
219        if let Some(p) = self.paths.get(&path_id) {
220            p.mark_seen();
221        }
222    }
223
224    /// Allocate a fresh challenge for the path and transition it to
225    /// `Validating`. The caller is responsible for transmitting the
226    /// returned bytes (typically inside a `PATH_VALIDATION`-flagged
227    /// `PhantomPacket`).
228    ///
229    /// Returns `None` if the path is unknown or if it is already in
230    /// `Validated` / `Failed` (re-issuing a challenge from those
231    /// terminal states is the caller's explicit decision).
232    pub fn issue_challenge(&self, path_id: u8) -> Option<[u8; PATH_CHALLENGE_LEN]> {
233        let path = self.paths.get(&path_id)?;
234        match path.state() {
235            PathStateKind::Unvalidated | PathStateKind::Validating => {
236                // OK to issue or re-issue.
237            }
238            PathStateKind::Validated | PathStateKind::Failed => return None,
239        }
240        // PATH-003: hold the pending-challenge lock across the decision so a
241        // re-issue on a path that already has a challenge in flight returns that
242        // SAME challenge (idempotent) instead of clobbering it — otherwise a late
243        // but valid response to the original challenge would no longer match and
244        // would push the path to `Failed`.
245        let mut pending = path.pending_challenge.lock();
246        if let Some(existing) = *pending {
247            return Some(existing);
248        }
249        // Draw the challenge from the `OsRng` seam (SUPPLY-04b). Under
250        // `--features fips` this routes through aws-lc-rs's CTR_DRBG; otherwise
251        // `getrandom`. The seam owns the inventoried getrandom-failure
252        // PANIC-SAFETY contract, so we add no fresh `unwrap`/`expect` here. A
253        // server-issued path challenge is security-sensitive (Invariant 6), so
254        // it must come from the CSPRNG, not a non-cryptographic source.
255        let mut challenge = [0u8; PATH_CHALLENGE_LEN];
256        OsRng.fill_bytes(&mut challenge);
257        *pending = Some(challenge);
258        drop(pending);
259        path.set_state(PathStateKind::Validating);
260        Some(challenge)
261    }
262
263    /// Verify a peer's response to a previously-issued challenge. On a
264    /// constant-time match, transitions the path to `Validated` and
265    /// returns `true`. On mismatch or unknown state, transitions to
266    /// `Failed` and returns `false`. On unknown path, returns `false`
267    /// without side-effects.
268    ///
269    /// `subtle::ConstantTimeEq` is used so a timing observer cannot
270    /// distinguish "wrong byte at position 0" from "wrong byte at
271    /// position 31" — same posture as the cookie check in
272    /// `transport::handshake::validate_cookie`.
273    pub fn verify_response(&self, path_id: u8, response: &[u8]) -> bool {
274        let path = match self.paths.get(&path_id) {
275            Some(p) => p,
276            None => return false,
277        };
278        if response.len() != PATH_CHALLENGE_LEN {
279            return false;
280        }
281        if path.state() != PathStateKind::Validating {
282            return false;
283        }
284        let mut guard = path.pending_challenge.lock();
285        let expected = match guard.take() {
286            Some(e) => e,
287            None => {
288                // Validating state without a pending challenge is
289                // inconsistent — fail closed.
290                drop(guard);
291                path.set_state(PathStateKind::Failed);
292                return false;
293            }
294        };
295        drop(guard);
296        let matched: bool = expected.ct_eq(response).into();
297        if matched {
298            path.set_state(PathStateKind::Validated);
299            true
300        } else {
301            path.set_state(PathStateKind::Failed);
302            false
303        }
304    }
305
306    /// Current state of a path. Returns `None` for unknown ids.
307    pub fn state(&self, path_id: u8) -> Option<PathStateKind> {
308        self.paths.get(&path_id).map(|p| p.state())
309    }
310
311    /// Snapshot of all path ids currently in `Validated`.
312    pub fn validated_paths(&self) -> Vec<u8> {
313        self.paths
314            .iter()
315            .filter(|p| p.state() == PathStateKind::Validated)
316            .map(|p| *p.key())
317            .collect()
318    }
319
320    /// Number of paths in any state.
321    pub fn len(&self) -> usize {
322        self.paths.len()
323    }
324
325    pub fn is_empty(&self) -> bool {
326        self.paths.is_empty()
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn register_new_path_returns_created() {
336        let r = PathRegistry::new();
337        assert_eq!(r.register(7), RegistrationResult::Created);
338        assert_eq!(r.register(7), RegistrationResult::AlreadyKnown);
339    }
340
341    #[test]
342    fn freshly_registered_path_is_unvalidated() {
343        let r = PathRegistry::new();
344        r.register(1);
345        assert_eq!(r.state(1), Some(PathStateKind::Unvalidated));
346    }
347
348    #[test]
349    fn issue_challenge_transitions_to_validating() {
350        let r = PathRegistry::new();
351        r.register(1);
352        let challenge = r.issue_challenge(1).expect("challenge issued");
353        assert_eq!(challenge.len(), PATH_CHALLENGE_LEN);
354        assert_eq!(r.state(1), Some(PathStateKind::Validating));
355    }
356
357    #[test]
358    fn reissue_on_validating_path_returns_same_challenge() {
359        // PATH-003: a second issue_challenge while one is already in flight must
360        // return the SAME challenge, not mint+install a fresh one (which would
361        // invalidate a legitimate response to the original and push the path to
362        // Failed). Idempotency across the Unvalidated/Validating window.
363        let r = PathRegistry::new();
364        r.register(1);
365        let first = r.issue_challenge(1).expect("first challenge");
366        let second = r.issue_challenge(1).expect("re-issue returns existing");
367        assert_eq!(
368            first, second,
369            "re-issue must not clobber the in-flight challenge"
370        );
371        // The original challenge still verifies (it was never overwritten).
372        assert!(r.verify_response(1, &first));
373        assert_eq!(r.state(1), Some(PathStateKind::Validated));
374    }
375
376    #[test]
377    fn matching_response_transitions_to_validated() {
378        let r = PathRegistry::new();
379        r.register(1);
380        let challenge = r.issue_challenge(1).expect("challenge");
381        assert!(r.verify_response(1, &challenge));
382        assert_eq!(r.state(1), Some(PathStateKind::Validated));
383    }
384
385    #[test]
386    fn mismatched_response_transitions_to_failed() {
387        let r = PathRegistry::new();
388        r.register(1);
389        let mut challenge = r.issue_challenge(1).expect("challenge");
390        challenge[0] ^= 0xFF; // flip a byte
391        assert!(!r.verify_response(1, &challenge));
392        assert_eq!(r.state(1), Some(PathStateKind::Failed));
393    }
394
395    #[test]
396    fn response_without_challenge_fails() {
397        let r = PathRegistry::new();
398        r.register(1);
399        // Bypass issue_challenge — try to verify against nothing.
400        let zeros = [0u8; PATH_CHALLENGE_LEN];
401        assert!(!r.verify_response(1, &zeros));
402        // State stays Unvalidated since we never went into Validating.
403        assert_eq!(r.state(1), Some(PathStateKind::Unvalidated));
404    }
405
406    #[test]
407    fn response_for_wrong_length_fails() {
408        let r = PathRegistry::new();
409        r.register(1);
410        let _ = r.issue_challenge(1);
411        assert!(!r.verify_response(1, &[0u8; 16])); // wrong length
412                                                    // The path remains in Validating — short response is not a
413                                                    // failed validation, it's a malformed packet that doesn't even
414                                                    // get to the equality check.
415        assert_eq!(r.state(1), Some(PathStateKind::Validating));
416    }
417
418    #[test]
419    fn issue_challenge_on_unknown_path_returns_none() {
420        let r = PathRegistry::new();
421        assert!(r.issue_challenge(99).is_none());
422    }
423
424    #[test]
425    fn validated_paths_lists_only_validated() {
426        let r = PathRegistry::new();
427        for p in 0..5 {
428            r.register(p);
429        }
430        // Validate paths 1 and 3.
431        for p in [1u8, 3].iter().copied() {
432            let c = r.issue_challenge(p).unwrap();
433            assert!(r.verify_response(p, &c));
434        }
435        // Path 2: issue but fail.
436        let mut c = r.issue_challenge(2).unwrap();
437        c[0] ^= 1;
438        assert!(!r.verify_response(2, &c));
439        // Path 4: leave Validating.
440        r.issue_challenge(4);
441
442        let mut validated = r.validated_paths();
443        validated.sort();
444        assert_eq!(validated, vec![1, 3]);
445    }
446
447    #[test]
448    fn mark_seen_updates_last_packet_timestamp() {
449        let r = PathRegistry::new();
450        r.register(1);
451        // Sleep briefly to make the before/after distinguishable.
452        let before = Instant::now();
453        std::thread::sleep(std::time::Duration::from_millis(2));
454        r.mark_seen(1);
455        let path = r.paths.get(&1).unwrap();
456        let seen = path.last_packet_seen.read().expect("set");
457        assert!(seen >= before);
458    }
459
460    #[test]
461    fn re_validating_terminal_path_returns_none() {
462        let r = PathRegistry::new();
463        r.register(1);
464        let c = r.issue_challenge(1).unwrap();
465        assert!(r.verify_response(1, &c)); // Validated.
466
467        // Re-issuing on a Validated path is refused.
468        assert!(r.issue_challenge(1).is_none());
469
470        // Same for Failed.
471        r.register(2);
472        let mut c2 = r.issue_challenge(2).unwrap();
473        c2[0] ^= 1;
474        assert!(!r.verify_response(2, &c2)); // Failed.
475        assert!(r.issue_challenge(2).is_none());
476    }
477
478    #[test]
479    fn retire_removes_path_and_allows_re_register() {
480        let r = PathRegistry::new();
481        r.register(7);
482        let c = r.issue_challenge(7).expect("challenge");
483        assert!(r.verify_response(7, &c)); // path 7 -> Validated
484        assert_eq!(r.state(7), Some(PathStateKind::Validated));
485
486        // Retire frees the path_id.
487        r.retire(7);
488        assert_eq!(r.state(7), None, "retired path must be gone");
489
490        // It is now re-registerable as a fresh Unvalidated path (D5 — nonce-safe
491        // to reuse because ① took path_id out of the AEAD nonce).
492        assert_eq!(r.register(7), RegistrationResult::Created);
493        assert_eq!(r.state(7), Some(PathStateKind::Unvalidated));
494
495        // Retiring an unknown path is a harmless no-op.
496        r.retire(200);
497    }
498}