1#[cfg(not(feature = "std"))]
4use alloc::{format, string::String, string::ToString, vec::Vec};
5
6use alloc::collections::BTreeMap;
7
8use crate::constraints::evaluate_constraints;
9
10use crate::crypto::{
11 transaction_receipt_sign_bytes, verify_both, verify_challenge_signature_with_stream,
12 verify_delegation_signature_e, verify_session_token_e,
13};
14use crate::scope::{intersect_scopes, SCOPE_IDENTITY_DELEGATE};
15use crate::types::{
16 HybridPublicKey, HybridSignature, IdentityStatus, ProofBundle, SessionToken,
17 TransactionReceipt, TransactionReceiptResult, VerifyOptions, VerifyResult,
18 CHALLENGE_WINDOW_SECONDS, ED25519_PUBLIC_KEY_SIZE, MAX_DELEGATION_CHAIN_DEPTH,
19 MLDSA65_PUBLIC_KEY_SIZE, PROTOCOL_VERSION,
20};
21
22pub fn verify_bundle(bundle: &ProofBundle, opts: &VerifyOptions) -> VerifyResult {
26 let res = verify_bundle_inner(bundle, opts);
27 if let Some(audit) = &opts.audit {
28 audit.log_verification(&res, bundle);
29 }
30 res
31}
32
33fn verify_bundle_inner(bundle: &ProofBundle, opts: &VerifyOptions) -> VerifyResult {
34 let now = opts.now.unwrap_or_else(|| {
35 #[cfg(feature = "std")]
36 {
37 use std::time::{SystemTime, UNIX_EPOCH};
38 SystemTime::now()
39 .duration_since(UNIX_EPOCH)
40 .unwrap_or_default()
41 .as_secs() as i64
42 }
43 #[cfg(not(feature = "std"))]
44 {
45 panic!("verify_bundle: opts.now must be set when std feature is disabled")
47 }
48 });
49
50 if bundle.delegations.is_empty() {
52 return invalid(
53 "no_delegations",
54 "proof bundle contains no delegation certificates",
55 );
56 }
57 if bundle.delegations.len() > MAX_DELEGATION_CHAIN_DEPTH {
58 return invalid("chain_too_deep", "delegation chain exceeds maximum depth");
59 }
60 if bundle.challenge.is_empty() {
61 return invalid("no_challenge", "proof bundle contains no challenge");
62 }
63 if !bundle.session_context.is_empty() && bundle.session_context.len() != 32 {
64 return invalid(
65 "invalid_session_context",
66 &format!(
67 "session_context must be 32 bytes, got {}",
68 bundle.session_context.len()
69 ),
70 );
71 }
72 if !opts.session_context.is_empty() && opts.session_context.len() != 32 {
73 return invalid(
74 "invalid_session_context",
75 &format!(
76 "verify option session_context must be 32 bytes, got {}",
77 opts.session_context.len()
78 ),
79 );
80 }
81 if !opts.session_context.is_empty() {
82 if bundle.session_context.is_empty() {
83 return invalid(
84 "missing_session_context",
85 "verifier requires a session-bound challenge but bundle has no session_context",
86 );
87 }
88 if bundle.session_context != opts.session_context {
89 return invalid(
90 "session_context_mismatch",
91 "bundle session_context does not match verifier context",
92 );
93 }
94 } else if !bundle.session_context.is_empty() {
95 return invalid(
96 "session_context_unverifiable",
97 "bundle has session_context but verifier did not provide one",
98 );
99 }
100
101 if !bundle.stream_id.is_empty() && bundle.stream_id.len() != 32 {
103 return invalid(
104 "invalid_stream_id",
105 &format!("stream_id must be 32 bytes, got {}", bundle.stream_id.len()),
106 );
107 }
108 if bundle.stream_id.is_empty() && bundle.stream_seq != 0 {
109 return invalid("invalid_stream_seq", "stream_seq set without stream_id");
110 }
111 if !bundle.stream_id.is_empty() && bundle.stream_seq < 1 {
112 return invalid(
113 "invalid_stream_seq",
114 &format!("stream_seq must be >=1, got {}", bundle.stream_seq),
115 );
116 }
117 if let Some(stream) = &opts.stream {
118 if stream.stream_id.len() != 32 {
119 return invalid(
120 "invalid_stream_id",
121 &format!(
122 "verify option stream_id must be 32 bytes, got {}",
123 stream.stream_id.len()
124 ),
125 );
126 }
127 if bundle.stream_id.is_empty() {
128 return invalid(
129 "missing_stream_context",
130 "verifier requires a stream-bound challenge but bundle has no stream_id",
131 );
132 }
133 if bundle.stream_id != stream.stream_id {
134 return invalid(
135 "stream_id_mismatch",
136 "bundle stream_id does not match verifier stream context",
137 );
138 }
139 let expected = stream.last_seen_seq + 1;
140 if bundle.stream_seq <= stream.last_seen_seq {
141 return invalid(
142 "stream_seq_replay",
143 &format!(
144 "stream_seq {} already seen (last={})",
145 bundle.stream_seq, stream.last_seen_seq
146 ),
147 );
148 }
149 if bundle.stream_seq != expected {
150 return invalid(
151 "stream_seq_skip",
152 &format!(
153 "stream_seq {} skips expected {}",
154 bundle.stream_seq, expected
155 ),
156 );
157 }
158 } else if !bundle.stream_id.is_empty() {
159 return invalid(
160 "stream_context_unverifiable",
161 "bundle has stream_id but verifier did not provide a stream context",
162 );
163 }
164
165 if let Some(err) = validate_hybrid_pubkey_lens(&bundle.agent_pub_key, "agent") {
166 return invalid("invalid_agent_key", &err);
167 }
168
169 let first_cert = &bundle.delegations[0];
170 let human_id = bundle.delegations.last().unwrap().issuer_id.clone();
171
172 if !hybrid_pub_key_equal(&bundle.agent_pub_key, &first_cert.subject_pub_key) {
173 return invalid(
174 "key_mismatch",
175 "agent public key does not match delegation subject",
176 );
177 }
178 if bundle.agent_id != first_cert.subject_id {
179 return invalid(
180 "id_mismatch",
181 "agent ID does not match delegation subject ID",
182 );
183 }
184
185 #[allow(deprecated)]
186 let legacy_revoke = opts.is_revoked.as_ref();
187 if opts.force_revocation_check && legacy_revoke.is_none() && opts.revocation.is_none() {
188 return invalid(
189 "force_revocation_no_callback",
190 "force_revocation_check is true but neither is_revoked nor revocation provider is set",
191 );
192 }
193
194 for (i, cert) in bundle.delegations.iter().enumerate() {
196 if cert.version != PROTOCOL_VERSION {
197 return invalid(
198 "version_mismatch",
199 &format!("cert {} has unsupported version {}", i, cert.version),
200 );
201 }
202 if now > cert.expires_at {
203 return expired(&human_id, &bundle.agent_id);
204 }
205 if now < cert.issued_at {
206 return invalid("not_yet_valid", &format!("cert {} is not yet valid", i));
207 }
208 if let Some(provider) = &opts.revocation {
210 match provider.is_revoked(&cert.cert_id) {
211 Err(e) => {
212 return invalid(
213 "revocation_error",
214 &format!("cert {}: revocation lookup failed: {}", i, e),
215 )
216 }
217 Ok(true) => return revoked(&human_id, &bundle.agent_id),
218 Ok(false) => {}
219 }
220 } else if let Some(check) = legacy_revoke {
221 if check(&cert.cert_id) {
222 return revoked(&human_id, &bundle.agent_id);
223 }
224 }
225 if let Err(sig_err) = verify_delegation_signature_e(cert) {
226 return invalid("bad_signature", &format!("cert {}: {}", i, sig_err));
227 }
228 if let Err(constraint_err) = evaluate_constraints(
231 cert,
232 &opts.context,
233 now,
234 opts.constraint_evaluators.as_ref(),
235 ) {
236 let status = if constraint_err.contains("constraint_unverifiable") {
240 "constraint_unverifiable"
241 } else if constraint_err.contains("constraint_unknown") {
242 "constraint_unknown"
243 } else {
244 "constraint_denied"
245 };
246 return fail_with_status(status, &format!("cert {}: {}", i, constraint_err));
247 }
248 if i + 1 < bundle.delegations.len() {
250 let next = &bundle.delegations[i + 1];
251 if cert.issuer_id != next.subject_id {
252 return invalid(
253 "broken_chain",
254 &format!("cert {} issuer does not match cert {} subject", i, i + 1),
255 );
256 }
257 if !hybrid_pub_key_equal(&cert.issuer_pub_key, &next.subject_pub_key) {
258 return invalid(
259 "broken_chain_keys",
260 &format!(
261 "cert {} issuer key does not match cert {} subject key",
262 i,
263 i + 1
264 ),
265 );
266 }
267 if !next.scope.iter().any(|s| s == SCOPE_IDENTITY_DELEGATE) {
269 return fail_with_status(
270 "delegation_not_authorized",
271 &format!(
272 "cert {} issued by a subject whose parent cert {} did not grant \"{}\"",
273 i,
274 i + 1,
275 SCOPE_IDENTITY_DELEGATE
276 ),
277 );
278 }
279 }
280 }
281
282 let challenge_age = now - bundle.challenge_at;
284 if challenge_age < 0 || challenge_age > CHALLENGE_WINDOW_SECONDS {
285 return invalid(
286 "stale_challenge",
287 &format!(
288 "challenge is {} seconds old (max {})",
289 challenge_age, CHALLENGE_WINDOW_SECONDS
290 ),
291 );
292 }
293 if let Err(err) = verify_challenge_signature_with_stream(
294 &bundle.challenge,
295 bundle.challenge_at,
296 &bundle.session_context,
297 &bundle.stream_id,
298 bundle.stream_seq,
299 &bundle.challenge_sig,
300 &bundle.agent_pub_key,
301 ) {
302 return invalid(
303 "bad_challenge_sig",
304 &format!("challenge signature verification failed: {}", err),
305 );
306 }
307
308 let scope_refs: Vec<&[String]> = bundle
310 .delegations
311 .iter()
312 .map(|c| c.scope.as_slice())
313 .collect();
314 let effective = intersect_scopes(&scope_refs);
315
316 if !opts.required_scope.is_empty() && !effective.iter().any(|s| s == &opts.required_scope) {
317 return fail_with_status(
318 "scope_denied",
319 &format!(
320 "required scope \"{}\" not in effective delegation scope",
321 opts.required_scope
322 ),
323 );
324 }
325
326 let mut result = VerifyResult {
327 valid: true,
328 identity_status: IdentityStatus::AuthorizedAgent,
329 human_id: human_id.clone(),
330 agent_id: bundle.agent_id.clone(),
331 agent_name: String::new(),
332 agent_type: String::new(),
333 granted_scope: effective,
334 error_reason: String::new(),
335 anchor: None,
336 };
337
338 if let Some(resolver) = &opts.anchor_resolver {
343 if let Ok(Some(anchor)) = resolver.resolve_anchor(&human_id) {
344 result.anchor = Some(anchor);
345 }
346 }
347
348 if let (Some(verdict), Some(secret)) = (&opts.policy_verdict, &opts.policy_secret) {
353 if !opts.required_scope.is_empty() {
354 match crate::receipts::verifier_context_hash(&opts.context) {
355 Err(e) => {
356 return invalid(
357 "policy_error",
358 &format!("verifier context hash failed: {}", e),
359 );
360 }
361 Ok(ctx_hash) => {
362 let v_err = crate::receipts::verify_policy_verdict(
363 verdict,
364 secret,
365 &bundle.agent_id,
366 &opts.required_scope,
367 &ctx_hash,
368 now,
369 );
370 match v_err {
371 Ok(()) => return result,
372 Err(e) if e.starts_with("policy_verdict_denied") => {
373 return fail_with_status(
374 "scope_denied",
375 "policy verdict (cached) denied access",
376 );
377 }
378 Err(_) => {
379 }
381 }
382 }
383 }
384 }
385 }
386
387 if let Some(policy) = &opts.policy {
388 match policy.evaluate_policy(bundle, &opts.context) {
389 Err(e) => {
390 return invalid(
391 "policy_error",
392 &format!("advanced policy evaluation failed: {}", e),
393 )
394 }
395 Ok(false) => {
396 return fail_with_status(
397 "scope_denied",
398 "advanced policy evaluation denied access",
399 )
400 }
401 Ok(true) => {}
402 }
403 }
404
405 result
406}
407
408fn hybrid_pub_key_equal(a: &HybridPublicKey, b: &HybridPublicKey) -> bool {
411 a.ed25519 == b.ed25519 && a.ml_dsa_65 == b.ml_dsa_65
412}
413
414fn validate_hybrid_pubkey_lens(pub_key: &HybridPublicKey, label: &str) -> Option<String> {
415 if pub_key.ed25519.len() != ED25519_PUBLIC_KEY_SIZE {
416 return Some(format!(
417 "{} Ed25519 public key has wrong length: {}",
418 label,
419 pub_key.ed25519.len()
420 ));
421 }
422 if pub_key.ml_dsa_65.len() != MLDSA65_PUBLIC_KEY_SIZE {
423 return Some(format!(
424 "{} ML-DSA-65 public key has wrong length: {}",
425 label,
426 pub_key.ml_dsa_65.len()
427 ));
428 }
429 None
430}
431
432fn invalid(reason: &str, msg: &str) -> VerifyResult {
433 VerifyResult {
434 valid: false,
435 identity_status: IdentityStatus::Invalid,
436 human_id: String::new(),
437 agent_id: String::new(),
438 agent_name: String::new(),
439 agent_type: String::new(),
440 granted_scope: Vec::new(),
441 error_reason: format!("{}: {}", reason, msg),
442 anchor: None,
443 }
444}
445
446fn fail_with_status(status: &str, msg: &str) -> VerifyResult {
452 let st = IdentityStatus::from_wire(status).unwrap_or(IdentityStatus::Invalid);
453 VerifyResult {
454 valid: false,
455 identity_status: st,
456 human_id: String::new(),
457 agent_id: String::new(),
458 agent_name: String::new(),
459 agent_type: String::new(),
460 granted_scope: Vec::new(),
461 error_reason: format!("{}: {}", status, msg),
462 anchor: None,
463 }
464}
465
466fn expired(human_id: &str, agent_id: &str) -> VerifyResult {
467 VerifyResult {
468 valid: false,
469 identity_status: IdentityStatus::Expired,
470 human_id: human_id.to_string(),
471 agent_id: agent_id.to_string(),
472 agent_name: String::new(),
473 agent_type: String::new(),
474 granted_scope: Vec::new(),
475 error_reason: "delegation certificate has expired".to_string(),
476 anchor: None,
477 }
478}
479
480fn revoked(human_id: &str, agent_id: &str) -> VerifyResult {
481 VerifyResult {
482 valid: false,
483 identity_status: IdentityStatus::Revoked,
484 human_id: human_id.to_string(),
485 agent_id: agent_id.to_string(),
486 agent_name: String::new(),
487 agent_type: String::new(),
488 granted_scope: Vec::new(),
489 error_reason: "delegation certificate has been revoked".to_string(),
490 anchor: None,
491 }
492}
493
494pub fn verify_transaction_receipt(
501 receipt: &TransactionReceipt,
502 now: i64,
503) -> TransactionReceiptResult {
504 if receipt.version != PROTOCOL_VERSION {
505 return receipt_fail(&format!(
506 "version_mismatch: unsupported version {}",
507 receipt.version
508 ));
509 }
510 if receipt.transaction_id.is_empty() {
511 return receipt_fail("missing_transaction_id: transaction_id must not be empty");
512 }
513 if receipt.terms_schema_uri.is_empty() {
514 return receipt_fail("missing_terms_schema_uri: terms_schema_uri must not be empty");
515 }
516 if receipt.terms_canonical_json.is_empty() {
517 return receipt_fail(
518 "missing_terms_canonical_json: terms_canonical_json must not be empty",
519 );
520 }
521 if receipt.parties.is_empty() {
522 return receipt_fail("no_parties: receipt must list at least one party");
523 }
524
525 let mut party_idx: BTreeMap<&str, usize> = BTreeMap::new();
527 for (i, p) in receipt.parties.iter().enumerate() {
528 if p.party_id.is_empty() {
529 return receipt_fail(&format!("empty_party_id: party {} has no party_id", i));
530 }
531 if party_idx.contains_key(p.party_id.as_str()) {
532 return receipt_fail(&format!(
533 "duplicate_party_id: {:?} listed more than once",
534 p.party_id
535 ));
536 }
537 party_idx.insert(&p.party_id, i);
538 }
539
540 let mut sig_by_party: BTreeMap<&str, usize> = BTreeMap::new();
543 for (i, s) in receipt.party_signatures.iter().enumerate() {
544 if !party_idx.contains_key(s.party_id.as_str()) {
545 return receipt_fail(&format!(
546 "unknown_party_signature: signature {} references unknown party_id {:?}",
547 i, s.party_id
548 ));
549 }
550 if sig_by_party.contains_key(s.party_id.as_str()) {
551 return receipt_fail(&format!(
552 "duplicate_party_signature: party {:?} has multiple signatures",
553 s.party_id
554 ));
555 }
556 sig_by_party.insert(&s.party_id, i);
557 }
558 for p in &receipt.parties {
559 if !sig_by_party.contains_key(p.party_id.as_str()) {
560 return receipt_fail(&format!(
561 "missing_party_signature: party {:?} has no signature",
562 p.party_id
563 ));
564 }
565 }
566
567 let signable = transaction_receipt_sign_bytes(receipt);
569
570 let mut party_results = Vec::with_capacity(receipt.parties.len());
571 for p in &receipt.parties {
572 if p.proof_bundle.agent_id != p.agent_id {
574 return receipt_fail_with_results(
575 &format!(
576 "party_agent_id_mismatch: party {:?} proof_bundle.agent_id={:?} != party.agent_id={:?}",
577 p.party_id, p.proof_bundle.agent_id, p.agent_id
578 ),
579 party_results,
580 );
581 }
582 if !hybrid_pub_key_equal(&p.proof_bundle.agent_pub_key, &p.agent_pub_key) {
583 return receipt_fail_with_results(
584 &format!(
585 "party_agent_key_mismatch: party {:?} proof_bundle.agent_pub_key != party.agent_pub_key",
586 p.party_id
587 ),
588 party_results,
589 );
590 }
591
592 let bundle_opts = VerifyOptions {
594 now: Some(now),
595 ..VerifyOptions::default()
596 };
597 let r = verify_bundle(&p.proof_bundle, &bundle_opts);
598 party_results.push(r.clone());
599 if !r.valid {
600 return receipt_fail_with_results(
601 &format!(
602 "party_bundle_invalid: party {:?} status={} reason={}",
603 p.party_id,
604 r.identity_status.as_str(),
605 r.error_reason
606 ),
607 party_results,
608 );
609 }
610
611 let sig_idx = sig_by_party[p.party_id.as_str()];
613 let sig = &receipt.party_signatures[sig_idx].signature;
614 if let Err(e) = verify_both(&signable, sig, &p.agent_pub_key) {
615 return receipt_fail_with_results(
616 &format!("party_signature_invalid: party {:?}: {}", p.party_id, e),
617 party_results,
618 );
619 }
620 }
621
622 TransactionReceiptResult {
623 valid: true,
624 error_reason: String::new(),
625 party_results,
626 }
627}
628
629fn receipt_fail(reason: &str) -> TransactionReceiptResult {
630 TransactionReceiptResult {
631 valid: false,
632 error_reason: reason.to_string(),
633 party_results: Vec::new(),
634 }
635}
636
637fn receipt_fail_with_results(
638 reason: &str,
639 party_results: Vec<VerifyResult>,
640) -> TransactionReceiptResult {
641 TransactionReceiptResult {
642 valid: false,
643 error_reason: reason.to_string(),
644 party_results,
645 }
646}
647
648#[allow(clippy::too_many_arguments)]
657pub fn verify_streamed_turn(
658 token: &SessionToken,
659 session_secret: &[u8],
660 challenge: &[u8],
661 challenge_at: i64,
662 challenge_sig: &HybridSignature,
663 session_context: &[u8],
664 stream_id: &[u8],
665 stream_seq: i64,
666 now: i64,
667) -> VerifyResult {
668 if let Err(e) = verify_session_token_e(token, session_secret, now) {
669 return invalid("session_token_invalid", &e);
670 }
671 if challenge.is_empty() {
672 return invalid("no_challenge", "streamed turn contains no challenge");
673 }
674 if !session_context.is_empty() && session_context.len() != 32 {
675 return invalid(
676 "invalid_session_context",
677 &format!(
678 "session_context must be 32 bytes, got {}",
679 session_context.len()
680 ),
681 );
682 }
683 if !stream_id.is_empty() && stream_id.len() != 32 {
684 return invalid(
685 "invalid_stream_id",
686 &format!("stream_id must be 32 bytes, got {}", stream_id.len()),
687 );
688 }
689 if !stream_id.is_empty() && stream_seq < 1 {
690 return invalid(
691 "invalid_stream_seq",
692 &format!("stream_seq must be >=1, got {}", stream_seq),
693 );
694 }
695 let challenge_age = now - challenge_at;
696 if challenge_age < 0 || challenge_age > CHALLENGE_WINDOW_SECONDS {
697 return invalid(
698 "stale_challenge",
699 &format!(
700 "challenge is {} seconds old (max {})",
701 challenge_age, CHALLENGE_WINDOW_SECONDS
702 ),
703 );
704 }
705 if let Err(err) = verify_challenge_signature_with_stream(
706 challenge,
707 challenge_at,
708 session_context,
709 stream_id,
710 stream_seq,
711 challenge_sig,
712 &token.agent_pub_key,
713 ) {
714 return invalid(
715 "bad_challenge_sig",
716 &format!("challenge signature verification failed: {}", err),
717 );
718 }
719 VerifyResult {
720 valid: true,
721 identity_status: IdentityStatus::AuthorizedAgent,
722 human_id: token.human_id.clone(),
723 agent_id: token.agent_id.clone(),
724 agent_name: String::new(),
725 agent_type: String::new(),
726 granted_scope: token.granted_scope.clone(),
727 error_reason: String::new(),
728 anchor: None,
729 }
730}