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    // SP-observability-completeness-v1 Axis C: attribute each effective cap
311    // to this chain's leaf issuer + depth (root = 0). Return type unchanged
312    // (CapabilitySet) — the provenance rides inside it, so no caller/test
313    // signature changes.
314    //
315    // Cost note (#9): provenance is built eagerly here, at verify time,
316    // regardless of whether an audit sink will read it. This is intentional —
317    // verify_jwt is the ONLY layer that still holds the chain (issuer + depth);
318    // those are discarded once we return the flat CapabilitySet, so the
319    // metadata cannot be reconstructed downstream. Gating construction on
320    // "is audit enabled" would require threading an audit flag from dispatch
321    // through the ucan layer (a cross-layer dependency) to save a per-Hello,
322    // O(caps) allocation — not worth the coupling. Per-connection, not
323    // per-call, so volume is low.
324    let caps = leaf.payload.args.caps.clone();
325    let issuer_did = leaf.payload.iss.clone();
326    let chain_depth = chain.len().saturating_sub(1) as u8;
327    let provenance = caps
328        .iter()
329        .map(|c| crate::audit::CapProvenance {
330            cap: c.clone(),
331            source: crate::audit::ProvSource::UcanChain {
332                issuer_did: issuer_did.clone(),
333                chain_depth,
334            },
335        })
336        .collect();
337    Ok(CapabilitySet::with_provenance(caps, provenance))
338}
339
340/// Verify multiple independent UCAN chains and union their capability
341/// sets. Spec §5.3 — a Hello carrying N roots gets each verified
342/// independently; granted = ∪ each_chain's_effective_caps.
343///
344/// All chains must verify successfully; one failure rejects the whole
345/// Hello (no partial grants).
346pub fn verify_tokens(
347    tokens: &[String],
348    cfg: &VerifyConfig,
349    now: SystemTime,
350) -> Result<CapabilitySet, UcanVerifyError> {
351    let mut acc = CapabilitySet::default();
352    for tok in tokens {
353        let chain_caps = verify_jwt(tok, cfg, now)?;
354        acc = acc.union(&chain_caps);
355    }
356    Ok(acc)
357}
358
359// =================== TESTS ===================
360
361#[cfg(test)]
362mod tests {
363    //! Phase B.2 verify-stage tests — spec §8.1 cases 5-11 (the chain
364    //! walker / signature / attenuation / revocation set).
365    //!
366    //! `test_helpers::build_chain` signs each link with a fresh Ed25519
367    //! keypair derived deterministically from the link's depth so test
368    //! assertions can name specific DIDs in advance.
369
370    use super::*;
371    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
372    use ed25519_dalek::{Signer, SigningKey};
373    use serde_json::json;
374    use std::sync::Mutex;
375    use std::time::Duration;
376
377    // ---- helpers -----------------------------------------------------------
378
379    fn signing_key_for_seed(seed: u8) -> SigningKey {
380        let mut bytes = [0u8; 32];
381        bytes[0] = seed;
382        SigningKey::from_bytes(&bytes)
383    }
384
385    /// Encode `<multicodec-prefix><raw-32-byte-pubkey>` as
386    /// `did:key:z<base58btc-multibase>`.
387    fn did_key_for(sk: &SigningKey) -> String {
388        let raw = sk.verifying_key().to_bytes();
389        let mut prefixed = Vec::with_capacity(34);
390        prefixed.extend_from_slice(&[0xed, 0x01]);
391        prefixed.extend_from_slice(&raw);
392        let mb = multibase::encode(multibase::Base::Base58Btc, &prefixed);
393        format!("did:key:{mb}")
394    }
395
396    /// Build one UCAN JWT compact form signed by `sk`. The header is
397    /// canonical (alg=EdDSA, typ=ucan/1.0+jwt, ucv=1.0); `payload` is
398    /// passed in to allow per-test mutation. The signature is computed
399    /// over `<header>.<payload>` per JWT compact-form rules.
400    fn build_jwt(payload: serde_json::Value, sk: &SigningKey) -> String {
401        let header = json!({"alg": "EdDSA", "typ": "ucan/1.0+jwt", "ucv": "1.0"});
402        let h = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
403        let p = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap());
404        let signed = format!("{h}.{p}");
405        let sig = sk.sign(signed.as_bytes());
406        let s = URL_SAFE_NO_PAD.encode(sig.to_bytes());
407        format!("{h}.{p}.{s}")
408    }
409
410    fn future_exp() -> i64 {
411        let now = SystemTime::now()
412            .duration_since(UNIX_EPOCH)
413            .unwrap()
414            .as_secs() as i64;
415        now + 3600
416    }
417
418    /// A minimal payload for cases where the test doesn't care about
419    /// most fields.
420    fn payload_with(
421        iss: &str,
422        aud: &str,
423        caps: &[&str],
424        prf: &[String],
425        exp: i64,
426    ) -> serde_json::Value {
427        json!({
428            "iss":  iss,
429            "aud":  aud,
430            "sub":  iss,
431            "cmd":  "atd-cap",
432            "args": { "caps": caps, "with": [] },
433            "nonce": "test-nonce-fixed-value",
434            "exp":  exp,
435            "prf":  prf
436        })
437    }
438
439    /// A fake revocation store for tests. Lock-protected so the test
440    /// can mutate the revoked set.
441    #[derive(Debug, Default)]
442    struct MockRevocationStore {
443        revoked: Mutex<Vec<String>>,
444    }
445    impl MockRevocationStore {
446        fn revoke(&self, cid: &str) {
447            self.revoked.lock().unwrap().push(cid.to_string());
448        }
449    }
450    impl UcanRevocationStore for MockRevocationStore {
451        fn is_revoked(&self, cid: &str) -> bool {
452            self.revoked.lock().unwrap().iter().any(|c| c == cid)
453        }
454    }
455
456    // ---- spec §8.1 cases ---------------------------------------------------
457
458    #[test]
459    fn verify_well_formed_single_link_chain_succeeds() {
460        // Baseline — a root UCAN signed by the resource owner, with no prf.
461        let sk_a = signing_key_for_seed(1);
462        let sk_b = signing_key_for_seed(2);
463        let p = payload_with(
464            &did_key_for(&sk_a),
465            &did_key_for(&sk_b),
466            &["records:read"],
467            &[],
468            future_exp(),
469        );
470        let jwt = build_jwt(p, &sk_a);
471        let cfg = VerifyConfig::new(did_key_for(&sk_b));
472        let caps = verify_jwt(&jwt, &cfg, SystemTime::now()).expect("baseline must verify");
473        assert!(caps.contains("records:read"));
474    }
475
476    #[test]
477    fn verify_signature_with_wrong_key_rejects() {
478        // Issuer DID claims sk_a, but the JWT was signed by sk_x.
479        let sk_a = signing_key_for_seed(1);
480        let sk_b = signing_key_for_seed(2);
481        let sk_x = signing_key_for_seed(99);
482        let p = payload_with(
483            &did_key_for(&sk_a), // iss claims to be A
484            &did_key_for(&sk_b),
485            &["records:read"],
486            &[],
487            future_exp(),
488        );
489        let jwt = build_jwt(p, &sk_x); // but signed by X
490        let cfg = VerifyConfig::new(did_key_for(&sk_b));
491        match verify_jwt(&jwt, &cfg, SystemTime::now()) {
492            Err(UcanVerifyError::BadSignature { .. }) => {}
493            other => panic!("expected BadSignature, got {other:?}"),
494        }
495    }
496
497    #[test]
498    fn expired_token_returns_err_expired() {
499        let sk_a = signing_key_for_seed(1);
500        let sk_b = signing_key_for_seed(2);
501        let past_exp = (SystemTime::now() - Duration::from_secs(3600))
502            .duration_since(UNIX_EPOCH)
503            .unwrap()
504            .as_secs() as i64;
505        let p = payload_with(
506            &did_key_for(&sk_a),
507            &did_key_for(&sk_b),
508            &["records:read"],
509            &[],
510            past_exp,
511        );
512        let jwt = build_jwt(p, &sk_a);
513        let cfg = VerifyConfig::new(did_key_for(&sk_b));
514        match verify_jwt(&jwt, &cfg, SystemTime::now()) {
515            Err(UcanVerifyError::Expired { exp, now, .. }) => {
516                assert!(exp <= now, "exp should be ≤ now in the Expired error");
517            }
518            other => panic!("expected Expired, got {other:?}"),
519        }
520    }
521
522    #[test]
523    fn chain_depth_exceeded_rejects() {
524        // Build a 6-deep chain (root → 5 children); default max is 5.
525        let sks: Vec<SigningKey> = (0..6).map(|i| signing_key_for_seed(i + 1)).collect();
526        let exp = future_exp();
527        // Build root → leaf, accumulating prf.
528        let mut current_prf: Vec<String> = vec![];
529        let mut latest_jwt = String::new();
530        for i in 0..6 {
531            let iss = did_key_for(&sks[i]);
532            let aud = did_key_for(&sks[(i + 1) % 6]); // arbitrary; chain validity isn't what we're testing
533            let p = payload_with(&iss, &aud, &["records:read"], &current_prf, exp);
534            latest_jwt = build_jwt(p, &sks[i]);
535            current_prf = vec![latest_jwt.clone()];
536        }
537        let cfg = VerifyConfig::new(did_key_for(&sks[0])); // bogus audience for this test
538        match verify_jwt(&latest_jwt, &cfg, SystemTime::now()) {
539            Err(UcanVerifyError::ChainTooDeep { depth, max }) => {
540                assert!(depth > max, "depth={depth} must exceed max={max}");
541                assert_eq!(max, 5);
542            }
543            other => panic!("expected ChainTooDeep, got {other:?}"),
544        }
545    }
546
547    #[test]
548    fn audience_mismatch_rejects() {
549        let sk_a = signing_key_for_seed(1);
550        let sk_b = signing_key_for_seed(2);
551        let sk_c = signing_key_for_seed(3);
552        let p = payload_with(
553            &did_key_for(&sk_a),
554            &did_key_for(&sk_b),
555            &["records:read"],
556            &[],
557            future_exp(),
558        );
559        let jwt = build_jwt(p, &sk_a);
560        let cfg = VerifyConfig::new(did_key_for(&sk_c)); // expect C, but leaf.aud = B
561        match verify_jwt(&jwt, &cfg, SystemTime::now()) {
562            Err(UcanVerifyError::AudienceMismatch { leaf_aud, expected }) => {
563                assert_eq!(leaf_aud, did_key_for(&sk_b));
564                assert_eq!(expected, did_key_for(&sk_c));
565            }
566            other => panic!("expected AudienceMismatch, got {other:?}"),
567        }
568    }
569
570    #[test]
571    fn attenuation_intersect_succeeds() {
572        // U → A grants [records:read, summary:read, fs.write]
573        //   → B grants [records:read, summary:read]   (drops fs.write)
574        //     → C grants [records:read]               (drops summary:read)
575        // Effective: [records:read]
576        let sk_u = signing_key_for_seed(1);
577        let sk_a = signing_key_for_seed(2);
578        let sk_b = signing_key_for_seed(3);
579        let sk_c = signing_key_for_seed(4);
580        let exp = future_exp();
581
582        let root = payload_with(
583            &did_key_for(&sk_u),
584            &did_key_for(&sk_a),
585            &["records:read", "summary:read", "fs.write"],
586            &[],
587            exp,
588        );
589        let root_jwt = build_jwt(root, &sk_u);
590
591        let mid = payload_with(
592            &did_key_for(&sk_a),
593            &did_key_for(&sk_b),
594            &["records:read", "summary:read"],
595            std::slice::from_ref(&root_jwt),
596            exp,
597        );
598        let mid_jwt = build_jwt(mid, &sk_a);
599
600        let leaf = payload_with(
601            &did_key_for(&sk_b),
602            &did_key_for(&sk_c),
603            &["records:read"],
604            std::slice::from_ref(&mid_jwt),
605            exp,
606        );
607        let leaf_jwt = build_jwt(leaf, &sk_b);
608
609        let cfg = VerifyConfig::new(did_key_for(&sk_c));
610        let caps = verify_jwt(&leaf_jwt, &cfg, SystemTime::now())
611            .expect("3-link attenuated chain must verify");
612        assert!(caps.contains("records:read"));
613        assert!(!caps.contains("summary:read"));
614        assert!(!caps.contains("fs.write"));
615    }
616
617    #[test]
618    fn attenuation_widening_rejects() {
619        // Parent grants [a, b, c]; child claims [a, b, c, d]. Widening
620        // — child must not gain a cap the parent didn't grant.
621        let sk_u = signing_key_for_seed(1);
622        let sk_a = signing_key_for_seed(2);
623        let sk_b = signing_key_for_seed(3);
624        let exp = future_exp();
625
626        let root = payload_with(
627            &did_key_for(&sk_u),
628            &did_key_for(&sk_a),
629            &["a", "b", "c"],
630            &[],
631            exp,
632        );
633        let root_jwt = build_jwt(root, &sk_u);
634
635        let leaf = payload_with(
636            &did_key_for(&sk_a),
637            &did_key_for(&sk_b),
638            &["a", "b", "c", "d"], // adds "d" — widening
639            std::slice::from_ref(&root_jwt),
640            exp,
641        );
642        let leaf_jwt = build_jwt(leaf, &sk_a);
643
644        let cfg = VerifyConfig::new(did_key_for(&sk_b));
645        match verify_jwt(&leaf_jwt, &cfg, SystemTime::now()) {
646            Err(UcanVerifyError::WideningAttenuation { parent, child, .. }) => {
647                assert_eq!(parent, vec!["a", "b", "c"]);
648                assert_eq!(child, vec!["a", "b", "c", "d"]);
649            }
650            other => panic!("expected WideningAttenuation, got {other:?}"),
651        }
652    }
653
654    #[test]
655    fn revoked_cid_rejects() {
656        let sk_a = signing_key_for_seed(1);
657        let sk_b = signing_key_for_seed(2);
658        let p = payload_with(
659            &did_key_for(&sk_a),
660            &did_key_for(&sk_b),
661            &["records:read"],
662            &[],
663            future_exp(),
664        );
665        let jwt = build_jwt(p, &sk_a);
666        let cid = compute_cid(&jwt);
667
668        let store = Arc::new(MockRevocationStore::default());
669        store.revoke(&cid);
670
671        let mut cfg = VerifyConfig::new(did_key_for(&sk_b));
672        cfg.revocation_store = Some(store as Arc<dyn UcanRevocationStore>);
673
674        match verify_jwt(&jwt, &cfg, SystemTime::now()) {
675            Err(UcanVerifyError::Revoked { cid: c }) => assert_eq!(c, cid),
676            other => panic!("expected Revoked, got {other:?}"),
677        }
678    }
679
680    // ---- Phase E: real InMemoryUcanRevocationStore × multi-link chain ----
681
682    #[test]
683    fn revoking_root_cid_via_in_memory_store_rejects_3_link_descendant() {
684        // U → A → B → C 3-link chain. Revoke the ROOT (U's UCAN) and
685        // confirm the LEAF (C's request) rejects with the root's CID
686        // surfaced. Exercises the real InMemoryUcanRevocationStore impl
687        // (vs the MockRevocationStore in `revoked_cid_rejects`) and the
688        // verifier's "consult on every link" guarantee.
689        use super::super::InMemoryUcanRevocationStore;
690
691        let sk_u = signing_key_for_seed(1);
692        let sk_a = signing_key_for_seed(2);
693        let sk_b = signing_key_for_seed(3);
694        let sk_c = signing_key_for_seed(4);
695        let exp = future_exp();
696
697        let root = payload_with(
698            &did_key_for(&sk_u),
699            &did_key_for(&sk_a),
700            &["records:read"],
701            &[],
702            exp,
703        );
704        let root_jwt = build_jwt(root, &sk_u);
705        let root_cid = compute_cid(&root_jwt);
706
707        let mid = payload_with(
708            &did_key_for(&sk_a),
709            &did_key_for(&sk_b),
710            &["records:read"],
711            std::slice::from_ref(&root_jwt),
712            exp,
713        );
714        let mid_jwt = build_jwt(mid, &sk_a);
715
716        let leaf = payload_with(
717            &did_key_for(&sk_b),
718            &did_key_for(&sk_c),
719            &["records:read"],
720            &[mid_jwt],
721            exp,
722        );
723        let leaf_jwt = build_jwt(leaf, &sk_b);
724
725        // Pre-revoke: full 3-link chain verifies.
726        let store = Arc::new(InMemoryUcanRevocationStore::new());
727        let mut cfg = VerifyConfig::new(did_key_for(&sk_c));
728        cfg.revocation_store = Some(store.clone() as Arc<dyn UcanRevocationStore>);
729        assert!(verify_jwt(&leaf_jwt, &cfg, SystemTime::now()).is_ok());
730
731        // Revoke ROOT → leaf request rejects with root's CID.
732        store.revoke(&root_cid);
733        match verify_jwt(&leaf_jwt, &cfg, SystemTime::now()) {
734            Err(UcanVerifyError::Revoked { cid }) => assert_eq!(cid, root_cid),
735            other => panic!("expected Revoked at root cid, got {other:?}"),
736        }
737    }
738
739    // ---- additional coverage ----------------------------------------------
740
741    #[test]
742    fn chain_broken_when_parent_aud_ne_child_iss_rejects() {
743        // Build a chain where parent's aud is sk_X but child's iss is sk_Y
744        // (sk_Y signs the child to make the signature valid, but the
745        // delegation chain is broken).
746        let sk_u = signing_key_for_seed(1);
747        let sk_x = signing_key_for_seed(10); // legitimate child of U
748        let sk_y = signing_key_for_seed(20); // a different agent
749        let sk_b = signing_key_for_seed(30);
750        let exp = future_exp();
751
752        let root = payload_with(
753            &did_key_for(&sk_u),
754            &did_key_for(&sk_x),
755            &["records:read"],
756            &[],
757            exp,
758        );
759        let root_jwt = build_jwt(root, &sk_u);
760
761        // Child's iss claims to be Y (not X). Y signs it — signature OK.
762        // But parent.aud (X) ≠ child.iss (Y) → ChainBroken.
763        let leaf = payload_with(
764            &did_key_for(&sk_y),
765            &did_key_for(&sk_b),
766            &["records:read"],
767            &[root_jwt],
768            exp,
769        );
770        let leaf_jwt = build_jwt(leaf, &sk_y);
771
772        let cfg = VerifyConfig::new(did_key_for(&sk_b));
773        match verify_jwt(&leaf_jwt, &cfg, SystemTime::now()) {
774            Err(UcanVerifyError::ChainBroken { .. }) => {}
775            other => panic!("expected ChainBroken, got {other:?}"),
776        }
777    }
778
779    #[test]
780    fn multi_parent_prf_rejects() {
781        let sk_a = signing_key_for_seed(1);
782        let sk_b = signing_key_for_seed(2);
783        // Build a payload with two parent JWTs in prf — unsupported in v1.
784        let leaf = json!({
785            "iss":   did_key_for(&sk_a),
786            "aud":   did_key_for(&sk_b),
787            "sub":   did_key_for(&sk_a),
788            "cmd":   "atd-cap",
789            "args":  { "caps": ["records:read"], "with": [] },
790            "nonce": "nonce",
791            "exp":   future_exp(),
792            "prf":   ["parent1.jwt.placeholder", "parent2.jwt.placeholder"]
793        });
794        let jwt = build_jwt(leaf, &sk_a);
795        let cfg = VerifyConfig::new(did_key_for(&sk_b));
796        match verify_jwt(&jwt, &cfg, SystemTime::now()) {
797            Err(UcanVerifyError::MultiParentNotSupported { n_parents, .. }) => {
798                assert_eq!(n_parents, 2);
799            }
800            other => panic!("expected MultiParentNotSupported, got {other:?}"),
801        }
802    }
803
804    #[test]
805    fn verify_tokens_unions_multi_root_results() {
806        // Spec §5.3: two independent root chains, each granting one cap.
807        // Granted set = union.
808        let sk_u1 = signing_key_for_seed(1);
809        let sk_u2 = signing_key_for_seed(2);
810        let sk_b = signing_key_for_seed(99);
811        let exp = future_exp();
812
813        let p1 = payload_with(
814            &did_key_for(&sk_u1),
815            &did_key_for(&sk_b),
816            &["records:read"],
817            &[],
818            exp,
819        );
820        let jwt1 = build_jwt(p1, &sk_u1);
821
822        let p2 = payload_with(
823            &did_key_for(&sk_u2),
824            &did_key_for(&sk_b),
825            &["summary:read"],
826            &[],
827            exp,
828        );
829        let jwt2 = build_jwt(p2, &sk_u2);
830
831        let cfg = VerifyConfig::new(did_key_for(&sk_b));
832        let caps = verify_tokens(&[jwt1, jwt2], &cfg, SystemTime::now())
833            .expect("two independent valid chains must verify and union");
834        assert!(caps.contains("records:read"));
835        assert!(caps.contains("summary:read"));
836    }
837
838    #[test]
839    fn malformed_did_key_at_iss_rejects_at_verify_stage() {
840        // parse_jwt requires only the "did:key:z" prefix — invalid
841        // multibase payload after that prefix is caught at signature-
842        // verify time when we try to extract the public key.
843        let sk_a = signing_key_for_seed(1);
844        let sk_b = signing_key_for_seed(2);
845        let p = json!({
846            "iss":   "did:key:zNOTAREALKEY", // valid prefix; garbage payload
847            "aud":   did_key_for(&sk_b),
848            "sub":   did_key_for(&sk_a),
849            "cmd":   "atd-cap",
850            "args":  { "caps": ["records:read"], "with": [] },
851            "nonce": "nonce",
852            "exp":   future_exp(),
853        });
854        let jwt = build_jwt(p, &sk_a);
855        let cfg = VerifyConfig::new(did_key_for(&sk_b));
856        match verify_jwt(&jwt, &cfg, SystemTime::now()) {
857            Err(UcanVerifyError::MalformedDidKey { .. }) => {}
858            other => panic!("expected MalformedDidKey, got {other:?}"),
859        }
860    }
861}