Skip to main content

continuity_receipt/
verify.rs

1//! Bundle verification (0.1 + 0.2).
2//!
3//! Port of `continuity_receipt/verify.py`. Verdict precedence is
4//! `errors -> UNTRUSTED`, `insufficient -> INSUFFICIENT_EVIDENCE`,
5//! `provisional -> PROVISIONAL`, else `TRUSTED`. Error codes are identical to
6//! the Python verifier. Docstrings note the few places where malformed input
7//! that would raise in Python (floats in canonicalization, unhashable
8//! members, non-object shapes) is failed closed with a structured error
9//! instead.
10
11use std::collections::{BTreeMap, BTreeSet};
12
13use serde_json::{Map, Value};
14
15use crate::canon::{canonical_bytes, commit_field, sha256_prefixed, CanonError};
16use crate::didkey;
17
18pub const SUPPORTED_SPECS: [&str; 3] = [
19    "continuity-receipt/0.1",
20    "continuity-receipt/0.2",
21    "continuity-receipt/0.3",
22];
23pub const RECORD_TYPES: [&str; 9] = [
24    "session.pass.created",
25    "task.decision",
26    "task.execution",
27    "delivery.attestation",
28    "task.termination",
29    "settlement",
30    "authority.succession",
31    "agreement.offer",
32    "agreement.accept",
33];
34
35const ANCHOR_TYPES: [&str; 3] = ["opentimestamps", "public-chain", "custom"];
36const PROVENANCE_PREFIXES: [&str; 2] = ["sha256:", "merkle-sha256:"];
37
38const PASS_FIELDS: [&str; 7] = [
39    "gate_id",
40    "mandala_class",
41    "quotas",
42    "expires_at",
43    "policy_version",
44    "mandate_ref",
45    "agent_id",
46];
47const DECISION_FIELDS: [&str; 6] = [
48    "action",
49    "action_args_hash",
50    "model",
51    "input_provenance",
52    "decision",
53    "policy_version",
54];
55const EXECUTION_FIELDS: [&str; 4] = ["tool_calls", "egress", "resources", "sandbox_class"];
56const DELIVERY_FIELDS: [&str; 3] = ["request_hash", "response_hash", "counterparty"];
57const TERMINATION_FIELDS: [&str; 3] = ["reason", "limits_at_stop", "remaining"];
58const SETTLEMENT_FIELDS: [&str; 5] = [
59    "rail",
60    "rail_ref",
61    "amount",
62    "gated_on_delivery",
63    "settled_at",
64];
65const SUCCESSION_FIELDS: [&str; 4] = ["from_authority", "to_authority", "effective_at", "reason"];
66const OFFER_FIELDS: [&str; 5] = ["offer_id", "offeree", "terms_hash", "valid_until", "nonce"];
67const ACCEPT_FIELDS: [&str; 3] = ["offer_ref", "offer_id", "terms_hash"];
68
69fn required_fields(record_type: &str) -> Option<&'static [&'static str]> {
70    match record_type {
71        "session.pass.created" => Some(&PASS_FIELDS),
72        "task.decision" => Some(&DECISION_FIELDS),
73        "task.execution" => Some(&EXECUTION_FIELDS),
74        "delivery.attestation" => Some(&DELIVERY_FIELDS),
75        "task.termination" => Some(&TERMINATION_FIELDS),
76        "settlement" => Some(&SETTLEMENT_FIELDS),
77        "authority.succession" => Some(&SUCCESSION_FIELDS),
78        "agreement.offer" => Some(&OFFER_FIELDS),
79        "agreement.accept" => Some(&ACCEPT_FIELDS),
80        _ => None,
81    }
82}
83
84/// One structured verification failure.
85#[derive(Debug, Clone)]
86pub struct ErrorEntry {
87    pub code: String,
88    pub detail: String,
89    pub receipt_id: Value,
90}
91
92/// Mirrors Python's `VerifyResult` (`as_dict` produces the same JSON shape).
93#[derive(Debug, Clone, Default)]
94pub struct VerifyResult {
95    pub errors: Vec<ErrorEntry>,
96    pub provisional_reasons: Vec<String>,
97    pub insufficient_reasons: Vec<String>,
98    pub summary: Map<String, Value>,
99}
100
101impl VerifyResult {
102    /// CTQ-aligned verdict, computed from the collected reasons/errors.
103    pub fn verdict(&self) -> &'static str {
104        if !self.errors.is_empty() {
105            "UNTRUSTED"
106        } else if !self.insufficient_reasons.is_empty() {
107            "INSUFFICIENT_EVIDENCE"
108        } else if !self.provisional_reasons.is_empty() {
109            "PROVISIONAL"
110        } else {
111            "TRUSTED"
112        }
113    }
114
115    pub fn codes(&self) -> Vec<&str> {
116        self.errors
117            .iter()
118            .map(|entry| entry.code.as_str())
119            .collect()
120    }
121
122    /// `VerifyResult.as_dict()` equivalent.
123    pub fn as_dict(&self) -> Value {
124        let mut map = Map::new();
125        map.insert(
126            "verdict".to_string(),
127            Value::String(self.verdict().to_string()),
128        );
129        map.insert("errors".to_string(), self.errors_value());
130        map.insert(
131            "provisional_reasons".to_string(),
132            Value::Array(
133                self.provisional_reasons
134                    .iter()
135                    .map(|reason| Value::String(reason.clone()))
136                    .collect(),
137            ),
138        );
139        map.insert(
140            "insufficient_reasons".to_string(),
141            Value::Array(
142                self.insufficient_reasons
143                    .iter()
144                    .map(|reason| Value::String(reason.clone()))
145                    .collect(),
146            ),
147        );
148        map.insert("summary".to_string(), Value::Object(self.summary.clone()));
149        Value::Object(map)
150    }
151
152    fn errors_value(&self) -> Value {
153        Value::Array(
154            self.errors
155                .iter()
156                .map(|entry| {
157                    let mut item = Map::new();
158                    item.insert("code".to_string(), Value::String(entry.code.clone()));
159                    item.insert("detail".to_string(), Value::String(entry.detail.clone()));
160                    item.insert("receipt_id".to_string(), entry.receipt_id.clone());
161                    Value::Object(item)
162                })
163                .collect(),
164        )
165    }
166
167    /// Result carrying a single `malformed` error (used for unparseable input).
168    pub fn malformed(detail: impl Into<String>) -> Self {
169        let mut result = Self::default();
170        fatal(&mut result, "malformed", detail.into(), None);
171        result
172    }
173}
174
175fn fatal(
176    result: &mut VerifyResult,
177    code: &str,
178    detail: impl Into<String>,
179    receipt_id: Option<&Value>,
180) {
181    result.errors.push(ErrorEntry {
182        code: code.to_string(),
183        detail: detail.into(),
184        receipt_id: receipt_id.cloned().unwrap_or(Value::Null),
185    });
186}
187
188fn spec_supported(value: Option<&Value>) -> bool {
189    value
190        .and_then(Value::as_str)
191        .map(|spec| SUPPORTED_SPECS.contains(&spec))
192        .unwrap_or(false)
193}
194
195/// Verify a parsed bundle (Python `verify_bundle`).
196pub fn verify_bundle(bundle: &Value, require_anchor: bool) -> VerifyResult {
197    let mut result = VerifyResult::default();
198
199    let Some(bundle_object) = bundle.as_object() else {
200        fatal(&mut result, "malformed", "bundle is not an object", None);
201        return result;
202    };
203
204    if !spec_supported(bundle_object.get("spec")) {
205        fatal(
206            &mut result,
207            "version_unsupported",
208            format!("spec={}", py_repr(bundle_object.get("spec"))),
209            None,
210        );
211        return result;
212    }
213
214    let Some(receipts) = bundle_object
215        .get("receipts")
216        .and_then(Value::as_array)
217        .filter(|items| !items.is_empty())
218    else {
219        fatal(&mut result, "malformed", "bundle has no receipts", None);
220        return result;
221    };
222
223    // Missing and explicit-null compare equal here, as Python's dict.get does.
224    let task_id = bundle_object
225        .get("task_id")
226        .filter(|value| !value.is_null());
227    let mut expected_prev: Option<String> = None;
228    let mut type_by_seq: BTreeMap<usize, String> = BTreeMap::new();
229
230    for (index, receipt) in receipts.iter().enumerate() {
231        let Some(record) = receipt.as_object() else {
232            fatal(
233                &mut result,
234                "malformed",
235                format!("receipt {index} is not an object"),
236                None,
237            );
238            continue;
239        };
240        let receipt_id = record.get("receipt_id");
241
242        if !spec_supported(record.get("spec")) {
243            fatal(
244                &mut result,
245                "version_unsupported",
246                format!("receipt spec={}", py_repr(record.get("spec"))),
247                receipt_id,
248            );
249        }
250        if record.get("task_id").filter(|value| !value.is_null()) != task_id {
251            fatal(
252                &mut result,
253                "task_mismatch",
254                "receipt task_id != bundle task_id",
255                receipt_id,
256            );
257        }
258
259        let record_type = record.get("type").and_then(Value::as_str);
260        let Some(record_type) = record_type.filter(|name| RECORD_TYPES.contains(name)) else {
261            fatal(
262                &mut result,
263                "unknown_type",
264                format!("type={}", py_repr(record.get("type"))),
265                receipt_id,
266            );
267            continue;
268        };
269        type_by_seq.insert(index, record_type.to_string());
270
271        let Some(body) = record.get("body").and_then(Value::as_object) else {
272            fatal(
273                &mut result,
274                "malformed",
275                "body is not an object",
276                receipt_id,
277            );
278            continue;
279        };
280        let missing: Vec<&str> = required_fields(record_type)
281            .unwrap_or(&[])
282            .iter()
283            .copied()
284            .filter(|name| !body.contains_key(*name))
285            .collect();
286        if !missing.is_empty() {
287            let listed = missing
288                .iter()
289                .map(|name| quote_py_string(name))
290                .collect::<Vec<_>>()
291                .join(", ");
292            fatal(
293                &mut result,
294                "malformed",
295                format!("missing body fields [{listed}]"),
296                receipt_id,
297            );
298        }
299
300        if !validate_timestamp(record.get("issued_at")) {
301            fatal(
302                &mut result,
303                "malformed",
304                format!(
305                    "issued_at not RFC 3339 UTC: {}",
306                    py_repr(record.get("issued_at"))
307                ),
308                receipt_id,
309            );
310        }
311
312        if !seq_matches(record.get("seq"), index) {
313            fatal(
314                &mut result,
315                "chain_break",
316                format!("seq {} != position {index}", py_str(record.get("seq"))),
317                receipt_id,
318            );
319        }
320        let prev = record.get("prev").filter(|value| !value.is_null());
321        let prev_matches = match (prev, expected_prev.as_deref()) {
322            (None, None) => true,
323            (Some(Value::String(text)), Some(expected)) => text == expected,
324            _ => false,
325        };
326        if !prev_matches {
327            fatal(
328                &mut result,
329                "chain_break",
330                "prev digest mismatch",
331                receipt_id,
332            );
333        }
334
335        match receipt_digest(record) {
336            Ok(digest) => expected_prev = Some(digest),
337            Err(error) => {
338                fatal(
339                    &mut result,
340                    "malformed",
341                    format!("canonicalization failed: {error}"),
342                    receipt_id,
343                );
344                expected_prev = None;
345                continue;
346            }
347        }
348
349        let sig = record.get("sig").and_then(Value::as_object);
350        let sig_shape_ok = sig
351            .and_then(|value| value.get("alg"))
352            .and_then(Value::as_str)
353            == Some("ed25519")
354            && py_truthy(sig.and_then(|value| value.get("value")));
355        if !sig_shape_ok {
356            fatal(
357                &mut result,
358                "bad_signature",
359                "missing or unsupported sig",
360                receipt_id,
361            );
362            continue;
363        }
364        let issuer = record
365            .get("issuer")
366            .and_then(Value::as_object)
367            .and_then(|issuer| issuer.get("id"))
368            .and_then(Value::as_str)
369            .unwrap_or("");
370        let signature = sig
371            .and_then(|value| value.get("value"))
372            .and_then(Value::as_str);
373        match canonical_bytes(&Value::Object(unsigned_view(record))) {
374            Ok(message) => {
375                let verified = signature
376                    .map(|value| didkey::verify(issuer, &message, value))
377                    .unwrap_or(false);
378                if !verified {
379                    fatal(
380                        &mut result,
381                        "bad_signature",
382                        "signature does not verify",
383                        receipt_id,
384                    );
385                }
386            }
387            Err(error) => fatal(
388                &mut result,
389                "malformed",
390                format!("canonicalization failed: {error}"),
391                receipt_id,
392            ),
393        }
394    }
395
396    check_cross_record(&mut result, receipts, &type_by_seq);
397    check_agreements(&mut result, receipts);
398    check_redactions(
399        &mut result,
400        receipts,
401        bundle_object
402            .get("disclosure_map")
403            .and_then(Value::as_object),
404    );
405    check_attestations(&mut result, receipts);
406    check_provenance(&mut result, receipts);
407    check_revocations(&mut result, bundle_object, receipts);
408    check_anchors(&mut result, bundle_object, receipts, require_anchor);
409
410    let summary = build_summary(receipts, &type_by_seq);
411    let extras = std::mem::take(&mut result.summary);
412    result.summary = summary;
413    for (key, value) in extras {
414        result.summary.insert(key, value);
415    }
416    result
417}
418
419fn check_cross_record(
420    result: &mut VerifyResult,
421    receipts: &[Value],
422    type_by_seq: &BTreeMap<usize, String>,
423) {
424    let pass_receipts: Vec<&Map<String, Value>> = receipts
425        .iter()
426        .filter_map(Value::as_object)
427        .filter(|record| record.get("type").and_then(Value::as_str) == Some("session.pass.created"))
428        .collect();
429    if pass_receipts.is_empty() {
430        fatal(
431            result,
432            "malformed",
433            "chain has no session.pass.created receipt",
434            None,
435        );
436        return;
437    }
438    let pass_body = pass_receipts[0].get("body").and_then(Value::as_object);
439    let policy_version = pass_body.and_then(|body| body.get("policy_version"));
440
441    for receipt in receipts.iter().filter_map(Value::as_object) {
442        if receipt.get("type").and_then(Value::as_str) != Some("task.decision") {
443            continue;
444        }
445        let decision_policy = receipt
446            .get("body")
447            .and_then(Value::as_object)
448            .and_then(|body| body.get("policy_version"));
449        if decision_policy != policy_version {
450            fatal(
451                result,
452                "policy_mismatch",
453                format!(
454                    "decision policy {} != pass policy {}",
455                    py_repr(decision_policy),
456                    py_repr(policy_version)
457                ),
458                receipt.get("receipt_id"),
459            );
460        }
461    }
462
463    let spend_cap = pass_body
464        .and_then(|body| body.get("spend_cap"))
465        .filter(|value| !value.is_null());
466    let settlement_indexes: Vec<usize> = type_by_seq
467        .iter()
468        .filter(|(_, record_type)| record_type.as_str() == "settlement")
469        .map(|(index, _)| *index)
470        .collect();
471    let delivery_indexes: Vec<usize> = type_by_seq
472        .iter()
473        .filter(|(_, record_type)| record_type.as_str() == "delivery.attestation")
474        .map(|(index, _)| *index)
475        .collect();
476
477    for index in settlement_indexes {
478        let Some(settlement) = receipts.get(index).and_then(Value::as_object) else {
479            continue;
480        };
481        let body = settlement.get("body").and_then(Value::as_object);
482        let amount = body.and_then(|body| body.get("amount"));
483        if let Some(cap) = spend_cap {
484            let cap_object = cap.as_object();
485            let amount_object = amount.and_then(Value::as_object);
486            let currency_mismatch = amount_object.and_then(|item| item.get("currency"))
487                != cap_object.and_then(|item| item.get("currency"));
488            let exceeded = if currency_mismatch {
489                true
490            } else {
491                match (minor_units(amount_object), minor_units(cap_object)) {
492                    (Some(settled), Some(limit)) => settled > limit,
493                    _ => true, // fail closed where Python would raise
494                }
495            };
496            if exceeded {
497                let amount_repr = amount
498                    .map(py_repr_value)
499                    .unwrap_or_else(|| "{}".to_string());
500                fatal(
501                    result,
502                    "cap_exceeded",
503                    format!(
504                        "settlement {amount_repr} exceeds cap {}",
505                        py_repr(Some(cap))
506                    ),
507                    settlement.get("receipt_id"),
508                );
509            }
510        }
511        if py_truthy(body.and_then(|body| body.get("gated_on_delivery"))) {
512            let gated_late =
513                delivery_indexes.is_empty() || delivery_indexes.iter().min().copied() > Some(index);
514            if gated_late {
515                fatal(
516                    result,
517                    "delivery_before_settlement",
518                    "gated settlement recorded before any delivery attestation",
519                    settlement.get("receipt_id"),
520                );
521            }
522        }
523    }
524
525    if !type_by_seq
526        .values()
527        .any(|record_type| record_type == "task.termination")
528    {
529        fatal(
530            result,
531            "missing_termination",
532            "task has no termination receipt",
533            None,
534        );
535    }
536}
537
538/// 0.3: `agreement.accept` binds to a preceding `agreement.offer` (spec ยง4.9).
539///
540/// `offer_ref` is the digest of the offer receipt; an accept whose offer is
541/// absent from the bundle is unverifiable, not false (INSUFFICIENT_EVIDENCE,
542/// `missing_offer`). A present offer with a different `offer_id`/`terms_hash`
543/// is `offer_mismatch`; an accept issued after `valid_until` is
544/// `offer_expired`.
545fn check_agreements(result: &mut VerifyResult, receipts: &[Value]) {
546    let mut offers: BTreeMap<String, &Map<String, Value>> = BTreeMap::new();
547    for receipt in receipts.iter().filter_map(Value::as_object) {
548        if receipt.get("type").and_then(Value::as_str) != Some("agreement.offer") {
549            continue;
550        }
551        if let Ok(digest) = receipt_digest(receipt) {
552            offers.insert(digest, receipt);
553        }
554    }
555
556    for receipt in receipts.iter().filter_map(Value::as_object) {
557        if receipt.get("type").and_then(Value::as_str) != Some("agreement.accept") {
558            continue;
559        }
560        let body = receipt.get("body").and_then(Value::as_object);
561        let offer = body
562            .and_then(|body| body.get("offer_ref"))
563            .and_then(Value::as_str)
564            .and_then(|reference| offers.get(reference));
565        let Some(offer) = offer else {
566            result.insufficient_reasons.push(format!(
567                "missing_offer:{}",
568                receipt
569                    .get("receipt_id")
570                    .and_then(Value::as_str)
571                    .unwrap_or("")
572            ));
573            continue;
574        };
575        let offer_body = offer.get("body").and_then(Value::as_object);
576        let valid_until = offer_body.and_then(|body| body.get("valid_until"));
577        if !validate_timestamp(valid_until) {
578            fatal(
579                result,
580                "malformed",
581                format!(
582                    "offer valid_until not RFC 3339 UTC: {}",
583                    py_repr(valid_until)
584                ),
585                offer.get("receipt_id"),
586            );
587            continue;
588        }
589        let same_offer_id = body.and_then(|body| body.get("offer_id"))
590            == offer_body.and_then(|body| body.get("offer_id"));
591        let same_terms = body.and_then(|body| body.get("terms_hash"))
592            == offer_body.and_then(|body| body.get("terms_hash"));
593        if !same_offer_id || !same_terms {
594            fatal(
595                result,
596                "offer_mismatch",
597                "accept does not match the referenced offer",
598                receipt.get("receipt_id"),
599            );
600            continue;
601        }
602        let issued_at = receipt
603            .get("issued_at")
604            .and_then(Value::as_str)
605            .and_then(parse_timestamp);
606        let valid_until = valid_until
607            .and_then(Value::as_str)
608            .and_then(parse_timestamp);
609        if let (Some(issued_at), Some(valid_until)) = (issued_at, valid_until) {
610            if issued_at > valid_until {
611                fatal(
612                    result,
613                    "offer_expired",
614                    format!(
615                        "accept issued after offer valid_until {}",
616                        py_repr(offer_body.and_then(|body| body.get("valid_until")))
617                    ),
618                    receipt.get("receipt_id"),
619                );
620            }
621        }
622    }
623}
624
625fn check_redactions(
626    result: &mut VerifyResult,
627    receipts: &[Value],
628    disclosure_map: Option<&Map<String, Value>>,
629) {
630    let root = Value::Array(receipts.to_vec());
631    let mut redactions: Vec<(String, &Map<String, Value>)> = Vec::new();
632    iter_redactions(&root, "receipts", &mut redactions);
633
634    for (path, field) in redactions {
635        if required_field_for_path(&path, receipts).is_some() {
636            fatal(
637                result,
638                "redacted_required",
639                format!("required field redacted at {path}"),
640                None,
641            );
642            continue;
643        }
644        if let Some(entry) = disclosure_map
645            .and_then(|map| map.get(path.as_str()))
646            .and_then(Value::as_object)
647        {
648            if !entry.is_empty() && entry.contains_key("salt") && entry.contains_key("value") {
649                let computed = match (
650                    entry.get("salt").and_then(Value::as_str),
651                    entry.get("value"),
652                ) {
653                    (Some(salt), Some(value)) => commit_field(salt, value).ok(),
654                    _ => None,
655                };
656                if computed.as_deref() != field.get("commit").and_then(Value::as_str) {
657                    fatal(
658                        result,
659                        "commit_mismatch",
660                        format!("commit mismatch at {path}"),
661                        None,
662                    );
663                }
664                continue;
665            }
666        }
667        if py_truthy(field.get("erased")) {
668            result
669                .insufficient_reasons
670                .push(format!("erased_content:{path}"));
671        } else {
672            result
673                .provisional_reasons
674                .push(format!("redacted_without_disclosure:{path}"));
675        }
676    }
677}
678
679/// Collect `(path, node)` for every `{"redacted": true}` node, Python order not
680/// required for verdicts (paths/reasons are identical).
681fn iter_redactions<'a>(
682    node: &'a Value,
683    path: &str,
684    out: &mut Vec<(String, &'a Map<String, Value>)>,
685) {
686    match node {
687        Value::Object(map) => {
688            if map.get("redacted") == Some(&Value::Bool(true)) {
689                out.push((path.to_string(), map));
690                return;
691            }
692            for (key, value) in map {
693                let child = if path.is_empty() {
694                    key.clone()
695                } else {
696                    format!("{path}.{key}")
697                };
698                iter_redactions(value, &child, out);
699            }
700        }
701        Value::Array(items) => {
702            for (index, value) in items.iter().enumerate() {
703                iter_redactions(value, &format!("{path}[{index}]"), out);
704            }
705        }
706        _ => {}
707    }
708}
709
710/// `_required_field_for_path`: required body fields may not be redacted.
711pub(crate) fn required_field_for_path(path: &str, receipts: &[Value]) -> Option<String> {
712    let parts: Vec<&str> = path.split('.').collect();
713    if parts.len() < 3 || !parts[0].starts_with("receipts[") || parts[1] != "body" {
714        return None;
715    }
716    let index_text = parts[0].strip_prefix("receipts[")?.trim_end_matches(']');
717    let index: usize = index_text.parse().ok()?;
718    let receipt = receipts.get(index).and_then(Value::as_object)?;
719    let record_type = receipt.get("type").and_then(Value::as_str)?;
720    required_fields(record_type)
721        .filter(|fields| fields.contains(&parts[2]))
722        .map(|_| parts[2].to_string())
723}
724
725/// 0.2: counterparty attestations are verified per-signature and reported.
726/// Absence is reported in the summary but does not change the verdict.
727fn check_attestations(result: &mut VerifyResult, receipts: &[Value]) {
728    let mut seen: Vec<Value> = Vec::new();
729    for receipt in receipts.iter().filter_map(Value::as_object) {
730        if receipt.get("type").and_then(Value::as_str) != Some("delivery.attestation") {
731            continue;
732        }
733        let body = receipt.get("body").and_then(Value::as_object);
734        let counterparty = body
735            .and_then(|body| body.get("counterparty"))
736            .and_then(Value::as_object);
737        let counterparty_id = counterparty.and_then(|item| item.get("id"));
738        let attestation = counterparty.and_then(|item| item.get("attestation"));
739
740        if !py_truthy(attestation) {
741            let mut entry = Map::new();
742            entry.insert(
743                "receipt_id".to_string(),
744                receipt.get("receipt_id").cloned().unwrap_or(Value::Null),
745            );
746            entry.insert(
747                "counterparty".to_string(),
748                counterparty_id.cloned().unwrap_or(Value::Null),
749            );
750            entry.insert(
751                "attestation".to_string(),
752                Value::String("absent".to_string()),
753            );
754            seen.push(Value::Object(entry));
755            continue;
756        }
757
758        let attestation_object = attestation.and_then(Value::as_object);
759        let algorithm_ok = attestation_object
760            .and_then(|item| item.get("alg"))
761            .and_then(Value::as_str)
762            == Some("ed25519");
763        let key = attestation_object
764            .and_then(|item| item.get("key"))
765            .and_then(Value::as_str);
766        let value = attestation_object
767            .and_then(|item| item.get("value"))
768            .and_then(Value::as_str);
769        let valid = match (body, key, value) {
770            (Some(body), Some(key), Some(value)) if algorithm_ok => {
771                let message = Value::Object(attestation_view(body));
772                canonical_bytes(&message)
773                    .map(|bytes| didkey::verify(key, &bytes, value))
774                    .unwrap_or(false)
775            }
776            _ => false,
777        };
778
779        let mut entry = Map::new();
780        entry.insert(
781            "receipt_id".to_string(),
782            receipt.get("receipt_id").cloned().unwrap_or(Value::Null),
783        );
784        entry.insert(
785            "counterparty".to_string(),
786            counterparty_id.cloned().unwrap_or(Value::Null),
787        );
788        entry.insert(
789            "attestation".to_string(),
790            Value::String(if valid { "valid" } else { "invalid" }.to_string()),
791        );
792        entry.insert(
793            "key".to_string(),
794            attestation_object
795                .and_then(|item| item.get("key"))
796                .cloned()
797                .unwrap_or(Value::Null),
798        );
799        seen.push(Value::Object(entry));
800
801        if !valid {
802            fatal(
803                result,
804                "bad_attestation",
805                "counterparty attestation does not verify",
806                receipt.get("receipt_id"),
807            );
808        }
809    }
810    if !seen.is_empty() {
811        result
812            .summary
813            .insert("attestations".to_string(), Value::Array(seen));
814    }
815}
816
817/// The view a counterparty attestation signs: body without
818/// `counterparty.attestation`.
819fn attestation_view(body: &Map<String, Value>) -> Map<String, Value> {
820    let mut view = body.clone();
821    if let Some(counterparty) = view.get("counterparty").and_then(Value::as_object).cloned() {
822        let mut counterparty = counterparty;
823        counterparty.remove("attestation");
824        view.insert("counterparty".to_string(), Value::Object(counterparty));
825    }
826    view
827}
828
829/// 0.2: `observed_sources_hash` is a flat sha256 or a merkle-sha256 root.
830fn check_provenance(result: &mut VerifyResult, receipts: &[Value]) {
831    for receipt in receipts.iter().filter_map(Value::as_object) {
832        if receipt.get("type").and_then(Value::as_str) != Some("task.decision") {
833            continue;
834        }
835        let Some(provenance) = receipt
836            .get("body")
837            .and_then(Value::as_object)
838            .and_then(|body| body.get("input_provenance"))
839            .and_then(Value::as_object)
840        else {
841            continue;
842        };
843        let Some(observed) = provenance
844            .get("observed_sources_hash")
845            .filter(|value| !value.is_null())
846        else {
847            continue;
848        };
849        let supported = observed
850            .as_str()
851            .map(|text| {
852                PROVENANCE_PREFIXES
853                    .iter()
854                    .any(|prefix| text.starts_with(*prefix))
855            })
856            .unwrap_or(false);
857        if !supported {
858            fatal(
859                result,
860                "provenance_invalid",
861                format!(
862                    "observed_sources_hash has unsupported form: {}",
863                    py_repr(Some(observed))
864                ),
865                receipt.get("receipt_id"),
866            );
867        }
868    }
869}
870
871/// 0.2: bundle-level revocation statements, self-signed by the revoked key.
872///
873/// A receipt is `UNTRUSTED` (`key_revoked`) when its issuer key was revoked at
874/// or before the receipt's `issued_at`; receipts before revocation remain
875/// valid, and invalid statements are themselves an error (fail-closed).
876fn check_revocations(result: &mut VerifyResult, bundle: &Map<String, Value>, receipts: &[Value]) {
877    let raw = bundle.get("revocations");
878    if !py_truthy(raw) {
879        return;
880    }
881    let Some(statements) = raw.and_then(Value::as_array) else {
882        fatal(result, "bad_revocation", "revocations must be a list", None);
883        return;
884    };
885
886    let (revoked, statement_errors) = verify_revocation_statements(statements);
887    result.errors.extend(statement_errors);
888    let checked = revoked.len();
889
890    for receipt in receipts.iter().filter_map(Value::as_object) {
891        let key_id = receipt
892            .get("issuer")
893            .and_then(Value::as_object)
894            .and_then(|issuer| issuer.get("id"))
895            .and_then(Value::as_str);
896        let Some(key_id) = key_id else {
897            continue;
898        };
899        let issued_at = receipt.get("issued_at");
900        if !validate_timestamp(issued_at) {
901            continue;
902        }
903        let Some(issued_timestamp) = issued_at.and_then(Value::as_str).and_then(parse_timestamp)
904        else {
905            continue;
906        };
907        for (revoked_key, revoked_at) in &revoked {
908            if revoked_key == key_id && issued_timestamp >= *revoked_at {
909                fatal(
910                    result,
911                    "key_revoked",
912                    format!(
913                        "issuer key {key_id} was revoked at {}",
914                        isoformat(revoked_at)
915                    ),
916                    receipt.get("receipt_id"),
917                );
918            }
919        }
920    }
921    result
922        .summary
923        .insert("revocations_checked".to_string(), Value::from(checked));
924}
925
926/// Verify self-signed revocation statements (0.2 ยง7.5), shared by the bundle
927/// verifier and the verification-receipt verifier.
928///
929/// Returns the verified `(key, revoked_at)` pairs plus structured
930/// `bad_revocation` errors for statements that do not verify. Callers decide
931/// fatality (the bundle verifier fails closed) and how to apply the times.
932pub(crate) fn verify_revocation_statements(
933    statements: &[Value],
934) -> (Vec<(String, Timestamp)>, Vec<ErrorEntry>) {
935    let mut revoked: Vec<(String, Timestamp)> = Vec::new();
936    let mut errors: Vec<ErrorEntry> = Vec::new();
937    for statement in statements {
938        let Some(statement) = statement.as_object() else {
939            errors.push(ErrorEntry {
940                code: "bad_revocation".to_string(),
941                detail: "revocation statement is not an object".to_string(),
942                receipt_id: Value::Null,
943            });
944            continue;
945        };
946        let key_id = statement.get("key").and_then(Value::as_str);
947        let revoked_at = statement.get("revoked_at");
948        if key_id.is_none() || !validate_timestamp(revoked_at) {
949            errors.push(ErrorEntry {
950                code: "bad_revocation".to_string(),
951                detail: format!(
952                    "malformed revocation statement for {}",
953                    py_repr(statement.get("key"))
954                ),
955                receipt_id: Value::Null,
956            });
957            continue;
958        }
959        let key_id = key_id.unwrap_or_default();
960        let revoked_at_text = revoked_at.and_then(Value::as_str).unwrap_or_default();
961
962        let sig = statement.get("sig").and_then(Value::as_object);
963        let signature_ok = sig.and_then(|item| item.get("alg")).and_then(Value::as_str)
964            == Some("ed25519")
965            && py_truthy(sig.and_then(|item| item.get("value")));
966        if !signature_ok {
967            errors.push(ErrorEntry {
968                code: "bad_revocation".to_string(),
969                detail: format!(
970                    "revocation statement unsigned for {}",
971                    py_repr(statement.get("key"))
972                ),
973                receipt_id: Value::Null,
974            });
975            continue;
976        }
977
978        let mut unsigned = statement.clone();
979        unsigned.remove("sig");
980        let verified = match canonical_bytes(&Value::Object(unsigned)) {
981            Ok(message) => sig
982                .and_then(|item| item.get("value"))
983                .and_then(Value::as_str)
984                .map(|value| didkey::verify(key_id, &message, value))
985                .unwrap_or(false),
986            Err(_) => false,
987        };
988        if !verified {
989            errors.push(ErrorEntry {
990                code: "bad_revocation".to_string(),
991                detail: format!(
992                    "revocation signature invalid for {}",
993                    py_repr(statement.get("key"))
994                ),
995                receipt_id: Value::Null,
996            });
997            continue;
998        }
999        let Some(revoked_timestamp) = parse_timestamp(revoked_at_text) else {
1000            continue; // validated above; defensive
1001        };
1002        revoked.push((key_id.to_string(), revoked_timestamp));
1003    }
1004    (revoked, errors)
1005}
1006
1007/// Anchors: shape and digest binding only (`anchor.hash == receipt_digest`).
1008fn check_anchors(
1009    result: &mut VerifyResult,
1010    bundle: &Map<String, Value>,
1011    receipts: &[Value],
1012    require_anchor: bool,
1013) {
1014    let anchors = bundle.get("anchors");
1015    if !py_truthy(anchors) {
1016        if require_anchor {
1017            result
1018                .provisional_reasons
1019                .push("anchor_missing".to_string());
1020        }
1021        return;
1022    }
1023    let Some(anchor_list) = anchors.and_then(Value::as_array) else {
1024        fatal(result, "anchor_invalid", "anchors must be a list", None);
1025        result.summary.insert(
1026            "anchors".to_string(),
1027            Value::Array(vec![Value::String("hash-only".to_string())]),
1028        );
1029        return;
1030    };
1031
1032    let mut kinds: Vec<Value> = Vec::new();
1033    for anchor in anchor_list {
1034        let anchor_object = anchor.as_object();
1035        let target = anchor_object.and_then(|item| item.get("target"));
1036        let target_receipt = find_receipt_by_id(receipts, target);
1037        let bound = match (
1038            target_receipt,
1039            anchor_object
1040                .and_then(|item| item.get("hash"))
1041                .and_then(Value::as_str),
1042        ) {
1043            (Some(receipt), Some(hash)) => receipt_digest(receipt)
1044                .map(|digest| digest == hash)
1045                .unwrap_or(false),
1046            _ => false,
1047        };
1048        if !bound {
1049            fatal(
1050                result,
1051                "anchor_invalid",
1052                format!("anchor invalid for {}", py_str(target)),
1053                None,
1054            );
1055            continue;
1056        }
1057        let meta = anchor_object
1058            .and_then(|item| item.get("anchor"))
1059            .filter(|value| !value.is_null());
1060        if let Some(meta) = meta {
1061            let anchor_type = meta.as_object().and_then(|item| item.get("type"));
1062            match anchor_type
1063                .and_then(Value::as_str)
1064                .filter(|name| ANCHOR_TYPES.contains(name))
1065            {
1066                Some(name) => kinds.push(Value::String(name.to_string())),
1067                None => {
1068                    let rendered = if meta.is_object() {
1069                        py_repr(anchor_type)
1070                    } else {
1071                        py_repr(Some(meta))
1072                    };
1073                    fatal(
1074                        result,
1075                        "anchor_invalid",
1076                        format!("unknown anchor type: {rendered}"),
1077                        None,
1078                    );
1079                }
1080            }
1081        }
1082    }
1083    let summary_kinds = if kinds.is_empty() {
1084        vec![Value::String("hash-only".to_string())]
1085    } else {
1086        kinds
1087    };
1088    result
1089        .summary
1090        .insert("anchors".to_string(), Value::Array(summary_kinds));
1091}
1092
1093/// Last-receipt-wins lookup by `receipt_id`, mirroring the Python dict build.
1094fn find_receipt_by_id<'a>(
1095    receipts: &'a [Value],
1096    target: Option<&Value>,
1097) -> Option<&'a Map<String, Value>> {
1098    let target = target.filter(|value| !value.is_null());
1099    let mut found = None;
1100    for receipt in receipts.iter().filter_map(Value::as_object) {
1101        let receipt_id = receipt.get("receipt_id").filter(|value| !value.is_null());
1102        if receipt_id == target {
1103            found = Some(receipt);
1104        }
1105    }
1106    found
1107}
1108
1109fn build_summary(receipts: &[Value], type_by_seq: &BTreeMap<usize, String>) -> Map<String, Value> {
1110    let mut summary = Map::new();
1111    summary.insert("receipts".to_string(), Value::from(receipts.len()));
1112    summary.insert(
1113        "types".to_string(),
1114        Value::Array(
1115            receipts
1116                .iter()
1117                .filter_map(Value::as_object)
1118                .map(|record| record.get("type").cloned().unwrap_or(Value::Null))
1119                .collect(),
1120        ),
1121    );
1122    let mut issuers: BTreeSet<String> = BTreeSet::new();
1123    for record in receipts.iter().filter_map(Value::as_object) {
1124        let id = record
1125            .get("issuer")
1126            .and_then(Value::as_object)
1127            .and_then(|issuer| issuer.get("id"))
1128            .and_then(Value::as_str)
1129            .unwrap_or("");
1130        issuers.insert(id.to_string());
1131    }
1132    summary.insert(
1133        "issuers".to_string(),
1134        Value::Array(issuers.into_iter().map(Value::String).collect()),
1135    );
1136    summary.insert(
1137        "terminated".to_string(),
1138        Value::Bool(
1139            type_by_seq
1140                .values()
1141                .any(|record_type| record_type == "task.termination"),
1142        ),
1143    );
1144    summary.insert(
1145        "settled".to_string(),
1146        Value::Bool(
1147            type_by_seq
1148                .values()
1149                .any(|record_type| record_type == "settlement"),
1150        ),
1151    );
1152    summary
1153}
1154
1155pub(crate) fn unsigned_view(receipt: &Map<String, Value>) -> Map<String, Value> {
1156    let mut view = receipt.clone();
1157    view.remove("sig");
1158    view
1159}
1160
1161pub(crate) fn receipt_digest(receipt: &Map<String, Value>) -> Result<String, CanonError> {
1162    canonical_bytes(&Value::Object(unsigned_view(receipt))).map(|bytes| sha256_prefixed(&bytes))
1163}
1164
1165fn seq_matches(value: Option<&Value>, index: usize) -> bool {
1166    match value {
1167        Some(Value::Number(number)) => {
1168            number.as_u64() == Some(index as u64) || number.as_i64() == Some(index as i64)
1169        }
1170        // Python's bool is an int subclass (`True == 1`).
1171        Some(Value::Bool(flag)) => usize::from(*flag) == index,
1172        _ => false,
1173    }
1174}
1175
1176fn minor_units(amount: Option<&Map<String, Value>>) -> Option<i128> {
1177    match amount.and_then(|item| item.get("minor")) {
1178        None => Some(0),
1179        Some(value) => py_int(value),
1180    }
1181}
1182
1183/// Python `int()` coercion for the shapes JSON can carry.
1184fn py_int(value: &Value) -> Option<i128> {
1185    match value {
1186        Value::Bool(flag) => Some(i128::from(*flag)),
1187        Value::Number(number) => number
1188            .as_i64()
1189            .map(i128::from)
1190            .or_else(|| number.as_u64().map(i128::from))
1191            .or_else(|| number.to_string().parse::<i128>().ok()),
1192        Value::String(text) => text.trim().parse::<i128>().ok(),
1193        _ => None,
1194    }
1195}
1196
1197/// Python truthiness (`bool(value)`).
1198fn py_truthy(value: Option<&Value>) -> bool {
1199    match value {
1200        None | Some(Value::Null) => false,
1201        Some(Value::Bool(flag)) => *flag,
1202        Some(Value::Number(number)) => number.as_f64() != Some(0.0),
1203        Some(Value::String(text)) => !text.is_empty(),
1204        Some(Value::Array(items)) => !items.is_empty(),
1205        Some(Value::Object(map)) => !map.is_empty(),
1206    }
1207}
1208
1209/// Python `repr()` for the value shapes that appear in verification details.
1210fn py_repr(value: Option<&Value>) -> String {
1211    match value {
1212        None | Some(Value::Null) => "None".to_string(),
1213        Some(Value::Bool(true)) => "True".to_string(),
1214        Some(Value::Bool(false)) => "False".to_string(),
1215        Some(Value::Number(number)) => number.to_string(),
1216        Some(Value::String(text)) => quote_py_string(text),
1217        Some(Value::Array(items)) => {
1218            let parts: Vec<String> = items.iter().map(|item| py_repr(Some(item))).collect();
1219            format!("[{}]", parts.join(", "))
1220        }
1221        Some(Value::Object(map)) => {
1222            let parts: Vec<String> = map
1223                .iter()
1224                .map(|(key, item)| format!("{}: {}", quote_py_string(key), py_repr(Some(item))))
1225                .collect();
1226            format!("{{{}}}", parts.join(", "))
1227        }
1228    }
1229}
1230
1231fn py_repr_value(value: &Value) -> String {
1232    py_repr(Some(value))
1233}
1234
1235/// Python `str()` for the value shapes that appear in verification details.
1236fn py_str(value: Option<&Value>) -> String {
1237    match value {
1238        None | Some(Value::Null) => "None".to_string(),
1239        Some(Value::Bool(true)) => "True".to_string(),
1240        Some(Value::Bool(false)) => "False".to_string(),
1241        Some(Value::Number(number)) => number.to_string(),
1242        Some(Value::String(text)) => text.clone(),
1243        Some(other) => py_repr(Some(other)),
1244    }
1245}
1246
1247fn quote_py_string(text: &str) -> String {
1248    let quote = if text.contains('\'') && !text.contains('"') {
1249        '"'
1250    } else {
1251        '\''
1252    };
1253    let mut out = String::with_capacity(text.len() + 2);
1254    out.push(quote);
1255    for character in text.chars() {
1256        match character {
1257            '\\' => out.push_str("\\\\"),
1258            '\n' => out.push_str("\\n"),
1259            '\r' => out.push_str("\\r"),
1260            '\t' => out.push_str("\\t"),
1261            c if c == quote => {
1262                out.push('\\');
1263                out.push(c);
1264            }
1265            c if (c as u32) < 0x20 || c as u32 == 0x7f => {
1266                out.push_str(&format!("\\x{:02x}", c as u32));
1267            }
1268            c => out.push(c),
1269        }
1270    }
1271    out.push(quote);
1272    out
1273}
1274
1275/// RFC 3339 UTC with optional 1-3 fractional digits (`records._TIMESTAMP_RE`).
1276pub(crate) fn validate_timestamp(value: Option<&Value>) -> bool {
1277    value
1278        .and_then(Value::as_str)
1279        .map(is_rfc3339_utc)
1280        .unwrap_or(false)
1281}
1282
1283fn is_rfc3339_utc(text: &str) -> bool {
1284    let bytes = text.as_bytes();
1285    let is_digit = |index: usize| {
1286        bytes
1287            .get(index)
1288            .map(|byte| byte.is_ascii_digit())
1289            .unwrap_or(false)
1290    };
1291    if bytes.len() < 20 {
1292        return false;
1293    }
1294    if !(is_digit(0) && is_digit(1) && is_digit(2) && is_digit(3)) {
1295        return false;
1296    }
1297    if bytes[4] != b'-' || !(is_digit(5) && is_digit(6)) {
1298        return false;
1299    }
1300    if bytes[7] != b'-' || !(is_digit(8) && is_digit(9)) {
1301        return false;
1302    }
1303    if bytes[10] != b'T' || !(is_digit(11) && is_digit(12)) {
1304        return false;
1305    }
1306    if bytes[13] != b':' || !(is_digit(14) && is_digit(15)) {
1307        return false;
1308    }
1309    if bytes[16] != b':' || !(is_digit(17) && is_digit(18)) {
1310        return false;
1311    }
1312    if bytes.len() == 20 {
1313        return bytes[19] == b'Z';
1314    }
1315    if bytes[19] != b'.' || bytes[bytes.len() - 1] != b'Z' {
1316        return false;
1317    }
1318    let fraction = &bytes[20..bytes.len() - 1];
1319    !fraction.is_empty() && fraction.len() <= 3 && fraction.iter().all(|byte| byte.is_ascii_digit())
1320}
1321
1322/// Chronologically comparable UTC timestamp (fraction normalized to millis).
1323#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1324pub(crate) struct Timestamp {
1325    year: u32,
1326    month: u32,
1327    day: u32,
1328    hour: u32,
1329    minute: u32,
1330    second: u32,
1331    millis: u32,
1332}
1333
1334pub(crate) fn parse_timestamp(text: &str) -> Option<Timestamp> {
1335    if !is_rfc3339_utc(text) {
1336        return None;
1337    }
1338    let part = |start: usize, end: usize| -> Option<u32> { text.get(start..end)?.parse().ok() };
1339    let millis = if text.as_bytes().get(19) == Some(&b'.') {
1340        let fraction = text.get(20..text.len().checked_sub(1)?)?;
1341        let mut value = fraction.parse::<u32>().ok()?;
1342        for _ in fraction.len()..3 {
1343            value = value.checked_mul(10)?;
1344        }
1345        value
1346    } else {
1347        0
1348    };
1349    Some(Timestamp {
1350        year: part(0, 4)?,
1351        month: part(5, 7)?,
1352        day: part(8, 10)?,
1353        hour: part(11, 13)?,
1354        minute: part(14, 16)?,
1355        second: part(17, 19)?,
1356        millis,
1357    })
1358}
1359
1360/// Python `datetime.isoformat()` for a parsed UTC timestamp.
1361pub(crate) fn isoformat(timestamp: &Timestamp) -> String {
1362    let base = format!(
1363        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
1364        timestamp.year,
1365        timestamp.month,
1366        timestamp.day,
1367        timestamp.hour,
1368        timestamp.minute,
1369        timestamp.second
1370    );
1371    if timestamp.millis == 0 {
1372        format!("{base}+00:00")
1373    } else {
1374        format!("{base}.{:06}+00:00", timestamp.millis * 1000)
1375    }
1376}