Skip to main content

nodedb_cluster/auth/
join_token.rs

1// SPDX-License-Identifier: BUSL-1.1
2
3//! HMAC-SHA256 join-token issuance and constant-time verification.
4//!
5//! Token format (opaque to callers, transmitted as hex):
6//! ```text
7//! [for_node: u64 LE | expiry_unix_secs: u64 LE | mac: 32 bytes]
8//! ```
9//! The MAC is HMAC-SHA256 over `for_node || expiry_unix_secs` keyed by
10//! the cluster's `cluster_secret`. Verification is constant-time via
11//! `hmac::Mac::verify_slice` (which uses the `subtle` crate internally).
12//!
13//! The `nodedb` crate's `ctl::join_token` module is a thin CLI wrapper
14//! that delegates issuance to [`issue_token`] here. Verification is
15//! consumed by the bootstrap-listener handler in
16//! `nodedb/src/control/cluster/bootstrap_listener.rs`.
17
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use hmac::{Hmac, Mac};
21use sha2::Sha256;
22
23/// Number of bytes in the token header (for_node + expiry).
24pub const TOKEN_HEADER_LEN: usize = 8 + 8;
25/// Number of bytes in the HMAC-SHA256 tag.
26pub const TOKEN_MAC_LEN: usize = 32;
27/// Total token byte length before hex encoding.
28pub const TOKEN_BYTE_LEN: usize = TOKEN_HEADER_LEN + TOKEN_MAC_LEN;
29/// Expected hex string length of a token.
30pub const TOKEN_HEX_LEN: usize = TOKEN_BYTE_LEN * 2;
31
32/// Error returned by token operations.
33#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
34pub enum TokenError {
35    #[error("token wrong length")]
36    WrongLength,
37    #[error("token contains invalid hex")]
38    InvalidHex,
39    #[error("invalid token MAC")]
40    InvalidMac,
41    #[error("token expired")]
42    Expired,
43    #[error("hmac key length invalid")]
44    HmacKeyLength,
45}
46
47/// Issue a new HMAC-SHA256 join token for `for_node` that expires at
48/// `expiry_unix_secs`. Returns the raw token bytes (hex-encode for
49/// printing or transmission).
50///
51/// Use [`token_to_hex`] to produce the hex string.
52pub fn issue_token_bytes(
53    secret: &[u8; 32],
54    for_node: u64,
55    expiry_unix_secs: u64,
56) -> Result<[u8; TOKEN_BYTE_LEN], TokenError> {
57    let mut buf = [0u8; TOKEN_BYTE_LEN];
58    buf[..8].copy_from_slice(&for_node.to_le_bytes());
59    buf[8..16].copy_from_slice(&expiry_unix_secs.to_le_bytes());
60    let mut mac = <Hmac<Sha256>>::new_from_slice(secret).map_err(|_| TokenError::HmacKeyLength)?;
61    mac.update(&buf[..TOKEN_HEADER_LEN]);
62    let tag = mac.finalize().into_bytes();
63    buf[TOKEN_HEADER_LEN..].copy_from_slice(&tag);
64    Ok(buf)
65}
66
67/// Convenience: issue a token and return it as a lowercase hex string.
68pub fn issue_token(
69    secret: &[u8; 32],
70    for_node: u64,
71    expiry_unix_secs: u64,
72) -> Result<String, TokenError> {
73    let bytes = issue_token_bytes(secret, for_node, expiry_unix_secs)?;
74    Ok(token_to_hex(&bytes))
75}
76
77/// Encode raw token bytes as a lowercase hex string.
78pub fn token_to_hex(bytes: &[u8]) -> String {
79    use std::fmt::Write as _;
80    let mut out = String::with_capacity(bytes.len() * 2);
81    for b in bytes {
82        let _ = write!(out, "{b:02x}");
83    }
84    out
85}
86
87/// Compute SHA-256 of the token bytes. Used as the stable identity for
88/// state-machine tracking (never stores the raw token).
89pub fn token_hash(token_hex: &str) -> Result<[u8; 32], TokenError> {
90    use sha2::Digest;
91    let bytes = hex_decode(token_hex)?;
92    Ok(sha2::Sha256::digest(&bytes).into())
93}
94
95/// Verify a hex-encoded token against `secret`. Returns the bound
96/// `(for_node, expiry_unix_secs)` on success.
97///
98/// The HMAC comparison is constant-time (via `hmac::Mac::verify_slice`
99/// which uses the `subtle` crate internally).
100pub fn verify_token(token_hex: &str, secret: &[u8; 32]) -> Result<(u64, u64), TokenError> {
101    if token_hex.len() != TOKEN_HEX_LEN {
102        return Err(TokenError::WrongLength);
103    }
104    let bytes = hex_decode(token_hex)?;
105    let (body, tag) = bytes.split_at(TOKEN_HEADER_LEN);
106    let mut mac = <Hmac<Sha256>>::new_from_slice(secret).map_err(|_| TokenError::HmacKeyLength)?;
107    mac.update(body);
108    mac.verify_slice(tag).map_err(|_| TokenError::InvalidMac)?;
109    let for_node = u64::from_le_bytes(body[..8].try_into().expect("slice is 8 bytes"));
110    let expiry = u64::from_le_bytes(body[8..].try_into().expect("slice is 8 bytes"));
111    let now = SystemTime::now()
112        .duration_since(UNIX_EPOCH)
113        .map(|d| d.as_secs())
114        .unwrap_or_else(|_| {
115            tracing::error!(
116                "system clock is before UNIX_EPOCH during token verification; \
117                 using 0 (epoch) — check NTP/RTC configuration"
118            );
119            0
120        });
121    if now > expiry {
122        return Err(TokenError::Expired);
123    }
124    Ok((for_node, expiry))
125}
126
127fn hex_decode(s: &str) -> Result<Vec<u8>, TokenError> {
128    let mut out = Vec::with_capacity(s.len() / 2);
129    for chunk in s.as_bytes().chunks(2) {
130        if chunk.len() != 2 {
131            return Err(TokenError::InvalidHex);
132        }
133        let hi = hex_digit(chunk[0]).ok_or(TokenError::InvalidHex)?;
134        let lo = hex_digit(chunk[1]).ok_or(TokenError::InvalidHex)?;
135        out.push((hi << 4) | lo);
136    }
137    Ok(out)
138}
139
140fn hex_digit(b: u8) -> Option<u8> {
141    match b {
142        b'0'..=b'9' => Some(b - b'0'),
143        b'a'..=b'f' => Some(10 + b - b'a'),
144        b'A'..=b'F' => Some(10 + b - b'A'),
145        _ => None,
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use std::time::Duration;
153
154    #[test]
155    fn roundtrip_verify_accepts_fresh_token() {
156        let secret = [0x11u8; 32];
157        let for_node = 42u64;
158        let expiry = SystemTime::now()
159            .duration_since(UNIX_EPOCH)
160            .unwrap()
161            .as_secs()
162            + 60;
163        let hex = issue_token(&secret, for_node, expiry).unwrap();
164        let (got_node, got_exp) = verify_token(&hex, &secret).unwrap();
165        assert_eq!(got_node, for_node);
166        assert_eq!(got_exp, expiry);
167    }
168
169    #[test]
170    fn rejects_tampered_mac() {
171        let secret = [0x11u8; 32];
172        let expiry = SystemTime::now()
173            .duration_since(UNIX_EPOCH)
174            .unwrap()
175            .as_secs()
176            + 60;
177        let hex = issue_token(&secret, 1, expiry).unwrap();
178        // Flip the last MAC byte. XOR with 0xFF so it always changes even
179        // if it was already 0x00 (a fixed "00" replacement would be a
180        // no-op ~1/256 of the time, since the MAC varies with the expiry).
181        let mut tampered = hex.clone();
182        let len = tampered.len();
183        let orig = u8::from_str_radix(&tampered[len - 2..len], 16).unwrap();
184        tampered.replace_range(len - 2..len, &format!("{:02x}", orig ^ 0xFF));
185        assert_eq!(
186            verify_token(&tampered, &secret).unwrap_err(),
187            TokenError::InvalidMac
188        );
189    }
190
191    #[test]
192    fn rejects_wrong_secret() {
193        let secret = [0x11u8; 32];
194        let expiry = SystemTime::now()
195            .duration_since(UNIX_EPOCH)
196            .unwrap()
197            .as_secs()
198            + 60;
199        let hex = issue_token(&secret, 5, expiry).unwrap();
200        let other = [0x22u8; 32];
201        assert_eq!(
202            verify_token(&hex, &other).unwrap_err(),
203            TokenError::InvalidMac
204        );
205    }
206
207    #[test]
208    fn rejects_expired() {
209        let secret = [0xAAu8; 32];
210        // expiry in the distant past
211        let expiry = 1u64;
212        let hex = issue_token(&secret, 1, expiry).unwrap();
213        assert_eq!(
214            verify_token(&hex, &secret).unwrap_err(),
215            TokenError::Expired
216        );
217    }
218
219    #[test]
220    fn rejects_wrong_length() {
221        let secret = [0x55u8; 32];
222        assert_eq!(
223            verify_token("deadbeef", &secret).unwrap_err(),
224            TokenError::WrongLength
225        );
226    }
227
228    #[test]
229    fn token_hash_stable() {
230        let secret = [0x33u8; 32];
231        let expiry = SystemTime::now()
232            .duration_since(UNIX_EPOCH)
233            .unwrap()
234            .as_secs()
235            + 60;
236        let hex = issue_token(&secret, 7, expiry).unwrap();
237        let h1 = token_hash(&hex).unwrap();
238        let h2 = token_hash(&hex).unwrap();
239        assert_eq!(h1, h2);
240        // Hash must differ from token bytes
241        let _bytes = hex_decode(&hex).unwrap();
242        // Just check it's non-zero
243        assert!(h1.iter().any(|&b| b != 0));
244    }
245
246    // Verify constant-time property indirectly: the rejection path for
247    // InvalidMac must go through `verify_slice`, not a byte-by-byte short-
248    // circuit. We can't measure timing in a unit test, but we can confirm
249    // the error variant is `InvalidMac` (not `WrongLength` or `InvalidHex`),
250    // which means the full MAC was fed into `verify_slice` before rejection.
251    #[test]
252    fn invalid_mac_error_variant_confirms_constant_time_path() {
253        let secret = [0x77u8; 32];
254        let expiry = SystemTime::now()
255            .duration_since(UNIX_EPOCH)
256            .unwrap()
257            .as_secs()
258            + 60;
259        let hex = issue_token(&secret, 9, expiry).unwrap();
260        // Replace entire MAC region with zeros (all-zero hex suffix).
261        let header_hex_len = TOKEN_HEADER_LEN * 2;
262        let zero_mac = "00".repeat(TOKEN_MAC_LEN);
263        let tampered = format!("{}{}", &hex[..header_hex_len], zero_mac);
264        assert_eq!(tampered.len(), TOKEN_HEX_LEN);
265        assert_eq!(
266            verify_token(&tampered, &secret).unwrap_err(),
267            TokenError::InvalidMac,
268            "rejection must be via constant-time verify_slice, not short-circuit"
269        );
270    }
271
272    /// Issue a token using the helper function that includes reading the
273    /// wall clock, identical to the CLI path.
274    #[test]
275    fn issue_token_with_duration() {
276        let secret = [0xBBu8; 32];
277        let ttl = Duration::from_secs(300);
278        let now = SystemTime::now()
279            .duration_since(UNIX_EPOCH)
280            .unwrap()
281            .as_secs();
282        let expiry = now + ttl.as_secs();
283        let hex = issue_token(&secret, 3, expiry).unwrap();
284        assert_eq!(hex.len(), TOKEN_HEX_LEN);
285        let (node, exp) = verify_token(&hex, &secret).unwrap();
286        assert_eq!(node, 3);
287        assert!(exp >= now + ttl.as_secs() - 2); // allow 2s clock slack
288    }
289}