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