Skip to main content

atd_runtime/ucan/
parse.rs

1//! SP-capability-v2 Phase B.1 — JWT compact-form structural decoder.
2//!
3//! Parses a UCAN-lite token (`<header>.<payload>.<signature>`) into a
4//! [`UcanPayload`]. **Does not verify the signature** — that's Phase B.2
5//! (`ucan::verify::verify_chain`). Pure structural validation: alg / typ /
6//! ucv / cmd / DID-method-prefix only.
7//!
8//! Spec: `docs/archive/superpowers/specs/2026-05-11-sp-capability-v2-design.md` §4.1, §4.3, §4.4, §4.5
9
10use base64::Engine;
11use base64::engine::general_purpose::URL_SAFE_NO_PAD;
12
13use super::error::UcanParseError;
14use super::types::{UcanHeader, UcanPayload};
15
16/// Decode a JWT compact-form UCAN-lite token into its payload.
17///
18/// Returns the [`UcanPayload`] on structural success. The header is
19/// discarded after validation (the caller never needs `alg` etc. once
20/// the parser has accepted it). Signature bytes are also discarded by
21/// this entry point; Phase B.2's verifier takes the raw token and
22/// re-splits to obtain the signed bytes.
23///
24/// # Errors
25///
26/// [`UcanParseError`] — every variant maps to wire code
27/// `ERR_UCAN_INVALID = 1010`. Always `retryable: false`.
28///
29/// # Out of scope
30///
31/// - Signature verification (Phase B.2)
32/// - Chain attenuation, audience pinning, expiry, depth limit (Phase B.2)
33/// - Revocation store consultation (Phase B.2)
34pub fn parse_jwt(token: &str) -> Result<UcanPayload, UcanParseError> {
35    let parts: Vec<&str> = token.split('.').collect();
36    if parts.len() != 3 {
37        return Err(UcanParseError::MalformedJwt(parts.len()));
38    }
39    let (header_b64, payload_b64, _sig_b64) = (parts[0], parts[1], parts[2]);
40
41    // Header: base64url-decode → JSON → validate alg / typ / ucv.
42    let header_bytes = URL_SAFE_NO_PAD
43        .decode(header_b64)
44        .map_err(|e| UcanParseError::base64("header", e))?;
45    let header: UcanHeader =
46        serde_json::from_slice(&header_bytes).map_err(|e| UcanParseError::json("header", e))?;
47
48    if header.alg != "EdDSA" {
49        return Err(UcanParseError::UnsupportedAlg(header.alg));
50    }
51    if header.typ != "ucan/1.0+jwt" {
52        return Err(UcanParseError::UnsupportedTyp(header.typ));
53    }
54    if header.ucv != "1.0" {
55        return Err(UcanParseError::UnsupportedUcv(header.ucv));
56    }
57
58    // Payload: base64url-decode → JSON → validate cmd + DID-method prefixes.
59    let payload_bytes = URL_SAFE_NO_PAD
60        .decode(payload_b64)
61        .map_err(|e| UcanParseError::base64("payload", e))?;
62    let payload: UcanPayload =
63        serde_json::from_slice(&payload_bytes).map_err(|e| UcanParseError::json("payload", e))?;
64
65    if payload.cmd != "atd-cap" {
66        return Err(UcanParseError::NonAtdCap(payload.cmd));
67    }
68    require_did_key(&payload.iss, "iss")?;
69    require_did_key(&payload.aud, "aud")?;
70
71    Ok(payload)
72}
73
74/// `did:key:z<base58btc multibase>` is the only DID method accepted in
75/// v1 (spec §4.4). `did:web` / `did:plc` / `did:agent` are explicit
76/// non-goals deferred to follow-up SPs.
77fn require_did_key(did: &str, field: &'static str) -> Result<(), UcanParseError> {
78    if did.starts_with("did:key:z") {
79        Ok(())
80    } else {
81        Err(UcanParseError::UnsupportedDidMethod {
82            field,
83            did: did.to_string(),
84        })
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    //! Phase B.1 parse-stage tests. Spec §8.1 — first 4 unit cases.
91    //!
92    //! Each test constructs the JWT compact form via the local
93    //! `build_jwt` helper. Signatures are arbitrary bytes (`b"sig"`)
94    //! because Phase B.1 does not verify them — that's Phase B.2.
95
96    use super::*;
97    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
98    use serde_json::json;
99
100    fn build_jwt(header: serde_json::Value, payload: serde_json::Value) -> String {
101        let h = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
102        let p = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap());
103        let s = URL_SAFE_NO_PAD.encode(b"signature-placeholder");
104        format!("{h}.{p}.{s}")
105    }
106
107    fn canonical_header() -> serde_json::Value {
108        json!({ "alg": "EdDSA", "typ": "ucan/1.0+jwt", "ucv": "1.0" })
109    }
110
111    fn canonical_payload() -> serde_json::Value {
112        json!({
113            "iss":  "did:key:z6MkA_abbreviated",
114            "aud":  "did:key:z6MkB_abbreviated",
115            "sub":  "did:key:z6MkU_abbreviated",
116            "cmd":  "atd-cap",
117            "args": { "caps": ["records:read"], "with": [{"patient": "Patient/X"}] },
118            "nonce": "Mv3K0000000000000000",
119            "exp":  9999999999_i64
120        })
121    }
122
123    #[test]
124    fn parse_well_formed_token_succeeds() {
125        let tok = build_jwt(canonical_header(), canonical_payload());
126        let parsed = parse_jwt(&tok).expect("well-formed UCAN should parse");
127        assert_eq!(parsed.iss, "did:key:z6MkA_abbreviated");
128        assert_eq!(parsed.aud, "did:key:z6MkB_abbreviated");
129        assert_eq!(parsed.cmd, "atd-cap");
130        assert_eq!(parsed.args.caps, vec!["records:read"]);
131        assert_eq!(parsed.exp, 9999999999_i64);
132    }
133
134    #[test]
135    fn parse_unsupported_alg_rejects() {
136        let mut h = canonical_header();
137        h["alg"] = json!("RS256");
138        let tok = build_jwt(h, canonical_payload());
139        match parse_jwt(&tok) {
140            Err(UcanParseError::UnsupportedAlg(alg)) => assert_eq!(alg, "RS256"),
141            other => panic!("expected UnsupportedAlg, got {other:?}"),
142        }
143    }
144
145    #[test]
146    fn parse_non_did_key_issuer_rejects() {
147        let mut p = canonical_payload();
148        p["iss"] = json!("did:web:example.org");
149        let tok = build_jwt(canonical_header(), p);
150        match parse_jwt(&tok) {
151            Err(UcanParseError::UnsupportedDidMethod { field, did }) => {
152                assert_eq!(field, "iss");
153                assert_eq!(did, "did:web:example.org");
154            }
155            other => panic!("expected UnsupportedDidMethod(iss), got {other:?}"),
156        }
157    }
158
159    #[test]
160    fn parse_malformed_jwt_rejects() {
161        // Only 2 dot-segments — should fail before any decode.
162        let tok = "abc.def";
163        match parse_jwt(tok) {
164            Err(UcanParseError::MalformedJwt(n)) => assert_eq!(n, 2),
165            other => panic!("expected MalformedJwt(2), got {other:?}"),
166        }
167
168        // 4 segments — also rejected.
169        let tok4 = "a.b.c.d";
170        match parse_jwt(tok4) {
171            Err(UcanParseError::MalformedJwt(n)) => assert_eq!(n, 4),
172            other => panic!("expected MalformedJwt(4), got {other:?}"),
173        }
174    }
175
176    // ---- Additional defensive tests (not in spec §8.1 but valuable) ----
177
178    #[test]
179    fn parse_non_atd_cap_command_rejects() {
180        let mut p = canonical_payload();
181        p["cmd"] = json!("/bsky/post");
182        let tok = build_jwt(canonical_header(), p);
183        match parse_jwt(&tok) {
184            Err(UcanParseError::NonAtdCap(c)) => assert_eq!(c, "/bsky/post"),
185            other => panic!("expected NonAtdCap, got {other:?}"),
186        }
187    }
188
189    #[test]
190    fn parse_wrong_typ_rejects() {
191        let mut h = canonical_header();
192        h["typ"] = json!("JWT");
193        let tok = build_jwt(h, canonical_payload());
194        assert!(matches!(
195            parse_jwt(&tok),
196            Err(UcanParseError::UnsupportedTyp(_))
197        ));
198    }
199
200    #[test]
201    fn parse_wrong_ucv_rejects() {
202        let mut h = canonical_header();
203        h["ucv"] = json!("0.9");
204        let tok = build_jwt(h, canonical_payload());
205        assert!(matches!(
206            parse_jwt(&tok),
207            Err(UcanParseError::UnsupportedUcv(_))
208        ));
209    }
210
211    #[test]
212    fn parse_garbage_base64_in_header_rejects() {
213        let tok = "!!!.aaaa.bbbb";
214        assert!(matches!(
215            parse_jwt(tok),
216            Err(UcanParseError::Base64Decode {
217                segment: "header",
218                ..
219            })
220        ));
221    }
222
223    #[test]
224    fn parse_audience_non_did_key_rejects() {
225        let mut p = canonical_payload();
226        p["aud"] = json!("did:plc:abcdef");
227        let tok = build_jwt(canonical_header(), p);
228        match parse_jwt(&tok) {
229            Err(UcanParseError::UnsupportedDidMethod { field, .. }) => assert_eq!(field, "aud"),
230            other => panic!("expected UnsupportedDidMethod(aud), got {other:?}"),
231        }
232    }
233}