Skip to main content

atd_runtime/ucan/
verify.rs

1//! SP-capability-v2 Phase B.2 — UCAN-lite signature + chain verifier.
2//!
3//! Walks a UCAN chain root→leaf, verifying:
4//! 1. Each link's Ed25519 signature against its `iss`'s did:key
5//! 2. Chain integrity: `parent.aud == child.iss` between adjacent links
6//! 3. Attenuation: `child.args.caps ⊆ parent.args.caps`
7//! 4. Each link's `exp > now()`
8//! 5. The leaf's `aud == expected_audience` (audience pinning)
9//! 6. Chain depth ≤ `max_chain_depth`
10//! 7. No link's CID is in the revocation store
11//!
12//! On success returns the effective [`CapabilitySet`] — the leaf's
13//! `args.caps`, which after attenuation walking is already the
14//! intersection of all link caps.
15//!
16//! Spec: `docs/archive/superpowers/specs/2026-05-11-sp-capability-v2-design.md` §4.6 + §4.7
17
18use std::sync::Arc;
19use std::time::{SystemTime, UNIX_EPOCH};
20
21use base64::Engine;
22use base64::engine::general_purpose::URL_SAFE_NO_PAD;
23use ed25519_dalek::{Signature, Verifier, VerifyingKey};
24use sha2::{Digest, Sha256};
25
26use super::error::UcanVerifyError;
27use super::parse::parse_jwt;
28use super::revocation::UcanRevocationStore;
29use super::types::UcanPayload;
30use crate::capability::CapabilitySet;
31
32/// Configuration for chain verification.
33#[derive(Clone)]
34pub struct VerifyConfig {
35    /// Hard cap on chain depth. Default 5 per spec §4.6. Prevents
36    /// stack-exhaustion via pathologically deep proof chains.
37    pub max_chain_depth: u8,
38
39    /// The leaf's `aud` must equal this value. Bound by dispatch to
40    /// the connection's `client_id` (UDS) or the bearer's caller (HTTP).
41    pub expected_audience: String,
42
43    /// Optional revocation store. When `None`, no revocation check is
44    /// performed (suitable for tests / closed-system adopters). When
45    /// `Some`, every link's CID is consulted.
46    pub revocation_store: Option<Arc<dyn UcanRevocationStore>>,
47}
48
49impl VerifyConfig {
50    /// Construct a config with the default `max_chain_depth = 5` and
51    /// no revocation store.
52    pub fn new(expected_audience: impl Into<String>) -> Self {
53        Self {
54            max_chain_depth: 5,
55            expected_audience: expected_audience.into(),
56            revocation_store: None,
57        }
58    }
59}
60
61/// Compute the canonical UCAN CID for a JWT compact form.
62///
63/// v1: SHA-256(jwt_bytes) hex-encoded (lowercase). Not a true IPLD
64/// CIDv1 — the spec deliberately diverges from UCAN v1.0's DAG-CBOR
65/// path (see SP-capability-v2 §4.1 rationale). The hash is sufficient
66/// for revocation-store keying and is deterministic across implementations.
67pub fn compute_cid(jwt: &str) -> String {
68    let mut h = Sha256::new();
69    h.update(jwt.as_bytes());
70    hex_encode(&h.finalize())
71}
72
73fn hex_encode(bytes: &[u8]) -> String {
74    let mut out = String::with_capacity(bytes.len() * 2);
75    for b in bytes {
76        out.push_str(&format!("{b:02x}"));
77    }
78    out
79}
80
81/// Extract an Ed25519 public key from a `did:key:z<base58btc>` DID.
82///
83/// Multicodec prefix for Ed25519 in did:key is `0xed 0x01`; the
84/// remaining 32 bytes are the raw public key. Any other prefix
85/// (P-256, secp256k1, RSA) is rejected — UCAN-lite v1 is Ed25519-only.
86fn parse_did_key(did: &str, field: &'static str) -> Result<VerifyingKey, UcanVerifyError> {
87    let suffix = did
88        .strip_prefix("did:key:")
89        .ok_or_else(|| UcanVerifyError::MalformedDidKey {
90            field,
91            reason: "missing did:key: prefix".into(),
92        })?;
93    let (base, bytes) =
94        multibase::decode(suffix).map_err(|e| UcanVerifyError::MalformedDidKey {
95            field,
96            reason: format!("multibase decode: {e}"),
97        })?;
98    if base != multibase::Base::Base58Btc {
99        return Err(UcanVerifyError::MalformedDidKey {
100            field,
101            reason: format!("expected base58btc multibase prefix 'z', got {base:?}"),
102        });
103    }
104    if bytes.len() != 34 {
105        return Err(UcanVerifyError::MalformedDidKey {
106            field,
107            reason: format!(
108                "expected 34 bytes (2-byte multicodec + 32-byte key), got {}",
109                bytes.len()
110            ),
111        });
112    }
113    if bytes[0..2] != [0xed, 0x01] {
114        return Err(UcanVerifyError::MalformedDidKey {
115            field,
116            reason: format!(
117                "non-Ed25519 multicodec prefix: 0x{:02x}{:02x} (Ed25519 is 0xed01)",
118                bytes[0], bytes[1]
119            ),
120        });
121    }
122    let key_arr: [u8; 32] = bytes[2..34]
123        .try_into()
124        .expect("just bounds-checked: bytes has 34 bytes, [2..34] is 32");
125    VerifyingKey::from_bytes(&key_arr).map_err(|e| UcanVerifyError::MalformedDidKey {
126        field,
127        reason: format!("Ed25519 key bytes invalid: {e}"),
128    })
129}
130
131/// Verify the Ed25519 signature on a JWT compact form against the
132/// public key encoded in its `iss` did:key.
133fn verify_signature(jwt: &str, payload: &UcanPayload) -> Result<(), UcanVerifyError> {
134    let cid = compute_cid(jwt);
135    let parts: Vec<&str> = jwt.split('.').collect();
136    // parse_jwt already guaranteed 3 segments by the time we get here,
137    // but assert defensively rather than panic.
138    if parts.len() != 3 {
139        return Err(UcanVerifyError::MalformedSignature {
140            cid: cid.clone(),
141            reason: format!(
142                "expected 3 JWT segments at verify-time, got {}",
143                parts.len()
144            ),
145        });
146    }
147    let signed_bytes = format!("{}.{}", parts[0], parts[1]).into_bytes();
148    let sig_bytes =
149        URL_SAFE_NO_PAD
150            .decode(parts[2])
151            .map_err(|e| UcanVerifyError::MalformedSignature {
152                cid: cid.clone(),
153                reason: format!("signature base64url decode: {e}"),
154            })?;
155    if sig_bytes.len() != 64 {
156        return Err(UcanVerifyError::MalformedSignature {
157            cid: cid.clone(),
158            reason: format!(
159                "Ed25519 signature must be 64 bytes, got {}",
160                sig_bytes.len()
161            ),
162        });
163    }
164    let sig =
165        Signature::from_slice(&sig_bytes).map_err(|e| UcanVerifyError::MalformedSignature {
166            cid: cid.clone(),
167            reason: format!("signature parse: {e}"),
168        })?;
169
170    let pubkey = parse_did_key(&payload.iss, "iss")?;
171    pubkey
172        .verify(&signed_bytes, &sig)
173        .map_err(|_| UcanVerifyError::BadSignature { cid })?;
174    Ok(())
175}
176
177/// One parsed link in the chain — keeps the raw JWT around for CID
178/// computation + signature verification re-checks.
179struct Link {
180    jwt: String,
181    payload: UcanPayload,
182}
183
184impl Link {
185    fn cid(&self) -> String {
186        compute_cid(&self.jwt)
187    }
188}
189
190/// Walk a leaf UCAN's `prf` chain bottom-up (leaf → root) and return
191/// the links ordered ROOT-FIRST.
192///
193/// Rejects multi-parent UCANs (`prf.len() > 1`) — v1 supports
194/// single-chain only. Rejects chains longer than `max_chain_depth`.
195fn collect_chain(leaf_jwt: &str, max_depth: u8) -> Result<Vec<Link>, UcanVerifyError> {
196    let mut leaf_first: Vec<Link> = Vec::new();
197    let mut cur_jwt = leaf_jwt.to_string();
198    loop {
199        let payload = parse_jwt(&cur_jwt)?;
200        if payload.prf.len() > 1 {
201            return Err(UcanVerifyError::MultiParentNotSupported {
202                cid: compute_cid(&cur_jwt),
203                n_parents: payload.prf.len(),
204            });
205        }
206        let next = payload.prf.first().cloned();
207        leaf_first.push(Link {
208            jwt: cur_jwt,
209            payload,
210        });
211        // Depth check: bail before we waste a parse on link N+1 when
212        // we've already accepted `max_depth` links.
213        if leaf_first.len() as u8 > max_depth {
214            return Err(UcanVerifyError::ChainTooDeep {
215                depth: leaf_first.len() as u8,
216                max: max_depth,
217            });
218        }
219        match next {
220            Some(parent_jwt) => cur_jwt = parent_jwt,
221            None => break,
222        }
223    }
224    leaf_first.reverse(); // → root-first
225    Ok(leaf_first)
226}
227
228/// Verify a single leaf UCAN-lite JWT and return the effective
229/// capability set.
230///
231/// Performs all seven checks in §4.6 in this order: chain assembly +
232/// depth → per-link signature → expiry → chain integrity (aud ↔ iss
233/// between adjacent links) → attenuation → audience pin (leaf.aud) →
234/// revocation. Failures short-circuit at the first violation.
235pub fn verify_jwt(
236    leaf_jwt: &str,
237    cfg: &VerifyConfig,
238    now: SystemTime,
239) -> Result<CapabilitySet, UcanVerifyError> {
240    let chain = collect_chain(leaf_jwt, cfg.max_chain_depth)?;
241    let now_secs: i64 = now
242        .duration_since(UNIX_EPOCH)
243        .map(|d| d.as_secs() as i64)
244        .unwrap_or(0);
245
246    // (1) Per-link signature verification.
247    for link in &chain {
248        verify_signature(&link.jwt, &link.payload)?;
249    }
250
251    // (2) Expiry.
252    for link in &chain {
253        if link.payload.exp <= now_secs {
254            return Err(UcanVerifyError::Expired {
255                cid: link.cid(),
256                exp: link.payload.exp,
257                now: now_secs,
258            });
259        }
260    }
261
262    // (3) Chain integrity + (4) attenuation, walking root → leaf.
263    for i in 1..chain.len() {
264        let parent = &chain[i - 1];
265        let child = &chain[i];
266        if parent.payload.aud != child.payload.iss {
267            return Err(UcanVerifyError::ChainBroken {
268                parent_cid: parent.cid(),
269                parent_aud: parent.payload.aud.clone(),
270                child_cid: child.cid(),
271                child_iss: child.payload.iss.clone(),
272            });
273        }
274        // Attenuation: every child cap must appear in the parent's caps.
275        let parent_caps: &Vec<String> = &parent.payload.args.caps;
276        let child_caps: &Vec<String> = &child.payload.args.caps;
277        for c in child_caps {
278            if !parent_caps.contains(c) {
279                return Err(UcanVerifyError::WideningAttenuation {
280                    cid: child.cid(),
281                    parent: parent_caps.clone(),
282                    child: child_caps.clone(),
283                });
284            }
285        }
286    }
287
288    // (5) Audience pin on leaf.
289    let leaf = chain
290        .last()
291        .expect("collect_chain returns at least one link");
292    if leaf.payload.aud != cfg.expected_audience {
293        return Err(UcanVerifyError::AudienceMismatch {
294            leaf_aud: leaf.payload.aud.clone(),
295            expected: cfg.expected_audience.clone(),
296        });
297    }
298
299    // (6) Revocation check on every link.
300    if let Some(store) = &cfg.revocation_store {
301        for link in &chain {
302            let cid = link.cid();
303            if store.is_revoked(&cid) {
304                return Err(UcanVerifyError::Revoked { cid });
305            }
306        }
307    }
308
309    // (7) Effective caps = leaf's caps (already attenuation-validated).
310    Ok(CapabilitySet::from_iter(leaf.payload.args.caps.clone()))
311}
312
313/// Verify multiple independent UCAN chains and union their capability
314/// sets. Spec §5.3 — a Hello carrying N roots gets each verified
315/// independently; granted = ∪ each_chain's_effective_caps.
316///
317/// All chains must verify successfully; one failure rejects the whole
318/// Hello (no partial grants).
319pub fn verify_tokens(
320    tokens: &[String],
321    cfg: &VerifyConfig,
322    now: SystemTime,
323) -> Result<CapabilitySet, UcanVerifyError> {
324    let mut acc = CapabilitySet::default();
325    for tok in tokens {
326        let chain_caps = verify_jwt(tok, cfg, now)?;
327        acc = acc.union(&chain_caps);
328    }
329    Ok(acc)
330}
331
332// =================== TESTS ===================
333
334#[cfg(test)]
335mod tests {
336    //! Phase B.2 verify-stage tests — spec §8.1 cases 5-11 (the chain
337    //! walker / signature / attenuation / revocation set).
338    //!
339    //! `test_helpers::build_chain` signs each link with a fresh Ed25519
340    //! keypair derived deterministically from the link's depth so test
341    //! assertions can name specific DIDs in advance.
342
343    use super::*;
344    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
345    use ed25519_dalek::{Signer, SigningKey};
346    use serde_json::json;
347    use std::sync::Mutex;
348    use std::time::Duration;
349
350    // ---- helpers -----------------------------------------------------------
351
352    fn signing_key_for_seed(seed: u8) -> SigningKey {
353        let mut bytes = [0u8; 32];
354        bytes[0] = seed;
355        SigningKey::from_bytes(&bytes)
356    }
357
358    /// Encode `<multicodec-prefix><raw-32-byte-pubkey>` as
359    /// `did:key:z<base58btc-multibase>`.
360    fn did_key_for(sk: &SigningKey) -> String {
361        let raw = sk.verifying_key().to_bytes();
362        let mut prefixed = Vec::with_capacity(34);
363        prefixed.extend_from_slice(&[0xed, 0x01]);
364        prefixed.extend_from_slice(&raw);
365        let mb = multibase::encode(multibase::Base::Base58Btc, &prefixed);
366        format!("did:key:{mb}")
367    }
368
369    /// Build one UCAN JWT compact form signed by `sk`. The header is
370    /// canonical (alg=EdDSA, typ=ucan/1.0+jwt, ucv=1.0); `payload` is
371    /// passed in to allow per-test mutation. The signature is computed
372    /// over `<header>.<payload>` per JWT compact-form rules.
373    fn build_jwt(payload: serde_json::Value, sk: &SigningKey) -> String {
374        let header = json!({"alg": "EdDSA", "typ": "ucan/1.0+jwt", "ucv": "1.0"});
375        let h = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
376        let p = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap());
377        let signed = format!("{h}.{p}");
378        let sig = sk.sign(signed.as_bytes());
379        let s = URL_SAFE_NO_PAD.encode(sig.to_bytes());
380        format!("{h}.{p}.{s}")
381    }
382
383    fn future_exp() -> i64 {
384        let now = SystemTime::now()
385            .duration_since(UNIX_EPOCH)
386            .unwrap()
387            .as_secs() as i64;
388        now + 3600
389    }
390
391    /// A minimal payload for cases where the test doesn't care about
392    /// most fields.
393    fn payload_with(
394        iss: &str,
395        aud: &str,
396        caps: &[&str],
397        prf: &[String],
398        exp: i64,
399    ) -> serde_json::Value {
400        json!({
401            "iss":  iss,
402            "aud":  aud,
403            "sub":  iss,
404            "cmd":  "atd-cap",
405            "args": { "caps": caps, "with": [] },
406            "nonce": "test-nonce-fixed-value",
407            "exp":  exp,
408            "prf":  prf
409        })
410    }
411
412    /// A fake revocation store for tests. Lock-protected so the test
413    /// can mutate the revoked set.
414    #[derive(Debug, Default)]
415    struct MockRevocationStore {
416        revoked: Mutex<Vec<String>>,
417    }
418    impl MockRevocationStore {
419        fn revoke(&self, cid: &str) {
420            self.revoked.lock().unwrap().push(cid.to_string());
421        }
422    }
423    impl UcanRevocationStore for MockRevocationStore {
424        fn is_revoked(&self, cid: &str) -> bool {
425            self.revoked.lock().unwrap().iter().any(|c| c == cid)
426        }
427    }
428
429    // ---- spec §8.1 cases ---------------------------------------------------
430
431    #[test]
432    fn verify_well_formed_single_link_chain_succeeds() {
433        // Baseline — a root UCAN signed by the resource owner, with no prf.
434        let sk_a = signing_key_for_seed(1);
435        let sk_b = signing_key_for_seed(2);
436        let p = payload_with(
437            &did_key_for(&sk_a),
438            &did_key_for(&sk_b),
439            &["records:read"],
440            &[],
441            future_exp(),
442        );
443        let jwt = build_jwt(p, &sk_a);
444        let cfg = VerifyConfig::new(did_key_for(&sk_b));
445        let caps = verify_jwt(&jwt, &cfg, SystemTime::now()).expect("baseline must verify");
446        assert!(caps.contains("records:read"));
447    }
448
449    #[test]
450    fn verify_signature_with_wrong_key_rejects() {
451        // Issuer DID claims sk_a, but the JWT was signed by sk_x.
452        let sk_a = signing_key_for_seed(1);
453        let sk_b = signing_key_for_seed(2);
454        let sk_x = signing_key_for_seed(99);
455        let p = payload_with(
456            &did_key_for(&sk_a), // iss claims to be A
457            &did_key_for(&sk_b),
458            &["records:read"],
459            &[],
460            future_exp(),
461        );
462        let jwt = build_jwt(p, &sk_x); // but signed by X
463        let cfg = VerifyConfig::new(did_key_for(&sk_b));
464        match verify_jwt(&jwt, &cfg, SystemTime::now()) {
465            Err(UcanVerifyError::BadSignature { .. }) => {}
466            other => panic!("expected BadSignature, got {other:?}"),
467        }
468    }
469
470    #[test]
471    fn expired_token_returns_err_expired() {
472        let sk_a = signing_key_for_seed(1);
473        let sk_b = signing_key_for_seed(2);
474        let past_exp = (SystemTime::now() - Duration::from_secs(3600))
475            .duration_since(UNIX_EPOCH)
476            .unwrap()
477            .as_secs() as i64;
478        let p = payload_with(
479            &did_key_for(&sk_a),
480            &did_key_for(&sk_b),
481            &["records:read"],
482            &[],
483            past_exp,
484        );
485        let jwt = build_jwt(p, &sk_a);
486        let cfg = VerifyConfig::new(did_key_for(&sk_b));
487        match verify_jwt(&jwt, &cfg, SystemTime::now()) {
488            Err(UcanVerifyError::Expired { exp, now, .. }) => {
489                assert!(exp <= now, "exp should be ≤ now in the Expired error");
490            }
491            other => panic!("expected Expired, got {other:?}"),
492        }
493    }
494
495    #[test]
496    fn chain_depth_exceeded_rejects() {
497        // Build a 6-deep chain (root → 5 children); default max is 5.
498        let sks: Vec<SigningKey> = (0..6).map(|i| signing_key_for_seed(i + 1)).collect();
499        let exp = future_exp();
500        // Build root → leaf, accumulating prf.
501        let mut current_prf: Vec<String> = vec![];
502        let mut latest_jwt = String::new();
503        for i in 0..6 {
504            let iss = did_key_for(&sks[i]);
505            let aud = did_key_for(&sks[(i + 1) % 6]); // arbitrary; chain validity isn't what we're testing
506            let p = payload_with(&iss, &aud, &["records:read"], &current_prf, exp);
507            latest_jwt = build_jwt(p, &sks[i]);
508            current_prf = vec![latest_jwt.clone()];
509        }
510        let cfg = VerifyConfig::new(did_key_for(&sks[0])); // bogus audience for this test
511        match verify_jwt(&latest_jwt, &cfg, SystemTime::now()) {
512            Err(UcanVerifyError::ChainTooDeep { depth, max }) => {
513                assert!(depth > max, "depth={depth} must exceed max={max}");
514                assert_eq!(max, 5);
515            }
516            other => panic!("expected ChainTooDeep, got {other:?}"),
517        }
518    }
519
520    #[test]
521    fn audience_mismatch_rejects() {
522        let sk_a = signing_key_for_seed(1);
523        let sk_b = signing_key_for_seed(2);
524        let sk_c = signing_key_for_seed(3);
525        let p = payload_with(
526            &did_key_for(&sk_a),
527            &did_key_for(&sk_b),
528            &["records:read"],
529            &[],
530            future_exp(),
531        );
532        let jwt = build_jwt(p, &sk_a);
533        let cfg = VerifyConfig::new(did_key_for(&sk_c)); // expect C, but leaf.aud = B
534        match verify_jwt(&jwt, &cfg, SystemTime::now()) {
535            Err(UcanVerifyError::AudienceMismatch { leaf_aud, expected }) => {
536                assert_eq!(leaf_aud, did_key_for(&sk_b));
537                assert_eq!(expected, did_key_for(&sk_c));
538            }
539            other => panic!("expected AudienceMismatch, got {other:?}"),
540        }
541    }
542
543    #[test]
544    fn attenuation_intersect_succeeds() {
545        // U → A grants [records:read, summary:read, fs.write]
546        //   → B grants [records:read, summary:read]   (drops fs.write)
547        //     → C grants [records:read]               (drops summary:read)
548        // Effective: [records:read]
549        let sk_u = signing_key_for_seed(1);
550        let sk_a = signing_key_for_seed(2);
551        let sk_b = signing_key_for_seed(3);
552        let sk_c = signing_key_for_seed(4);
553        let exp = future_exp();
554
555        let root = payload_with(
556            &did_key_for(&sk_u),
557            &did_key_for(&sk_a),
558            &["records:read", "summary:read", "fs.write"],
559            &[],
560            exp,
561        );
562        let root_jwt = build_jwt(root, &sk_u);
563
564        let mid = payload_with(
565            &did_key_for(&sk_a),
566            &did_key_for(&sk_b),
567            &["records:read", "summary:read"],
568            std::slice::from_ref(&root_jwt),
569            exp,
570        );
571        let mid_jwt = build_jwt(mid, &sk_a);
572
573        let leaf = payload_with(
574            &did_key_for(&sk_b),
575            &did_key_for(&sk_c),
576            &["records:read"],
577            std::slice::from_ref(&mid_jwt),
578            exp,
579        );
580        let leaf_jwt = build_jwt(leaf, &sk_b);
581
582        let cfg = VerifyConfig::new(did_key_for(&sk_c));
583        let caps = verify_jwt(&leaf_jwt, &cfg, SystemTime::now())
584            .expect("3-link attenuated chain must verify");
585        assert!(caps.contains("records:read"));
586        assert!(!caps.contains("summary:read"));
587        assert!(!caps.contains("fs.write"));
588    }
589
590    #[test]
591    fn attenuation_widening_rejects() {
592        // Parent grants [a, b, c]; child claims [a, b, c, d]. Widening
593        // — child must not gain a cap the parent didn't grant.
594        let sk_u = signing_key_for_seed(1);
595        let sk_a = signing_key_for_seed(2);
596        let sk_b = signing_key_for_seed(3);
597        let exp = future_exp();
598
599        let root = payload_with(
600            &did_key_for(&sk_u),
601            &did_key_for(&sk_a),
602            &["a", "b", "c"],
603            &[],
604            exp,
605        );
606        let root_jwt = build_jwt(root, &sk_u);
607
608        let leaf = payload_with(
609            &did_key_for(&sk_a),
610            &did_key_for(&sk_b),
611            &["a", "b", "c", "d"], // adds "d" — widening
612            std::slice::from_ref(&root_jwt),
613            exp,
614        );
615        let leaf_jwt = build_jwt(leaf, &sk_a);
616
617        let cfg = VerifyConfig::new(did_key_for(&sk_b));
618        match verify_jwt(&leaf_jwt, &cfg, SystemTime::now()) {
619            Err(UcanVerifyError::WideningAttenuation { parent, child, .. }) => {
620                assert_eq!(parent, vec!["a", "b", "c"]);
621                assert_eq!(child, vec!["a", "b", "c", "d"]);
622            }
623            other => panic!("expected WideningAttenuation, got {other:?}"),
624        }
625    }
626
627    #[test]
628    fn revoked_cid_rejects() {
629        let sk_a = signing_key_for_seed(1);
630        let sk_b = signing_key_for_seed(2);
631        let p = payload_with(
632            &did_key_for(&sk_a),
633            &did_key_for(&sk_b),
634            &["records:read"],
635            &[],
636            future_exp(),
637        );
638        let jwt = build_jwt(p, &sk_a);
639        let cid = compute_cid(&jwt);
640
641        let store = Arc::new(MockRevocationStore::default());
642        store.revoke(&cid);
643
644        let mut cfg = VerifyConfig::new(did_key_for(&sk_b));
645        cfg.revocation_store = Some(store as Arc<dyn UcanRevocationStore>);
646
647        match verify_jwt(&jwt, &cfg, SystemTime::now()) {
648            Err(UcanVerifyError::Revoked { cid: c }) => assert_eq!(c, cid),
649            other => panic!("expected Revoked, got {other:?}"),
650        }
651    }
652
653    // ---- Phase E: real InMemoryUcanRevocationStore × multi-link chain ----
654
655    #[test]
656    fn revoking_root_cid_via_in_memory_store_rejects_3_link_descendant() {
657        // U → A → B → C 3-link chain. Revoke the ROOT (U's UCAN) and
658        // confirm the LEAF (C's request) rejects with the root's CID
659        // surfaced. Exercises the real InMemoryUcanRevocationStore impl
660        // (vs the MockRevocationStore in `revoked_cid_rejects`) and the
661        // verifier's "consult on every link" guarantee.
662        use super::super::InMemoryUcanRevocationStore;
663
664        let sk_u = signing_key_for_seed(1);
665        let sk_a = signing_key_for_seed(2);
666        let sk_b = signing_key_for_seed(3);
667        let sk_c = signing_key_for_seed(4);
668        let exp = future_exp();
669
670        let root = payload_with(
671            &did_key_for(&sk_u),
672            &did_key_for(&sk_a),
673            &["records:read"],
674            &[],
675            exp,
676        );
677        let root_jwt = build_jwt(root, &sk_u);
678        let root_cid = compute_cid(&root_jwt);
679
680        let mid = payload_with(
681            &did_key_for(&sk_a),
682            &did_key_for(&sk_b),
683            &["records:read"],
684            std::slice::from_ref(&root_jwt),
685            exp,
686        );
687        let mid_jwt = build_jwt(mid, &sk_a);
688
689        let leaf = payload_with(
690            &did_key_for(&sk_b),
691            &did_key_for(&sk_c),
692            &["records:read"],
693            &[mid_jwt],
694            exp,
695        );
696        let leaf_jwt = build_jwt(leaf, &sk_b);
697
698        // Pre-revoke: full 3-link chain verifies.
699        let store = Arc::new(InMemoryUcanRevocationStore::new());
700        let mut cfg = VerifyConfig::new(did_key_for(&sk_c));
701        cfg.revocation_store = Some(store.clone() as Arc<dyn UcanRevocationStore>);
702        assert!(verify_jwt(&leaf_jwt, &cfg, SystemTime::now()).is_ok());
703
704        // Revoke ROOT → leaf request rejects with root's CID.
705        store.revoke(&root_cid);
706        match verify_jwt(&leaf_jwt, &cfg, SystemTime::now()) {
707            Err(UcanVerifyError::Revoked { cid }) => assert_eq!(cid, root_cid),
708            other => panic!("expected Revoked at root cid, got {other:?}"),
709        }
710    }
711
712    // ---- additional coverage ----------------------------------------------
713
714    #[test]
715    fn chain_broken_when_parent_aud_ne_child_iss_rejects() {
716        // Build a chain where parent's aud is sk_X but child's iss is sk_Y
717        // (sk_Y signs the child to make the signature valid, but the
718        // delegation chain is broken).
719        let sk_u = signing_key_for_seed(1);
720        let sk_x = signing_key_for_seed(10); // legitimate child of U
721        let sk_y = signing_key_for_seed(20); // a different agent
722        let sk_b = signing_key_for_seed(30);
723        let exp = future_exp();
724
725        let root = payload_with(
726            &did_key_for(&sk_u),
727            &did_key_for(&sk_x),
728            &["records:read"],
729            &[],
730            exp,
731        );
732        let root_jwt = build_jwt(root, &sk_u);
733
734        // Child's iss claims to be Y (not X). Y signs it — signature OK.
735        // But parent.aud (X) ≠ child.iss (Y) → ChainBroken.
736        let leaf = payload_with(
737            &did_key_for(&sk_y),
738            &did_key_for(&sk_b),
739            &["records:read"],
740            &[root_jwt],
741            exp,
742        );
743        let leaf_jwt = build_jwt(leaf, &sk_y);
744
745        let cfg = VerifyConfig::new(did_key_for(&sk_b));
746        match verify_jwt(&leaf_jwt, &cfg, SystemTime::now()) {
747            Err(UcanVerifyError::ChainBroken { .. }) => {}
748            other => panic!("expected ChainBroken, got {other:?}"),
749        }
750    }
751
752    #[test]
753    fn multi_parent_prf_rejects() {
754        let sk_a = signing_key_for_seed(1);
755        let sk_b = signing_key_for_seed(2);
756        // Build a payload with two parent JWTs in prf — unsupported in v1.
757        let leaf = json!({
758            "iss":   did_key_for(&sk_a),
759            "aud":   did_key_for(&sk_b),
760            "sub":   did_key_for(&sk_a),
761            "cmd":   "atd-cap",
762            "args":  { "caps": ["records:read"], "with": [] },
763            "nonce": "nonce",
764            "exp":   future_exp(),
765            "prf":   ["parent1.jwt.placeholder", "parent2.jwt.placeholder"]
766        });
767        let jwt = build_jwt(leaf, &sk_a);
768        let cfg = VerifyConfig::new(did_key_for(&sk_b));
769        match verify_jwt(&jwt, &cfg, SystemTime::now()) {
770            Err(UcanVerifyError::MultiParentNotSupported { n_parents, .. }) => {
771                assert_eq!(n_parents, 2);
772            }
773            other => panic!("expected MultiParentNotSupported, got {other:?}"),
774        }
775    }
776
777    #[test]
778    fn verify_tokens_unions_multi_root_results() {
779        // Spec §5.3: two independent root chains, each granting one cap.
780        // Granted set = union.
781        let sk_u1 = signing_key_for_seed(1);
782        let sk_u2 = signing_key_for_seed(2);
783        let sk_b = signing_key_for_seed(99);
784        let exp = future_exp();
785
786        let p1 = payload_with(
787            &did_key_for(&sk_u1),
788            &did_key_for(&sk_b),
789            &["records:read"],
790            &[],
791            exp,
792        );
793        let jwt1 = build_jwt(p1, &sk_u1);
794
795        let p2 = payload_with(
796            &did_key_for(&sk_u2),
797            &did_key_for(&sk_b),
798            &["summary:read"],
799            &[],
800            exp,
801        );
802        let jwt2 = build_jwt(p2, &sk_u2);
803
804        let cfg = VerifyConfig::new(did_key_for(&sk_b));
805        let caps = verify_tokens(&[jwt1, jwt2], &cfg, SystemTime::now())
806            .expect("two independent valid chains must verify and union");
807        assert!(caps.contains("records:read"));
808        assert!(caps.contains("summary:read"));
809    }
810
811    #[test]
812    fn malformed_did_key_at_iss_rejects_at_verify_stage() {
813        // parse_jwt requires only the "did:key:z" prefix — invalid
814        // multibase payload after that prefix is caught at signature-
815        // verify time when we try to extract the public key.
816        let sk_a = signing_key_for_seed(1);
817        let sk_b = signing_key_for_seed(2);
818        let p = json!({
819            "iss":   "did:key:zNOTAREALKEY", // valid prefix; garbage payload
820            "aud":   did_key_for(&sk_b),
821            "sub":   did_key_for(&sk_a),
822            "cmd":   "atd-cap",
823            "args":  { "caps": ["records:read"], "with": [] },
824            "nonce": "nonce",
825            "exp":   future_exp(),
826        });
827        let jwt = build_jwt(p, &sk_a);
828        let cfg = VerifyConfig::new(did_key_for(&sk_b));
829        match verify_jwt(&jwt, &cfg, SystemTime::now()) {
830            Err(UcanVerifyError::MalformedDidKey { .. }) => {}
831            other => panic!("expected MalformedDidKey, got {other:?}"),
832        }
833    }
834}