Skip to main content

nodedb_cluster/swim/wire/
authenticated.rs

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