Skip to main content

nodedb_cluster/swim/wire/
authenticated.rs

1// SPDX-License-Identifier: BUSL-1.1
2
3//! Authenticated SWIM datagram wire.
4//!
5//! SWIM has no TLS layer — UDP datagrams travel in the clear. The
6//! authenticated envelope is the only thing standing between the cluster
7//! and an off-network attacker who wants to forge or replay SWIM packets.
8//!
9//! # Envelope
10//!
11//! Each UDP datagram is an
12//! [`auth_envelope`](crate::rpc_codec::auth_envelope) around the zerompk
13//! encoding of [`SwimMessage`]:
14//!
15//! ```text
16//! [env_ver | addr_hash | seq | inner_len | msgpack(SwimMessage) | mac]
17//! ```
18//!
19//! - `addr_hash` is a stable 64-bit hash of the **sender's** UDP socket
20//!   address. On receive the verifier must assert `addr_hash ==
21//!   hash(observed_remote)` — this binds the envelope claim to the
22//!   packet's actual origin, rejecting captured frames replayed from a
23//!   different IP by an attacker who somehow has the MAC key.
24//! - `mac` is HMAC-SHA256 over every byte of the envelope except the MAC.
25//! - `seq` is a per-remote-address monotonic counter with a 64-entry
26//!   sliding-window replay detector.
27//!
28//! # Relationship to Raft envelope
29//!
30//! Same [`auth_envelope`] primitive, same MAC algorithm, same cluster
31//! MAC key. The only layering difference is the choice of `from_node_id`
32//! field — Raft uses the u64 node id, SWIM uses the hash of the socket
33//! address because SWIM's identity model is address-centric. The MAC
34//! key is the cluster-wide shared secret so cross-transport replay
35//! attacks (captured Raft frame replayed as SWIM, or vice versa) are
36//! still detected — the envelope's `env_ver` field is shared and MAC
37//! verification will succeed, but the inner bytes are in different
38//! formats so deserialisation in the target handler will fail.
39
40use std::net::SocketAddr;
41
42use sha2::{Digest, Sha256};
43
44use crate::rpc_codec::auth_envelope;
45use crate::rpc_codec::{MacKey, PeerSeqSender, PeerSeqWindow};
46use crate::swim::error::SwimError;
47
48use super::{codec, message::SwimMessage};
49
50/// Deterministic hash of a `SocketAddr` into a `u64`.
51///
52/// Uses SHA-256 truncated to the first 8 bytes. Deterministic across
53/// processes and hosts so that sender and receiver agree on the value.
54/// Not a MAC — the envelope MAC is the actual integrity primitive; this
55/// hash exists only as a binding between the envelope's claimed origin
56/// and the packet's observed source address.
57pub fn addr_hash(addr: SocketAddr) -> u64 {
58    let mut h = Sha256::new();
59    h.update(addr.to_string().as_bytes());
60    let digest = h.finalize();
61    u64::from_le_bytes(digest[..8].try_into().expect("sha256 is 32 bytes"))
62}
63
64/// Per-transport auth state: MAC key, per-peer outbound counters, and a
65/// per-peer inbound replay-detection window. Owned by the
66/// [`UdpTransport`](crate::swim::detector::transport::udp::UdpTransport).
67#[derive(Debug)]
68pub struct SwimAuth {
69    mac_key: MacKey,
70    local_addr_hash: u64,
71    seq_out: PeerSeqSender,
72    seq_in: PeerSeqWindow,
73}
74
75impl SwimAuth {
76    /// Construct from the cluster MAC key and the bound local address.
77    /// Passing [`MacKey::zero`] is the insecure-mode opt-out — MAC and
78    /// replay detection are cosmetic in that mode but the wire format
79    /// stays identical so mixed-mode misconfiguration fails loudly
80    /// instead of succeeding by accident.
81    pub fn new(mac_key: MacKey, local_addr: SocketAddr) -> Self {
82        Self {
83            mac_key,
84            local_addr_hash: addr_hash(local_addr),
85            seq_out: PeerSeqSender::new(),
86            seq_in: PeerSeqWindow::new(),
87        }
88    }
89
90    /// Hash of the local bound address — used as the envelope's
91    /// `from_node_id` on every outbound datagram.
92    pub fn local_addr_hash(&self) -> u64 {
93        self.local_addr_hash
94    }
95}
96
97/// Encode `msg` and wrap it in an authenticated envelope destined for
98/// `to`. Returns the bytes to hand to `UdpSocket::send_to`.
99///
100/// `to` is retained in the signature as documentation (callers pass the
101/// destination they are about to `send_to`), but the outbound seq is a
102/// single sender-global counter — see `PeerSeqSender`.
103pub fn wrap(auth: &SwimAuth, _to: SocketAddr, msg: &SwimMessage) -> Result<Vec<u8>, SwimError> {
104    let inner = codec::encode(msg)?;
105    let seq = auth.seq_out.next();
106    let mut out = Vec::with_capacity(auth_envelope::ENVELOPE_OVERHEAD + inner.len());
107    auth_envelope::write_envelope(auth.local_addr_hash, seq, &inner, &auth.mac_key, &mut out)
108        .map_err(|e| SwimError::Encode {
109            detail: format!("swim envelope: {e}"),
110        })?;
111    Ok(out)
112}
113
114/// Parse an inbound datagram: verify MAC, bind envelope's advertised
115/// origin to the observed remote address, reject replays, then decode
116/// the inner [`SwimMessage`].
117pub fn unwrap(auth: &SwimAuth, from: SocketAddr, bytes: &[u8]) -> Result<SwimMessage, SwimError> {
118    let (fields, inner_frame) =
119        auth_envelope::parse_envelope(bytes, &auth.mac_key).map_err(|e| SwimError::Decode {
120            detail: format!("swim envelope: {e}"),
121        })?;
122
123    let expected = addr_hash(from);
124    if fields.from_node_id != expected {
125        return Err(SwimError::Decode {
126            detail: format!(
127                "swim envelope from {from} claimed addr_hash {}, observed hash {}",
128                fields.from_node_id, expected
129            ),
130        });
131    }
132
133    auth.seq_in
134        .accept(fields.from_node_id, fields.seq)
135        .map_err(|e| SwimError::Decode {
136            detail: format!("swim replay: {e}"),
137        })?;
138
139    codec::decode(inner_frame)
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::swim::incarnation::Incarnation;
146    use crate::swim::wire::probe::{Ping, ProbeId};
147    use nodedb_types::NodeId;
148    use std::net::{IpAddr, Ipv4Addr};
149
150    fn addr(port: u16) -> SocketAddr {
151        SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)
152    }
153
154    fn sample_msg() -> SwimMessage {
155        SwimMessage::Ping(Ping {
156            probe_id: ProbeId::new(1),
157            from: NodeId::try_new("a").expect("test fixture"),
158            incarnation: Incarnation::ZERO,
159            piggyback: vec![],
160        })
161    }
162
163    #[test]
164    fn addr_hash_is_deterministic() {
165        // addr_hash is deterministic across processes — same input,
166        // same output. This is required for cross-node agreement on
167        // the envelope's from_node_id binding.
168        assert_eq!(addr_hash(addr(7001)), addr_hash(addr(7001)));
169        assert_ne!(addr_hash(addr(7001)), addr_hash(addr(7002)));
170    }
171
172    #[test]
173    fn roundtrip_across_independent_endpoints() {
174        // Two separate SwimAuth instances (as in a real two-node
175        // cluster) must agree on the envelope binding because
176        // addr_hash is deterministic.
177        let key = MacKey::from_bytes([0x11u8; 32]);
178        let sender = SwimAuth::new(key.clone(), addr(7001));
179        let receiver = SwimAuth::new(key, addr(7002));
180        let bytes = wrap(&sender, addr(7002), &sample_msg()).unwrap();
181        // Receiver observed the datagram coming from addr(7001).
182        let msg = unwrap(&receiver, addr(7001), &bytes).unwrap();
183        assert_eq!(msg, sample_msg());
184    }
185
186    #[test]
187    fn rejects_spoofed_source_address() {
188        // Attacker captures a datagram sender A → receiver B, then
189        // replays it with their own IP as source. The receiver's
190        // addr_hash(observed_source) will differ from the envelope's
191        // embedded addr_hash, so binding check fails.
192        let key = MacKey::from_bytes([0x33u8; 32]);
193        let real_sender = SwimAuth::new(key.clone(), addr(7001));
194        let receiver = SwimAuth::new(key, addr(7002));
195        let bytes = wrap(&real_sender, addr(7002), &sample_msg()).unwrap();
196        let err = unwrap(&receiver, addr(9999), &bytes).unwrap_err();
197        assert!(err.to_string().contains("addr_hash"));
198    }
199
200    #[test]
201    fn rejects_tampered_mac() {
202        let key = MacKey::from_bytes([3u8; 32]);
203        let sender = SwimAuth::new(key.clone(), addr(7001));
204        let receiver = SwimAuth::new(key, addr(7002));
205        let mut bytes = wrap(&sender, addr(7002), &sample_msg()).unwrap();
206        let mac_start = bytes.len() - 32;
207        bytes[mac_start] ^= 0xFF;
208        let err = unwrap(&receiver, addr(7001), &bytes).unwrap_err();
209        assert!(err.to_string().contains("MAC verification failed"));
210    }
211
212    #[test]
213    fn rejects_replay() {
214        let key = MacKey::from_bytes([4u8; 32]);
215        let sender = SwimAuth::new(key.clone(), addr(7001));
216        let receiver = SwimAuth::new(key, addr(7002));
217        let bytes = wrap(&sender, addr(7002), &sample_msg()).unwrap();
218        unwrap(&receiver, addr(7001), &bytes).unwrap();
219        let err = unwrap(&receiver, addr(7001), &bytes).unwrap_err();
220        assert!(err.to_string().contains("replayed"));
221    }
222
223    #[test]
224    fn rejects_wrong_cluster_key() {
225        let k1 = MacKey::from_bytes([1u8; 32]);
226        let k2 = MacKey::from_bytes([2u8; 32]);
227        let sender = SwimAuth::new(k1, addr(7001));
228        let receiver = SwimAuth::new(k2, addr(7002));
229        let bytes = wrap(&sender, addr(7002), &sample_msg()).unwrap();
230        let err = unwrap(&receiver, addr(7001), &bytes).unwrap_err();
231        assert!(err.to_string().contains("MAC verification failed"));
232    }
233}