Skip to main content

nodedb_cluster/auth/
bundle.rs

1// SPDX-License-Identifier: BUSL-1.1
2
3//! Authenticated join bundle: wraps the raw cred bytes sent to a joiner
4//! with an HMAC-SHA256 MAC so a MitM cannot substitute their own CA even
5//! if they intercept the token.
6//!
7//! MAC key derivation: `HMAC-SHA256(cluster_secret XOR token_hash)` over
8//! the bundle bytes. This binds the bundle to both the cluster secret and
9//! the specific token, so a replayed bundle from a different session or a
10//! bundle signed under a different secret is rejected.
11//!
12//! The joiner calls [`verify_bundle`] before installing any cert material.
13
14use hmac::{Hmac, Mac};
15use sha2::Sha256;
16
17use crate::wire_version::WireVersion;
18
19/// Authenticated wrapper around the raw join bundle bytes.
20#[derive(Debug, Clone)]
21pub struct AuthenticatedJoinBundle {
22    /// Wire protocol version (currently [`WireVersion::CURRENT`]).
23    pub version: WireVersion,
24    /// Serialised bundle bytes (MessagePack of `BootstrapCredsResponse`
25    /// or any opaque byte payload the caller provides).
26    pub bundle: Vec<u8>,
27    /// HMAC-SHA256 of `bundle` keyed on `derive_mac_key(cluster_secret, token_hash)`.
28    pub mac: [u8; 32],
29}
30
31/// Error from bundle operations.
32#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
33pub enum BundleError {
34    #[error("bundle MAC verification failed")]
35    MacMismatch,
36    #[error("hmac key length invalid")]
37    HmacKeyLength,
38    #[error("bundle version mismatch: expected {expected:?}, got {got:?}")]
39    VersionMismatch {
40        expected: WireVersion,
41        got: WireVersion,
42    },
43}
44
45/// Derive the MAC key as `cluster_secret XOR token_hash`.
46///
47/// Both inputs are exactly 32 bytes. The XOR ensures neither value alone
48/// is sufficient to forge a MAC — an attacker who has the cluster secret
49/// but not the token (or vice versa) cannot produce a valid MAC.
50pub fn derive_mac_key(cluster_secret: &[u8; 32], token_hash: &[u8; 32]) -> [u8; 32] {
51    let mut key = [0u8; 32];
52    for i in 0..32 {
53        key[i] = cluster_secret[i] ^ token_hash[i];
54    }
55    key
56}
57
58/// Wrap `bundle_bytes` in an `AuthenticatedJoinBundle`.
59pub fn seal_bundle(
60    bundle_bytes: Vec<u8>,
61    cluster_secret: &[u8; 32],
62    token_hash: &[u8; 32],
63) -> Result<AuthenticatedJoinBundle, BundleError> {
64    let key = derive_mac_key(cluster_secret, token_hash);
65    let mut mac = <Hmac<Sha256>>::new_from_slice(&key).map_err(|_| BundleError::HmacKeyLength)?;
66    mac.update(&bundle_bytes);
67    let tag: [u8; 32] = mac.finalize().into_bytes().into();
68    Ok(AuthenticatedJoinBundle {
69        version: WireVersion::CURRENT,
70        bundle: bundle_bytes,
71        mac: tag,
72    })
73}
74
75/// Verify the MAC on `sealed` and return the inner bundle bytes.
76///
77/// The comparison is constant-time via `hmac::Mac::verify_slice`.
78pub fn open_bundle<'a>(
79    sealed: &'a AuthenticatedJoinBundle,
80    cluster_secret: &[u8; 32],
81    token_hash: &[u8; 32],
82) -> Result<&'a [u8], BundleError> {
83    if sealed.version != WireVersion::CURRENT {
84        return Err(BundleError::VersionMismatch {
85            expected: WireVersion::CURRENT,
86            got: sealed.version,
87        });
88    }
89    let key = derive_mac_key(cluster_secret, token_hash);
90    let mut mac = <Hmac<Sha256>>::new_from_slice(&key).map_err(|_| BundleError::HmacKeyLength)?;
91    mac.update(&sealed.bundle);
92    mac.verify_slice(&sealed.mac)
93        .map_err(|_| BundleError::MacMismatch)?;
94    Ok(&sealed.bundle)
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn seal_and_open_roundtrip() {
103        let secret = [0x1Au8; 32];
104        let token_hash = [0x2Bu8; 32];
105        let payload = b"hello cluster".to_vec();
106        let sealed = seal_bundle(payload.clone(), &secret, &token_hash).unwrap();
107        let out = open_bundle(&sealed, &secret, &token_hash).unwrap();
108        assert_eq!(out, payload.as_slice());
109    }
110
111    #[test]
112    fn tampered_bundle_rejected() {
113        let secret = [0x3Cu8; 32];
114        let token_hash = [0x4Du8; 32];
115        let mut sealed = seal_bundle(b"real payload".to_vec(), &secret, &token_hash).unwrap();
116        // Flip first byte of bundle
117        sealed.bundle[0] ^= 0xFF;
118        assert_eq!(
119            open_bundle(&sealed, &secret, &token_hash).unwrap_err(),
120            BundleError::MacMismatch
121        );
122    }
123
124    #[test]
125    fn wrong_cluster_secret_rejected() {
126        let secret = [0x5Eu8; 32];
127        let wrong = [0x6Fu8; 32];
128        let token_hash = [0x70u8; 32];
129        let sealed = seal_bundle(b"payload".to_vec(), &secret, &token_hash).unwrap();
130        assert_eq!(
131            open_bundle(&sealed, &wrong, &token_hash).unwrap_err(),
132            BundleError::MacMismatch
133        );
134    }
135
136    #[test]
137    fn wrong_token_hash_rejected() {
138        let secret = [0x81u8; 32];
139        let token_hash = [0x92u8; 32];
140        let wrong_hash = [0xA3u8; 32];
141        let sealed = seal_bundle(b"payload".to_vec(), &secret, &token_hash).unwrap();
142        assert_eq!(
143            open_bundle(&sealed, &secret, &wrong_hash).unwrap_err(),
144            BundleError::MacMismatch
145        );
146    }
147
148    #[test]
149    fn version_mismatch_rejected() {
150        let secret = [0xB4u8; 32];
151        let token_hash = [0xC5u8; 32];
152        let mut sealed = seal_bundle(b"payload".to_vec(), &secret, &token_hash).unwrap();
153        sealed.version = WireVersion::V1;
154        assert!(matches!(
155            open_bundle(&sealed, &secret, &token_hash).unwrap_err(),
156            BundleError::VersionMismatch { .. }
157        ));
158    }
159
160    #[test]
161    fn derive_mac_key_xors_both_inputs() {
162        let secret = [0xFFu8; 32];
163        let hash = [0x0Fu8; 32];
164        let key = derive_mac_key(&secret, &hash);
165        // 0xFF ^ 0x0F = 0xF0
166        assert!(key.iter().all(|&b| b == 0xF0));
167    }
168}