Skip to main content

capsule_runtime/
mac_token.rs

1//! One opaque token grammar, and the memory-only key that seals it.
2//!
3//! ```text
4//! <tag> "." base64url-nopad(payload JSON) "." base64url-nopad(HMAC-SHA256)
5//! ```
6//!
7//! Two authorities mint tokens in this shape: the peer file handles in [`crate::peer_handoff`],
8//! and the spawn credentials and approvals `mur-roost` issues. The parts that must not vary live
9//! here — the constant-time comparison, the unambiguous MAC input, and a key that never leaves the
10//! process it was generated in.
11//!
12//! **A token is bound to its family by two values a caller cannot influence**: the version tag,
13//! compared before the MAC, and the MAC domain, mixed into the MAC. Distinct pairs mean a token of
14//! one family can never verify as another, so an authority that mints several families cannot be
15//! made to accept one in another's place.
16//!
17//! **Verification order is fixed**: shape → MAC → payload. Nothing downstream of the MAC is
18//! evaluated before it passes, so a caller that fails it learns nothing about which field to
19//! change next. For the same reason [`MacTokenError`] carries two variants and not five.
20//!
21//! A value passed as `bound_to` is covered by the MAC without being carried in the token. That is
22//! what makes a token non-bearer: holding it is not enough, the holder must also assert the exact
23//! value it was sealed against.
24
25use base64::Engine as _;
26use hmac::{Hmac, Mac};
27use sha2::Sha256;
28use std::sync::atomic::Ordering;
29
30/// Nonce width in bytes, for a payload field that has to distinguish two otherwise identical
31/// tokens.
32pub const NONCE_BYTES: usize = 16;
33
34/// Width of a [`token_id`], in lowercase hex characters.
35const TOKEN_ID_HEX_CHARS: usize = 16;
36
37/// Separator between the MAC input's fields. ASCII unit separator: it cannot occur in base64url,
38/// so no pair of distinct inputs can produce the same MAC input by moving a boundary between them.
39const MAC_FIELD_SEPARATOR: u8 = 0x1f;
40
41/// The base64 alphabet used for both token segments: URL-safe and unpadded, so a whole token is
42/// safe in a header value or a request path and has no `=` for a transport to mangle.
43pub const B64: base64::engine::general_purpose::GeneralPurpose =
44    base64::engine::general_purpose::URL_SAFE_NO_PAD;
45
46type HmacSha256 = Hmac<Sha256>;
47
48/// The 32-byte HMAC key one authority mints and verifies with.
49///
50/// Never written to disk, never placed in an environment variable, and never copied out of this
51/// type: a key that reaches durable storage outlives the process whose lifetime is the only
52/// revocation mechanism there is. Dropping it makes every outstanding token unverifiable at once
53/// — revoke-all with no revocation list.
54pub struct MintKey([u8; 32]);
55
56impl MintKey {
57    /// 32 bytes from the operating system's CSPRNG.
58    pub fn generate() -> Result<Self, String> {
59        let mut bytes = [0u8; 32];
60        getrandom::fill(&mut bytes)
61            .map_err(|error| format!("failed to generate a mint key: {error}"))?;
62        Ok(Self(bytes))
63    }
64
65    /// Builds a fresh MAC context. Private: the key bytes never leave this module.
66    fn mac(&self) -> HmacSha256 {
67        HmacSha256::new_from_slice(&self.0).expect("HMAC-SHA256 accepts a key of any length")
68    }
69
70    /// Overwrites the key bytes with volatile writes.
71    fn zeroize(&mut self) {
72        zeroize(&mut self.0);
73    }
74}
75
76/// Overwrites every byte of `bytes` with volatile writes.
77///
78/// Volatile so the compiler cannot elide a write whose result is provably never read — which is
79/// the whole of what the write is for.
80#[allow(unsafe_code)]
81pub(crate) fn zeroize(bytes: &mut [u8]) {
82    for byte in bytes.iter_mut() {
83        // SAFETY: `byte` is a valid, aligned, exclusively-borrowed `u8` inside a slice the caller
84        // holds mutably. `write_volatile` of a `u8` through such a reference is always defined.
85        unsafe { std::ptr::write_volatile(byte, 0) };
86    }
87    std::sync::atomic::compiler_fence(Ordering::SeqCst);
88}
89
90impl Drop for MintKey {
91    fn drop(&mut self) {
92        self.zeroize();
93    }
94}
95
96impl std::fmt::Debug for MintKey {
97    /// Prints no key material. A key that can reach a log line is a key on disk by another route.
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        f.write_str("MintKey(<redacted>)")
100    }
101}
102
103/// Why a token's shape or MAC was not accepted.
104///
105/// Two variants and not more: a caller that cannot tell a tampered payload from a token minted by
106/// a different key cannot use the endpoint as an oracle. Whatever an authority checks *after*
107/// [`open`] returns is its own to distinguish.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum MacTokenError {
110    /// Not `<tag>.<base64url>.<base64url>`, or a segment that is not base64url, or empty.
111    Malformed,
112    /// MAC verification failed, for any reason at all.
113    NotValid,
114}
115
116/// The stable identifier a mint and a redemption are correlated by: the first
117/// [`TOKEN_ID_HEX_CHARS`] lowercase hex characters of `sha256(token)`.
118///
119/// This — never the token — is what may appear in a trace, a log line or an error body.
120pub fn token_id(token: &str) -> String {
121    murmur_artifact::sha256_hex(token.as_bytes())[..TOKEN_ID_HEX_CHARS].to_string()
122}
123
124/// `bytes` random bytes as lowercase hex.
125pub fn random_hex(bytes: usize) -> Result<String, String> {
126    let mut buffer = vec![0u8; bytes];
127    getrandom::fill(&mut buffer).map_err(|error| format!("failed to generate a nonce: {error}"))?;
128    Ok(buffer
129        .iter()
130        .map(|byte| format!("{byte:02x}"))
131        .collect::<String>())
132}
133
134/// Mints one token over `payload_json`, for the family named by `tag` and `domain`.
135///
136/// Each value in `bound_to` is covered by the MAC and left out of the token, so redemption
137/// requires asserting it.
138pub fn seal(
139    key: &MintKey,
140    tag: &str,
141    domain: &[u8],
142    payload_json: &[u8],
143    bound_to: &[&str],
144) -> String {
145    let payload_b64 = B64.encode(payload_json);
146    let mut mac = key.mac();
147    mac.update(&mac_input(domain, &payload_b64, bound_to));
148    let signature = B64.encode(mac.finalize().into_bytes());
149    format!("{tag}.{payload_b64}.{signature}")
150}
151
152/// Verifies one token of the family named by `tag` and `domain` and returns its payload bytes.
153///
154/// The MAC comparison is `verify_slice`, which is constant-time; comparing decoded bytes with `==`
155/// would leak the signature one byte at a time.
156pub fn open(
157    key: &MintKey,
158    tag: &str,
159    domain: &[u8],
160    token: &str,
161    bound_to: &[&str],
162) -> Result<Vec<u8>, MacTokenError> {
163    let (payload_b64, payload, signature) = split_token(tag, token)?;
164    let mut mac = key.mac();
165    mac.update(&mac_input(domain, payload_b64, bound_to));
166    mac.verify_slice(&signature)
167        .map_err(|_| MacTokenError::NotValid)?;
168    Ok(payload)
169}
170
171/// The payload bytes of a token whose MAC has *not* been checked, for the family named by `tag`.
172///
173/// The value is caller-controlled and must never decide anything. Two uses are legitimate:
174/// rejecting a string that is not a token of this family before asking for anything else, and
175/// naming a local artefact after a token this process could not have minted.
176pub fn payload_segment(tag: &str, token: &str) -> Result<Vec<u8>, MacTokenError> {
177    split_token(tag, token).map(|(_, payload, _)| payload)
178}
179
180/// The bytes a token's MAC is taken over: `<domain> ‖ 0x1f ‖ <payload base64url>`, then `0x1f` and
181/// each bound value in turn.
182fn mac_input(domain: &[u8], payload_b64: &str, bound_to: &[&str]) -> Vec<u8> {
183    let mut input = Vec::with_capacity(domain.len() + payload_b64.len() + 1);
184    input.extend_from_slice(domain);
185    input.push(MAC_FIELD_SEPARATOR);
186    input.extend_from_slice(payload_b64.as_bytes());
187    for bound in bound_to {
188        input.push(MAC_FIELD_SEPARATOR);
189        input.extend_from_slice(bound.as_bytes());
190    }
191    input
192}
193
194/// The token's three segments, checked for shape alone. Nothing here looks at what the payload
195/// *says*. The payload segment is returned both as base64 text, which is what the MAC covers, and
196/// decoded.
197fn split_token<'a>(
198    expected_tag: &str,
199    token: &'a str,
200) -> Result<(&'a str, Vec<u8>, Vec<u8>), MacTokenError> {
201    let mut segments = token.split('.');
202    let tag = segments.next().ok_or(MacTokenError::Malformed)?;
203    let payload_b64 = segments.next().ok_or(MacTokenError::Malformed)?;
204    let signature_b64 = segments.next().ok_or(MacTokenError::Malformed)?;
205    if segments.next().is_some() || tag != expected_tag {
206        return Err(MacTokenError::Malformed);
207    }
208    let payload = B64
209        .decode(payload_b64)
210        .map_err(|_| MacTokenError::Malformed)?;
211    let signature = B64
212        .decode(signature_b64)
213        .map_err(|_| MacTokenError::Malformed)?;
214    if payload.is_empty() || signature.is_empty() {
215        return Err(MacTokenError::Malformed);
216    }
217    Ok((payload_b64, payload, signature))
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    const TAG: &str = "tst1";
225    const DOMAIN: &[u8] = b"murmur-mac-token-test";
226
227    /// `Drop` is `zeroize` and nothing else, so testing the helper tests the teardown guarantee
228    /// without reading memory the value has already released.
229    #[test]
230    fn zeroizing_clears_every_key_byte() {
231        let mut key = MintKey([0xAB; 32]);
232        assert_ne!(key.0, [0u8; 32]);
233        key.zeroize();
234        assert_eq!(key.0, [0u8; 32]);
235    }
236
237    /// A generated key is not the all-zero array a zeroized one becomes, and two are distinct —
238    /// enough to catch a `generate` that stopped reaching the OS CSPRNG.
239    #[test]
240    fn a_generated_key_is_random() {
241        let one = MintKey::generate().unwrap();
242        let two = MintKey::generate().unwrap();
243        assert_ne!(one.0, [0u8; 32]);
244        assert_ne!(one.0, two.0);
245    }
246
247    #[test]
248    fn a_key_prints_no_material() {
249        assert_eq!(format!("{:?}", MintKey([0xAB; 32])), "MintKey(<redacted>)");
250    }
251
252    #[test]
253    fn a_sealed_token_opens_with_the_key_that_sealed_it() {
254        let key = MintKey::generate().unwrap();
255        let token = seal(&key, TAG, DOMAIN, b"{\"a\":1}", &[]);
256        assert!(token.starts_with("tst1."));
257        assert_eq!(open(&key, TAG, DOMAIN, &token, &[]).unwrap(), b"{\"a\":1}");
258    }
259
260    #[test]
261    fn another_key_does_not_open_it() {
262        let token = seal(&MintKey::generate().unwrap(), TAG, DOMAIN, b"{}", &[]);
263        assert_eq!(
264            open(&MintKey::generate().unwrap(), TAG, DOMAIN, &token, &[]),
265            Err(MacTokenError::NotValid)
266        );
267    }
268
269    /// The tag is compared before the MAC and the domain is mixed into it, so neither a token of
270    /// another family nor the same payload under another domain verifies.
271    #[test]
272    fn a_token_of_another_family_does_not_open() {
273        let key = MintKey::generate().unwrap();
274        let token = seal(&key, TAG, DOMAIN, b"{}", &[]);
275        assert_eq!(
276            open(&key, "oth1", DOMAIN, &token, &[]),
277            Err(MacTokenError::Malformed)
278        );
279        assert_eq!(
280            open(&key, TAG, b"murmur-other-domain", &token, &[]),
281            Err(MacTokenError::NotValid)
282        );
283    }
284
285    /// A bound value is covered by the MAC and absent from the token, so asserting the wrong one
286    /// fails and the right one is not recoverable from the token itself.
287    #[test]
288    fn a_bound_value_is_required_but_not_carried() {
289        let key = MintKey::generate().unwrap();
290        let token = seal(&key, TAG, DOMAIN, b"{}", &["reporter@localhost:1"]);
291        assert!(!token.contains("reporter"));
292        assert!(open(&key, TAG, DOMAIN, &token, &["reporter@localhost:1"]).is_ok());
293        assert_eq!(
294            open(&key, TAG, DOMAIN, &token, &["reporter@localhost:2"]),
295            Err(MacTokenError::NotValid)
296        );
297        assert_eq!(
298            open(&key, TAG, DOMAIN, &token, &[]),
299            Err(MacTokenError::NotValid)
300        );
301    }
302
303    #[test]
304    fn a_token_that_is_not_three_base64_segments_is_malformed() {
305        let key = MintKey::generate().unwrap();
306        for token in [
307            "",
308            "tst1",
309            "tst1.abc",
310            "tst1.abc.def.ghi",
311            "tst1..def",
312            "tst1.abc.",
313            "tst1.not base64!.def",
314        ] {
315            assert_eq!(
316                open(&key, TAG, DOMAIN, token, &[]),
317                Err(MacTokenError::Malformed),
318                "{token}"
319            );
320        }
321    }
322
323    #[test]
324    fn a_payload_segment_reads_without_the_key() {
325        let token = seal(
326            &MintKey::generate().unwrap(),
327            TAG,
328            DOMAIN,
329            b"{\"a\":1}",
330            &[],
331        );
332        assert_eq!(payload_segment(TAG, &token).unwrap(), b"{\"a\":1}");
333        assert_eq!(
334            payload_segment("oth1", &token),
335            Err(MacTokenError::Malformed)
336        );
337    }
338
339    #[test]
340    fn a_token_id_is_stable_and_distinguishing() {
341        let id = token_id("tst1.abc.def");
342        assert_eq!(id.len(), TOKEN_ID_HEX_CHARS);
343        assert_eq!(id, token_id("tst1.abc.def"));
344        assert_ne!(id, token_id("tst1.abc.deg"));
345    }
346
347    #[test]
348    fn a_nonce_is_hex_of_the_requested_width() {
349        let nonce = random_hex(NONCE_BYTES).unwrap();
350        assert_eq!(nonce.len(), NONCE_BYTES * 2);
351        assert!(nonce.chars().all(|c| c.is_ascii_hexdigit()));
352        assert_ne!(nonce, random_hex(NONCE_BYTES).unwrap());
353    }
354}