Skip to main content

ratify_protocol/
verify.rs

1//! Verify — the core verifier. Mirrors the Go reference verify.go exactly.
2
3use 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
20/// `verify_bundle` is the entry point. Audit hook (SPEC §17.3) wraps the
21/// inner verifier so it fires on every call — success AND failure — and its
22/// errors are swallowed so auditing never alters the verdict.
23pub 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    // --- Structure ---
40    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    // --- v1.1 stream binding checks (SPEC §5.8, §6.4.2) ---
91    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    // --- Per-cert ---
184    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        // Revocation: provider (SPEC §17.1) takes precedence over legacy closure.
198        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        // Constraint evaluation — each cert's first-class constraints must all
218        // pass against the caller-supplied VerifierContext. Fail-closed.
219        if let Err(constraint_err) = evaluate_constraints(
220            cert,
221            &opts.context,
222            now,
223            opts.constraint_evaluators.as_ref(),
224        ) {
225            // Route constraint failures to the specific identity_status so
226            // audit layers can distinguish unverifiable / unknown / denied.
227            // Matches Go/TS/Python.
228            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        // Chain linkage
238        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            // Sub-delegation gate: parent cert must have granted identity:delegate.
257            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    // --- Liveness ---
272    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    // --- Effective scope ---
298    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    // --- Anchor resolution (SPEC §17.8) ---
328    // Best-effort: populate anchor on the success result so downstream
329    // AuditProviders observe an identity-bound receipt. Resolver errors are
330    // non-fatal.
331    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    // --- Advanced Policy Gating (SPEC §17.2 / §17.6) ---
338    //
339    // Fast path: if a PolicyVerdict is supplied AND verifies cleanly, skip
340    // the live Policy provider entirely.
341    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                            // stale verdict — fall through to live policy
369                        }
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
397// ----------------------------------------------------------------------
398
399fn 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
435/// fail_with_status is used when the failure maps to its own identity_status
436/// (scope_denied, constraint_denied, constraint_unverifiable,
437/// delegation_not_authorized). Unknown `status` strings fall back to Invalid
438/// — the wire form for error_reason still reflects the intended status so
439/// audits aren't lossy.
440fn 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
483// ----------------------------------------------------------------------
484// v1.1 transaction receipt verification
485// ----------------------------------------------------------------------
486
487/// Verify a TransactionReceipt: envelope checks, per-party bundle
488/// verification, and party signature verification over the canonical signable.
489pub 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    // Party IDs must be unique.
515    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    // Each listed party must have exactly one signature; every signature's
530    // party_id must refer to a listed party.
531    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    // Canonical signable bytes.
557    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        // Proof bundle's agent_id / agent_pub_key MUST match the party's.
562        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        // Bundle verification.
582        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        // Party signature check over the atomic signable.
601        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// ----------------------------------------------------------------------
638// v1.1 session cert cache (ROADMAP 2.3) streamed-turn verification
639// ----------------------------------------------------------------------
640
641/// Fast-path verifier for streamed turns that present a SessionToken in
642/// place of the full cert chain. Checks HMAC, validity window, challenge
643/// freshness, and hybrid challenge signature against token.agent_pub_key.
644/// The chain is NOT re-verified — that's the point of the token.
645#[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}