Skip to main content

wire/
macaroon.rs

1//! Speculative macaroon-style delegation scaffold.
2//!
3//! This module is deliberately not wired into CLI or relay paths. It proves the
4//! consent-token shape can fit wire events if a future version chooses portable
5//! scoped delegation over receiver-local policy.
6
7use anyhow::{Result, anyhow, bail};
8use base64::Engine as _;
9use hmac::{Hmac, Mac};
10use serde::{Deserialize, Serialize};
11use sha2::Sha256;
12
13type HmacSha256 = Hmac<Sha256>;
14
15#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
16pub struct Macaroon {
17    pub root_key_id: String,
18    pub identifier: String,
19    pub caveats: Vec<Caveat>,
20    pub signature: String,
21}
22
23#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(tag = "type", content = "value")]
25pub enum Caveat {
26    Sender(String),
27    Recipient(String),
28    Kind(u32),
29    Expiry(String),
30    MaxRate { max: u32, window_secs: u64 },
31}
32
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct VerifyContext {
35    pub sender: String,
36    pub recipient: String,
37    pub kind: u32,
38    pub now: String,
39    pub rate_count: Option<u32>,
40}
41
42impl Macaroon {
43    pub fn mint(
44        root_key_id: impl Into<String>,
45        identifier: impl Into<String>,
46        caveats: Vec<Caveat>,
47        root_key: &[u8],
48    ) -> Result<Self> {
49        let root_key_id = root_key_id.into();
50        let identifier = identifier.into();
51        let signature = compute_signature(root_key, &identifier, &caveats)?;
52        Ok(Self {
53            root_key_id,
54            identifier,
55            caveats,
56            signature,
57        })
58    }
59
60    pub fn verify(&self, root_key: &[u8], context: &VerifyContext) -> Result<()> {
61        let expected = compute_signature(root_key, &self.identifier, &self.caveats)?;
62        if self.signature != expected {
63            bail!("macaroon signature mismatch");
64        }
65        for caveat in &self.caveats {
66            match caveat {
67                Caveat::Sender(sender) if sender != &context.sender => {
68                    bail!("sender caveat mismatch")
69                }
70                Caveat::Recipient(recipient) if recipient != &context.recipient => {
71                    bail!("recipient caveat mismatch")
72                }
73                Caveat::Kind(kind) if kind != &context.kind => bail!("kind caveat mismatch"),
74                Caveat::Expiry(expiry) => {
75                    let expiry = parse_rfc3339(expiry)?;
76                    let now = parse_rfc3339(&context.now)?;
77                    if now > expiry {
78                        bail!("expiry caveat elapsed");
79                    }
80                }
81                Caveat::MaxRate { max, .. }
82                    if context.rate_count.is_some_and(|count| count >= *max) =>
83                {
84                    bail!("max-rate caveat exceeded");
85                }
86                _ => {}
87            }
88        }
89        Ok(())
90    }
91
92    pub fn serialize(&self) -> Result<String> {
93        let bytes = serde_json::to_vec(self)?;
94        Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes))
95    }
96
97    pub fn deserialize(encoded: &str) -> Result<Self> {
98        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
99            .decode(encoded)
100            .map_err(|e| anyhow!("macaroon base64 decode failed: {e}"))?;
101        Ok(serde_json::from_slice(&bytes)?)
102    }
103}
104
105fn compute_signature(root_key: &[u8], identifier: &str, caveats: &[Caveat]) -> Result<String> {
106    let mut sig = hmac_bytes(root_key, identifier.as_bytes())?;
107    for caveat in caveats {
108        let body = serde_json::to_vec(caveat)?;
109        sig = hmac_bytes(&sig, &body)?;
110    }
111    Ok(hex::encode(sig))
112}
113
114fn hmac_bytes(key: &[u8], body: &[u8]) -> Result<Vec<u8>> {
115    let mut mac = HmacSha256::new_from_slice(key)?;
116    mac.update(body);
117    Ok(mac.finalize().into_bytes().to_vec())
118}
119
120fn parse_rfc3339(s: &str) -> Result<time::OffsetDateTime> {
121    time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)
122        .map_err(|e| anyhow!("invalid RFC3339 timestamp {s:?}: {e}"))
123}