1use 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#[derive(Clone)]
34pub struct VerifyConfig {
35 pub max_chain_depth: u8,
38
39 pub expected_audience: String,
42
43 pub revocation_store: Option<Arc<dyn UcanRevocationStore>>,
47}
48
49impl VerifyConfig {
50 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
61pub 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
81fn 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
131fn verify_signature(jwt: &str, payload: &UcanPayload) -> Result<(), UcanVerifyError> {
134 let cid = compute_cid(jwt);
135 let parts: Vec<&str> = jwt.split('.').collect();
136 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
177struct Link {
180 jwt: String,
181 payload: UcanPayload,
182}
183
184impl Link {
185 fn cid(&self) -> String {
186 compute_cid(&self.jwt)
187 }
188}
189
190fn 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 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(); Ok(leaf_first)
226}
227
228pub 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 for link in &chain {
248 verify_signature(&link.jwt, &link.payload)?;
249 }
250
251 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 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 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 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 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 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
340pub 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#[cfg(test)]
362mod tests {
363 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 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 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 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 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 #[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 #[test]
459 fn verify_well_formed_single_link_chain_succeeds() {
460 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 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), &did_key_for(&sk_b),
485 &["records:read"],
486 &[],
487 future_exp(),
488 );
489 let jwt = build_jwt(p, &sk_x); 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 let sks: Vec<SigningKey> = (0..6).map(|i| signing_key_for_seed(i + 1)).collect();
526 let exp = future_exp();
527 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]); let p = payload_with(&iss, &aud, &["records:read"], ¤t_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])); 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)); 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 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 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"], 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 #[test]
683 fn revoking_root_cid_via_in_memory_store_rejects_3_link_descendant() {
684 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 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 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 #[test]
742 fn chain_broken_when_parent_aud_ne_child_iss_rejects() {
743 let sk_u = signing_key_for_seed(1);
747 let sk_x = signing_key_for_seed(10); let sk_y = signing_key_for_seed(20); 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 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 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 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 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", "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}