Skip to main content

irtt_proto/
echo.rs

1use crate::{
2    envelope::{self, FlagRule},
3    flags::{FLAG_OPEN, FLAG_REPLY},
4    layout::{echo_packet_len, PacketLayout},
5    params::{Clock, Params, StampAt},
6    ProtoError, Result, SEQ_SIZE, TIMESTAMP_SIZE, TOKEN_SIZE,
7};
8
9/// Upstream irtt 0.9.1 always emits both midpoint timestamp fields (wall,
10/// then monotonic) in an ECHO reply for single-clock `StampAt::Midpoint`
11/// negotiations, even though only one clock was negotiated. That makes the
12/// compatibility header exactly one timestamp longer than the negotiated
13/// header.
14const MIDPOINT_COMPAT_EXTRA: usize = TIMESTAMP_SIZE;
15
16/// Whether the negotiation selects a midpoint timestamp from exactly one clock,
17/// which is the only case the upstream dual-field compatibility form applies to.
18///
19/// [`Clock::Unspecified`] selects no clock and therefore lays out no midpoint
20/// field at all, so it is not a single-clock negotiation.
21fn is_midpoint_single_clock(params: &Params) -> bool {
22    params.stamp_at == StampAt::Midpoint && matches!(params.clock, Clock::Wall | Clock::Monotonic)
23}
24
25/// Total datagram length of the upstream dual-midpoint compatibility form.
26///
27/// Both lengths are `max(negotiated_length, header)`, so this is *not*
28/// `normal_len + TIMESTAMP_SIZE`. The extra midpoint field only lengthens the
29/// datagram while the larger header is still pushing past the negotiated
30/// length; beyond that it displaces payload instead, and the two forms have
31/// identical length. The difference is therefore 8, 7..1, or 0 depending on
32/// the negotiated length.
33///
34/// Deriving this from `normal_len` keeps the identity exact —
35/// `max(negotiated_length, compat_header) == max(compat_header, normal_len)` —
36/// and cannot overflow, because `compat_header` is a small bounded header
37/// size rather than an attacker-influenced packet length.
38fn midpoint_compat_packet_len(layout: PacketLayout, normal_len: usize) -> usize {
39    (layout.header_len() + MIDPOINT_COMPAT_EXTRA).max(normal_len)
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct EchoReply {
44    pub flags: u8,
45    pub token: u64,
46    pub sequence: u32,
47    pub recv_count: Option<u32>,
48    pub recv_window: Option<u64>,
49    pub timestamps: TimestampFields,
50    pub payload: Vec<u8>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Default)]
54pub struct TimestampFields {
55    pub recv_wall: Option<i64>,
56    pub recv_mono: Option<i64>,
57    pub midpoint_wall: Option<i64>,
58    pub midpoint_mono: Option<i64>,
59    pub send_wall: Option<i64>,
60    pub send_mono: Option<i64>,
61}
62
63/// Encodes an ECHO reply.
64///
65/// `params` supplies the negotiated layout and length, exactly as on the
66/// request side, and every optional field the layout requires must be present
67/// in `reply`: a `Some` where the layout has no field, or a `None` where it has
68/// one, is an error. `hmac_key` is authoritative: `Some(key)` sets `FLAG_HMAC`
69/// and signs the finished datagram.
70///
71/// # Errors
72///
73/// Returns [`ProtoError::MissingFlag`] or [`ProtoError::UnexpectedFlag`] when
74/// the reply does not carry exactly the packet-type flags this encoder
75/// requires, [`ProtoError::MissingField`] or [`ProtoError::UnexpectedField`]
76/// when `reply`'s optional fields do not match the negotiated layout, and
77/// [`ProtoError::PayloadTooLarge`] when the payload does not fit the negotiated
78/// length.
79///
80/// # Example
81///
82/// ```
83/// use irtt_proto::{
84///     decode_echo_reply, encode_echo_reply, EchoReply, Params, ReceivedStats,
85///     TimestampFields, FLAG_REPLY,
86/// };
87///
88/// // `received_stats` negotiates a receive counter; four payload bytes.
89/// let params = Params {
90///     length: 24,
91///     received_stats: ReceivedStats::Count,
92///     ..Params::default()
93/// };
94/// let reply = EchoReply {
95///     flags: FLAG_REPLY,
96///     token: 0x1234_5678_9abc_def0,
97///     sequence: 7,
98///     recv_count: Some(3),
99///     recv_window: None,
100///     timestamps: TimestampFields::default(),
101///     payload: vec![1, 2, 3, 4],
102/// };
103///
104/// let packet = encode_echo_reply(&reply, &params, None).unwrap();
105/// assert_eq!(decode_echo_reply(&packet, &params, None).unwrap(), reply);
106/// ```
107pub fn encode_echo_reply(
108    reply: &EchoReply,
109    params: &Params,
110    hmac_key: Option<&[u8]>,
111) -> Result<Vec<u8>> {
112    let layout = PacketLayout::echo(hmac_key.is_some(), params);
113    let len = echo_packet_len(hmac_key.is_some(), params)?;
114    let payload_offset = layout.header_len();
115    let available_payload_len = len.saturating_sub(payload_offset);
116    if reply.payload.len() > available_payload_len {
117        return Err(ProtoError::PayloadTooLarge {
118            available: available_payload_len,
119            provided: reply.payload.len(),
120        });
121    }
122
123    let mut out = envelope::begin_checked(
124        reply.flags,
125        hmac_key,
126        &[FlagRule::Reject(FLAG_OPEN), FlagRule::Require(FLAG_REPLY)],
127        len,
128    )?;
129    out.extend_from_slice(&reply.token.to_le_bytes());
130    out.extend_from_slice(&reply.sequence.to_le_bytes());
131    push_echo_reply_tail(reply, layout, &mut out)?;
132    out.resize(len, 0);
133    out[payload_offset..payload_offset + reply.payload.len()].copy_from_slice(&reply.payload);
134
135    envelope::finish(out, hmac_key)
136}
137
138/// Decodes an ECHO reply, accepting the upstream irtt 0.9.1 dual-midpoint
139/// compatibility form when it is identifiable by datagram length.
140///
141/// For single-clock `StampAt::Midpoint` negotiations upstream emits both
142/// midpoint fields (wall, then monotonic). When that larger header makes the
143/// datagram longer than the negotiated length, the compatibility form is
144/// unambiguous and both fields are parsed, exposing only the negotiated
145/// clock's value.
146///
147/// When the negotiated length already covers the larger header the two forms
148/// have identical length and are indistinguishable from the packet alone. The
149/// reply is still accepted, parsed against the negotiated layout. For a
150/// monotonic-only negotiation that means the value exposed as `midpoint_mono`
151/// is whatever occupies the negotiated field position, which for an upstream
152/// peer is its wall timestamp — this cannot be corrected deterministically
153/// without out-of-band knowledge of the peer, so no heuristic is applied.
154pub fn decode_echo_reply(
155    packet: &[u8],
156    params: &Params,
157    hmac_key: Option<&[u8]>,
158) -> Result<EchoReply> {
159    let envelope = envelope::decode(
160        packet,
161        hmac_key,
162        &[FlagRule::Reject(FLAG_OPEN), FlagRule::Require(FLAG_REPLY)],
163    )?;
164    let layout = PacketLayout::echo(hmac_key.is_some(), params);
165    let midpoint_compat = validate_echo_length(packet, params, layout)?;
166    envelope::verify(packet, hmac_key)?;
167
168    let header_len = layout.header_len()
169        + if midpoint_compat {
170            MIDPOINT_COMPAT_EXTRA
171        } else {
172            0
173        };
174    let mut pos = envelope.body_offset;
175    let token = read_u64(packet, &mut pos);
176    let sequence = read_u32(packet, &mut pos);
177    let recv_count = layout.recv_count.then(|| read_u32(packet, &mut pos));
178    let recv_window = layout.recv_window.then(|| read_u64(packet, &mut pos));
179    let recv_wall = layout.recv_wall.then(|| read_i64(packet, &mut pos));
180    let recv_mono = layout.recv_mono.then(|| read_i64(packet, &mut pos));
181    let (midpoint_wall, midpoint_mono) = if midpoint_compat {
182        let wall = read_i64(packet, &mut pos);
183        let mono = read_i64(packet, &mut pos);
184        match params.clock {
185            Clock::Wall => (Some(wall), None),
186            Clock::Monotonic => (None, Some(mono)),
187            Clock::Unspecified | Clock::Both => {
188                unreachable!("midpoint compat only applies to single-clock negotiations")
189            }
190        }
191    } else {
192        (
193            layout.midpoint_wall.then(|| read_i64(packet, &mut pos)),
194            layout.midpoint_mono.then(|| read_i64(packet, &mut pos)),
195        )
196    };
197    let send_wall = layout.send_wall.then(|| read_i64(packet, &mut pos));
198    let send_mono = layout.send_mono.then(|| read_i64(packet, &mut pos));
199
200    Ok(EchoReply {
201        flags: envelope.flags,
202        token,
203        sequence,
204        recv_count,
205        recv_window,
206        timestamps: TimestampFields {
207            recv_wall,
208            recv_mono,
209            midpoint_wall,
210            midpoint_mono,
211            send_wall,
212            send_mono,
213        },
214        payload: packet[header_len..].to_vec(),
215    })
216}
217
218/// Validates the packet length against the negotiated layout. Returns
219/// `Ok(true)` when the packet matched the upstream midpoint compatibility
220/// length rather than the exact negotiated length.
221///
222/// The negotiated length is always checked first, so when the compatibility
223/// form is not longer than the negotiated form the two are indistinguishable
224/// by length and normal negotiated parsing wins. See [`decode_echo_reply`].
225fn validate_echo_length(packet: &[u8], params: &Params, layout: PacketLayout) -> Result<bool> {
226    let header_len = layout.header_len();
227    if packet.len() < header_len {
228        return Err(ProtoError::PacketTooShort {
229            needed: header_len,
230            actual: packet.len(),
231        });
232    }
233    let expected_len = echo_packet_len(layout.hmac, params)?;
234    if packet.len() == expected_len {
235        return Ok(false);
236    }
237    if is_midpoint_single_clock(params) {
238        let compat_len = midpoint_compat_packet_len(layout, expected_len);
239        if compat_len > expected_len && packet.len() == compat_len {
240            return Ok(true);
241        }
242    }
243    Err(ProtoError::PacketLengthMismatch {
244        expected: expected_len,
245        actual: packet.len(),
246    })
247}
248
249fn push_echo_reply_tail(reply: &EchoReply, layout: PacketLayout, out: &mut Vec<u8>) -> Result<()> {
250    if let Some(value) = field_value(layout.recv_count, reply.recv_count, "recv_count")? {
251        out.extend_from_slice(&value.to_le_bytes());
252    }
253    if let Some(value) = field_value(layout.recv_window, reply.recv_window, "recv_window")? {
254        out.extend_from_slice(&value.to_le_bytes());
255    }
256
257    let timestamps = &reply.timestamps;
258    for (present, value, name) in [
259        (layout.recv_wall, timestamps.recv_wall, "recv_wall"),
260        (layout.recv_mono, timestamps.recv_mono, "recv_mono"),
261        (
262            layout.midpoint_wall,
263            timestamps.midpoint_wall,
264            "midpoint_wall",
265        ),
266        (
267            layout.midpoint_mono,
268            timestamps.midpoint_mono,
269            "midpoint_mono",
270        ),
271        (layout.send_wall, timestamps.send_wall, "send_wall"),
272        (layout.send_mono, timestamps.send_mono, "send_mono"),
273    ] {
274        if let Some(value) = field_value(present, value, name)? {
275            out.extend_from_slice(&value.to_le_bytes());
276        }
277    }
278    Ok(())
279}
280
281fn field_value<T: Copy>(present: bool, value: Option<T>, name: &'static str) -> Result<Option<T>> {
282    match (present, value) {
283        (true, Some(value)) => Ok(Some(value)),
284        (true, None) => Err(ProtoError::MissingField(name)),
285        (false, Some(_)) => Err(ProtoError::UnexpectedField(name)),
286        (false, None) => Ok(None),
287    }
288}
289
290fn read_u32(packet: &[u8], pos: &mut usize) -> u32 {
291    let value = u32::from_le_bytes(packet[*pos..*pos + SEQ_SIZE].try_into().unwrap());
292    *pos += SEQ_SIZE;
293    value
294}
295
296fn read_u64(packet: &[u8], pos: &mut usize) -> u64 {
297    let value = u64::from_le_bytes(packet[*pos..*pos + TOKEN_SIZE].try_into().unwrap());
298    *pos += TOKEN_SIZE;
299    value
300}
301
302fn read_i64(packet: &[u8], pos: &mut usize) -> i64 {
303    let value = i64::from_le_bytes(packet[*pos..*pos + TIMESTAMP_SIZE].try_into().unwrap());
304    *pos += TIMESTAMP_SIZE;
305    value
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::{
312        hmac,
313        params::{Clock, ReceivedStats, StampAt},
314        write_header, FLAG_HMAC, HMAC_SIZE, RECV_COUNT_SIZE, RECV_WINDOW_SIZE,
315    };
316
317    fn default_params() -> Params {
318        Params {
319            received_stats: ReceivedStats::Both,
320            stamp_at: StampAt::Both,
321            clock: Clock::Both,
322            ..Params::default()
323        }
324    }
325
326    fn params_with_payload_space(payload_space: usize) -> Params {
327        let header_len = PacketLayout::echo(false, &Params::default()).header_len();
328        Params {
329            length: (header_len + payload_space) as i64,
330            ..Params::default()
331        }
332    }
333
334    /// Zero-fills every negotiated reply field, matching what a peer that
335    /// reports no optional values would put on the wire.
336    fn push_zeroed_layout_tail(layout: PacketLayout, out: &mut Vec<u8>) {
337        if layout.recv_count {
338            out.extend_from_slice(&[0; RECV_COUNT_SIZE]);
339        }
340        if layout.recv_window {
341            out.extend_from_slice(&[0; RECV_WINDOW_SIZE]);
342        }
343        for _ in 0..layout.timestamp_count() {
344            out.extend_from_slice(&[0; TIMESTAMP_SIZE]);
345        }
346    }
347
348    #[test]
349    fn echo_reply_decodes_default_fields() {
350        let packet = [
351            0x14, 0xa7, 0x5b, 0x02, 0x13, 0x52, 0x71, 0x87, 0xab, 0xb6, 0x96, 0x78, 0x02, 0x00,
352            0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
353            0xb8, 0x1a, 0x33, 0x0c, 0x86, 0x6d, 0xaa, 0x18, 0xde, 0x26, 0x35, 0x95, 0x00, 0x00,
354            0x00, 0x00, 0x80, 0x4d, 0x33, 0x0c, 0x86, 0x6d, 0xaa, 0x18, 0xb2, 0x57, 0x35, 0x95,
355            0x00, 0x00, 0x00, 0x00,
356        ];
357        let reply = decode_echo_reply(&packet, &default_params(), None).unwrap();
358        assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
359        assert_eq!(reply.sequence, 2);
360        assert_eq!(reply.recv_count, Some(3));
361        assert_eq!(reply.recv_window, Some(7));
362        assert!(reply.timestamps.recv_wall.is_some());
363        assert!(reply.timestamps.midpoint_wall.is_none());
364    }
365
366    #[test]
367    fn echo_reply_decodes_exact_negotiated_length_with_payload() {
368        let params = params_with_payload_space(4);
369        let mut packet = Vec::new();
370        write_header(&mut packet, FLAG_REPLY);
371        packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
372        packet.extend_from_slice(&2_u32.to_le_bytes());
373        packet.extend_from_slice(&[1, 2, 3, 4]);
374
375        let reply = decode_echo_reply(&packet, &params, None).unwrap();
376        assert_eq!(reply.payload, vec![1, 2, 3, 4]);
377    }
378
379    #[test]
380    fn echo_reply_rejects_shorter_than_negotiated_length() {
381        let params = params_with_payload_space(4);
382        let mut packet = Vec::new();
383        write_header(&mut packet, FLAG_REPLY);
384        packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
385        packet.extend_from_slice(&2_u32.to_le_bytes());
386
387        assert_eq!(
388            decode_echo_reply(&packet, &params, None),
389            Err(ProtoError::PacketLengthMismatch {
390                expected: 20,
391                actual: 16,
392            })
393        );
394    }
395
396    #[test]
397    fn echo_reply_rejects_longer_than_negotiated_length() {
398        let params = Params::default();
399        let mut packet = Vec::new();
400        write_header(&mut packet, FLAG_REPLY);
401        packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
402        packet.extend_from_slice(&2_u32.to_le_bytes());
403        packet.push(0);
404
405        assert_eq!(
406            decode_echo_reply(&packet, &params, None),
407            Err(ProtoError::PacketLengthMismatch {
408                expected: 16,
409                actual: 17,
410            })
411        );
412    }
413
414    #[test]
415    fn hmac_echo_reply_decodes_default_fields_after_hmac() {
416        let params = default_params();
417        let layout = PacketLayout::echo(true, &params);
418        let mut packet = Vec::with_capacity(layout.header_len());
419        write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
420        packet.extend_from_slice(&[0; HMAC_SIZE]);
421        packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
422        packet.extend_from_slice(&2_u32.to_le_bytes());
423        push_zeroed_layout_tail(layout, &mut packet);
424        hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
425
426        assert_eq!(packet.len(), 76);
427        let reply = decode_echo_reply(&packet, &params, Some(b"testkey")).unwrap();
428        assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
429        assert_eq!(reply.sequence, 2);
430        assert_eq!(reply.recv_count, Some(0));
431        assert_eq!(reply.recv_window, Some(0));
432        assert_eq!(reply.payload.len(), 0);
433    }
434
435    #[test]
436    fn hmac_echo_reply_decodes_exact_negotiated_length_with_payload() {
437        let mut params = Params {
438            length: 48,
439            ..Params::default()
440        };
441        params.received_stats = ReceivedStats::Both;
442        let layout = PacketLayout::echo(true, &params);
443        let mut packet = Vec::with_capacity(echo_packet_len(true, &params).unwrap());
444        write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
445        packet.extend_from_slice(&[0; HMAC_SIZE]);
446        packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
447        packet.extend_from_slice(&2_u32.to_le_bytes());
448        push_zeroed_layout_tail(layout, &mut packet);
449        packet.extend_from_slice(&[1, 2, 3, 4]);
450        hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
451
452        let reply = decode_echo_reply(&packet, &params, Some(b"testkey")).unwrap();
453        assert_eq!(reply.payload, vec![1, 2, 3, 4]);
454    }
455
456    #[test]
457    fn hmac_echo_reply_rejects_length_mismatch_before_hmac_verification() {
458        let params = Params::default();
459        let mut packet = Vec::new();
460        write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
461        packet.extend_from_slice(&[0; HMAC_SIZE]);
462        packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
463        packet.extend_from_slice(&2_u32.to_le_bytes());
464        hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
465        packet.push(0);
466
467        assert_eq!(
468            decode_echo_reply(&packet, &params, Some(b"testkey")),
469            Err(ProtoError::PacketLengthMismatch {
470                expected: 32,
471                actual: 33,
472            })
473        );
474    }
475}