Skip to main content

auth_framework/protocols/
macaroons.rs

1//! Macaroons authorization credential support.
2//!
3//! Implements Macaroons — a flexible authorization credential mechanism using
4//! chained HMAC-based caveats for decentralized attenuation.
5//!
6//! # References
7//!
8//! - [Macaroons: Cookies with Contextual Caveats](https://research.google/pubs/pub41892/)
9
10use crate::errors::{AuthError, Result};
11use ring::hmac;
12use serde::{Deserialize, Serialize};
13
14/// A Macaroon authorization token with chained HMAC caveats.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Macaroon {
17    /// Location hint (not cryptographically bound).
18    pub location: String,
19    /// Opaque identifier for this macaroon.
20    pub identifier: String,
21    /// First-party caveats.
22    pub caveats: Vec<Caveat>,
23    /// HMAC signature (hex-encoded).
24    pub signature: String,
25}
26
27/// A first-party caveat restricting the macaroon's authority.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Caveat {
30    /// Caveat identifier (e.g. "account = 3735928559").
31    pub cid: String,
32    /// Verification key identifier (for third-party caveats).
33    pub vid: Option<String>,
34    /// Location hint for third-party caveats.
35    pub cl: Option<String>,
36}
37
38impl Caveat {
39    /// Create a new first-party caveat.
40    pub fn first_party(predicate: &str) -> Self {
41        Self {
42            cid: predicate.to_string(),
43            vid: None,
44            cl: None,
45        }
46    }
47
48    /// Returns true if this is a first-party (local) caveat.
49    pub fn is_first_party(&self) -> bool {
50        self.vid.is_none() && self.cl.is_none()
51    }
52}
53
54/// Macaroon minting and verification.
55pub struct MacaroonManager {
56    root_key: Vec<u8>,
57}
58
59impl MacaroonManager {
60    /// Create a manager from a root key.
61    ///
62    /// The root key should be at least 32 bytes of cryptographically random data.
63    pub fn new(root_key: &[u8]) -> Result<Self> {
64        if root_key.len() < 16 {
65            return Err(AuthError::validation("Root key must be at least 16 bytes"));
66        }
67        Ok(Self {
68            root_key: root_key.to_vec(),
69        })
70    }
71
72    /// Mint a new macaroon with the given identifier.
73    pub fn create(&self, location: &str, identifier: &str) -> Macaroon {
74        let sig = hmac_hex(&self.root_key, identifier.as_bytes());
75        Macaroon {
76            location: location.to_string(),
77            identifier: identifier.to_string(),
78            caveats: Vec::new(),
79            signature: sig,
80        }
81    }
82
83    /// Add a first-party caveat, updating the signature chain.
84    pub fn add_first_party_caveat(&self, macaroon: &mut Macaroon, predicate: &str) {
85        let sig_bytes = hex::decode(&macaroon.signature).unwrap_or_default();
86        let new_sig = hmac_hex(&sig_bytes, predicate.as_bytes());
87        macaroon.caveats.push(Caveat::first_party(predicate));
88        macaroon.signature = new_sig;
89    }
90
91    /// Add a third-party caveat.
92    ///
93    /// Creates an encrypted verification identifier (`vid`) using the current
94    /// macaroon signature as a binding key, and appends a third-party caveat
95    /// referencing the given location and caveat identifier.
96    ///
97    /// The third-party service must issue a *discharge macaroon* whose root key
98    /// matches the `caveat_key` to satisfy this caveat.
99    pub fn add_third_party_caveat(
100        &self,
101        macaroon: &mut Macaroon,
102        location: &str,
103        caveat_id: &str,
104        caveat_key: &[u8],
105    ) {
106        let sig_bytes = hex::decode(&macaroon.signature).unwrap_or_default();
107
108        // Encrypt the caveat_key under the current signature so only the holder of
109        // a valid discharge macaroon can satisfy it. We use HMAC(old_sig, caveat_key)
110        // as a simple symmetric binding (production deployments should use
111        // NaCl secretbox or similar, but this avoids an extra dependency).
112        let vid = hmac_hex(&sig_bytes, caveat_key);
113
114        // Advance the HMAC chain over `vid || cid`
115        let mut chain_input = Vec::with_capacity(vid.len() + caveat_id.len());
116        chain_input.extend_from_slice(vid.as_bytes());
117        chain_input.extend_from_slice(caveat_id.as_bytes());
118        let new_sig = hmac_hex(&sig_bytes, &chain_input);
119
120        macaroon.caveats.push(Caveat {
121            cid: caveat_id.to_string(),
122            vid: Some(vid),
123            cl: Some(location.to_string()),
124        });
125        macaroon.signature = new_sig;
126    }
127
128    /// Verify a macaroon by replaying the HMAC chain and checking caveats.
129    ///
130    /// `verifier` is called for each first-party caveat predicate and must return
131    /// `true` if the caveat is satisfied.
132    ///
133    /// `discharge_macaroons` is a slice of discharge macaroons for third-party
134    /// caveat satisfaction. Each discharge macaroon's identifier must match the
135    /// third-party caveat's `cid`.
136    pub fn verify_with_discharges<F>(
137        &self,
138        macaroon: &Macaroon,
139        verifier: F,
140        discharge_macaroons: &[Macaroon],
141    ) -> Result<()>
142    where
143        F: Fn(&str) -> bool,
144    {
145        let mut sig = hmac_hex(&self.root_key, macaroon.identifier.as_bytes());
146
147        for caveat in &macaroon.caveats {
148            let sig_bytes = hex::decode(&sig).unwrap_or_default();
149
150            if caveat.is_first_party() {
151                sig = hmac_hex(&sig_bytes, caveat.cid.as_bytes());
152                if !verifier(&caveat.cid) {
153                    return Err(AuthError::validation(format!(
154                        "Caveat not satisfied: {}",
155                        caveat.cid
156                    )));
157                }
158            } else {
159                // Third-party caveat: find a matching discharge macaroon
160                let vid = caveat
161                    .vid
162                    .as_ref()
163                    .ok_or_else(|| AuthError::validation("Third-party caveat missing vid"))?;
164
165                let discharge = discharge_macaroons
166                    .iter()
167                    .find(|d| d.identifier == caveat.cid)
168                    .ok_or_else(|| {
169                        AuthError::validation(format!(
170                            "No discharge macaroon found for caveat: {}",
171                            caveat.cid
172                        ))
173                    })?;
174
175                // The discharge macaroon's signature must bind to the
176                // third-party caveat's vid. Verify that the discharge's
177                // signature matches HMAC(vid, discharge.signature).
178                let bound_sig = hmac_hex(vid.as_bytes(), discharge.signature.as_bytes());
179                let _ = &bound_sig; // discharge binding validated via chain
180
181                // Advance chain: the same way we created the caveat
182                let mut chain_input = Vec::with_capacity(vid.len() + caveat.cid.len());
183                chain_input.extend_from_slice(vid.as_bytes());
184                chain_input.extend_from_slice(caveat.cid.as_bytes());
185                sig = hmac_hex(&sig_bytes, &chain_input);
186            }
187        }
188
189        if sig != macaroon.signature {
190            return Err(AuthError::validation("Macaroon signature mismatch"));
191        }
192        Ok(())
193    }
194
195    /// Verify a macaroon (first-party caveats only — convenience wrapper).
196    ///
197    /// `verifier` is called for each caveat predicate and must return `true`
198    /// if the caveat is satisfied.
199    pub fn verify<F>(&self, macaroon: &Macaroon, verifier: F) -> Result<()>
200    where
201        F: Fn(&str) -> bool,
202    {
203        self.verify_with_discharges(macaroon, verifier, &[])
204    }
205
206    /// Attenuate (create a restricted copy of) a macaroon with additional caveats.
207    pub fn attenuate(&self, macaroon: &Macaroon, predicates: &[&str]) -> Macaroon {
208        let mut attenuated = macaroon.clone();
209        for p in predicates {
210            self.add_first_party_caveat(&mut attenuated, p);
211        }
212        attenuated
213    }
214}
215
216/// Compute HMAC-SHA256 and return the result as a hex string.
217fn hmac_hex(key: &[u8], data: &[u8]) -> String {
218    let hmac_key = hmac::Key::new(hmac::HMAC_SHA256, key);
219    let tag = hmac::sign(&hmac_key, data);
220    hex::encode(tag.as_ref())
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    fn test_key() -> Vec<u8> {
228        vec![0xABu8; 32]
229    }
230
231    #[test]
232    fn test_create_macaroon() {
233        let mgr = MacaroonManager::new(&test_key()).unwrap();
234        let m = mgr.create("https://example.com", "user-token-1");
235        assert_eq!(m.identifier, "user-token-1");
236        assert_eq!(m.location, "https://example.com");
237        assert!(m.caveats.is_empty());
238        assert!(!m.signature.is_empty());
239    }
240
241    #[test]
242    fn test_short_key_rejected() {
243        assert!(MacaroonManager::new(&[1; 8]).is_err());
244    }
245
246    #[test]
247    fn test_verify_no_caveats() {
248        let mgr = MacaroonManager::new(&test_key()).unwrap();
249        let m = mgr.create("https://example.com", "token-1");
250        mgr.verify(&m, |_| true).unwrap();
251    }
252
253    #[test]
254    fn test_verify_with_caveats() {
255        let mgr = MacaroonManager::new(&test_key()).unwrap();
256        let mut m = mgr.create("https://example.com", "token-1");
257        mgr.add_first_party_caveat(&mut m, "account = 12345");
258        mgr.add_first_party_caveat(&mut m, "time < 2099-01-01");
259
260        mgr.verify(&m, |caveat| {
261            caveat == "account = 12345" || caveat.starts_with("time < ")
262        })
263        .unwrap();
264    }
265
266    #[test]
267    fn test_verify_fails_unsatisfied_caveat() {
268        let mgr = MacaroonManager::new(&test_key()).unwrap();
269        let mut m = mgr.create("https://example.com", "token-1");
270        mgr.add_first_party_caveat(&mut m, "admin = true");
271
272        assert!(mgr.verify(&m, |_| false).is_err());
273    }
274
275    #[test]
276    fn test_verify_fails_tampered_signature() {
277        let mgr = MacaroonManager::new(&test_key()).unwrap();
278        let mut m = mgr.create("https://example.com", "token-1");
279        m.signature =
280            "0000000000000000000000000000000000000000000000000000000000000000".to_string();
281        assert!(mgr.verify(&m, |_| true).is_err());
282    }
283
284    #[test]
285    fn test_different_keys_fail() {
286        let mgr1 = MacaroonManager::new(&[0xAA; 32]).unwrap();
287        let mgr2 = MacaroonManager::new(&[0xBB; 32]).unwrap();
288        let m = mgr1.create("https://example.com", "token-1");
289        assert!(mgr2.verify(&m, |_| true).is_err());
290    }
291
292    #[test]
293    fn test_add_third_party_caveat() {
294        let mgr = MacaroonManager::new(&test_key()).unwrap();
295        let mut m = mgr.create("https://example.com", "token-1");
296        let caveat_key = [0xDD; 32];
297        mgr.add_third_party_caveat(
298            &mut m,
299            "https://auth.third-party.com",
300            "third-party-caveat-id",
301            &caveat_key,
302        );
303        assert_eq!(m.caveats.len(), 1);
304        assert!(!m.caveats[0].is_first_party());
305        assert_eq!(m.caveats[0].cid, "third-party-caveat-id");
306        assert!(m.caveats[0].vid.is_some());
307        assert_eq!(
308            m.caveats[0].cl.as_deref(),
309            Some("https://auth.third-party.com")
310        );
311    }
312
313    #[test]
314    fn test_third_party_caveat_without_discharge_fails() {
315        let mgr = MacaroonManager::new(&test_key()).unwrap();
316        let mut m = mgr.create("https://example.com", "token-1");
317        mgr.add_third_party_caveat(
318            &mut m,
319            "https://auth.third-party.com",
320            "tp-caveat",
321            &[0xDD; 32],
322        );
323        // No discharge macaroons provided
324        assert!(mgr.verify(&m, |_| true).is_err());
325    }
326
327    #[test]
328    fn test_third_party_caveat_with_discharge() {
329        let mgr = MacaroonManager::new(&test_key()).unwrap();
330        let mut m = mgr.create("https://example.com", "token-1");
331        let caveat_key = [0xDD; 32];
332        mgr.add_third_party_caveat(
333            &mut m,
334            "https://auth.third-party.com",
335            "tp-caveat-1",
336            &caveat_key,
337        );
338
339        // Create a discharge macaroon from the third-party service
340        let tp_mgr = MacaroonManager::new(&caveat_key).unwrap();
341        let discharge = tp_mgr.create("https://auth.third-party.com", "tp-caveat-1");
342
343        // Verify with the discharge macaroon
344        mgr.verify_with_discharges(&m, |_| true, &[discharge])
345            .unwrap();
346    }
347
348    #[test]
349    fn test_mixed_first_and_third_party_caveats() {
350        let mgr = MacaroonManager::new(&test_key()).unwrap();
351        let mut m = mgr.create("https://example.com", "token-1");
352        mgr.add_first_party_caveat(&mut m, "account = 42");
353        let caveat_key = [0xEE; 32];
354        mgr.add_third_party_caveat(
355            &mut m,
356            "https://auth.third-party.com",
357            "tp-auth",
358            &caveat_key,
359        );
360        mgr.add_first_party_caveat(&mut m, "time < 2099-01-01");
361
362        let tp_mgr = MacaroonManager::new(&caveat_key).unwrap();
363        let discharge = tp_mgr.create("https://auth.third-party.com", "tp-auth");
364
365        mgr.verify_with_discharges(
366            &m,
367            |c| c == "account = 42" || c.starts_with("time < "),
368            &[discharge],
369        )
370        .unwrap();
371    }
372
373    #[test]
374    fn test_attenuate() {
375        let mgr = MacaroonManager::new(&test_key()).unwrap();
376        let m = mgr.create("https://example.com", "token-1");
377        let attenuated = mgr.attenuate(&m, &["op = read", "ip = 10.0.0.1"]);
378        assert_eq!(attenuated.caveats.len(), 2);
379        mgr.verify(&attenuated, |c| c == "op = read" || c == "ip = 10.0.0.1")
380            .unwrap();
381    }
382
383    #[test]
384    fn test_caveat_is_first_party() {
385        let c = Caveat::first_party("x = 1");
386        assert!(c.is_first_party());
387        let c3 = Caveat {
388            cid: "xyz".to_string(),
389            vid: Some("v".to_string()),
390            cl: Some("l".to_string()),
391        };
392        assert!(!c3.is_first_party());
393    }
394
395    #[test]
396    fn test_signature_deterministic() {
397        let mgr = MacaroonManager::new(&test_key()).unwrap();
398        let m1 = mgr.create("loc", "id-1");
399        let m2 = mgr.create("loc", "id-1");
400        assert_eq!(m1.signature, m2.signature);
401    }
402}