bibeam_protocol/multihop.rs
1#![forbid(unsafe_code)]
2//! Multi-hop forwarder chain control-plane shapes (R-MULTIHOP-PROTO).
3//!
4//! When the coordinator routes a peer through one or more intermediate
5//! forwarders (rather than directly to an exit), it ships a
6//! [`crate::control::MatchResponse::MultiHopAssignment`] carrying the
7//! types in this module:
8//!
9//! - [`WgPeerConfig`]: the public side of the client↔exit `WireGuard`
10//! session. Public keys + endpoints + allowed-ips + keepalive. The
11//! coordinator NEVER puts private key material in this struct; the
12//! local endpoint already holds its own private key from registration.
13//! - [`ForwarderLease`]: one row per forwarder in the chain. Each row
14//! tells a forwarder which upstream and downstream sockets it is
15//! authorised to relay between, until [`ForwarderLease::lease_expires_at`].
16//! - [`RelayFrame`]: the on-the-wire datagram a forwarder sees and
17//! demultiplexes against its lease table.
18//!
19//! # Packet-to-lease binding (resolved here)
20//!
21//! A forwarder must verify "this packet belongs to one of my leased
22//! chains" without (i) holding `WireGuard` private keys and (ii)
23//! decrypting payload. Three candidates were considered:
24//!
25//! - **(A) `(src, dst)` tuple matching.** Forwarder routing table
26//! keyed by `(allowed_src, allowed_dst)`; incoming packet's
27//! source-address ↔ destination-address pair must hit a leased row.
28//! Observable at the IP layer; zero encapsulation overhead.
29//! **Rejected.** Vulnerable if the upstream peer's NAT remaps the
30//! source address mid-session — the lease would wrongly bind, and the
31//! forwarder would either drop a legitimate flow or accept a
32//! misrouted one.
33//!
34//! - **(B) Explicit `bibeam-relay` encapsulation.** Wrap `WireGuard`
35//! UDP packets in a small `[chain_id (16 bytes) | wg_payload]`
36//! framing — that is exactly [`RelayFrame`]. Forwarder reads
37//! `chain_id`, looks up the row, strips the frame, forwards the
38//! `wg_payload`. **CHOSEN.** NAT-robust (no fragility on a NAT
39//! remap mid-session), lease-explicit (forwarder verifies a
40//! cryptographically opaque chain id, not just heuristic
41//! addresses), 16 bytes fixed overhead is negligible alongside
42//! `WireGuard`'s existing 16-byte AEAD tag, and avoids the
43//! coord-round-trip cost of option (C).
44//!
45//! - **(C) Post-handshake sender-index registration.** After the
46//! `WireGuard` handshake completes, the client and exit register
47//! their final sender-indices with the coordinator; the coordinator
48//! then forwards an index-to-chain map to the forwarders.
49//! Forwarders demultiplex by reading the `WireGuard` transport
50//! header's sender-index field. Zero encapsulation overhead.
51//! **Rejected.** Requires a coordinator round-trip after every
52//! handshake — both at session start and at every WG rekey — which
53//! increases coordinator traffic and complicates the recovery path
54//! when the coordinator is unreachable.
55//!
56//! # Frame layout for option (B)
57//!
58//! [`RelayFrame::encode`] writes a fixed 16-byte `chain_id` prefix
59//! followed by the raw `wg_payload` bytes — no varint length, no
60//! postcard framing on the body. [`RelayFrame::decode`] requires the
61//! buffer to be at least 16 bytes (enough for the `chain_id`) and
62//! treats every remaining byte as the payload. That delivers the
63//! "16 bytes per packet" claim above literally and makes the frame
64//! cheap to demultiplex without a serde-aware decoder on the hot path.
65//!
66//! Control-plane carriers of the multi-hop types ([`WgPeerConfig`],
67//! [`ForwarderLease`], `MatchResponse::MultiHopAssignment`) ride the
68//! existing postcard envelope ([`crate::codec`]); only the relay-frame
69//! datagram itself uses the fixed-prefix layout. The two formats serve
70//! distinct fitness functions — control plane prioritises typed
71//! schema evolution, data plane prioritises per-packet overhead.
72//!
73//! # Relationship to `bibeam_crypto::WgPublicKey`
74//!
75//! [`WgPublicKey`] in this module is the wire-form newtype carried in
76//! the control plane: 32 raw bytes plus `serde` derives. It is
77//! deliberately parallel to `bibeam_crypto::WgPublicKey`, which is the
78//! richer in-memory form that wraps an `x25519_dalek::PublicKey` and
79//! lives in the crypto crate. The two types stay separate because
80//! `bibeam-crypto` already depends on `bibeam-protocol`; importing the
81//! richer form back into the protocol crate would close that dependency
82//! cycle. `bibeam-crypto` may add `From`/`Into` conversions between the
83//! two whenever needed; the protocol-side type stays serde-only.
84
85use core::net::SocketAddr;
86
87use bibeam_core::{ChainId, NodeId, Timestamp};
88use bytes::Bytes;
89use ipnet::IpNet;
90use postcard::Error as PostcardError;
91use serde::{Deserialize, Serialize};
92use thiserror::Error;
93
94/// Structural-invariant failures rejected when deserialising a
95/// [`crate::control::MultiHopAssignment`].
96///
97/// Surfaces inside the `serde` deserialize pipeline via the
98/// `#[serde(try_from = "...")]` shim; callers see it through the
99/// standard postcard / serde error path.
100#[derive(Debug, Error)]
101pub enum MultiHopAssignmentError {
102 /// Decoded chain held zero forwarders. A multi-hop assignment with
103 /// no forwarders is by definition the single-hop case and MUST be
104 /// expressed as [`crate::control::MatchResponse::SingleHop`].
105 #[error("multi-hop assignment has empty forwarder chain; use SingleHop instead")]
106 EmptyChain,
107}
108
109/// Length of a `WireGuard` X25519 public key in bytes.
110///
111/// Matches `bibeam_crypto::WG_KEY_LEN`; we duplicate the constant here
112/// so the protocol crate stays free of the crypto crate (the dependency
113/// arrow runs `bibeam-crypto` → `bibeam-protocol`, not the reverse).
114pub const WG_KEY_LEN: usize = 32;
115
116/// Wire-form `WireGuard` X25519 public key.
117///
118/// Carries the raw 32 bytes only; the protocol crate never sees the
119/// matching private key. The richer in-memory form (with
120/// `x25519_dalek::PublicKey` accessors and base64 helpers) lives in
121/// `bibeam_crypto::WgPublicKey`; see the module-level docs for why the
122/// two types are deliberately parallel.
123#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
124#[serde(transparent)]
125pub struct WgPublicKey(pub [u8; WG_KEY_LEN]);
126
127impl WgPublicKey {
128 /// Wrap 32 raw `WireGuard` public-key bytes.
129 #[must_use]
130 pub const fn from_bytes(bytes: [u8; WG_KEY_LEN]) -> Self {
131 Self(bytes)
132 }
133
134 /// Borrow the underlying 32 bytes.
135 #[must_use]
136 pub const fn as_bytes(&self) -> &[u8; WG_KEY_LEN] {
137 &self.0
138 }
139}
140
141/// Paired public keys + endpoint info that lets one end of a multi-hop
142/// `WireGuard` session bring its tunnel up.
143///
144/// The coordinator renders one [`WgPeerConfig`] per endpoint of the
145/// session: one for the client (with `peer_endpoint` pointing at the
146/// first-hop forwarder, so the client's outbound WG datagrams enter
147/// the chain) and one for the exit (with `peer_endpoint` pointing at
148/// the exit's inbound socket, populated as forwarders learn the
149/// session's source after the first authenticated frame).
150///
151/// `local_static_public` is the *local* endpoint's static public key,
152/// rendered so the local endpoint can sanity-check that the coordinator
153/// addressed this config to the right peer. `peer_static_public` is
154/// the other endpoint's static public key (the value `wg setconf`
155/// expects after `PublicKey =`).
156///
157/// The coordinator NEVER includes private key material in
158/// [`WgPeerConfig`]; the local endpoint already holds its own
159/// `WgSecretKey` from registration.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct WgPeerConfig {
162 /// Public side of the local endpoint's static keypair.
163 ///
164 /// Rendered to the client carries the client's public key; rendered
165 /// to the exit carries the exit's public key. The local endpoint
166 /// uses this field to detect a coordinator-side addressing error
167 /// (the local public key it sees here MUST match the public key
168 /// it registered with).
169 pub local_static_public: WgPublicKey,
170 /// Static public key of the peer at the far end of the WG session.
171 pub peer_static_public: WgPublicKey,
172 /// Socket address WG packets are sent to.
173 ///
174 /// For the client this is the first-hop forwarder's socket; for the
175 /// exit this is the direct inbound socket the exit listens on.
176 pub peer_endpoint: SocketAddr,
177 /// CIDR blocks the local endpoint should route through the peer.
178 ///
179 /// Typically `0.0.0.0/0` + `::/0` on the client side (egress
180 /// everywhere through the exit) and the client's tunnel-IP on the
181 /// exit side (the exit only accepts return traffic destined for
182 /// the client's address).
183 pub allowed_ips: Vec<IpNet>,
184 /// `WireGuard` persistent-keepalive interval, in seconds.
185 ///
186 /// `25` is the standard NAT-punching value. `0` disables the
187 /// keepalive (acceptable on a network with no stateful NAT in
188 /// the path).
189 pub persistent_keepalive_secs: u16,
190}
191
192/// One forwarder's authorisation row inside a multi-hop chain.
193///
194/// `MatchResponse::MultiHopAssignment::per_forwarder_routing` carries
195/// one entry per forwarder in `forwarder_chain`. The coordinator hands
196/// each forwarder its own copy of the matching [`ForwarderLease`]
197/// out-of-band; the forwarder writes the row into its lease table and
198/// matches every incoming [`RelayFrame`] against it.
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200pub struct ForwarderLease {
201 /// Forwarder this lease authorises.
202 pub forwarder: NodeId,
203 /// Opaque chain identifier the forwarder uses to look up this row.
204 ///
205 /// Same value rides each [`RelayFrame::chain_id`] sent through the
206 /// chain; matching the two is how the forwarder demultiplexes
207 /// packets to the right downstream peer.
208 pub chain_id: ChainId,
209 /// Upstream peer's socket — client for hop 1, previous forwarder
210 /// for inner hops.
211 pub allowed_src: SocketAddr,
212 /// Downstream peer's socket — next forwarder, or the exit for the
213 /// last hop.
214 pub allowed_dst: SocketAddr,
215 /// Wall-clock instant after which the forwarder MUST tear the row
216 /// down.
217 ///
218 /// Typical value is `now + 15 min`; the coordinator renews the
219 /// lease before expiry by issuing a fresh [`ForwarderLease`] with
220 /// the same `chain_id`.
221 pub lease_expires_at: Timestamp,
222}
223
224/// Forwarder-visible wrapper around a single `WireGuard` UDP datagram.
225///
226/// Layout on the wire:
227///
228/// ```text
229/// [chain_id (16 bytes)] || [wg_payload (variable, opaque to the forwarder)]
230/// ```
231///
232/// The forwarder reads the 16-byte prefix, looks `chain_id` up in its
233/// lease table, and forwards `wg_payload` verbatim to
234/// [`ForwarderLease::allowed_dst`]. Forwarders never decrypt
235/// `wg_payload` — that requires `WireGuard` keys that only the client
236/// and the exit hold.
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct RelayFrame {
239 /// Chain this frame belongs to.
240 pub chain_id: ChainId,
241 /// Opaque `WireGuard` payload — typically a `WireGuard` transport
242 /// or handshake datagram. The forwarder never reads this field.
243 pub wg_payload: Bytes,
244}
245
246/// Number of fixed prefix bytes a [`RelayFrame`] writes before its
247/// `wg_payload` — the 16 raw bytes of a `chain_id` ULID.
248pub const RELAY_FRAME_PREFIX_LEN: usize = 16;
249
250impl RelayFrame {
251 /// Encode this frame as `chain_id (16) || wg_payload`.
252 ///
253 /// The returned buffer is freshly allocated; callers may share it
254 /// cheaply because [`Bytes`] is reference-counted.
255 #[must_use]
256 pub fn encode(&self) -> Bytes {
257 let mut buf = Vec::with_capacity(RELAY_FRAME_PREFIX_LEN + self.wg_payload.len());
258 buf.extend_from_slice(&self.chain_id.into_ulid().to_bytes());
259 buf.extend_from_slice(&self.wg_payload);
260 Bytes::from(buf)
261 }
262
263 /// Decode a forwarder-visible buffer.
264 ///
265 /// Returns [`PostcardError::DeserializeUnexpectedEnd`] if `buf`
266 /// is shorter than [`RELAY_FRAME_PREFIX_LEN`] — the only failure
267 /// mode of the fixed-prefix layout, since every remaining byte is
268 /// accepted as the opaque payload.
269 pub fn decode(buf: &[u8]) -> Result<Self, PostcardError> {
270 let Some((prefix, body)) = buf.split_first_chunk::<RELAY_FRAME_PREFIX_LEN>() else {
271 return Err(PostcardError::DeserializeUnexpectedEnd);
272 };
273 let chain_id = ChainId(ulid::Ulid::from_bytes(*prefix));
274 Ok(Self {
275 chain_id,
276 wg_payload: Bytes::copy_from_slice(body),
277 })
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 //! Unit tests in this module cover the `RelayFrame` encode/decode
284 //! contract — the fixed-prefix data-plane layout that does NOT ride
285 //! the postcard envelope, so it needs its own hand-written byte-
286 //! layout coverage here.
287 //!
288 //! Wire-format round-trip coverage for the postcard-encoded
289 //! control-plane structs declared in this module — [`WgPeerConfig`]
290 //! and [`ForwarderLease`] — lives in
291 //! `crates/bibeam-protocol/tests/codec_roundtrip.rs` as the
292 //! property-based strategies `arb_wg_peer_config` and
293 //! `arb_forwarder_lease`, which exercise varied generated cases
294 //! (random field values with shrinking on failure).
295
296 use super::*;
297
298 #[test]
299 fn relay_frame_round_trip_at_typical_wg_mtu() {
300 // Contract: encode then decode is identity for a payload
301 // sized at a typical WireGuard MTU. Catches a regression
302 // that misaligns the chain_id prefix or truncates the
303 // payload's tail.
304 let chain_id = ChainId::new();
305 let frame = RelayFrame {
306 chain_id,
307 wg_payload: Bytes::from(vec![0x42; 1280]),
308 };
309 let encoded = frame.encode();
310 assert_eq!(encoded.len(), RELAY_FRAME_PREFIX_LEN + 1280);
311 let decoded = RelayFrame::decode(&encoded).expect("decode round-trip");
312 assert_eq!(decoded, frame);
313 }
314
315 #[test]
316 fn relay_frame_decode_rejects_truncated_prefix() {
317 // Contract: a buffer shorter than the 16-byte chain_id
318 // prefix MUST surface as an Err. Catches a regression that
319 // silently produced a zero-ULID chain_id for short buffers
320 // (which would let a forwarder route packets it has no
321 // lease for).
322 for short_len in 0..RELAY_FRAME_PREFIX_LEN {
323 let buf = vec![0u8; short_len];
324 let result = RelayFrame::decode(&buf);
325 assert!(result.is_err(), "buffer of length {short_len} must error, got {result:?}");
326 }
327 }
328}