Skip to main content

slim_datapath/
header_mac.rs

1// Copyright AGNTCY Contributors (https://github.com/agntcy)
2// SPDX-License-Identifier: Apache-2.0
3
4//! HMAC-SHA256 integrity for SLIM headers on inter-node links.
5//!
6//! The preimage intentionally **excludes** `incoming_conn` (local connection index),
7//! `header_mac`, and any session/payload fields so peers can set `incoming_conn`
8//! after verify without breaking the MAC.
9//!
10//! Performance: [`HeaderMacSession`] holds only an [`hmac::Key`]. A thread-local
11//! `Vec` is reused for the canonical preimage to avoid a per-connection mutex and
12//! to cut allocator traffic on the hot path (see workspace performance skill notes).
13
14use std::cell::RefCell;
15
16use thiserror::Error;
17
18use crate::api::proto::dataplane::v1::{Name, SlimHeader};
19
20// HMAC-SHA256 backend selection. Native uses `aws_lc_rs`; the browser build uses
21// the pure-Rust `hmac` + `sha2` crates. Both compute an identical RFC 2104
22// HMAC-SHA256, so signatures interoperate across a native↔browser link. Each
23// backend exposes the same surface (`MacKey`, `new`, `sign`, `verify`), so the
24// signing path below carries no `#[cfg]`.
25#[cfg(not(target_arch = "wasm32"))]
26#[path = "header_mac/backend_awslc.rs"]
27mod backend;
28#[cfg(target_arch = "wasm32")]
29#[path = "header_mac/backend_pure.rs"]
30mod backend;
31
32// The pure backend is additionally compiled into native test builds (under a
33// distinct name) so the parity test can pin it byte-for-byte to the production
34// `aws_lc_rs` backend. On wasm the production backend already *is* the pure one.
35#[cfg(all(test, not(target_arch = "wasm32")))]
36#[path = "header_mac/backend_pure.rs"]
37mod backend_pure;
38
39type MacKey = backend::MacKey;
40
41thread_local! {
42    static PREIMAGE_BUF: RefCell<Vec<u8>> = RefCell::new(Vec::with_capacity(512));
43}
44
45const DOMAIN_V1: &[u8] = b"SLIM-DP-HDR-v1\0";
46const MIN_KEY_LEN: usize = 32;
47const TAG_LEN: usize = 32;
48
49#[derive(Debug, Error)]
50pub enum HeaderMacError {
51    #[error("header_mac key must be at least {MIN_KEY_LEN} bytes")]
52    KeyTooShort,
53    #[error("link_id must not be empty")]
54    EmptyLinkId,
55    #[error("missing SLIM header integrity tag")]
56    MissingTag,
57    #[error("invalid integrity tag length")]
58    InvalidTagLength,
59    #[error("SLIM header integrity verification failed")]
60    VerificationFailed,
61    #[error("inter-node link key agreement failed")]
62    KeyAgreement,
63    #[error("key generation failed")]
64    KeyGenerationFailed(String),
65}
66
67/// Per-link HMAC state: only the key material. Preimage buffers are thread-local (see module docs).
68#[derive(Clone)]
69pub struct HeaderMacSession {
70    key: MacKey,
71}
72
73impl std::fmt::Debug for HeaderMacSession {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.debug_struct("HeaderMacSession").finish_non_exhaustive()
76    }
77}
78
79impl HeaderMacSession {
80    /// Build session from raw secret bytes (≥32 bytes).
81    pub fn new(secret: &[u8]) -> Result<Self, HeaderMacError> {
82        if secret.len() < MIN_KEY_LEN {
83            return Err(HeaderMacError::KeyTooShort);
84        }
85        Ok(Self {
86            key: backend::new(secret),
87        })
88    }
89
90    /// Compute MAC and store it on `header` (clears any previous tag first).
91    pub fn sign_slim_header(
92        &self,
93        header: &mut SlimHeader,
94        link_id: &str,
95    ) -> Result<(), HeaderMacError> {
96        if link_id.is_empty() {
97            return Err(HeaderMacError::EmptyLinkId);
98        }
99        header.header_mac = None;
100        PREIMAGE_BUF.with(|cell| {
101            let mut buf = cell.borrow_mut();
102            buf.clear();
103            reserve_preimage_upper_bound(&mut buf, header, link_id);
104            write_preimage(&mut buf, header, link_id.as_bytes());
105            header.header_mac = Some(backend::sign(&self.key, buf.as_slice()));
106            Ok(())
107        })
108    }
109
110    /// Verify MAC on `header` without mutating it.
111    pub fn verify_slim_header(
112        &self,
113        header: &SlimHeader,
114        link_id: &str,
115    ) -> Result<(), HeaderMacError> {
116        if link_id.is_empty() {
117            return Err(HeaderMacError::EmptyLinkId);
118        }
119        let tag = header
120            .header_mac
121            .as_deref()
122            .ok_or(HeaderMacError::MissingTag)?;
123        if tag.len() != TAG_LEN {
124            return Err(HeaderMacError::InvalidTagLength);
125        }
126        PREIMAGE_BUF.with(|cell| {
127            let mut buf = cell.borrow_mut();
128            buf.clear();
129            reserve_preimage_upper_bound(&mut buf, header, link_id);
130            write_preimage(&mut buf, header, link_id.as_bytes());
131            if backend::verify(&self.key, buf.as_slice(), tag) {
132                Ok(())
133            } else {
134                Err(HeaderMacError::VerificationFailed)
135            }
136        })
137    }
138}
139
140/// Ensure `buf` can hold the worst-case preimage without reallocation during `write_preimage`.
141#[inline]
142fn reserve_preimage_upper_bound(buf: &mut Vec<u8>, hdr: &SlimHeader, link_id: &str) {
143    let need = preimage_upper_bound(hdr, link_id);
144    let cap = buf.capacity();
145    if need > cap {
146        buf.reserve(need - cap);
147    }
148}
149
150#[inline]
151fn preimage_upper_bound(header: &SlimHeader, link_id: &str) -> usize {
152    /// Byte size of the link_id length prefix (u32).
153    const LINK_ID_LEN_PREFIX: usize = 4;
154
155    /// Byte size of `fanout`, serialized by [`to_le_bytes`]
156    /// * 4 bytes for `u32`
157    const FANOUT_SIZE: usize = 4;
158
159    /// The total byte size of a `recv_from` field.
160    ///
161    /// This is calculated as 9 bytes:
162    /// * 1 byte for the presence tag (boolean `0` or `1`).
163    /// * 8 bytes for the `u64` value (via [`push_u64_opt`]).
164    const RECV_FROM_SIZE: usize = 9;
165
166    /// The total byte size of a `forward_to` field.
167    ///
168    /// This is calculated as 9 bytes:
169    /// * 1 byte for the presence tag (boolean `0` or `1`).
170    /// * 8 bytes for the `u64` value (via [`push_u64_opt`]).
171    const FORWARD_TO_SIZE: usize = 9;
172
173    /// Byte size of error: Option<bool> encoded by [`push_bool_opt`]
174    const ERROR_SIZE: usize = 2;
175
176    /// Byte size of TTL: u32 serialized by [`to_le_bytes`]
177    const TTL_SIZE: usize = 4;
178
179    DOMAIN_V1.len()
180        + LINK_ID_LEN_PREFIX
181        + link_id.len()
182        + FANOUT_SIZE
183        + RECV_FROM_SIZE
184        + FORWARD_TO_SIZE
185        + ERROR_SIZE
186        + TTL_SIZE
187        + encoded_name_upper_bound(&header.source)
188        + encoded_name_upper_bound(&header.destination)
189}
190
191#[inline]
192fn encoded_name_upper_bound(name_opt: &Option<Name>) -> usize {
193    match name_opt {
194        None => 1,
195        Some(name) => {
196            /// Byte size of presence flags:
197            /// * 1 byte for Some(name) that [`push_encoded_name`] pushes.
198            /// * 1 byte for the flag showing if name.name is present.
199            /// * 1 byte for `str_name` present/absent.
200            const PRESENCE_FLAGS_SIZE: usize = 3;
201            /// Byte size of 3 `u64` components + name_id (1 presence byte + 2 `u64`):
202            /// * 3*8 bytes for component 0..2 serialized by [`to_le_bytes`].
203            /// * 1 byte for name_id presence flag
204            /// * 2*8 bytes for name_id.id_0 and id_1 (when present)
205            const ENCODED_NAME_SIZE: usize = 24 + 1 + 16;
206
207            // Byte sizs of 3 `u32` prefixes:
208            // * 3*4 byte length size prefix before each component
209            const LENGTH_PREFIXES_SIZE: usize = 12;
210
211            let mut encoded_name_bound = PRESENCE_FLAGS_SIZE + ENCODED_NAME_SIZE;
212            if let Some(sn) = name.str_name.as_ref() {
213                encoded_name_bound += LENGTH_PREFIXES_SIZE
214                    + sn.str_component_0.len()
215                    + sn.str_component_1.len()
216                    + sn.str_component_2.len();
217            }
218            encoded_name_bound
219        }
220    }
221}
222
223#[inline]
224fn push_bytes(buf: &mut Vec<u8>, data: &[u8]) {
225    buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
226    buf.extend_from_slice(data);
227}
228
229#[inline]
230fn push_u64_opt(buf: &mut Vec<u8>, v: Option<u64>) {
231    match v {
232        None => buf.push(0),
233        Some(x) => {
234            buf.push(1);
235            buf.extend_from_slice(&x.to_le_bytes());
236        }
237    }
238}
239
240#[inline]
241fn push_bool_opt(buf: &mut Vec<u8>, v: Option<bool>) {
242    match v {
243        None => buf.push(0),
244        Some(b) => {
245            buf.push(1);
246            buf.push(u8::from(b));
247        }
248    }
249}
250
251fn push_encoded_name(buf: &mut Vec<u8>, n: &Option<Name>) {
252    match n {
253        None => buf.push(0),
254        Some(name) => {
255            buf.push(1);
256            if let Some(enc) = name.name.as_ref() {
257                buf.push(1);
258                for v in [enc.component_0, enc.component_1, enc.component_2] {
259                    buf.extend_from_slice(&v.to_le_bytes());
260                }
261                match enc.name_id.as_ref() {
262                    Some(nid) => {
263                        buf.push(1);
264                        buf.extend_from_slice(&nid.id_0.to_le_bytes());
265                        buf.extend_from_slice(&nid.id_1.to_le_bytes());
266                    }
267                    None => buf.push(0),
268                }
269            } else {
270                buf.push(0);
271            }
272            if let Some(sn) = name.str_name.as_ref() {
273                buf.push(1);
274                push_bytes(buf, sn.str_component_0.as_bytes());
275                push_bytes(buf, sn.str_component_1.as_bytes());
276                push_bytes(buf, sn.str_component_2.as_bytes());
277            } else {
278                buf.push(0);
279            }
280        }
281    }
282}
283
284/// Canonical preimage: domain || len(link_id) || link_id || routing header fields (no incoming_conn, no tag).
285fn write_preimage(buf: &mut Vec<u8>, hdr: &SlimHeader, link_id_bytes: &[u8]) {
286    buf.extend_from_slice(DOMAIN_V1);
287    buf.extend_from_slice(&(link_id_bytes.len() as u32).to_le_bytes());
288    buf.extend_from_slice(link_id_bytes);
289    push_encoded_name(buf, &hdr.source);
290    push_encoded_name(buf, &hdr.destination);
291    push_bytes(buf, hdr.identity.as_bytes());
292    buf.extend_from_slice(&hdr.fanout.to_le_bytes());
293    push_u64_opt(buf, hdr.recv_from);
294    push_u64_opt(buf, hdr.forward_to);
295    push_bool_opt(buf, hdr.error);
296    buf.extend_from_slice(&hdr.ttl.to_le_bytes());
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use crate::api::proto::dataplane::v1::{EncodedName, Name, NameId, StringName};
303    use crate::messages::utils::DEFAULT_TTL;
304    use uuid::Uuid;
305
306    // The pure backend under its native-test name, or the production backend on
307    // wasm (where it already is the pure one). Used by the parity tests below.
308    #[cfg(target_arch = "wasm32")]
309    use super::backend as backend_pure;
310    #[cfg(not(target_arch = "wasm32"))]
311    use super::backend_pure;
312
313    fn test_key() -> Vec<u8> {
314        b"01234567890123456789012345678901".to_vec()
315    }
316
317    fn sample_header() -> SlimHeader {
318        SlimHeader {
319            source: Some(Name {
320                name: Some(EncodedName {
321                    component_0: 1,
322                    component_1: 2,
323                    component_2: 3,
324                    name_id: Some(NameId { id_0: 0, id_1: 4 }),
325                }),
326                str_name: Some(StringName {
327                    str_component_0: "a".into(),
328                    str_component_1: "b".into(),
329                    str_component_2: "c".into(),
330                }),
331            }),
332            destination: Some(Name {
333                name: Some(EncodedName {
334                    component_0: 5,
335                    component_1: 6,
336                    component_2: 7,
337                    name_id: Some(NameId { id_0: 0, id_1: 8 }),
338                }),
339                str_name: Some(StringName {
340                    str_component_0: "x".into(),
341                    str_component_1: "y".into(),
342                    str_component_2: "z".into(),
343                }),
344            }),
345            identity: "id1".into(),
346            fanout: 2,
347            version: String::new(),
348            recv_from: Some(9),
349            forward_to: Some(10),
350            incoming_conn: Some(999),
351            error: Some(false),
352            header_mac: None,
353            ttl: DEFAULT_TTL,
354            e2e_header_sig: None,
355        }
356    }
357
358    #[test]
359    fn sign_verify_round_trip() {
360        let lid = Uuid::new_v4().to_string();
361        let mac = HeaderMacSession::new(&test_key()).unwrap();
362        let mut hdr = sample_header();
363        mac.sign_slim_header(&mut hdr, &lid).unwrap();
364        mac.verify_slim_header(&hdr, &lid).unwrap();
365    }
366
367    #[test]
368    fn incoming_conn_does_not_affect_mac() {
369        let lid = Uuid::new_v4().to_string();
370        let mac = HeaderMacSession::new(&test_key()).unwrap();
371        let mut h1 = sample_header();
372        mac.sign_slim_header(&mut h1, &lid).unwrap();
373        let mut h2 = h1.clone();
374        h2.incoming_conn = Some(12345);
375        mac.verify_slim_header(&h2, &lid).unwrap();
376    }
377
378    #[test]
379    fn tampered_fanout_fail() {
380        let lid = Uuid::new_v4().to_string();
381        let mac = HeaderMacSession::new(&test_key()).unwrap();
382        let mut hdr = sample_header();
383        mac.sign_slim_header(&mut hdr, &lid).unwrap();
384        hdr.fanout = 3;
385        assert!(mac.verify_slim_header(&hdr, &lid).is_err());
386    }
387
388    #[test]
389    fn tampered_mac_fail() {
390        let lid = Uuid::new_v4().to_string();
391        let mac = HeaderMacSession::new(&test_key()).unwrap();
392        let mut hdr = sample_header();
393        mac.sign_slim_header(&mut hdr, &lid).unwrap();
394        let mac_val = hdr.header_mac.as_mut().unwrap();
395        // HMAC key is tampered
396        mac_val[0] ^= 1;
397        let err = mac.verify_slim_header(&hdr, &lid).unwrap_err();
398        assert!(matches!(err, HeaderMacError::VerificationFailed));
399    }
400
401    #[test]
402    fn cross_link_replay_fail() {
403        let lid1 = Uuid::new_v4().to_string();
404        let lid2 = Uuid::new_v4().to_string();
405        assert_ne!(lid1, lid2);
406
407        let mac = HeaderMacSession::new(&test_key()).unwrap();
408        let mut hdr = sample_header();
409
410        // Sign for link 1
411        mac.sign_slim_header(&mut hdr, &lid1).unwrap();
412
413        // Verification for link 2 must fail even if the key is the same,
414        // because the link_id is part of the MAC preimage.
415        let err = mac.verify_slim_header(&hdr, &lid2).unwrap_err();
416        assert!(matches!(err, HeaderMacError::VerificationFailed));
417    }
418
419    #[test]
420    fn name_id_none_sign_verify() {
421        let lid = Uuid::new_v4().to_string();
422        let mac = HeaderMacSession::new(&test_key()).unwrap();
423        let mut hdr = sample_header();
424        // Remove name_id from source
425        hdr.source.as_mut().unwrap().name.as_mut().unwrap().name_id = None;
426        mac.sign_slim_header(&mut hdr, &lid).unwrap();
427        mac.verify_slim_header(&hdr, &lid).unwrap();
428    }
429
430    #[test]
431    fn tampered_name_id_fail() {
432        let lid = Uuid::new_v4().to_string();
433        let mac = HeaderMacSession::new(&test_key()).unwrap();
434        let mut hdr = sample_header();
435        mac.sign_slim_header(&mut hdr, &lid).unwrap();
436        // Tamper with source name_id
437        hdr.source.as_mut().unwrap().name.as_mut().unwrap().name_id =
438            Some(NameId { id_0: 99, id_1: 99 });
439        assert!(mac.verify_slim_header(&hdr, &lid).is_err());
440    }
441
442    // Exercises the browser HMAC backend (`backend_pure`) on the native coverage
443    // run and pins it byte-for-byte to the active production backend, so a
444    // header signed on one side of a native↔browser link verifies on the other.
445    #[test]
446    fn pure_hmac_backend_matches_production_backend() {
447        let secret = test_key();
448        let data = b"SLIM-DP-HDR-v1\0canonical-preimage-under-test";
449
450        let production = backend::sign(&backend::new(&secret), data);
451        let pure = backend_pure::sign(&backend_pure::new(&secret), data);
452
453        assert_eq!(
454            production.len(),
455            TAG_LEN,
456            "HMAC-SHA256 tag must be {TAG_LEN} bytes"
457        );
458        assert_eq!(
459            production, pure,
460            "pure-Rust HMAC backend must match the production backend byte-for-byte"
461        );
462    }
463
464    // The pure backend verifies its own tag and rejects a tampered one.
465    #[test]
466    fn pure_hmac_backend_verify_round_trip() {
467        let secret = test_key();
468        let data = b"some-data-to-authenticate";
469
470        let key = backend_pure::new(&secret);
471        let tag = backend_pure::sign(&key, data);
472        assert!(backend_pure::verify(&key, data, &tag), "valid tag verifies");
473
474        let mut bad = tag.clone();
475        bad[0] ^= 0xFF;
476        assert!(
477            !backend_pure::verify(&key, data, &bad),
478            "tampered tag must fail"
479        );
480        assert!(
481            !backend_pure::verify(&key, b"other-data", &tag),
482            "tag must not verify over different data"
483        );
484    }
485}