Skip to main content

macula_rust/
ucan.rs

1//! macula 12's UCAN, macula's `macula_ucan` in Rust: tokens signed in the
2//! node's crypto profile (D7), and a provider's authorization of one.
3//! macula's `test/vectors/UCAN_V1.md` is the contract, and
4//! `tests/vectors/ucan` holds its vectors; every verdict here is macula's,
5//! reached in the same order.
6//!
7//! A token is a JWT, header.payload.signature, each part base64url without
8//! padding. The header names the profile's algorithm: ML-DSA-87 in pq_pure,
9//! ML-DSA-87-PS384 (the LAMPS composite id-MLDSA87-RSA4096-PSS-SHA512) in
10//! pq_hybrid. The signature is the issuer's node key's over the header and
11//! payload as sent. The payload's `iss` is a did:key for the issuer's key as
12//! carried; `aud` is the lowercase hex node_id of the node that presents the
13//! token, which must be the request's verified caller; `cap` lists
14//! `{with, can}`; `exp` is in seconds, at most [`MAX_LIFETIME`] past now.
15//!
16//! A token may rest on a parent: its `prf` names the parent by [`proof_id`],
17//! and the parents travel beside it in the request's caller-signed proofs.
18//! The chain is walked to its root, which must be the issuer the policy
19//! names; each link's own signature and validity window hold, each parent's
20//! `aud` is the node_id of its child's issuer, `can` is equal at every step,
21//! and a child's capability is covered by one of its parent's ([`covers`]).
22//! Every proof that travelled must be used.
23
24mod capability;
25mod chain;
26mod did_key;
27mod first_wins;
28mod token;
29
30use std::collections::HashMap;
31use std::fmt;
32
33use base64::Engine;
34use serde_json::{Map, Value};
35use sha2::{Digest, Sha384};
36
37use crate::node_key::{KeyError, NodeKey, Purpose};
38use crate::profile::Profile;
39
40pub use capability::covers;
41pub use did_key::{carried_key, did_key};
42
43/// The furthest past now, in seconds, that a token's `exp` may lie: ten
44/// years of 365.25 days, as `macula_ucan:max_lifetime/0`. UCAN_V1 has no
45/// revocation, so `exp` is the only bound on a token's life. An `exp` in
46/// milliseconds lies far beyond it, so a token minted with one is refused,
47/// by [`create`] and by [`authorize`], rather than valid for tens of
48/// thousands of years.
49pub const MAX_LIFETIME: i64 = 315_576_000;
50
51/// Why a token does not authorize: one of `macula_ucan`'s refusals, by the
52/// same name ([`Refusal::name`]).
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub enum Refusal {
55    Malformed,
56    WrongAlgorithm,
57    SignatureInvalid,
58    NotTheIssuer,
59    NotTheAudience,
60    Expired,
61    ExpBeyondMaxLifetime,
62    NotYetValid,
63    MissingCapability,
64    MissingProof,
65    UnreferencedProof,
66    NotTheDelegate,
67    ChainNotLinear,
68    GrantsMoreThanProof,
69    CanChanged,
70    WrongRealm,
71    RealmNameNotCanonical,
72    ProcedureWithoutOrg,
73}
74
75impl Refusal {
76    /// The refusal's name in macula: `not_the_audience`, say.
77    pub fn name(self) -> &'static str {
78        match self {
79            Refusal::Malformed => "malformed",
80            Refusal::WrongAlgorithm => "wrong_algorithm",
81            Refusal::SignatureInvalid => "signature_invalid",
82            Refusal::NotTheIssuer => "not_the_issuer",
83            Refusal::NotTheAudience => "not_the_audience",
84            Refusal::Expired => "expired",
85            Refusal::ExpBeyondMaxLifetime => "exp_beyond_max_lifetime",
86            Refusal::NotYetValid => "not_yet_valid",
87            Refusal::MissingCapability => "missing_capability",
88            Refusal::MissingProof => "missing_proof",
89            Refusal::UnreferencedProof => "unreferenced_proof",
90            Refusal::NotTheDelegate => "not_the_delegate",
91            Refusal::ChainNotLinear => "chain_not_linear",
92            Refusal::GrantsMoreThanProof => "grants_more_than_proof",
93            Refusal::CanChanged => "can_changed",
94            Refusal::WrongRealm => "wrong_realm",
95            Refusal::RealmNameNotCanonical => "realm_name_not_canonical",
96            Refusal::ProcedureWithoutOrg => "procedure_without_org",
97        }
98    }
99}
100
101impl fmt::Display for Refusal {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        write!(f, "ucan: {}", self.name())
104    }
105}
106
107impl std::error::Error for Refusal {}
108
109/// What a gated procedure requires of a request's token.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum Policy {
112    /// A token whose chain is rooted at the identity key of this node_id.
113    UcanRequired { issuer: [u8; 32] },
114    /// A token whose chain is rooted at the realm key of this key id,
115    /// granting a capability with this `can`.
116    RealmMemberRequired { key_id: [u8; 32], can: String },
117}
118
119impl Policy {
120    /// Whether a procedure may be served under the policy, as macula's
121    /// advertise checks it: a realm member policy names a `can`.
122    pub fn valid(&self) -> bool {
123        match self {
124            Policy::UcanRequired { .. } => true,
125            Policy::RealmMemberRequired { can, .. } => !can.is_empty(),
126        }
127    }
128}
129
130/// The request a token is presented with: its realm id and procedure.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct Request {
133    pub realm: [u8; 32],
134    pub procedure: String,
135}
136
137/// What a token is authorized against: the request's verified caller, the
138/// provider's profile, the time in seconds, the request (`None` to check the
139/// token alone, as a gate with no request in front of it), and the proofs
140/// that travelled with it, keyed by [`proof_id`].
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct Context {
143    pub caller: [u8; 32],
144    pub profile: Profile,
145    pub now: i64,
146    pub request: Option<Request>,
147    pub proofs: HashMap<String, Vec<u8>>,
148}
149
150impl Context {
151    /// The proofs as a request carries them, keyed by [`proof_id`].
152    pub fn keyed_proofs(proofs: &[Vec<u8>]) -> HashMap<String, Vec<u8>> {
153        proofs.iter().map(|p| (proof_id(p), p.clone())).collect()
154    }
155}
156
157/// One grant: a `with` (an MRI) and a `can`.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct Capability {
160    pub with: String,
161    pub can: String,
162}
163
164/// A token's claims beyond `iss`, `aud` and `cap`: `exp` (seconds) is
165/// required, the rest optional. `prf` names the token's parent by
166/// [`proof_id`]; at most one.
167#[derive(Debug, Clone, Default, PartialEq)]
168pub struct Options {
169    pub exp: i64,
170    pub nbf: Option<i64>,
171    pub nnc: Option<String>,
172    pub fct: Option<Map<String, Value>>,
173    pub prf: Option<Vec<String>>,
174}
175
176/// Why [`create`] mints no token, naming the values it compared.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub enum CreateError {
179    /// A token is signed by an identity key, and this key is not one.
180    NotAnIdentityKey(Purpose),
181    /// `exp` lies more than [`MAX_LIFETIME`] past `now`: at most `max`.
182    ExpBeyondMaxLifetime { exp: i64, now: i64, max: i64 },
183    /// `nbf` is not before `exp`, so the token would never be valid.
184    WindowNeverOpens { nbf: i64, exp: i64 },
185    /// The issuer's key could not sign.
186    Sign(KeyError),
187}
188
189impl fmt::Display for CreateError {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        match self {
192            CreateError::NotAnIdentityKey(purpose) => {
193                write!(
194                    f,
195                    "ucan: a token is signed by an identity key, not a {purpose} key"
196                )
197            }
198            CreateError::ExpBeyondMaxLifetime { exp, now, max } => write!(
199                f,
200                "ucan: exp_beyond_max_lifetime: exp {exp}, now {now}, at most {max} \
201                 (milliseconds, where seconds are meant?)"
202            ),
203            CreateError::WindowNeverOpens { nbf, exp } => write!(
204                f,
205                "ucan: nbf is not before exp, so the token is never valid: nbf {nbf}, exp {exp}"
206            ),
207            CreateError::Sign(e) => write!(f, "ucan: {e}"),
208        }
209    }
210}
211
212impl std::error::Error for CreateError {}
213
214const TYP: &str = "JWT";
215const UCV: &str = "0.10.0";
216
217/// A JWT part's base64url: no padding, as macula writes it.
218const BASE64URL: base64::engine::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD;
219
220/// The `alg` a token names in `profile`.
221fn algorithm(profile: Profile) -> &'static str {
222    match profile {
223        Profile::PqPure => "ML-DSA-87",
224        Profile::PqHybrid => "ML-DSA-87-PS384",
225    }
226}
227
228/// A token from `issuer`'s identity key, for the audience's node_id,
229/// granting `caps` until `o.exp`. An `exp` more than [`MAX_LIFETIME`] past
230/// now, or an `nbf` not before `exp`, is refused, naming the values.
231pub fn create(
232    issuer: &NodeKey,
233    audience: &[u8; 32],
234    caps: &[Capability],
235    o: &Options,
236) -> Result<Vec<u8>, CreateError> {
237    if issuer.purpose() != Purpose::Identity {
238        return Err(CreateError::NotAnIdentityKey(issuer.purpose()));
239    }
240    window_opens(o, now_seconds())?;
241    let caps: Vec<Value> = caps
242        .iter()
243        .map(|c| serde_json::json!({"with": c.with, "can": c.can}))
244        .collect();
245    let mut claims = Map::new();
246    claims.insert(
247        "iss".into(),
248        Value::String(did_key(&issuer.public_key(), issuer.profile())),
249    );
250    claims.insert("aud".into(), Value::String(hex(audience)));
251    claims.insert("cap".into(), Value::Array(caps));
252    claims.insert("exp".into(), Value::from(o.exp));
253    if let Some(nbf) = o.nbf {
254        claims.insert("nbf".into(), Value::from(nbf));
255    }
256    if let Some(nnc) = o.nnc.as_ref().filter(|n| !n.is_empty()) {
257        claims.insert("nnc".into(), Value::String(nnc.clone()));
258    }
259    if let Some(fct) = &o.fct {
260        claims.insert("fct".into(), Value::Object(fct.clone()));
261    }
262    if let Some(prf) = &o.prf {
263        claims.insert(
264            "prf".into(),
265            Value::Array(prf.iter().cloned().map(Value::String).collect()),
266        );
267    }
268    let header = serde_json::json!({"alg": algorithm(issuer.profile()), "typ": TYP, "ucv": UCV});
269    let input = format!(
270        "{}.{}",
271        BASE64URL.encode(header.to_string()),
272        BASE64URL.encode(Value::Object(claims).to_string())
273    );
274    let signature = issuer.sign(input.as_bytes()).map_err(CreateError::Sign)?;
275    Ok(format!("{input}.{}", BASE64URL.encode(signature)).into_bytes())
276}
277
278/// Whether `o`'s validity window is one [`create`] mints at `now`: an `exp`
279/// at most [`MAX_LIFETIME`] past now, and an `nbf`, if any, before `exp`.
280fn window_opens(o: &Options, now: i64) -> Result<(), CreateError> {
281    let max = now.saturating_add(MAX_LIFETIME);
282    if o.exp > max {
283        return Err(CreateError::ExpBeyondMaxLifetime {
284            exp: o.exp,
285            now,
286            max,
287        });
288    }
289    match o.nbf {
290        Some(nbf) if nbf >= o.exp => Err(CreateError::WindowNeverOpens { nbf, exp: o.exp }),
291        _ => Ok(()),
292    }
293}
294
295/// The id a child's `prf` names a parent token by: the lowercase hex of the
296/// SHA-384 of the token's bytes as they travel.
297pub fn proof_id(token: &[u8]) -> String {
298    hex(&Sha384::digest(token))
299}
300
301/// Whether `token` authorizes `ctx`'s caller under `p`: the token's claims
302/// when it does, or the first refusal, in `macula_ucan`'s order.
303pub fn authorize(token: &[u8], p: &Policy, ctx: &Context) -> Result<Map<String, Value>, Refusal> {
304    let mut leaf = token::Checked::parsed(token)?;
305    leaf.algorithm_is(ctx.profile)?;
306    leaf.signed_by_iss(ctx.profile)?;
307    leaf.audience_is(&ctx.caller)?;
308    leaf.valid_at(ctx.now)?;
309    let granted = capability::grants(p, ctx.request.as_ref(), &leaf.claims)?;
310    chain::chained(p, ctx, &leaf, granted)?;
311    Ok(leaf.claims)
312}
313
314/// The time in unix seconds.
315pub(crate) fn now_seconds() -> i64 {
316    std::time::SystemTime::now()
317        .duration_since(std::time::UNIX_EPOCH)
318        .map(|d| d.as_secs() as i64)
319        .unwrap_or(0)
320}
321
322fn hex(bytes: &[u8]) -> String {
323    bytes.iter().map(|b| format!("{b:02x}")).collect()
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn a_window_beyond_the_max_lifetime_or_never_open_is_not_minted() {
332        let now = 1_790_000_000;
333        let at = |exp, nbf| Options {
334            exp,
335            nbf,
336            ..Options::default()
337        };
338        assert_eq!(window_opens(&at(now + MAX_LIFETIME, None), now), Ok(()));
339        assert_eq!(
340            window_opens(&at(now + MAX_LIFETIME + 1, None), now),
341            Err(CreateError::ExpBeyondMaxLifetime {
342                exp: now + MAX_LIFETIME + 1,
343                now,
344                max: now + MAX_LIFETIME
345            })
346        );
347        assert_eq!(window_opens(&at(now + 10, Some(now + 9)), now), Ok(()));
348        assert_eq!(
349            window_opens(&at(now + 10, Some(now + 10)), now),
350            Err(CreateError::WindowNeverOpens {
351                nbf: now + 10,
352                exp: now + 10
353            })
354        );
355    }
356
357    #[test]
358    fn the_max_lifetime_is_ten_years_of_365_25_days() {
359        assert_eq!(MAX_LIFETIME, 10 * 36525 * 24 * 3600 / 100);
360    }
361}