1use 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#[derive(Debug, Clone)]
86pub struct ErrorEntry {
87 pub code: String,
88 pub detail: String,
89 pub receipt_id: Value,
90}
91
92#[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 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 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 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
195pub 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 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, }
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
538fn 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
679fn 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
710pub(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
725fn 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
817fn 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
829fn 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
871fn 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
926pub(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; };
1002 revoked.push((key_id.to_string(), revoked_timestamp));
1003 }
1004 (revoked, errors)
1005}
1006
1007fn 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
1093fn 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 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
1183fn 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
1197fn 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
1209fn 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
1235fn 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
1275pub(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#[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
1360pub(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}