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 {
21 let now = opts.now.unwrap_or_else(|| {
22 SystemTime::now()
23 .duration_since(UNIX_EPOCH)
24 .unwrap_or_default()
25 .as_secs() as i64
26 });
27
28 if bundle.delegations.is_empty() {
30 return invalid(
31 "no_delegations",
32 "proof bundle contains no delegation certificates",
33 );
34 }
35 if bundle.delegations.len() > MAX_DELEGATION_CHAIN_DEPTH {
36 return invalid("chain_too_deep", "delegation chain exceeds maximum depth");
37 }
38 if bundle.challenge.is_empty() {
39 return invalid("no_challenge", "proof bundle contains no challenge");
40 }
41 if !bundle.session_context.is_empty() && bundle.session_context.len() != 32 {
42 return invalid(
43 "invalid_session_context",
44 &format!(
45 "session_context must be 32 bytes, got {}",
46 bundle.session_context.len()
47 ),
48 );
49 }
50 if !opts.session_context.is_empty() && opts.session_context.len() != 32 {
51 return invalid(
52 "invalid_session_context",
53 &format!(
54 "verify option session_context must be 32 bytes, got {}",
55 opts.session_context.len()
56 ),
57 );
58 }
59 if !opts.session_context.is_empty() {
60 if bundle.session_context.is_empty() {
61 return invalid(
62 "missing_session_context",
63 "verifier requires a session-bound challenge but bundle has no session_context",
64 );
65 }
66 if bundle.session_context != opts.session_context {
67 return invalid(
68 "session_context_mismatch",
69 "bundle session_context does not match verifier context",
70 );
71 }
72 } else if !bundle.session_context.is_empty() {
73 return invalid(
74 "session_context_unverifiable",
75 "bundle has session_context but verifier did not provide one",
76 );
77 }
78
79 if !bundle.stream_id.is_empty() && bundle.stream_id.len() != 32 {
81 return invalid(
82 "invalid_stream_id",
83 &format!("stream_id must be 32 bytes, got {}", bundle.stream_id.len()),
84 );
85 }
86 if bundle.stream_id.is_empty() && bundle.stream_seq != 0 {
87 return invalid("invalid_stream_seq", "stream_seq set without stream_id");
88 }
89 if !bundle.stream_id.is_empty() && bundle.stream_seq < 1 {
90 return invalid(
91 "invalid_stream_seq",
92 &format!("stream_seq must be >=1, got {}", bundle.stream_seq),
93 );
94 }
95 if let Some(stream) = &opts.stream {
96 if stream.stream_id.len() != 32 {
97 return invalid(
98 "invalid_stream_id",
99 &format!(
100 "verify option stream_id must be 32 bytes, got {}",
101 stream.stream_id.len()
102 ),
103 );
104 }
105 if bundle.stream_id.is_empty() {
106 return invalid(
107 "missing_stream_context",
108 "verifier requires a stream-bound challenge but bundle has no stream_id",
109 );
110 }
111 if bundle.stream_id != stream.stream_id {
112 return invalid(
113 "stream_id_mismatch",
114 "bundle stream_id does not match verifier stream context",
115 );
116 }
117 let expected = stream.last_seen_seq + 1;
118 if bundle.stream_seq <= stream.last_seen_seq {
119 return invalid(
120 "stream_seq_replay",
121 &format!(
122 "stream_seq {} already seen (last={})",
123 bundle.stream_seq, stream.last_seen_seq
124 ),
125 );
126 }
127 if bundle.stream_seq != expected {
128 return invalid(
129 "stream_seq_skip",
130 &format!(
131 "stream_seq {} skips expected {}",
132 bundle.stream_seq, expected
133 ),
134 );
135 }
136 } else if !bundle.stream_id.is_empty() {
137 return invalid(
138 "stream_context_unverifiable",
139 "bundle has stream_id but verifier did not provide a stream context",
140 );
141 }
142
143 if let Some(err) = validate_hybrid_pubkey_lens(&bundle.agent_pub_key, "agent") {
144 return invalid("invalid_agent_key", &err);
145 }
146
147 let first_cert = &bundle.delegations[0];
148 let human_id = bundle.delegations.last().unwrap().issuer_id.clone();
149
150 if !hybrid_pub_key_equal(&bundle.agent_pub_key, &first_cert.subject_pub_key) {
151 return invalid(
152 "key_mismatch",
153 "agent public key does not match delegation subject",
154 );
155 }
156 if bundle.agent_id != first_cert.subject_id {
157 return invalid(
158 "id_mismatch",
159 "agent ID does not match delegation subject ID",
160 );
161 }
162
163 if opts.force_revocation_check && opts.is_revoked.is_none() {
164 return invalid(
165 "force_revocation_no_callback",
166 "force_revocation_check is true but is_revoked callback is missing",
167 );
168 }
169
170 for (i, cert) in bundle.delegations.iter().enumerate() {
172 if cert.version != PROTOCOL_VERSION {
173 return invalid(
174 "version_mismatch",
175 &format!("cert {} has unsupported version {}", i, cert.version),
176 );
177 }
178 if now > cert.expires_at {
179 return expired(&human_id, &bundle.agent_id);
180 }
181 if now < cert.issued_at {
182 return invalid("not_yet_valid", &format!("cert {} is not yet valid", i));
183 }
184 if let Some(check) = &opts.is_revoked {
185 if check(&cert.cert_id) {
186 return revoked(&human_id, &bundle.agent_id);
187 }
188 }
189 if let Err(sig_err) = verify_delegation_signature_e(cert) {
190 return invalid("bad_signature", &format!("cert {}: {}", i, sig_err));
191 }
192 if let Err(constraint_err) = evaluate_constraints(cert, &opts.context, now) {
195 let status = if constraint_err.contains("constraint_unverifiable") {
199 "constraint_unverifiable"
200 } else if constraint_err.contains("constraint_unknown") {
201 "constraint_unknown"
202 } else {
203 "constraint_denied"
204 };
205 return fail_with_status(status, &format!("cert {}: {}", i, constraint_err));
206 }
207 if i + 1 < bundle.delegations.len() {
209 let next = &bundle.delegations[i + 1];
210 if cert.issuer_id != next.subject_id {
211 return invalid(
212 "broken_chain",
213 &format!("cert {} issuer does not match cert {} subject", i, i + 1),
214 );
215 }
216 if !hybrid_pub_key_equal(&cert.issuer_pub_key, &next.subject_pub_key) {
217 return invalid(
218 "broken_chain_keys",
219 &format!(
220 "cert {} issuer key does not match cert {} subject key",
221 i,
222 i + 1
223 ),
224 );
225 }
226 if !next.scope.iter().any(|s| s == SCOPE_IDENTITY_DELEGATE) {
228 return fail_with_status(
229 "delegation_not_authorized",
230 &format!(
231 "cert {} issued by a subject whose parent cert {} did not grant \"{}\"",
232 i,
233 i + 1,
234 SCOPE_IDENTITY_DELEGATE
235 ),
236 );
237 }
238 }
239 }
240
241 let challenge_age = now - bundle.challenge_at;
243 if challenge_age < 0 || challenge_age > CHALLENGE_WINDOW_SECONDS {
244 return invalid(
245 "stale_challenge",
246 &format!(
247 "challenge is {} seconds old (max {})",
248 challenge_age, CHALLENGE_WINDOW_SECONDS
249 ),
250 );
251 }
252 if let Err(err) = verify_challenge_signature_with_stream(
253 &bundle.challenge,
254 bundle.challenge_at,
255 &bundle.session_context,
256 &bundle.stream_id,
257 bundle.stream_seq,
258 &bundle.challenge_sig,
259 &bundle.agent_pub_key,
260 ) {
261 return invalid(
262 "bad_challenge_sig",
263 &format!("challenge signature verification failed: {}", err),
264 );
265 }
266
267 let scope_refs: Vec<&[String]> = bundle
269 .delegations
270 .iter()
271 .map(|c| c.scope.as_slice())
272 .collect();
273 let effective = intersect_scopes(&scope_refs);
274
275 if !opts.required_scope.is_empty() && !effective.iter().any(|s| s == &opts.required_scope) {
276 return fail_with_status(
277 "scope_denied",
278 &format!(
279 "required scope \"{}\" not in effective delegation scope",
280 opts.required_scope
281 ),
282 );
283 }
284
285 VerifyResult {
286 valid: true,
287 identity_status: IdentityStatus::AuthorizedAgent,
288 human_id,
289 agent_id: bundle.agent_id.clone(),
290 agent_name: String::new(),
291 agent_type: String::new(),
292 granted_scope: effective,
293 error_reason: String::new(),
294 }
295}
296
297fn hybrid_pub_key_equal(a: &HybridPublicKey, b: &HybridPublicKey) -> bool {
300 a.ed25519 == b.ed25519 && a.ml_dsa_65 == b.ml_dsa_65
301}
302
303fn validate_hybrid_pubkey_lens(pub_key: &HybridPublicKey, label: &str) -> Option<String> {
304 if pub_key.ed25519.len() != ED25519_PUBLIC_KEY_SIZE {
305 return Some(format!(
306 "{} Ed25519 public key has wrong length: {}",
307 label,
308 pub_key.ed25519.len()
309 ));
310 }
311 if pub_key.ml_dsa_65.len() != MLDSA65_PUBLIC_KEY_SIZE {
312 return Some(format!(
313 "{} ML-DSA-65 public key has wrong length: {}",
314 label,
315 pub_key.ml_dsa_65.len()
316 ));
317 }
318 None
319}
320
321fn invalid(reason: &str, msg: &str) -> VerifyResult {
322 VerifyResult {
323 valid: false,
324 identity_status: IdentityStatus::Invalid,
325 human_id: String::new(),
326 agent_id: String::new(),
327 agent_name: String::new(),
328 agent_type: String::new(),
329 granted_scope: Vec::new(),
330 error_reason: format!("{}: {}", reason, msg),
331 }
332}
333
334fn fail_with_status(status: &str, msg: &str) -> VerifyResult {
340 let st = IdentityStatus::from_wire(status).unwrap_or(IdentityStatus::Invalid);
341 VerifyResult {
342 valid: false,
343 identity_status: st,
344 human_id: String::new(),
345 agent_id: String::new(),
346 agent_name: String::new(),
347 agent_type: String::new(),
348 granted_scope: Vec::new(),
349 error_reason: format!("{}: {}", status, msg),
350 }
351}
352
353fn expired(human_id: &str, agent_id: &str) -> VerifyResult {
354 VerifyResult {
355 valid: false,
356 identity_status: IdentityStatus::Expired,
357 human_id: human_id.to_string(),
358 agent_id: agent_id.to_string(),
359 agent_name: String::new(),
360 agent_type: String::new(),
361 granted_scope: Vec::new(),
362 error_reason: "delegation certificate has expired".to_string(),
363 }
364}
365
366fn revoked(human_id: &str, agent_id: &str) -> VerifyResult {
367 VerifyResult {
368 valid: false,
369 identity_status: IdentityStatus::Revoked,
370 human_id: human_id.to_string(),
371 agent_id: agent_id.to_string(),
372 agent_name: String::new(),
373 agent_type: String::new(),
374 granted_scope: Vec::new(),
375 error_reason: "delegation certificate has been revoked".to_string(),
376 }
377}
378
379pub fn verify_transaction_receipt(
386 receipt: &TransactionReceipt,
387 now: i64,
388) -> TransactionReceiptResult {
389 if receipt.version != PROTOCOL_VERSION {
390 return receipt_fail(&format!(
391 "version_mismatch: unsupported version {}",
392 receipt.version
393 ));
394 }
395 if receipt.transaction_id.is_empty() {
396 return receipt_fail("missing_transaction_id: transaction_id must not be empty");
397 }
398 if receipt.terms_schema_uri.is_empty() {
399 return receipt_fail("missing_terms_schema_uri: terms_schema_uri must not be empty");
400 }
401 if receipt.terms_canonical_json.is_empty() {
402 return receipt_fail(
403 "missing_terms_canonical_json: terms_canonical_json must not be empty",
404 );
405 }
406 if receipt.parties.is_empty() {
407 return receipt_fail("no_parties: receipt must list at least one party");
408 }
409
410 let mut party_idx: HashMap<&str, usize> = HashMap::new();
412 for (i, p) in receipt.parties.iter().enumerate() {
413 if p.party_id.is_empty() {
414 return receipt_fail(&format!("empty_party_id: party {} has no party_id", i));
415 }
416 if party_idx.contains_key(p.party_id.as_str()) {
417 return receipt_fail(&format!(
418 "duplicate_party_id: {:?} listed more than once",
419 p.party_id
420 ));
421 }
422 party_idx.insert(&p.party_id, i);
423 }
424
425 let mut sig_by_party: HashMap<&str, usize> = HashMap::new();
428 for (i, s) in receipt.party_signatures.iter().enumerate() {
429 if !party_idx.contains_key(s.party_id.as_str()) {
430 return receipt_fail(&format!(
431 "unknown_party_signature: signature {} references unknown party_id {:?}",
432 i, s.party_id
433 ));
434 }
435 if sig_by_party.contains_key(s.party_id.as_str()) {
436 return receipt_fail(&format!(
437 "duplicate_party_signature: party {:?} has multiple signatures",
438 s.party_id
439 ));
440 }
441 sig_by_party.insert(&s.party_id, i);
442 }
443 for p in &receipt.parties {
444 if !sig_by_party.contains_key(p.party_id.as_str()) {
445 return receipt_fail(&format!(
446 "missing_party_signature: party {:?} has no signature",
447 p.party_id
448 ));
449 }
450 }
451
452 let signable = transaction_receipt_sign_bytes(receipt);
454
455 let mut party_results = Vec::with_capacity(receipt.parties.len());
456 for p in &receipt.parties {
457 if p.proof_bundle.agent_id != p.agent_id {
459 return receipt_fail_with_results(
460 &format!(
461 "party_agent_id_mismatch: party {:?} proof_bundle.agent_id={:?} != party.agent_id={:?}",
462 p.party_id, p.proof_bundle.agent_id, p.agent_id
463 ),
464 party_results,
465 );
466 }
467 if !hybrid_pub_key_equal(&p.proof_bundle.agent_pub_key, &p.agent_pub_key) {
468 return receipt_fail_with_results(
469 &format!(
470 "party_agent_key_mismatch: party {:?} proof_bundle.agent_pub_key != party.agent_pub_key",
471 p.party_id
472 ),
473 party_results,
474 );
475 }
476
477 let bundle_opts = VerifyOptions {
479 now: Some(now),
480 ..VerifyOptions::default()
481 };
482 let r = verify_bundle(&p.proof_bundle, &bundle_opts);
483 party_results.push(r.clone());
484 if !r.valid {
485 return receipt_fail_with_results(
486 &format!(
487 "party_bundle_invalid: party {:?} status={} reason={}",
488 p.party_id,
489 r.identity_status.as_str(),
490 r.error_reason
491 ),
492 party_results,
493 );
494 }
495
496 let sig_idx = sig_by_party[p.party_id.as_str()];
498 let sig = &receipt.party_signatures[sig_idx].signature;
499 if let Err(e) = verify_both(&signable, sig, &p.agent_pub_key) {
500 return receipt_fail_with_results(
501 &format!("party_signature_invalid: party {:?}: {}", p.party_id, e),
502 party_results,
503 );
504 }
505 }
506
507 TransactionReceiptResult {
508 valid: true,
509 error_reason: String::new(),
510 party_results,
511 }
512}
513
514fn receipt_fail(reason: &str) -> TransactionReceiptResult {
515 TransactionReceiptResult {
516 valid: false,
517 error_reason: reason.to_string(),
518 party_results: Vec::new(),
519 }
520}
521
522fn receipt_fail_with_results(
523 reason: &str,
524 party_results: Vec<VerifyResult>,
525) -> TransactionReceiptResult {
526 TransactionReceiptResult {
527 valid: false,
528 error_reason: reason.to_string(),
529 party_results,
530 }
531}
532
533#[allow(clippy::too_many_arguments)]
542pub fn verify_streamed_turn(
543 token: &SessionToken,
544 session_secret: &[u8],
545 challenge: &[u8],
546 challenge_at: i64,
547 challenge_sig: &HybridSignature,
548 session_context: &[u8],
549 stream_id: &[u8],
550 stream_seq: i64,
551 now: i64,
552) -> VerifyResult {
553 if let Err(e) = verify_session_token_e(token, session_secret, now) {
554 return invalid("session_token_invalid", &e);
555 }
556 if challenge.is_empty() {
557 return invalid("no_challenge", "streamed turn contains no challenge");
558 }
559 if !session_context.is_empty() && session_context.len() != 32 {
560 return invalid(
561 "invalid_session_context",
562 &format!(
563 "session_context must be 32 bytes, got {}",
564 session_context.len()
565 ),
566 );
567 }
568 if !stream_id.is_empty() && stream_id.len() != 32 {
569 return invalid(
570 "invalid_stream_id",
571 &format!("stream_id must be 32 bytes, got {}", stream_id.len()),
572 );
573 }
574 if !stream_id.is_empty() && stream_seq < 1 {
575 return invalid(
576 "invalid_stream_seq",
577 &format!("stream_seq must be >=1, got {}", stream_seq),
578 );
579 }
580 let challenge_age = now - challenge_at;
581 if challenge_age < 0 || challenge_age > CHALLENGE_WINDOW_SECONDS {
582 return invalid(
583 "stale_challenge",
584 &format!(
585 "challenge is {} seconds old (max {})",
586 challenge_age, CHALLENGE_WINDOW_SECONDS
587 ),
588 );
589 }
590 if let Err(err) = verify_challenge_signature_with_stream(
591 challenge,
592 challenge_at,
593 session_context,
594 stream_id,
595 stream_seq,
596 challenge_sig,
597 &token.agent_pub_key,
598 ) {
599 return invalid(
600 "bad_challenge_sig",
601 &format!("challenge signature verification failed: {}", err),
602 );
603 }
604 VerifyResult {
605 valid: true,
606 identity_status: IdentityStatus::AuthorizedAgent,
607 human_id: token.human_id.clone(),
608 agent_id: token.agent_id.clone(),
609 agent_name: String::new(),
610 agent_type: String::new(),
611 granted_scope: token.granted_scope.clone(),
612 error_reason: String::new(),
613 }
614}