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
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    // --- Structure ---
29    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    // --- v1.1 stream binding checks (SPEC §5.8, §6.4.2) ---
80    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    // --- Per-cert ---
171    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        // Constraint evaluation — each cert's first-class constraints must all
193        // pass against the caller-supplied VerifierContext. Fail-closed.
194        if let Err(constraint_err) = evaluate_constraints(cert, &opts.context, now) {
195            // Route constraint failures to the specific identity_status so
196            // audit layers can distinguish unverifiable / unknown / denied.
197            // Matches Go/TS/Python.
198            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        // Chain linkage
208        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            // Sub-delegation gate: parent cert must have granted identity:delegate.
227            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    // --- Liveness ---
242    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    // --- Effective scope ---
268    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
297// ----------------------------------------------------------------------
298
299fn 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
334/// fail_with_status is used when the failure maps to its own identity_status
335/// (scope_denied, constraint_denied, constraint_unverifiable,
336/// delegation_not_authorized). Unknown `status` strings fall back to Invalid
337/// — the wire form for error_reason still reflects the intended status so
338/// audits aren't lossy.
339fn 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
379// ----------------------------------------------------------------------
380// v1.1 transaction receipt verification
381// ----------------------------------------------------------------------
382
383/// Verify a TransactionReceipt: envelope checks, per-party bundle
384/// verification, and party signature verification over the canonical signable.
385pub 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    // Party IDs must be unique.
411    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    // Each listed party must have exactly one signature; every signature's
426    // party_id must refer to a listed party.
427    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    // Canonical signable bytes.
453    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        // Proof bundle's agent_id / agent_pub_key MUST match the party's.
458        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        // Bundle verification.
478        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        // Party signature check over the atomic signable.
497        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// ----------------------------------------------------------------------
534// v1.1 session cert cache (ROADMAP 2.3) streamed-turn verification
535// ----------------------------------------------------------------------
536
537/// Fast-path verifier for streamed turns that present a SessionToken in
538/// place of the full cert chain. Checks HMAC, validity window, challenge
539/// freshness, and hybrid challenge signature against token.agent_pub_key.
540/// The chain is NOT re-verified — that's the point of the token.
541#[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}