`Params::decode` seeded `Params::default()` and overwrote only the tags it saw, so an omitted tag and one explicitly encoded as zero were indistinguishable afterwards. The protocol gives those the same value but not the same meaning, and a server cannot apply its open admission rules without telling them apart: an absent Duration or Interval is accepted as the wire default zero, while one explicitly encoded as zero is invalid. Add `Params::decode_with_presence`, returning the decoded values alongside a `ParamPresence` recording which known tags appeared. `Params::decode` now delegates to it, so there is still exactly one parser and the two cannot diverge. Unknown tags stay ignored and untracked, and repeated known tags keep last-value-wins. Add `Clock::Unspecified = 0` as the default, which is the wire default for an absent Clock tag and what an empty parameter payload decodes to. An explicit Clock tag encoding zero remains an invalid enum value, so `Unspecified` is only ever reached by omission; `encode` omits the tag for it rather than emitting an explicit zero. It selects no clock, so `has_wall`/`has_mono` are false and no timestamp field is laid out. `ClientConfig::clock` is public and therefore now constructible as `Unspecified`, which is a wire default rather than a client mode. Reject it during config validation. The client default stays `Clock::Both`, so normal client-generated params and wire vectors are unchanged.
432 lines
16 KiB
Rust
432 lines
16 KiB
Rust
use crate::{
|
|
envelope::{self, FlagRule},
|
|
flags::{FLAG_OPEN, FLAG_REPLY},
|
|
layout::{echo_packet_len, PacketLayout},
|
|
params::{Clock, Params, StampAt},
|
|
ProtoError, Result, SEQ_SIZE, TIMESTAMP_SIZE, TOKEN_SIZE,
|
|
};
|
|
|
|
/// Upstream irtt 0.9.1 always emits both midpoint timestamp fields (wall,
|
|
/// then monotonic) in an ECHO reply for single-clock `StampAt::Midpoint`
|
|
/// negotiations, even though only one clock was negotiated. That makes the
|
|
/// compatibility header exactly one timestamp longer than the negotiated
|
|
/// header.
|
|
const MIDPOINT_COMPAT_EXTRA: usize = TIMESTAMP_SIZE;
|
|
|
|
/// Whether the negotiation selects a midpoint timestamp from exactly one clock,
|
|
/// which is the only case the upstream dual-field compatibility form applies to.
|
|
///
|
|
/// [`Clock::Unspecified`] selects no clock and therefore lays out no midpoint
|
|
/// field at all, so it is not a single-clock negotiation.
|
|
fn is_midpoint_single_clock(params: &Params) -> bool {
|
|
params.stamp_at == StampAt::Midpoint && matches!(params.clock, Clock::Wall | Clock::Monotonic)
|
|
}
|
|
|
|
/// Total datagram length of the upstream dual-midpoint compatibility form.
|
|
///
|
|
/// Both lengths are `max(negotiated_length, header)`, so this is *not*
|
|
/// `normal_len + TIMESTAMP_SIZE`. The extra midpoint field only lengthens the
|
|
/// datagram while the larger header is still pushing past the negotiated
|
|
/// length; beyond that it displaces payload instead, and the two forms have
|
|
/// identical length. The difference is therefore 8, 7..1, or 0 depending on
|
|
/// the negotiated length.
|
|
///
|
|
/// Deriving this from `normal_len` keeps the identity exact —
|
|
/// `max(negotiated_length, compat_header) == max(compat_header, normal_len)` —
|
|
/// and cannot overflow, because `compat_header` is a small bounded header
|
|
/// size rather than an attacker-influenced packet length.
|
|
fn midpoint_compat_packet_len(layout: PacketLayout, normal_len: usize) -> usize {
|
|
(layout.header_len() + MIDPOINT_COMPAT_EXTRA).max(normal_len)
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct EchoReply {
|
|
pub flags: u8,
|
|
pub token: u64,
|
|
pub sequence: u32,
|
|
pub recv_count: Option<u32>,
|
|
pub recv_window: Option<u64>,
|
|
pub timestamps: TimestampFields,
|
|
pub payload: Vec<u8>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub struct TimestampFields {
|
|
pub recv_wall: Option<i64>,
|
|
pub recv_mono: Option<i64>,
|
|
pub midpoint_wall: Option<i64>,
|
|
pub midpoint_mono: Option<i64>,
|
|
pub send_wall: Option<i64>,
|
|
pub send_mono: Option<i64>,
|
|
}
|
|
|
|
pub fn encode_echo_reply(
|
|
reply: &EchoReply,
|
|
params: &Params,
|
|
hmac_key: Option<&[u8]>,
|
|
) -> Result<Vec<u8>> {
|
|
let layout = PacketLayout::echo(hmac_key.is_some(), params);
|
|
let len = echo_packet_len(hmac_key.is_some(), params)?;
|
|
let payload_offset = layout.header_len();
|
|
let available_payload_len = len.saturating_sub(payload_offset);
|
|
if reply.payload.len() > available_payload_len {
|
|
return Err(ProtoError::PayloadTooLarge {
|
|
available: available_payload_len,
|
|
provided: reply.payload.len(),
|
|
});
|
|
}
|
|
|
|
let mut out = envelope::begin_checked(
|
|
reply.flags,
|
|
hmac_key,
|
|
&[FlagRule::Reject(FLAG_OPEN), FlagRule::Require(FLAG_REPLY)],
|
|
len,
|
|
)?;
|
|
out.extend_from_slice(&reply.token.to_le_bytes());
|
|
out.extend_from_slice(&reply.sequence.to_le_bytes());
|
|
push_echo_reply_tail(reply, layout, &mut out)?;
|
|
out.resize(len, 0);
|
|
out[payload_offset..payload_offset + reply.payload.len()].copy_from_slice(&reply.payload);
|
|
|
|
envelope::finish(out, hmac_key)
|
|
}
|
|
|
|
/// Decodes an ECHO reply, accepting the upstream irtt 0.9.1 dual-midpoint
|
|
/// compatibility form when it is identifiable by datagram length.
|
|
///
|
|
/// For single-clock `StampAt::Midpoint` negotiations upstream emits both
|
|
/// midpoint fields (wall, then monotonic). When that larger header makes the
|
|
/// datagram longer than the negotiated length, the compatibility form is
|
|
/// unambiguous and both fields are parsed, exposing only the negotiated
|
|
/// clock's value.
|
|
///
|
|
/// When the negotiated length already covers the larger header the two forms
|
|
/// have identical length and are indistinguishable from the packet alone. The
|
|
/// reply is still accepted, parsed against the negotiated layout. For a
|
|
/// monotonic-only negotiation that means the value exposed as `midpoint_mono`
|
|
/// is whatever occupies the negotiated field position, which for an upstream
|
|
/// peer is its wall timestamp — this cannot be corrected deterministically
|
|
/// without out-of-band knowledge of the peer, so no heuristic is applied.
|
|
pub fn decode_echo_reply(
|
|
packet: &[u8],
|
|
params: &Params,
|
|
hmac_key: Option<&[u8]>,
|
|
) -> Result<EchoReply> {
|
|
let envelope = envelope::decode(
|
|
packet,
|
|
hmac_key,
|
|
&[FlagRule::Reject(FLAG_OPEN), FlagRule::Require(FLAG_REPLY)],
|
|
)?;
|
|
let layout = PacketLayout::echo(hmac_key.is_some(), params);
|
|
let midpoint_compat = validate_echo_length(packet, params, layout)?;
|
|
envelope::verify(packet, hmac_key)?;
|
|
|
|
let header_len = layout.header_len()
|
|
+ if midpoint_compat {
|
|
MIDPOINT_COMPAT_EXTRA
|
|
} else {
|
|
0
|
|
};
|
|
let mut pos = envelope.body_offset;
|
|
let token = read_u64(packet, &mut pos);
|
|
let sequence = read_u32(packet, &mut pos);
|
|
let recv_count = layout.recv_count.then(|| read_u32(packet, &mut pos));
|
|
let recv_window = layout.recv_window.then(|| read_u64(packet, &mut pos));
|
|
let recv_wall = layout.recv_wall.then(|| read_i64(packet, &mut pos));
|
|
let recv_mono = layout.recv_mono.then(|| read_i64(packet, &mut pos));
|
|
let (midpoint_wall, midpoint_mono) = if midpoint_compat {
|
|
let wall = read_i64(packet, &mut pos);
|
|
let mono = read_i64(packet, &mut pos);
|
|
match params.clock {
|
|
Clock::Wall => (Some(wall), None),
|
|
Clock::Monotonic => (None, Some(mono)),
|
|
Clock::Unspecified | Clock::Both => {
|
|
unreachable!("midpoint compat only applies to single-clock negotiations")
|
|
}
|
|
}
|
|
} else {
|
|
(
|
|
layout.midpoint_wall.then(|| read_i64(packet, &mut pos)),
|
|
layout.midpoint_mono.then(|| read_i64(packet, &mut pos)),
|
|
)
|
|
};
|
|
let send_wall = layout.send_wall.then(|| read_i64(packet, &mut pos));
|
|
let send_mono = layout.send_mono.then(|| read_i64(packet, &mut pos));
|
|
|
|
Ok(EchoReply {
|
|
flags: envelope.flags,
|
|
token,
|
|
sequence,
|
|
recv_count,
|
|
recv_window,
|
|
timestamps: TimestampFields {
|
|
recv_wall,
|
|
recv_mono,
|
|
midpoint_wall,
|
|
midpoint_mono,
|
|
send_wall,
|
|
send_mono,
|
|
},
|
|
payload: packet[header_len..].to_vec(),
|
|
})
|
|
}
|
|
|
|
/// Validates the packet length against the negotiated layout. Returns
|
|
/// `Ok(true)` when the packet matched the upstream midpoint compatibility
|
|
/// length rather than the exact negotiated length.
|
|
///
|
|
/// The negotiated length is always checked first, so when the compatibility
|
|
/// form is not longer than the negotiated form the two are indistinguishable
|
|
/// by length and normal negotiated parsing wins. See [`decode_echo_reply`].
|
|
fn validate_echo_length(packet: &[u8], params: &Params, layout: PacketLayout) -> Result<bool> {
|
|
let header_len = layout.header_len();
|
|
if packet.len() < header_len {
|
|
return Err(ProtoError::PacketTooShort {
|
|
needed: header_len,
|
|
actual: packet.len(),
|
|
});
|
|
}
|
|
let expected_len = echo_packet_len(layout.hmac, params)?;
|
|
if packet.len() == expected_len {
|
|
return Ok(false);
|
|
}
|
|
if is_midpoint_single_clock(params) {
|
|
let compat_len = midpoint_compat_packet_len(layout, expected_len);
|
|
if compat_len > expected_len && packet.len() == compat_len {
|
|
return Ok(true);
|
|
}
|
|
}
|
|
Err(ProtoError::PacketLengthMismatch {
|
|
expected: expected_len,
|
|
actual: packet.len(),
|
|
})
|
|
}
|
|
|
|
fn push_echo_reply_tail(reply: &EchoReply, layout: PacketLayout, out: &mut Vec<u8>) -> Result<()> {
|
|
if let Some(value) = field_value(layout.recv_count, reply.recv_count, "recv_count")? {
|
|
out.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
if let Some(value) = field_value(layout.recv_window, reply.recv_window, "recv_window")? {
|
|
out.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
let timestamps = &reply.timestamps;
|
|
for (present, value, name) in [
|
|
(layout.recv_wall, timestamps.recv_wall, "recv_wall"),
|
|
(layout.recv_mono, timestamps.recv_mono, "recv_mono"),
|
|
(
|
|
layout.midpoint_wall,
|
|
timestamps.midpoint_wall,
|
|
"midpoint_wall",
|
|
),
|
|
(
|
|
layout.midpoint_mono,
|
|
timestamps.midpoint_mono,
|
|
"midpoint_mono",
|
|
),
|
|
(layout.send_wall, timestamps.send_wall, "send_wall"),
|
|
(layout.send_mono, timestamps.send_mono, "send_mono"),
|
|
] {
|
|
if let Some(value) = field_value(present, value, name)? {
|
|
out.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn field_value<T: Copy>(present: bool, value: Option<T>, name: &'static str) -> Result<Option<T>> {
|
|
match (present, value) {
|
|
(true, Some(value)) => Ok(Some(value)),
|
|
(true, None) => Err(ProtoError::MissingField(name)),
|
|
(false, Some(_)) => Err(ProtoError::UnexpectedField(name)),
|
|
(false, None) => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn read_u32(packet: &[u8], pos: &mut usize) -> u32 {
|
|
let value = u32::from_le_bytes(packet[*pos..*pos + SEQ_SIZE].try_into().unwrap());
|
|
*pos += SEQ_SIZE;
|
|
value
|
|
}
|
|
|
|
fn read_u64(packet: &[u8], pos: &mut usize) -> u64 {
|
|
let value = u64::from_le_bytes(packet[*pos..*pos + TOKEN_SIZE].try_into().unwrap());
|
|
*pos += TOKEN_SIZE;
|
|
value
|
|
}
|
|
|
|
fn read_i64(packet: &[u8], pos: &mut usize) -> i64 {
|
|
let value = i64::from_le_bytes(packet[*pos..*pos + TIMESTAMP_SIZE].try_into().unwrap());
|
|
*pos += TIMESTAMP_SIZE;
|
|
value
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{
|
|
hmac,
|
|
params::{Clock, ReceivedStats, StampAt},
|
|
write_header, FLAG_HMAC, HMAC_SIZE, RECV_COUNT_SIZE, RECV_WINDOW_SIZE,
|
|
};
|
|
|
|
fn default_params() -> Params {
|
|
Params {
|
|
received_stats: ReceivedStats::Both,
|
|
stamp_at: StampAt::Both,
|
|
clock: Clock::Both,
|
|
..Params::default()
|
|
}
|
|
}
|
|
|
|
fn params_with_payload_space(payload_space: usize) -> Params {
|
|
let header_len = PacketLayout::echo(false, &Params::default()).header_len();
|
|
Params {
|
|
length: (header_len + payload_space) as i64,
|
|
..Params::default()
|
|
}
|
|
}
|
|
|
|
/// Zero-fills every negotiated reply field, matching what a peer that
|
|
/// reports no optional values would put on the wire.
|
|
fn push_zeroed_layout_tail(layout: PacketLayout, out: &mut Vec<u8>) {
|
|
if layout.recv_count {
|
|
out.extend_from_slice(&[0; RECV_COUNT_SIZE]);
|
|
}
|
|
if layout.recv_window {
|
|
out.extend_from_slice(&[0; RECV_WINDOW_SIZE]);
|
|
}
|
|
for _ in 0..layout.timestamp_count() {
|
|
out.extend_from_slice(&[0; TIMESTAMP_SIZE]);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn echo_reply_decodes_default_fields() {
|
|
let packet = [
|
|
0x14, 0xa7, 0x5b, 0x02, 0x13, 0x52, 0x71, 0x87, 0xab, 0xb6, 0x96, 0x78, 0x02, 0x00,
|
|
0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
0xb8, 0x1a, 0x33, 0x0c, 0x86, 0x6d, 0xaa, 0x18, 0xde, 0x26, 0x35, 0x95, 0x00, 0x00,
|
|
0x00, 0x00, 0x80, 0x4d, 0x33, 0x0c, 0x86, 0x6d, 0xaa, 0x18, 0xb2, 0x57, 0x35, 0x95,
|
|
0x00, 0x00, 0x00, 0x00,
|
|
];
|
|
let reply = decode_echo_reply(&packet, &default_params(), None).unwrap();
|
|
assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
|
|
assert_eq!(reply.sequence, 2);
|
|
assert_eq!(reply.recv_count, Some(3));
|
|
assert_eq!(reply.recv_window, Some(7));
|
|
assert!(reply.timestamps.recv_wall.is_some());
|
|
assert!(reply.timestamps.midpoint_wall.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn echo_reply_decodes_exact_negotiated_length_with_payload() {
|
|
let params = params_with_payload_space(4);
|
|
let mut packet = Vec::new();
|
|
write_header(&mut packet, FLAG_REPLY);
|
|
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
packet.extend_from_slice(&2_u32.to_le_bytes());
|
|
packet.extend_from_slice(&[1, 2, 3, 4]);
|
|
|
|
let reply = decode_echo_reply(&packet, ¶ms, None).unwrap();
|
|
assert_eq!(reply.payload, vec![1, 2, 3, 4]);
|
|
}
|
|
|
|
#[test]
|
|
fn echo_reply_rejects_shorter_than_negotiated_length() {
|
|
let params = params_with_payload_space(4);
|
|
let mut packet = Vec::new();
|
|
write_header(&mut packet, FLAG_REPLY);
|
|
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
packet.extend_from_slice(&2_u32.to_le_bytes());
|
|
|
|
assert_eq!(
|
|
decode_echo_reply(&packet, ¶ms, None),
|
|
Err(ProtoError::PacketLengthMismatch {
|
|
expected: 20,
|
|
actual: 16,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn echo_reply_rejects_longer_than_negotiated_length() {
|
|
let params = Params::default();
|
|
let mut packet = Vec::new();
|
|
write_header(&mut packet, FLAG_REPLY);
|
|
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
packet.extend_from_slice(&2_u32.to_le_bytes());
|
|
packet.push(0);
|
|
|
|
assert_eq!(
|
|
decode_echo_reply(&packet, ¶ms, None),
|
|
Err(ProtoError::PacketLengthMismatch {
|
|
expected: 16,
|
|
actual: 17,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hmac_echo_reply_decodes_default_fields_after_hmac() {
|
|
let params = default_params();
|
|
let layout = PacketLayout::echo(true, ¶ms);
|
|
let mut packet = Vec::with_capacity(layout.header_len());
|
|
write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
|
|
packet.extend_from_slice(&[0; HMAC_SIZE]);
|
|
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
packet.extend_from_slice(&2_u32.to_le_bytes());
|
|
push_zeroed_layout_tail(layout, &mut packet);
|
|
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
|
|
|
|
assert_eq!(packet.len(), 76);
|
|
let reply = decode_echo_reply(&packet, ¶ms, Some(b"testkey")).unwrap();
|
|
assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
|
|
assert_eq!(reply.sequence, 2);
|
|
assert_eq!(reply.recv_count, Some(0));
|
|
assert_eq!(reply.recv_window, Some(0));
|
|
assert_eq!(reply.payload.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn hmac_echo_reply_decodes_exact_negotiated_length_with_payload() {
|
|
let mut params = Params {
|
|
length: 48,
|
|
..Params::default()
|
|
};
|
|
params.received_stats = ReceivedStats::Both;
|
|
let layout = PacketLayout::echo(true, ¶ms);
|
|
let mut packet = Vec::with_capacity(echo_packet_len(true, ¶ms).unwrap());
|
|
write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
|
|
packet.extend_from_slice(&[0; HMAC_SIZE]);
|
|
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
packet.extend_from_slice(&2_u32.to_le_bytes());
|
|
push_zeroed_layout_tail(layout, &mut packet);
|
|
packet.extend_from_slice(&[1, 2, 3, 4]);
|
|
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
|
|
|
|
let reply = decode_echo_reply(&packet, ¶ms, Some(b"testkey")).unwrap();
|
|
assert_eq!(reply.payload, vec![1, 2, 3, 4]);
|
|
}
|
|
|
|
#[test]
|
|
fn hmac_echo_reply_rejects_length_mismatch_before_hmac_verification() {
|
|
let params = Params::default();
|
|
let mut packet = Vec::new();
|
|
write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
|
|
packet.extend_from_slice(&[0; HMAC_SIZE]);
|
|
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
packet.extend_from_slice(&2_u32.to_le_bytes());
|
|
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
|
|
packet.push(0);
|
|
|
|
assert_eq!(
|
|
decode_echo_reply(&packet, ¶ms, Some(b"testkey")),
|
|
Err(ProtoError::PacketLengthMismatch {
|
|
expected: 32,
|
|
actual: 33,
|
|
})
|
|
);
|
|
}
|
|
}
|