Skip to main content

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}