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