Skip to main content

phantom_protocol/transport/
path.rs

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