use blake2::{
digest::{consts::U32, Mac},
Blake2sMac,
};
use crate::adapter::net::route::{RoutingHeader, ROUTING_HEADER_SIZE};
pub const ROUTE_HOP_MAC_DOMAIN: &[u8] = b"net.subnet.route-hop.v1";
pub const ROUTE_HOP_MAGIC: u16 = 0x5248;
pub const ROUTE_HOP_PREFIX_SIZE: usize = 18;
pub const ROUTE_HOP_TAG_SIZE: usize = 16;
pub const ROUTE_HOP_OVERHEAD: usize = ROUTE_HOP_PREFIX_SIZE + ROUTE_HOP_TAG_SIZE;
pub const ROUTE_HOP_REPLAY_WINDOW: u64 = 128;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthenticatedNextHop {
pub node_id: u64,
pub addr: std::net::SocketAddr,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteHopError {
Malformed,
BadRoutingHeader,
BadTag,
Replay,
Contended,
BufferTooSmall,
}
impl std::fmt::Display for RouteHopError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Malformed => "route_hop_malformed",
Self::BadRoutingHeader => "route_hop_bad_routing_header",
Self::BadTag => "route_hop_bad_tag",
Self::Replay => "route_hop_replay",
Self::Contended => "route_hop_contended",
Self::BufferTooSmall => "route_hop_buffer_too_small",
})
}
}
impl std::error::Error for RouteHopError {}
#[expect(
clippy::expect_used,
reason = "Blake2sMac::new_from_slice rejects only keys longer than 32 bytes; the key parameter is [u8; 32]"
)]
pub fn compute_tag(
key: &[u8; 32],
hop_session_id: u64,
hop_sequence: u64,
routing_header_bytes: &[u8],
inner: &[u8],
) -> [u8; ROUTE_HOP_TAG_SIZE] {
let mut mac = <Blake2sMac<U32> as Mac>::new_from_slice(key)
.expect("BLAKE2s accepts variable-length keys up to 32 bytes");
Mac::update(&mut mac, ROUTE_HOP_MAC_DOMAIN);
Mac::update(&mut mac, &hop_session_id.to_le_bytes());
Mac::update(&mut mac, &hop_sequence.to_le_bytes());
Mac::update(&mut mac, routing_header_bytes);
Mac::update(&mut mac, inner);
let full = mac.finalize().into_bytes();
let mut tag = [0u8; ROUTE_HOP_TAG_SIZE];
tag.copy_from_slice(&full[..ROUTE_HOP_TAG_SIZE]);
tag
}
#[inline]
pub const fn sealed_len(inner_len: usize) -> usize {
(ROUTE_HOP_OVERHEAD + ROUTING_HEADER_SIZE).saturating_add(inner_len)
}
pub fn seal_into(
out: &mut [u8],
key: &[u8; 32],
hop_session_id: u64,
hop_sequence: u64,
header: &RoutingHeader,
inner: &[u8],
) -> Result<usize, RouteHopError> {
let total = sealed_len(inner.len());
if out.len() < total {
return Err(RouteHopError::BufferTooSmall);
}
let header_end = ROUTE_HOP_PREFIX_SIZE + ROUTING_HEADER_SIZE;
let inner_end = header_end + inner.len();
out[0..2].copy_from_slice(&ROUTE_HOP_MAGIC.to_le_bytes());
out[2..10].copy_from_slice(&hop_session_id.to_le_bytes());
out[10..ROUTE_HOP_PREFIX_SIZE].copy_from_slice(&hop_sequence.to_le_bytes());
header.write_at(&mut out[ROUTE_HOP_PREFIX_SIZE..header_end]);
out[header_end..inner_end].copy_from_slice(inner);
let tag = compute_tag(
key,
hop_session_id,
hop_sequence,
&out[ROUTE_HOP_PREFIX_SIZE..header_end],
inner,
);
out[inner_end..total].copy_from_slice(&tag);
Ok(total)
}
#[expect(
clippy::expect_used,
reason = "the buffer is sized by sealed_len on the line above, which is the same length \
seal_into checks against; a failure here would be a contradiction in this module"
)]
pub fn seal(
key: &[u8; 32],
hop_session_id: u64,
hop_sequence: u64,
header: &RoutingHeader,
inner: &[u8],
) -> Vec<u8> {
let mut out = vec![0u8; sealed_len(inner.len())];
seal_into(&mut out, key, hop_session_id, hop_sequence, header, inner)
.expect("a buffer sized by sealed_len always fits");
out
}
#[derive(Debug, Clone, Copy)]
pub struct OpenedHop<'a> {
pub hop_session_id: u64,
pub hop_sequence: u64,
pub header: RoutingHeader,
pub inner: &'a [u8],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnauthenticatedHopPrefix {
pub hop_session_id: u64,
}
pub fn parse_prefix(buf: &[u8]) -> Result<UnauthenticatedHopPrefix, RouteHopError> {
let opened = parse_unverified(buf)?;
Ok(UnauthenticatedHopPrefix {
hop_session_id: opened.hop_session_id,
})
}
fn parse_unverified(buf: &[u8]) -> Result<OpenedHop<'_>, RouteHopError> {
if buf.len() < ROUTE_HOP_OVERHEAD + ROUTING_HEADER_SIZE {
return Err(RouteHopError::Malformed);
}
if u16::from_le_bytes([buf[0], buf[1]]) != ROUTE_HOP_MAGIC {
return Err(RouteHopError::Malformed);
}
let hop_session_id = u64::from_le_bytes(
buf[2..10]
.try_into()
.map_err(|_| RouteHopError::Malformed)?,
);
let hop_sequence = u64::from_le_bytes(
buf[10..18]
.try_into()
.map_err(|_| RouteHopError::Malformed)?,
);
let header_start = ROUTE_HOP_PREFIX_SIZE;
let header_end = header_start + ROUTING_HEADER_SIZE;
let header = RoutingHeader::from_bytes(&buf[header_start..header_end])
.ok_or(RouteHopError::BadRoutingHeader)?;
let inner = &buf[header_end..buf.len() - ROUTE_HOP_TAG_SIZE];
Ok(OpenedHop {
hop_session_id,
hop_sequence,
header,
inner,
})
}
pub fn open<'a>(key: &[u8; 32], buf: &'a [u8]) -> Result<OpenedHop<'a>, RouteHopError> {
let opened = parse_unverified(buf)?;
let header_start = ROUTE_HOP_PREFIX_SIZE;
let header_end = header_start + ROUTING_HEADER_SIZE;
let expected = compute_tag(
key,
opened.hop_session_id,
opened.hop_sequence,
&buf[header_start..header_end],
opened.inner,
);
let received = &buf[buf.len() - ROUTE_HOP_TAG_SIZE..];
if !bool::from(subtle::ConstantTimeEq::ct_eq(&expected[..], received)) {
return Err(RouteHopError::BadTag);
}
Ok(opened)
}
#[derive(Debug, Clone)]
pub struct HopReplayWindow {
highest: u64,
seen: u128,
started: bool,
}
impl Default for HopReplayWindow {
fn default() -> Self {
Self::new()
}
}
impl HopReplayWindow {
pub fn new() -> Self {
Self {
highest: 0,
seen: 0,
started: false,
}
}
pub fn admit(&mut self, sequence: u64) -> Result<(), RouteHopError> {
const W: u64 = ROUTE_HOP_REPLAY_WINDOW;
if !self.started {
self.started = true;
self.highest = sequence;
return Ok(());
}
if sequence > self.highest {
let advance = sequence - self.highest;
self.seen = if advance > W {
0
} else if advance == W {
1u128 << (W - 1)
} else {
(self.seen << advance) | (1u128 << (advance - 1))
};
self.highest = sequence;
return Ok(());
}
if sequence == self.highest {
return Err(RouteHopError::Replay);
}
let behind = self.highest - sequence;
if behind > W {
return Err(RouteHopError::Replay);
}
#[expect(
clippy::cast_possible_truncation,
reason = "behind is bounded by W = 128 immediately above, so the cast is exact"
)]
let bit = 1u128 << ((behind - 1) as u32);
if self.seen & bit != 0 {
return Err(RouteHopError::Replay);
}
self.seen |= bit;
Ok(())
}
}
#[derive(Debug)]
pub struct SharedHopReplayWindow {
claimed: core::sync::atomic::AtomicBool,
started: core::sync::atomic::AtomicBool,
highest: core::sync::atomic::AtomicU64,
seen_lo: core::sync::atomic::AtomicU64,
seen_hi: core::sync::atomic::AtomicU64,
}
impl Default for SharedHopReplayWindow {
fn default() -> Self {
Self::new()
}
}
struct ReplayClaim<'a>(&'a SharedHopReplayWindow);
impl Drop for ReplayClaim<'_> {
fn drop(&mut self) {
self.0
.claimed
.store(false, core::sync::atomic::Ordering::Release);
}
}
impl SharedHopReplayWindow {
pub const fn new() -> Self {
use core::sync::atomic::{AtomicBool, AtomicU64};
Self {
claimed: AtomicBool::new(false),
started: AtomicBool::new(false),
highest: AtomicU64::new(0),
seen_lo: AtomicU64::new(0),
seen_hi: AtomicU64::new(0),
}
}
fn try_claim(&self) -> Option<ReplayClaim<'_>> {
use core::sync::atomic::Ordering;
self.claimed
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_ok()
.then(|| ReplayClaim(self))
}
pub fn admit(&self, sequence: u64) -> Result<(), RouteHopError> {
use core::sync::atomic::Ordering;
let Some(claim) = self.try_claim() else {
return Err(RouteHopError::Contended);
};
let mut window = HopReplayWindow {
highest: self.highest.load(Ordering::Relaxed),
seen: (u128::from(self.seen_hi.load(Ordering::Relaxed)) << 64)
| u128::from(self.seen_lo.load(Ordering::Relaxed)),
started: self.started.load(Ordering::Relaxed),
};
let result = window.admit(sequence);
if result.is_ok() {
self.highest.store(window.highest, Ordering::Relaxed);
#[expect(
clippy::cast_possible_truncation,
reason = "deliberate: the low half of the split u128 bitmap"
)]
self.seen_lo.store(window.seen as u64, Ordering::Relaxed);
self.seen_hi
.store((window.seen >> 64) as u64, Ordering::Relaxed);
self.started.store(window.started, Ordering::Relaxed);
}
drop(claim);
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapter::net::route::RoutingHeader;
fn header() -> RoutingHeader {
RoutingHeader::new(0xDEAD_BEEF_CAFE, 0x1234, 8)
}
const KEY: [u8; 32] = [0x11; 32];
const INNER: &[u8] = b"end-to-end ciphertext that a relay must not touch";
#[test]
fn seal_open_round_trips_and_preserves_inner_bytes() {
let buf = seal(&KEY, 42, 7, &header(), INNER);
let opened = open(&KEY, &buf).expect("tag verifies");
assert_eq!(opened.hop_session_id, 42);
assert_eq!(opened.hop_sequence, 7);
assert_eq!(opened.header.dest_id, header().dest_id);
assert_eq!(opened.inner, INNER, "inner packet must be byte-identical");
}
#[test]
fn every_transcript_field_is_covered() {
let buf = seal(&KEY, 42, 7, &header(), INNER);
assert_eq!(open(&[0x22; 32], &buf).unwrap_err(), RouteHopError::BadTag);
let mut t = buf.clone();
t[0] ^= 1;
assert_eq!(open(&KEY, &t).unwrap_err(), RouteHopError::Malformed);
let mut t = buf.clone();
t[2] ^= 1;
assert_eq!(open(&KEY, &t).unwrap_err(), RouteHopError::BadTag);
let mut t = buf.clone();
t[10] ^= 1;
assert_eq!(open(&KEY, &t).unwrap_err(), RouteHopError::BadTag);
for i in 0..ROUTING_HEADER_SIZE {
let mut t = buf.clone();
let at = ROUTE_HOP_PREFIX_SIZE + i;
t[at] ^= 1;
let err = open(&KEY, &t).unwrap_err();
assert!(
matches!(err, RouteHopError::BadTag | RouteHopError::BadRoutingHeader),
"routing header byte {i} must be covered, got {err:?}",
);
}
let inner_start = ROUTE_HOP_PREFIX_SIZE + ROUTING_HEADER_SIZE;
for i in 0..INNER.len() {
let mut t = buf.clone();
t[inner_start + i] ^= 1;
assert_eq!(
open(&KEY, &t).unwrap_err(),
RouteHopError::BadTag,
"inner byte {i} must be covered",
);
}
let mut t = buf.clone();
let last = t.len() - 1;
t[last] ^= 1;
assert_eq!(open(&KEY, &t).unwrap_err(), RouteHopError::BadTag);
}
#[test]
fn seal_into_matches_seal_byte_for_byte() {
for inner_len in [0usize, 1, 47, 1200] {
let inner: Vec<u8> = (0..inner_len).map(|i| (i % 251) as u8).collect();
let allocated = seal(&KEY, 9, 3, &header(), &inner);
let mut owned = vec![0xAAu8; sealed_len(inner_len)];
let n = seal_into(&mut owned, &KEY, 9, 3, &header(), &inner).expect("fits exactly");
assert_eq!(n, sealed_len(inner_len), "sealed_len must be exact");
assert_eq!(n, allocated.len());
assert_eq!(owned, allocated, "inner_len {inner_len}");
open(&KEY, &owned).expect("the buffer form still verifies");
}
}
#[test]
fn seal_into_writes_only_the_sealed_prefix() {
let mut buf = [0x5Au8; 512];
let n = seal_into(&mut buf, &KEY, 1, 1, &header(), INNER).expect("plenty of room");
assert_eq!(n, sealed_len(INNER.len()));
assert!(
buf[n..].iter().all(|&b| b == 0x5A),
"bytes past the envelope must be untouched",
);
open(&KEY, &buf[..n]).expect("the prefix is a complete envelope");
}
#[test]
fn seal_into_refuses_a_short_buffer_without_writing() {
let needed = sealed_len(INNER.len());
for len in [0usize, 1, ROUTE_HOP_PREFIX_SIZE, needed - 1] {
let mut buf = vec![0u8; len];
assert_eq!(
seal_into(&mut buf, &KEY, 1, 1, &header(), INNER).unwrap_err(),
RouteHopError::BufferTooSmall,
"len {len} must not be accepted",
);
assert!(
buf.iter().all(|&b| b == 0),
"len {len}: nothing may be written on refusal",
);
}
let mut exact = vec![0u8; needed];
assert_eq!(
seal_into(&mut exact, &KEY, 1, 1, &header(), INNER),
Ok(needed),
);
}
#[test]
fn a_reused_buffer_does_not_leak_the_previous_packet() {
let mut buf = vec![0u8; sealed_len(2048)];
let long = vec![0xEEu8; 1500];
let short = b"short".to_vec();
let n_long = seal_into(&mut buf, &KEY, 1, 1, &header(), &long).expect("fits");
open(&KEY, &buf[..n_long]).expect("long verifies");
let n_short = seal_into(&mut buf, &KEY, 1, 2, &header(), &short).expect("fits");
let opened = open(&KEY, &buf[..n_short]).expect("short verifies after long");
assert_eq!(
opened.inner,
&short[..],
"the shorter packet must not inherit the longer one's bytes",
);
}
#[test]
fn short_buffers_are_malformed_not_panics() {
for len in 0..(ROUTE_HOP_OVERHEAD + ROUTING_HEADER_SIZE) {
let mut buf = vec![0u8; len];
if len >= 2 {
buf[0..2].copy_from_slice(&ROUTE_HOP_MAGIC.to_le_bytes());
}
assert_eq!(open(&KEY, &buf).unwrap_err(), RouteHopError::Malformed);
}
}
#[test]
fn a_legacy_routing_packet_is_not_an_envelope() {
let mut legacy = Vec::new();
legacy.extend_from_slice(&header().to_bytes());
legacy.extend_from_slice(INNER);
assert_eq!(open(&KEY, &legacy).unwrap_err(), RouteHopError::Malformed);
}
#[test]
fn replay_window_admits_once_and_tolerates_reorder() {
let mut w = HopReplayWindow::new();
assert!(w.admit(100).is_ok());
assert_eq!(w.admit(100).unwrap_err(), RouteHopError::Replay);
assert!(w.admit(101).is_ok());
assert_eq!(w.admit(101).unwrap_err(), RouteHopError::Replay);
assert!(w.admit(99).is_ok());
assert_eq!(w.admit(99).unwrap_err(), RouteHopError::Replay);
assert!(w.admit(100_000).is_ok());
assert_eq!(w.admit(101).unwrap_err(), RouteHopError::Replay);
assert_eq!(w.admit(99).unwrap_err(), RouteHopError::Replay);
}
#[test]
fn sequences_older_than_the_window_are_refused() {
let mut w = HopReplayWindow::new();
assert!(w.admit(10_000).is_ok());
assert_eq!(
w.admit(10_000 - ROUTE_HOP_REPLAY_WINDOW - 1).unwrap_err(),
RouteHopError::Replay,
);
}
#[test]
fn a_duplicate_exactly_one_window_behind_is_refused() {
let mut w = HopReplayWindow::new();
assert!(w.admit(100).is_ok());
assert!(w.admit(100 + ROUTE_HOP_REPLAY_WINDOW).is_ok());
assert_eq!(
w.admit(100).unwrap_err(),
RouteHopError::Replay,
"a sequence exactly one window behind must stay marked seen",
);
}
#[test]
fn window_boundary_matrix() {
const W: u64 = ROUTE_HOP_REPLAY_WINDOW;
let base = 1_000_000u64;
for advance in [W - 1, W, W + 1] {
for offset in 1..=W {
let mut w = HopReplayWindow::new();
assert!(w.admit(base).is_ok());
let victim = base + advance - offset;
if victim != base {
if victim > base {
assert!(w.admit(victim).is_ok(), "advance={advance} offset={offset}");
} else {
continue;
}
}
assert!(w.admit(base + advance).is_ok());
assert_eq!(
w.admit(victim).unwrap_err(),
RouteHopError::Replay,
"advance={advance} offset={offset}: an admitted sequence must never be re-admitted",
);
}
}
let mut w = HopReplayWindow::new();
assert!(w.admit(base).is_ok());
assert_eq!(w.admit(base - W - 1).unwrap_err(), RouteHopError::Replay);
let mut w = HopReplayWindow::new();
assert!(w.admit(base).is_ok());
assert!(
w.admit(base - W).is_ok(),
"the far edge of the window is representable and must be usable",
);
assert_eq!(w.admit(base - W).unwrap_err(), RouteHopError::Replay);
}
#[test]
fn a_huge_advance_neither_panics_nor_remembers() {
let mut w = HopReplayWindow::new();
assert!(w.admit(1).is_ok());
assert!(w.admit(u64::MAX / 2).is_ok());
assert_eq!(w.admit(1).unwrap_err(), RouteHopError::Replay);
}
#[test]
fn a_tag_does_not_verify_under_the_reverse_direction_key() {
let tx = [0xAA; 32];
let rx = [0xBB; 32];
let buf = seal(&tx, 1, 1, &header(), INNER);
open(&tx, &buf).expect("verifies under the sending key");
assert_eq!(open(&rx, &buf).unwrap_err(), RouteHopError::BadTag);
}
#[test]
fn shared_window_matches_the_plain_window_verdict_for_verdict() {
const W: u64 = ROUTE_HOP_REPLAY_WINDOW;
let base = 100_000u64;
let stream = [
base,
base,
base - 5,
base - 5,
base - W,
base - W - 1,
base + W,
base,
u64::MAX / 2,
base + W,
];
let mut plain = HopReplayWindow::new();
let shared = SharedHopReplayWindow::new();
for (i, seq) in stream.into_iter().enumerate() {
assert_eq!(
plain.admit(seq),
shared.admit(seq),
"step {i} (seq {seq}): the shared window must give the plain window's verdict",
);
}
}
#[test]
fn a_held_claim_makes_admission_refuse_contended() {
let w = SharedHopReplayWindow::new();
assert!(w.admit(1).is_ok());
let claim = w.try_claim().expect("uncontended claim succeeds");
assert_eq!(w.admit(2).unwrap_err(), RouteHopError::Contended);
assert_eq!(
w.admit(1).unwrap_err(),
RouteHopError::Contended,
"a contended caller gets no replay verdict at all",
);
drop(claim);
assert!(w.admit(2).is_ok(), "the dropped claim frees the window");
assert_eq!(
w.admit(1).unwrap_err(),
RouteHopError::Replay,
"state survived the contended interval untouched",
);
}
#[test]
fn a_panic_while_claimed_does_not_wedge_the_window() {
let w = SharedHopReplayWindow::new();
assert!(w.admit(1).is_ok());
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _claim = w.try_claim().expect("uncontended");
panic!("simulated panic inside the admission window");
}));
assert!(caught.is_err(), "the panic must propagate to the caller");
assert!(w.admit(2).is_ok(), "the window must not be left claimed");
assert_eq!(w.admit(1).unwrap_err(), RouteHopError::Replay);
}
#[test]
fn shared_window_under_contention_never_double_admits() {
use std::collections::HashSet;
use std::sync::Arc;
let shared = Arc::new(SharedHopReplayWindow::new());
const THREADS: u64 = 8;
const SEQS: u64 = 64; let mut handles = Vec::new();
for t in 0..THREADS {
let w = Arc::clone(&shared);
handles.push(std::thread::spawn(move || {
let mut admitted = Vec::new();
for round in 0..SEQS {
let seq = 1 + ((round + t * 7) % SEQS);
match w.admit(seq) {
Ok(()) => admitted.push(seq),
Err(RouteHopError::Replay) | Err(RouteHopError::Contended) => {}
Err(other) => panic!("impossible admission verdict: {other}"),
}
}
admitted
}));
}
let mut seen = HashSet::new();
for h in handles {
for seq in h.join().expect("no panic under contention") {
assert!(
seen.insert(seq),
"sequence {seq} was admitted by more than one caller",
);
}
}
for seq in &seen {
assert_eq!(
shared.admit(*seq).unwrap_err(),
RouteHopError::Replay,
"post-contention state must remember sequence {seq}",
);
}
}
}