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