Skip to main content

macula_rust/
ucan.rs

1//! Macula's UCAN (User Controlled Authorization Networks) tokens:
2//! creation, verification, and introspection, plus the policy layer a
3//! provider gates an inbound CALL through.
4//!
5//! Ported from `macula-io/macula`'s `src/auth/macula_ucan_nif.erl` and its
6//! native Rust NIF (`native/macula_ucan_nif/src/lib.rs`) — both hand-roll a
7//! JWT-shaped token (`header.payload.signature`, base64url-no-pad), EdDSA
8//! over Ed25519, UCAN spec version `"0.10.0"` (the older JWT-based draft;
9//! **not** the current non-JWT/IPLD UCAN 1.0 spec). Confirmed directly by
10//! reading the NIF's own `Cargo.toml`: no UCAN-spec crate is depended on at
11//! all, only generic `ed25519-dalek`/`serde_json`/`base64`/`sha2` — because
12//! no library implements 0.10.0 (the only actively maintained Rust/Go UCAN
13//! libraries target the incompatible 1.0.0-rc.1 CBOR/IPLD format, per
14//! `macula-go`'s own `ucan` package doc, which made the identical
15//! choice porting this same reference). This module does the same: hand-
16//! rolled on the crypto/serialization primitives already in this crate
17//! (`ed25519-dalek` via [`crate::identity`], plus `serde`/`serde_json`/
18//! `base64` added for this module), matching the reference exactly rather
19//! than adopting an incompatible library.
20//!
21//! A token minted here verifies against `macula-go`'s `ucan` package,
22//! the Erlang macula SDK, or vice versa — same header shape, same payload
23//! field names (`iss`/`aud`/`exp`/`nbf`/`nnc`/`cap`/`fct`/`prf`), same
24//! signing input (`header_b64 + "." + payload_b64`), same algorithm. Field
25//! ORDER in the JSON is not part of the compatibility contract (a verifier
26//! decodes into a struct, never re-encodes and compares bytes) — only the
27//! field NAMES and the exact bytes signed matter.
28//!
29//! Cross-referenced against `macula-go/ucan/{ucan,policy}.go`, itself
30//! independently verified against this same Erlang/Rust reference earlier
31//! this session — the two ports should stay behaviorally identical.
32
33use std::collections::HashMap;
34use std::time::{SystemTime, UNIX_EPOCH};
35
36use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
37use serde::{Deserialize, Serialize};
38
39use crate::identity::{verify as identity_verify, KeyPair};
40
41const ALG: &str = "EdDSA";
42const TYP: &str = "JWT";
43const UCV: &str = "0.10.0";
44
45/// Errors from token creation, decoding, or verification.
46#[derive(Debug)]
47pub enum UcanError {
48    /// Not a well-formed `header.payload.signature` triple, or a part
49    /// isn't valid base64url/JSON — mirrors `macula_ucan_nif`'s
50    /// `{error, invalid_token}`.
51    InvalidToken,
52    /// The token parsed fine but its signature does not verify against
53    /// the given public key — mirrors `{error, invalid_signature}`.
54    InvalidSignature,
55    /// The supplied public key isn't a 32-byte Ed25519 key — mirrors
56    /// `{error, invalid_public_key}`.
57    InvalidPublicKey,
58    /// The token's `exp` claim is in the past — mirrors `{error, expired}`.
59    Expired,
60    /// The token's `nbf` claim is in the future — mirrors
61    /// `{error, not_yet_valid}`.
62    NotYetValid,
63    /// A UCAN-gated procedure was called with no token at all — mirrors
64    /// `macula_station_link.erl`'s `check_ucan(<<>>, _) -> unauthorized`
65    /// clause (an empty/absent token is refused before ever attempting to
66    /// verify anything).
67    NoToken,
68}
69
70impl std::fmt::Display for UcanError {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            UcanError::InvalidToken => write!(f, "ucan: invalid token"),
74            UcanError::InvalidSignature => write!(f, "ucan: invalid signature"),
75            UcanError::InvalidPublicKey => write!(f, "ucan: invalid public key"),
76            UcanError::Expired => write!(f, "ucan: token expired"),
77            UcanError::NotYetValid => write!(f, "ucan: token not yet valid"),
78            UcanError::NoToken => write!(f, "ucan: no token presented for a gated procedure"),
79        }
80    }
81}
82
83impl std::error::Error for UcanError {}
84
85/// One entry in a UCAN token's capability list — mirrors
86/// `macula_ucan_nif`'s `capability() :: #{with := binary(), can := binary()}`.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct Capability {
89    pub with: String,
90    pub can: String,
91}
92
93#[derive(Serialize, Deserialize)]
94struct Header {
95    alg: String,
96    typ: String,
97    ucv: String,
98}
99
100/// The JSON shape actually signed/transmitted. Field names match the
101/// reference exactly.
102#[derive(Serialize, Deserialize)]
103struct WirePayload {
104    iss: String,
105    aud: String,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    exp: Option<i64>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    nbf: Option<i64>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    nnc: Option<String>,
112    cap: Vec<Capability>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    fct: Option<HashMap<String, serde_json::Value>>,
115    prf: Vec<String>,
116}
117
118/// A UCAN token's decoded claims — the Rust-idiomatic counterpart to
119/// `WirePayload`, returned from [`decode`]/[`verify`].
120#[derive(Debug, Clone, PartialEq)]
121pub struct Payload {
122    pub issuer: String,
123    pub audience: String,
124    pub capabilities: Vec<Capability>,
125    pub expires_at: Option<i64>,
126    pub not_before: Option<i64>,
127    pub nonce: String,
128    pub facts: Option<HashMap<String, serde_json::Value>>,
129    pub proofs: Vec<String>,
130}
131
132/// Optional claims for [`create`] — mirrors `macula_ucan_nif`'s
133/// `ucan_opts()` map.
134#[derive(Debug, Clone, Default)]
135pub struct CreateOpts {
136    pub expires_at: Option<i64>,
137    pub not_before: Option<i64>,
138    pub nonce: Option<String>,
139    pub facts: Option<HashMap<String, serde_json::Value>>,
140    pub proofs: Option<Vec<String>>,
141}
142
143/// Mints a new UCAN token, self-issued and signed by `id`. `issuer` and
144/// `audience` are opaque DID strings (e.g. `"did:macula:io.macula.acme"`)
145/// — this module does not validate or resolve DID structure, matching
146/// `macula_ucan_nif:create/4,5`'s own scope exactly (that's
147/// `macula_did_nif`'s job on the Erlang side, out of scope here). `id`
148/// signs with its own Ed25519 private key; the resulting token verifies
149/// against `id`'s public key ([`KeyPair::node_id`]), the same convention
150/// every advertised capability in this SDK already uses.
151pub fn create(
152    issuer: &str,
153    audience: &str,
154    capabilities: Vec<Capability>,
155    id: &KeyPair,
156    opts: CreateOpts,
157) -> Result<Vec<u8>, UcanError> {
158    let payload = WirePayload {
159        iss: issuer.to_string(),
160        aud: audience.to_string(),
161        exp: opts.expires_at,
162        nbf: opts.not_before,
163        nnc: opts.nonce,
164        cap: capabilities,
165        fct: opts.facts,
166        prf: opts.proofs.unwrap_or_default(),
167    };
168
169    let header_json = serde_json::to_vec(&Header {
170        alg: ALG.into(),
171        typ: TYP.into(),
172        ucv: UCV.into(),
173    })
174    .map_err(|_| UcanError::InvalidToken)?;
175    let payload_json = serde_json::to_vec(&payload).map_err(|_| UcanError::InvalidToken)?;
176    let header_b64 = URL_SAFE_NO_PAD.encode(header_json);
177    let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json);
178    let signing_input = format!("{header_b64}.{payload_b64}");
179    let sig = id.sign(signing_input.as_bytes());
180    let sig_b64 = URL_SAFE_NO_PAD.encode(sig);
181    Ok(format!("{signing_input}.{sig_b64}").into_bytes())
182}
183
184fn split_token(token: &[u8]) -> Result<(&str, &str, &str), UcanError> {
185    let text = std::str::from_utf8(token).map_err(|_| UcanError::InvalidToken)?;
186    let mut parts = text.split('.');
187    let (Some(h), Some(p), Some(s), None) =
188        (parts.next(), parts.next(), parts.next(), parts.next())
189    else {
190        return Err(UcanError::InvalidToken);
191    };
192    Ok((h, p, s))
193}
194
195fn decode_payload(payload_b64: &str) -> Result<Payload, UcanError> {
196    let raw = URL_SAFE_NO_PAD
197        .decode(payload_b64)
198        .map_err(|_| UcanError::InvalidToken)?;
199    let wp: WirePayload = serde_json::from_slice(&raw).map_err(|_| UcanError::InvalidToken)?;
200    Ok(Payload {
201        issuer: wp.iss,
202        audience: wp.aud,
203        capabilities: wp.cap,
204        expires_at: wp.exp,
205        not_before: wp.nbf,
206        nonce: wp.nnc.unwrap_or_default(),
207        facts: wp.fct,
208        proofs: wp.prf,
209    })
210}
211
212/// Parses a UCAN token's payload WITHOUT verifying its signature or
213/// checking expiration. Mirrors `macula_ucan_nif:decode/1` — same warning
214/// applies: never use this for an authorization decision, only [`verify`]
215/// does that.
216pub fn decode(token: &[u8]) -> Result<Payload, UcanError> {
217    let (_, payload_b64, _) = split_token(token)?;
218    decode_payload(payload_b64)
219}
220
221fn now_unix() -> i64 {
222    SystemTime::now()
223        .duration_since(UNIX_EPOCH)
224        .map(|d| d.as_secs() as i64)
225        .unwrap_or(0)
226}
227
228/// Checks a UCAN token's signature against `public_key` (the claimed
229/// issuer's 32-byte Ed25519 public key) and its `exp`/`nbf` claims against
230/// the current time, returning the decoded payload only on full success.
231/// Mirrors `macula_ucan_nif:verify/2` exactly, including its check ORDER —
232/// public key shape, then token shape, then `exp`, then `nbf`, then
233/// signature — matching both the Erlang fallback and the Rust NIF, which
234/// check claims before the signature; this module preserves that order for
235/// parity even though it means an invalid-but-well-formed token's expiry
236/// is observable before its signature is checked.
237pub fn verify(token: &[u8], public_key: &[u8; 32]) -> Result<Payload, UcanError> {
238    let (header_b64, payload_b64, sig_b64) = split_token(token)?;
239    let payload = decode_payload(payload_b64)?;
240    let now = now_unix();
241    if let Some(exp) = payload.expires_at {
242        if now > exp {
243            return Err(UcanError::Expired);
244        }
245    }
246    if let Some(nbf) = payload.not_before {
247        if now < nbf {
248            return Err(UcanError::NotYetValid);
249        }
250    }
251    let sig_bytes = URL_SAFE_NO_PAD
252        .decode(sig_b64)
253        .map_err(|_| UcanError::InvalidToken)?;
254    let sig: [u8; 64] = sig_bytes.try_into().map_err(|_| UcanError::InvalidToken)?;
255    let signing_input = format!("{header_b64}.{payload_b64}");
256    if !identity_verify(signing_input.as_bytes(), &sig, public_key) {
257        return Err(UcanError::InvalidSignature);
258    }
259    Ok(payload)
260}
261
262/// Returns a UCAN token's content identifier: SHA-256 of the raw token
263/// bytes, base64url-no-pad encoded. NOT a real multihash/CIDv1 — matches
264/// `macula_ucan_nif:compute_cid/1`'s own (loosely-named) scheme exactly.
265/// Used only for proof-chain references between UCANs (a child token's
266/// `prf` entries name parent tokens by this value).
267pub fn compute_cid(token: &[u8]) -> String {
268    use sha2::{Digest, Sha256};
269    let mut hasher = Sha256::new();
270    hasher.update(token);
271    URL_SAFE_NO_PAD.encode(hasher.finalize())
272}
273
274/// Decodes `token` (without verifying it) and returns its `iss` claim.
275/// Mirrors `macula_ucan_nif:get_issuer/1`.
276pub fn get_issuer(token: &[u8]) -> Result<String, UcanError> {
277    decode(token).map(|p| p.issuer)
278}
279
280/// Decodes `token` (without verifying it) and returns its `aud` claim.
281/// Mirrors `macula_ucan_nif:get_audience/1`.
282pub fn get_audience(token: &[u8]) -> Result<String, UcanError> {
283    decode(token).map(|p| p.audience)
284}
285
286/// Decodes `token` (without verifying it) and returns its `cap` claim.
287/// Mirrors `macula_ucan_nif:get_capabilities/1`.
288pub fn get_capabilities(token: &[u8]) -> Result<Vec<Capability>, UcanError> {
289    decode(token).map(|p| p.capabilities)
290}
291
292/// Decodes `token` (without verifying it) and returns its `exp` claim, or
293/// `None` if absent. Mirrors `macula_ucan_nif:get_expiration/1`.
294pub fn get_expiration(token: &[u8]) -> Result<Option<i64>, UcanError> {
295    decode(token).map(|p| p.expires_at)
296}
297
298/// Decodes `token` (without verifying it) and returns its `prf` claim.
299/// Mirrors `macula_ucan_nif:get_proofs/1`.
300pub fn get_proofs(token: &[u8]) -> Result<Vec<String>, UcanError> {
301    decode(token).map(|p| p.proofs)
302}
303
304/// Decodes `token` (without verifying it) and reports whether its `exp`
305/// claim is in the past. A token with no `exp` claim is never expired.
306/// Mirrors `macula_ucan_nif:is_expired/1`.
307pub fn is_expired(token: &[u8]) -> Result<bool, UcanError> {
308    let payload = decode(token)?;
309    Ok(match payload.expires_at {
310        Some(exp) => now_unix() > exp,
311        None => false,
312    })
313}
314
315/// What a provider requires to answer one `(realm, procedure)`: open (any
316/// identified caller, the default) or UCAN-gated (the caller's token must
317/// verify against `required_issuer`). Mirrors `macula_station_link.erl`'s
318/// own policy shape exactly — `open | {ucan_required, Issuer}` — where
319/// `Issuer` there is the 32-byte Ed25519 public key the gate checks the
320/// token's signature against, not a DID string (the reference code passes
321/// it straight to `macula_ucan_nif:verify/2`, whose second argument is a
322/// raw public key).
323///
324/// Gating happens BEFORE a handler runs — see
325/// [`crate::connection::Session::serve_one_call_gated`] — so a rejected
326/// caller never reaches business logic, and an accepted caller's handler
327/// never sees the raw token either; the policy layer already did the only
328/// thing that mattered with it.
329#[derive(Debug, Clone, Default)]
330pub struct Policy {
331    pub gated: bool,
332    pub required_issuer: [u8; 32],
333}
334
335impl Policy {
336    /// The default, ungated policy: any identified caller may invoke the
337    /// procedure, no UCAN token needed. Equivalent to Erlang's `open`.
338    pub fn open() -> Self {
339        Self::default()
340    }
341
342    /// Builds a UCAN-gated policy: a caller must present a token that
343    /// verifies (signature, `exp`, `nbf`) against `issuer_public_key`.
344    /// Equivalent to Erlang's `{ucan_required, issuer_public_key}`.
345    pub fn required(issuer_public_key: [u8; 32]) -> Self {
346        Self {
347            gated: true,
348            required_issuer: issuer_public_key,
349        }
350    }
351
352    /// Applies this policy to an inbound CALL's `ucan_token`, returning
353    /// `Ok(())` if the call is authorized to proceed to lookup/dispatch.
354    /// An open policy always passes; a gated policy requires `ucan_token`
355    /// to [`verify`] against `required_issuer`.
356    pub fn check(&self, ucan_token: &[u8]) -> Result<(), UcanError> {
357        if !self.gated {
358            return Ok(());
359        }
360        if ucan_token.is_empty() {
361            return Err(UcanError::NoToken);
362        }
363        verify(ucan_token, &self.required_issuer).map(|_| ())
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    fn keypair() -> KeyPair {
372        KeyPair::generate()
373    }
374
375    #[test]
376    fn create_and_verify_round_trip() {
377        let id = keypair();
378        let token = create(
379            "did:macula:issuer",
380            "did:macula:audience",
381            vec![Capability {
382                with: "mri:x".into(),
383                can: "read".into(),
384            }],
385            &id,
386            CreateOpts::default(),
387        )
388        .unwrap();
389        let payload = verify(&token, &id.node_id()).unwrap();
390        assert_eq!(payload.issuer, "did:macula:issuer");
391        assert_eq!(payload.audience, "did:macula:audience");
392        assert_eq!(
393            payload.capabilities,
394            vec![Capability {
395                with: "mri:x".into(),
396                can: "read".into()
397            }]
398        );
399    }
400
401    #[test]
402    fn verify_rejects_tampered_payload() {
403        let id = keypair();
404        let token = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap();
405        let mut text = String::from_utf8(token).unwrap();
406        // Flip a byte in the payload segment without corrupting base64
407        // framing -- this is the same tamper strategy this crate's other
408        // signed-record tests already use (see dht.rs's tests).
409        let parts: Vec<&str> = text.split('.').collect();
410        let mut payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
411        payload_bytes[0] ^= 0xFF;
412        let tampered_payload = URL_SAFE_NO_PAD.encode(payload_bytes);
413        text = format!("{}.{}.{}", parts[0], tampered_payload, parts[2]);
414        let err = verify(text.as_bytes(), &id.node_id()).unwrap_err();
415        assert!(matches!(
416            err,
417            UcanError::InvalidToken | UcanError::InvalidSignature
418        ));
419    }
420
421    #[test]
422    fn verify_rejects_wrong_signer() {
423        let id = keypair();
424        let other = keypair();
425        let token = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap();
426        let err = verify(&token, &other.node_id()).unwrap_err();
427        assert!(matches!(err, UcanError::InvalidSignature));
428    }
429
430    #[test]
431    fn verify_rejects_expired() {
432        let id = keypair();
433        let opts = CreateOpts {
434            expires_at: Some(now_unix() - 60),
435            ..Default::default()
436        };
437        let token = create("iss", "aud", vec![], &id, opts).unwrap();
438        let err = verify(&token, &id.node_id()).unwrap_err();
439        assert!(matches!(err, UcanError::Expired));
440    }
441
442    #[test]
443    fn verify_rejects_not_yet_valid() {
444        let id = keypair();
445        let opts = CreateOpts {
446            not_before: Some(now_unix() + 3600),
447            ..Default::default()
448        };
449        let token = create("iss", "aud", vec![], &id, opts).unwrap();
450        let err = verify(&token, &id.node_id()).unwrap_err();
451        assert!(matches!(err, UcanError::NotYetValid));
452    }
453
454    #[test]
455    fn decode_does_not_check_signature() {
456        let id = keypair();
457        let other = keypair();
458        let token = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap();
459        // decode() against ANY key (or none at all) still returns the
460        // payload -- it never checks the signature, matching
461        // macula_ucan_nif:decode/1's own documented warning.
462        let payload = decode(&token).unwrap();
463        assert_eq!(payload.issuer, "iss");
464        let _ = other; // not used for verification here, on purpose
465    }
466
467    #[test]
468    fn getters_match_created_claims() {
469        let id = keypair();
470        let caps = vec![Capability {
471            with: "mri:x".into(),
472            can: "write".into(),
473        }];
474        let opts = CreateOpts {
475            expires_at: Some(now_unix() + 3600),
476            proofs: Some(vec!["parent-cid".into()]),
477            ..Default::default()
478        };
479        let token = create("did:iss", "did:aud", caps.clone(), &id, opts).unwrap();
480        assert_eq!(get_issuer(&token).unwrap(), "did:iss");
481        assert_eq!(get_audience(&token).unwrap(), "did:aud");
482        assert_eq!(get_capabilities(&token).unwrap(), caps);
483        assert!(get_expiration(&token).unwrap().is_some());
484        assert_eq!(get_proofs(&token).unwrap(), vec!["parent-cid".to_string()]);
485        assert!(!is_expired(&token).unwrap());
486    }
487
488    #[test]
489    fn is_expired_true_for_past_exp() {
490        let id = keypair();
491        let opts = CreateOpts {
492            expires_at: Some(now_unix() - 1),
493            ..Default::default()
494        };
495        let token = create("iss", "aud", vec![], &id, opts).unwrap();
496        // is_expired() never checks the signature either -- consistent
497        // with every other getter in this module.
498        assert!(is_expired(&token).unwrap());
499    }
500
501    #[test]
502    fn is_expired_false_with_no_exp_claim() {
503        let id = keypair();
504        let token = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap();
505        assert!(!is_expired(&token).unwrap());
506    }
507
508    #[test]
509    fn cid_is_deterministic_and_content_addressed() {
510        let id = keypair();
511        let token_a = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap();
512        assert_eq!(compute_cid(&token_a), compute_cid(&token_a));
513        let token_b = create("iss2", "aud", vec![], &id, CreateOpts::default()).unwrap();
514        assert_ne!(compute_cid(&token_a), compute_cid(&token_b));
515    }
516
517    #[test]
518    fn policy_open_never_requires_a_token() {
519        let policy = Policy::open();
520        assert!(policy.check(&[]).is_ok());
521    }
522
523    #[test]
524    fn policy_required_rejects_empty_token() {
525        let id = keypair();
526        let policy = Policy::required(id.node_id());
527        assert!(matches!(policy.check(&[]).unwrap_err(), UcanError::NoToken));
528    }
529
530    #[test]
531    fn policy_required_accepts_valid_token_from_the_right_issuer() {
532        let id = keypair();
533        let token = create("did:iss", "did:aud", vec![], &id, CreateOpts::default()).unwrap();
534        let policy = Policy::required(id.node_id());
535        assert!(policy.check(&token).is_ok());
536    }
537
538    #[test]
539    fn policy_required_rejects_token_from_the_wrong_issuer() {
540        let id = keypair();
541        let impostor = keypair();
542        let token = create(
543            "did:iss",
544            "did:aud",
545            vec![],
546            &impostor,
547            CreateOpts::default(),
548        )
549        .unwrap();
550        let policy = Policy::required(id.node_id());
551        assert!(matches!(
552            policy.check(&token).unwrap_err(),
553            UcanError::InvalidSignature
554        ));
555    }
556}