Skip to main content

phantom_protocol/transport/
path_validation_codec.rs

1//! Wire-format helpers for PATH_VALIDATION packets (Phase 4.2).
2//!
3//! The path-validation state machine lives in [`crate::transport::path`].
4//! This module is the **wire encoder/decoder** that turns those state
5//! transitions into V2 packets ready to push through a `SessionTransport`
6//! and the inverse decode on the receive side.
7//!
8//! ## Frame layout
9//!
10//! A PATH_VALIDATION frame is a V2 `PhantomPacket` with:
11//!
12//! - `header.flags` ⊇ [`PacketFlags::PATH_VALIDATION`]
13//! - `header.path_id` = the path the validation is for
14//! - `header.stream_id` = 0 (control stream)
15//! - `header.sequence` = caller-chosen (typically a small monotonic
16//!   counter; not security-critical here because the payload itself is
17//!   the unique-per-attempt random challenge)
18//! - `payload` = exactly 32 bytes (`PATH_CHALLENGE_LEN`) — either the
19//!   challenge (request) or the echoed challenge (response). Sender
20//!   role determines the interpretation.
21//!
22//! ## Authentication
23//!
24//! The cryptographic protection on PATH_VALIDATION packets comes from
25//! the **outer AEAD wrap** when the packet is emitted alongside normal
26//! application data — the same AEAD context that secures app-data
27//! protects the validation payload from forgery. Encoders here do not
28//! perform AEAD themselves; the caller threads them through
29//! `Session::encrypt_packet` / `decrypt_packet` exactly as it does for
30//! application-data packets, then sets the PATH_VALIDATION flag in the
31//! header.
32//!
33//! ## Why a separate module
34//!
35//! `transport::path` owns the state machine. `transport::types` owns
36//! the wire types. This module is the thin bridge so neither has to
37//! know about the other.
38
39use crate::transport::path::PATH_CHALLENGE_LEN;
40use crate::transport::types::{
41    PacketFlags, PacketHeader, PhantomPacket, SequenceNumber, SessionId, StreamId,
42};
43
44/// Whether a frame carries an outgoing challenge or an echoed
45/// response. The two are wire-identical; the distinction lives in the
46/// **sender** state machine.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum PathValidationKind {
49    Challenge,
50    Response,
51}
52
53/// Build a V2 PATH_VALIDATION packet carrying the given 32-byte
54/// challenge/response payload on the supplied `path_id`.
55///
56/// The control stream is hard-coded to id 0. The caller supplies a
57/// `sequence` value — pick a fresh per-(session, path_id) counter so
58/// the replay window can dedupe duplicates if the same challenge is
59/// retransmitted.
60pub fn build_path_validation_packet(
61    session_id: SessionId,
62    path_id: u8,
63    sequence: SequenceNumber,
64    payload: [u8; PATH_CHALLENGE_LEN],
65) -> PhantomPacket {
66    let stream_id: StreamId = 0;
67    let header = PacketHeader::new(
68        session_id,
69        stream_id,
70        sequence,
71        PacketFlags::new(PacketFlags::PATH_VALIDATION),
72    )
73    .with_path_id(path_id);
74    PhantomPacket::new(header, payload.to_vec())
75}
76
77/// A parsed incoming PATH_VALIDATION frame, with all fields the
78/// receiver needs to feed into the state machine.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct ParsedPathValidation {
81    pub path_id: u8,
82    pub payload: [u8; PATH_CHALLENGE_LEN],
83}
84
85/// Attempt to parse a V2 packet as a PATH_VALIDATION frame.
86///
87/// Returns:
88/// - `Ok(Some(...))` when the packet is a well-formed PATH_VALIDATION
89///   (correct flag + correct payload length).
90/// - `Ok(None)` when the packet is a valid V2 frame but NOT a
91///   PATH_VALIDATION frame — the caller routes it normally.
92/// - `Err(...)` when the PATH_VALIDATION flag is set but the payload
93///   length is wrong.
94pub fn parse_path_validation(
95    packet: &PhantomPacket,
96) -> Result<Option<ParsedPathValidation>, PathValidationParseError> {
97    if !packet.header.flags.contains(PacketFlags::PATH_VALIDATION) {
98        return Ok(None);
99    }
100    if packet.payload.len() != PATH_CHALLENGE_LEN {
101        return Err(PathValidationParseError::WrongPayloadLength {
102            got: packet.payload.len(),
103        });
104    }
105    let mut buf = [0u8; PATH_CHALLENGE_LEN];
106    buf.copy_from_slice(&packet.payload);
107    Ok(Some(ParsedPathValidation {
108        path_id: packet.header.path_id,
109        payload: buf,
110    }))
111}
112
113/// Errors from [`parse_path_validation`].
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum PathValidationParseError {
116    /// Flag set but payload was not exactly `PATH_CHALLENGE_LEN` bytes.
117    WrongPayloadLength { got: usize },
118}
119
120impl std::fmt::Display for PathValidationParseError {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        match self {
123            Self::WrongPayloadLength { got } => write!(
124                f,
125                "PATH_VALIDATION payload length is {}, expected {}",
126                got, PATH_CHALLENGE_LEN
127            ),
128        }
129    }
130}
131
132impl std::error::Error for PathValidationParseError {}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    fn fixed_session_id() -> SessionId {
139        SessionId::from_bytes([0x42; 32])
140    }
141
142    #[test]
143    fn build_round_trip_preserves_path_id_and_payload() {
144        let payload = [0xAA; PATH_CHALLENGE_LEN];
145        let v2 = build_path_validation_packet(fixed_session_id(), 7, 42, payload);
146        assert_eq!(v2.header.path_id, 7);
147        assert!(v2.header.flags.contains(PacketFlags::PATH_VALIDATION));
148        assert_eq!(v2.header.stream_id, 0u16);
149        assert_eq!(v2.header.sequence, 42u32);
150        assert_eq!(v2.payload, payload.to_vec());
151    }
152
153    #[test]
154    fn parse_path_validation_returns_payload_on_match() {
155        let payload = [0xCC; PATH_CHALLENGE_LEN];
156        let v2 = build_path_validation_packet(fixed_session_id(), 3, 1, payload);
157        let parsed = parse_path_validation(&v2).expect("ok").expect("some");
158        assert_eq!(parsed.path_id, 3);
159        assert_eq!(parsed.payload, payload);
160    }
161
162    #[test]
163    fn parse_returns_none_when_flag_missing() {
164        let header = PacketHeader::new(
165            fixed_session_id(),
166            0u16,
167            0u32,
168            PacketFlags::new(PacketFlags::ENCRYPTED), // not PATH_VALIDATION
169        );
170        let p = PhantomPacket::new(header, vec![0u8; PATH_CHALLENGE_LEN]);
171        let parsed = parse_path_validation(&p).expect("no error");
172        assert!(parsed.is_none());
173    }
174
175    #[test]
176    fn parse_errors_on_wrong_payload_length() {
177        let header = PacketHeader::new(
178            fixed_session_id(),
179            0u16,
180            0u32,
181            PacketFlags::new(PacketFlags::PATH_VALIDATION),
182        );
183        let p = PhantomPacket::new(header, vec![0u8; 16]); // wrong length
184        let err = parse_path_validation(&p).expect_err("err");
185        assert_eq!(
186            err,
187            PathValidationParseError::WrongPayloadLength { got: 16 }
188        );
189    }
190
191    #[test]
192    fn challenge_and_response_are_wire_identical() {
193        // Two builds with the same inputs must be byte-identical on the
194        // wire — the kind enum is a sender-side hint only. We compare
195        // by re-serializing and comparing.
196        let payload = [0x55; PATH_CHALLENGE_LEN];
197        let a = build_path_validation_packet(fixed_session_id(), 1, 5, payload);
198        let b = build_path_validation_packet(fixed_session_id(), 1, 5, payload);
199
200        let buf_a = a.to_wire();
201        let buf_b = b.to_wire();
202        assert_eq!(buf_a, buf_b);
203    }
204
205    #[test]
206    fn kind_enum_round_trips_for_documentation() {
207        // The kind enum exists purely so the sender can label its
208        // intent; it is not part of the wire layout. This test pins
209        // that it has the expected two variants.
210        assert_ne!(PathValidationKind::Challenge, PathValidationKind::Response);
211    }
212
213    /// End-to-end PATH_VALIDATION flow exercised through the wire
214    /// codec and the session-level `PathRegistry`. Demonstrates that a
215    /// receiver-issued challenge round-trips through this codec and
216    /// completes the state machine on the responder side.
217    #[test]
218    fn full_challenge_response_round_trip_via_codec() {
219        use crate::transport::path::{PathRegistry, PathStateKind, RegistrationResult};
220
221        // Side A is the validator (issues the challenge), Side B is
222        // the responder (echoes it back). Each side keeps its own
223        // PathRegistry; the path id is the shared identifier.
224        let side_a = PathRegistry::new();
225        let side_b = PathRegistry::new();
226        let path_id: u8 = 5;
227
228        // A sees a new path and issues a challenge.
229        assert_eq!(side_a.register(path_id), RegistrationResult::Created);
230        let challenge = side_a.issue_challenge(path_id).expect("challenge issued");
231        let session_id = fixed_session_id();
232
233        // A serializes the PATH_VALIDATION frame and hands it over to
234        // the network. We then immediately "receive" it as raw bytes
235        // and parse on side B.
236        let outgoing = build_path_validation_packet(session_id, path_id, 0, challenge);
237        let buf = outgoing.to_wire();
238        let v2 = PhantomPacket::from_wire(&buf).expect("deserialize");
239        let parsed = parse_path_validation(&v2)
240            .expect("ok")
241            .expect("flag matched");
242        assert_eq!(parsed.path_id, path_id);
243        assert_eq!(parsed.payload, challenge);
244
245        // Side B echoes the payload back. (It doesn't need a registry
246        // entry to do that — it just mirrors whatever it saw.)
247        let response = build_path_validation_packet(session_id, path_id, 0, parsed.payload);
248        let buf2 = response.to_wire();
249        let v2_echoed = PhantomPacket::from_wire(&buf2).expect("deserialize");
250        let echoed_parsed = parse_path_validation(&v2_echoed)
251            .expect("ok")
252            .expect("flag matched");
253
254        // A verifies the response against its in-flight challenge.
255        let accepted = side_a.verify_response(echoed_parsed.path_id, &echoed_parsed.payload);
256        assert!(accepted, "responder's echo must validate");
257        assert_eq!(side_a.state(path_id), Some(PathStateKind::Validated));
258
259        // The unrelated side_b registry has not learned anything —
260        // it's a stateless responder in this minimal test.
261        let _ = side_b;
262    }
263
264    #[test]
265    fn tampered_response_fails_validation() {
266        use crate::transport::path::{PathRegistry, PathStateKind};
267
268        let validator = PathRegistry::new();
269        validator.register(2);
270        let challenge = validator.issue_challenge(2).expect("challenge");
271
272        // Build a corrupt response: same path/header, flipped bytes.
273        let mut tampered = challenge;
274        tampered[7] ^= 0xFF;
275        let v2 = build_path_validation_packet(fixed_session_id(), 2, 0, tampered);
276        let parsed = parse_path_validation(&v2).unwrap().unwrap();
277
278        assert!(!validator.verify_response(parsed.path_id, &parsed.payload));
279        assert_eq!(validator.state(2), Some(PathStateKind::Failed));
280    }
281}