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 /// Remove a path so its `path_id` becomes re-registerable (D5 — Phase 4).
203 /// Called when a path is retired after a migration switch. Reuse is
204 /// nonce-safe because ① took `path_id` out of the AEAD nonce
205 /// (`nonce = nonce_prefix ‖ packet_number`). No-op for unknown paths.
206 pub fn retire(&self, path_id: u8) {
207 self.paths.remove(&path_id);
208 }
209
210 /// Update `last_packet_seen` on the path. No-op for unknown paths.
211 pub fn mark_seen(&self, path_id: u8) {
212 if let Some(p) = self.paths.get(&path_id) {
213 p.mark_seen();
214 }
215 }
216
217 /// Allocate a fresh challenge for the path and transition it to
218 /// `Validating`. The caller is responsible for transmitting the
219 /// returned bytes (typically inside a `PATH_VALIDATION`-flagged V2
220 /// packet).
221 ///
222 /// Returns `None` if the path is unknown or if it is already in
223 /// `Validated` / `Failed` (re-issuing a challenge from those
224 /// terminal states is the caller's explicit decision).
225 pub fn issue_challenge(&self, path_id: u8) -> Option<[u8; PATH_CHALLENGE_LEN]> {
226 let path = self.paths.get(&path_id)?;
227 match path.state() {
228 PathStateKind::Unvalidated | PathStateKind::Validating => {
229 // OK to issue or re-issue.
230 }
231 PathStateKind::Validated | PathStateKind::Failed => return None,
232 }
233 // PATH-003: hold the pending-challenge lock across the decision so a
234 // re-issue on a path that already has a challenge in flight returns that
235 // SAME challenge (idempotent) instead of clobbering it — otherwise a late
236 // but valid response to the original challenge would no longer match and
237 // would push the path to `Failed`.
238 let mut pending = path.pending_challenge.lock();
239 if let Some(existing) = *pending {
240 return Some(existing);
241 }
242 // Draw the challenge from the `OsRng` seam (SUPPLY-04b). Under
243 // `--features fips` this routes through aws-lc-rs's CTR_DRBG; otherwise
244 // `getrandom`. The seam owns the inventoried getrandom-failure
245 // PANIC-SAFETY contract, so we add no fresh `unwrap`/`expect` here. A
246 // server-issued path challenge is security-sensitive (Invariant 6), so
247 // it must come from the CSPRNG, not a non-cryptographic source.
248 let mut challenge = [0u8; PATH_CHALLENGE_LEN];
249 OsRng.fill_bytes(&mut challenge);
250 *pending = Some(challenge);
251 drop(pending);
252 path.set_state(PathStateKind::Validating);
253 Some(challenge)
254 }
255
256 /// Verify a peer's response to a previously-issued challenge. On a
257 /// constant-time match, transitions the path to `Validated` and
258 /// returns `true`. On mismatch or unknown state, transitions to
259 /// `Failed` and returns `false`. On unknown path, returns `false`
260 /// without side-effects.
261 ///
262 /// `subtle::ConstantTimeEq` is used so a timing observer cannot
263 /// distinguish "wrong byte at position 0" from "wrong byte at
264 /// position 31" — same posture as the cookie check in
265 /// `transport::handshake::validate_cookie`.
266 pub fn verify_response(&self, path_id: u8, response: &[u8]) -> bool {
267 let path = match self.paths.get(&path_id) {
268 Some(p) => p,
269 None => return false,
270 };
271 if response.len() != PATH_CHALLENGE_LEN {
272 return false;
273 }
274 if path.state() != PathStateKind::Validating {
275 return false;
276 }
277 let mut guard = path.pending_challenge.lock();
278 let expected = match guard.take() {
279 Some(e) => e,
280 None => {
281 // Validating state without a pending challenge is
282 // inconsistent — fail closed.
283 drop(guard);
284 path.set_state(PathStateKind::Failed);
285 return false;
286 }
287 };
288 drop(guard);
289 let matched: bool = expected.ct_eq(response).into();
290 if matched {
291 path.set_state(PathStateKind::Validated);
292 true
293 } else {
294 path.set_state(PathStateKind::Failed);
295 false
296 }
297 }
298
299 /// Current state of a path. Returns `None` for unknown ids.
300 pub fn state(&self, path_id: u8) -> Option<PathStateKind> {
301 self.paths.get(&path_id).map(|p| p.state())
302 }
303
304 /// Snapshot of all path ids currently in `Validated`.
305 pub fn validated_paths(&self) -> Vec<u8> {
306 self.paths
307 .iter()
308 .filter(|p| p.state() == PathStateKind::Validated)
309 .map(|p| *p.key())
310 .collect()
311 }
312
313 /// Number of paths in any state.
314 pub fn len(&self) -> usize {
315 self.paths.len()
316 }
317
318 pub fn is_empty(&self) -> bool {
319 self.paths.is_empty()
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn register_new_path_returns_created() {
329 let r = PathRegistry::new();
330 assert_eq!(r.register(7), RegistrationResult::Created);
331 assert_eq!(r.register(7), RegistrationResult::AlreadyKnown);
332 }
333
334 #[test]
335 fn freshly_registered_path_is_unvalidated() {
336 let r = PathRegistry::new();
337 r.register(1);
338 assert_eq!(r.state(1), Some(PathStateKind::Unvalidated));
339 }
340
341 #[test]
342 fn issue_challenge_transitions_to_validating() {
343 let r = PathRegistry::new();
344 r.register(1);
345 let challenge = r.issue_challenge(1).expect("challenge issued");
346 assert_eq!(challenge.len(), PATH_CHALLENGE_LEN);
347 assert_eq!(r.state(1), Some(PathStateKind::Validating));
348 }
349
350 #[test]
351 fn reissue_on_validating_path_returns_same_challenge() {
352 // PATH-003: a second issue_challenge while one is already in flight must
353 // return the SAME challenge, not mint+install a fresh one (which would
354 // invalidate a legitimate response to the original and push the path to
355 // Failed). Idempotency across the Unvalidated/Validating window.
356 let r = PathRegistry::new();
357 r.register(1);
358 let first = r.issue_challenge(1).expect("first challenge");
359 let second = r.issue_challenge(1).expect("re-issue returns existing");
360 assert_eq!(
361 first, second,
362 "re-issue must not clobber the in-flight challenge"
363 );
364 // The original challenge still verifies (it was never overwritten).
365 assert!(r.verify_response(1, &first));
366 assert_eq!(r.state(1), Some(PathStateKind::Validated));
367 }
368
369 #[test]
370 fn matching_response_transitions_to_validated() {
371 let r = PathRegistry::new();
372 r.register(1);
373 let challenge = r.issue_challenge(1).expect("challenge");
374 assert!(r.verify_response(1, &challenge));
375 assert_eq!(r.state(1), Some(PathStateKind::Validated));
376 }
377
378 #[test]
379 fn mismatched_response_transitions_to_failed() {
380 let r = PathRegistry::new();
381 r.register(1);
382 let mut challenge = r.issue_challenge(1).expect("challenge");
383 challenge[0] ^= 0xFF; // flip a byte
384 assert!(!r.verify_response(1, &challenge));
385 assert_eq!(r.state(1), Some(PathStateKind::Failed));
386 }
387
388 #[test]
389 fn response_without_challenge_fails() {
390 let r = PathRegistry::new();
391 r.register(1);
392 // Bypass issue_challenge — try to verify against nothing.
393 let zeros = [0u8; PATH_CHALLENGE_LEN];
394 assert!(!r.verify_response(1, &zeros));
395 // State stays Unvalidated since we never went into Validating.
396 assert_eq!(r.state(1), Some(PathStateKind::Unvalidated));
397 }
398
399 #[test]
400 fn response_for_wrong_length_fails() {
401 let r = PathRegistry::new();
402 r.register(1);
403 let _ = r.issue_challenge(1);
404 assert!(!r.verify_response(1, &[0u8; 16])); // wrong length
405 // The path remains in Validating — short response is not a
406 // failed validation, it's a malformed packet that doesn't even
407 // get to the equality check.
408 assert_eq!(r.state(1), Some(PathStateKind::Validating));
409 }
410
411 #[test]
412 fn issue_challenge_on_unknown_path_returns_none() {
413 let r = PathRegistry::new();
414 assert!(r.issue_challenge(99).is_none());
415 }
416
417 #[test]
418 fn validated_paths_lists_only_validated() {
419 let r = PathRegistry::new();
420 for p in 0..5 {
421 r.register(p);
422 }
423 // Validate paths 1 and 3.
424 for p in [1u8, 3].iter().copied() {
425 let c = r.issue_challenge(p).unwrap();
426 assert!(r.verify_response(p, &c));
427 }
428 // Path 2: issue but fail.
429 let mut c = r.issue_challenge(2).unwrap();
430 c[0] ^= 1;
431 assert!(!r.verify_response(2, &c));
432 // Path 4: leave Validating.
433 r.issue_challenge(4);
434
435 let mut validated = r.validated_paths();
436 validated.sort();
437 assert_eq!(validated, vec![1, 3]);
438 }
439
440 #[test]
441 fn mark_seen_updates_last_packet_timestamp() {
442 let r = PathRegistry::new();
443 r.register(1);
444 // Sleep briefly to make the before/after distinguishable.
445 let before = Instant::now();
446 std::thread::sleep(std::time::Duration::from_millis(2));
447 r.mark_seen(1);
448 let path = r.paths.get(&1).unwrap();
449 let seen = path.last_packet_seen.read().expect("set");
450 assert!(seen >= before);
451 }
452
453 #[test]
454 fn re_validating_terminal_path_returns_none() {
455 let r = PathRegistry::new();
456 r.register(1);
457 let c = r.issue_challenge(1).unwrap();
458 assert!(r.verify_response(1, &c)); // Validated.
459
460 // Re-issuing on a Validated path is refused.
461 assert!(r.issue_challenge(1).is_none());
462
463 // Same for Failed.
464 r.register(2);
465 let mut c2 = r.issue_challenge(2).unwrap();
466 c2[0] ^= 1;
467 assert!(!r.verify_response(2, &c2)); // Failed.
468 assert!(r.issue_challenge(2).is_none());
469 }
470
471 #[test]
472 fn retire_removes_path_and_allows_re_register() {
473 let r = PathRegistry::new();
474 r.register(7);
475 let c = r.issue_challenge(7).expect("challenge");
476 assert!(r.verify_response(7, &c)); // path 7 -> Validated
477 assert_eq!(r.state(7), Some(PathStateKind::Validated));
478
479 // Retire frees the path_id.
480 r.retire(7);
481 assert_eq!(r.state(7), None, "retired path must be gone");
482
483 // It is now re-registerable as a fresh Unvalidated path (D5 — nonce-safe
484 // to reuse because ① took path_id out of the AEAD nonce).
485 assert_eq!(r.register(7), RegistrationResult::Created);
486 assert_eq!(r.state(7), Some(PathStateKind::Unvalidated));
487
488 // Retiring an unknown path is a harmless no-op.
489 r.retire(200);
490 }
491}