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 Ok(CapabilitySet::from_iter(leaf.payload.args.caps.clone()))
311}
312
313pub 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#[cfg(test)]
335mod tests {
336 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 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 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 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 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 #[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 #[test]
432 fn verify_well_formed_single_link_chain_succeeds() {
433 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 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), &did_key_for(&sk_b),
458 &["records:read"],
459 &[],
460 future_exp(),
461 );
462 let jwt = build_jwt(p, &sk_x); 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 let sks: Vec<SigningKey> = (0..6).map(|i| signing_key_for_seed(i + 1)).collect();
499 let exp = future_exp();
500 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]); let p = payload_with(&iss, &aud, &["records:read"], ¤t_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])); 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)); 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 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 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"], 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 #[test]
656 fn revoking_root_cid_via_in_memory_store_rejects_3_link_descendant() {
657 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 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 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 #[test]
715 fn chain_broken_when_parent_aud_ne_child_iss_rejects() {
716 let sk_u = signing_key_for_seed(1);
720 let sk_x = signing_key_for_seed(10); let sk_y = signing_key_for_seed(20); 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 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 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 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 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", "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}