proofbundle 3.1.3__tar.gz → 3.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. {proofbundle-3.1.3/src/proofbundle.egg-info → proofbundle-3.2.0}/PKG-INFO +5 -1
  2. {proofbundle-3.1.3 → proofbundle-3.2.0}/README.md +4 -0
  3. {proofbundle-3.1.3 → proofbundle-3.2.0}/pyproject.toml +1 -1
  4. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/__init__.py +42 -1
  5. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/anchors_chia_add.py +51 -18
  6. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/cli.py +160 -0
  7. proofbundle-3.2.0/src/proofbundle/evidence_pack.py +98 -0
  8. proofbundle-3.2.0/src/proofbundle/hashalg.py +177 -0
  9. proofbundle-3.2.0/src/proofbundle/outcome.py +358 -0
  10. proofbundle-3.2.0/src/proofbundle/pqsig.py +114 -0
  11. proofbundle-3.2.0/src/proofbundle/public_transparency.py +201 -0
  12. proofbundle-3.2.0/src/proofbundle/renewal.py +483 -0
  13. proofbundle-3.2.0/src/proofbundle/run_ledger.py +295 -0
  14. proofbundle-3.2.0/src/proofbundle/sdjwt_vc.py +182 -0
  15. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/signature.py +2 -0
  16. proofbundle-3.2.0/src/proofbundle/subject_binding.py +115 -0
  17. proofbundle-3.2.0/src/proofbundle/trust_pack.py +432 -0
  18. proofbundle-3.2.0/src/proofbundle/verification_summary.py +252 -0
  19. {proofbundle-3.1.3 → proofbundle-3.2.0/src/proofbundle.egg-info}/PKG-INFO +5 -1
  20. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle.egg-info/SOURCES.txt +31 -0
  21. proofbundle-3.2.0/tests/test_anchor_longevity_property.py +169 -0
  22. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_anchors_chia_add.py +32 -0
  23. proofbundle-3.2.0/tests/test_checkpoint_quorum_property.py +85 -0
  24. proofbundle-3.2.0/tests/test_content_root_property.py +132 -0
  25. proofbundle-3.2.0/tests/test_dsse_adversarial.py +102 -0
  26. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_evalclaim.py +15 -0
  27. proofbundle-3.2.0/tests/test_evidence_pack.py +147 -0
  28. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_fuzz_parsers.py +77 -0
  29. proofbundle-3.2.0/tests/test_hashalg.py +145 -0
  30. proofbundle-3.2.0/tests/test_merkle_consistency_property.py +83 -0
  31. proofbundle-3.2.0/tests/test_official_vectors.py +94 -0
  32. proofbundle-3.2.0/tests/test_outcome_verify.py +281 -0
  33. proofbundle-3.2.0/tests/test_pqsig.py +126 -0
  34. proofbundle-3.2.0/tests/test_public_transparency.py +193 -0
  35. proofbundle-3.2.0/tests/test_renewal.py +179 -0
  36. proofbundle-3.2.0/tests/test_renewal_policy.py +100 -0
  37. proofbundle-3.2.0/tests/test_renewal_signed.py +214 -0
  38. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_rfc6962_external_vectors.py +20 -5
  39. proofbundle-3.2.0/tests/test_run_ledger.py +152 -0
  40. proofbundle-3.2.0/tests/test_sdjwt_adversarial.py +134 -0
  41. proofbundle-3.2.0/tests/test_sdjwt_vc.py +222 -0
  42. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_statuslist.py +46 -0
  43. proofbundle-3.2.0/tests/test_subject_binding.py +95 -0
  44. proofbundle-3.2.0/tests/test_trust_pack.py +430 -0
  45. proofbundle-3.2.0/tests/test_verification_summary.py +167 -0
  46. {proofbundle-3.1.3 → proofbundle-3.2.0}/LICENSE +0 -0
  47. {proofbundle-3.1.3 → proofbundle-3.2.0}/setup.cfg +0 -0
  48. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/_inspect_registry.py +0 -0
  49. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/_integration.py +0 -0
  50. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/_strict_json.py +0 -0
  51. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/adapters/__init__.py +0 -0
  52. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/adapters/_provenance.py +0 -0
  53. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/adapters/eee.py +0 -0
  54. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/adapters/inspect_ai.py +0 -0
  55. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/adapters/lm_eval.py +0 -0
  56. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/adapters/promptfoo.py +0 -0
  57. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/adapters/samples.py +0 -0
  58. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/anchors.py +0 -0
  59. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/anchors_chia.py +0 -0
  60. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/anchors_markovian.py +0 -0
  61. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/anchors_ots.py +0 -0
  62. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/anchors_rfc3161.py +0 -0
  63. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/beacon.py +0 -0
  64. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/bundle.py +0 -0
  65. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/canonical.py +0 -0
  66. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/checkpoint.py +0 -0
  67. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/decision.py +0 -0
  68. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/demo.py +0 -0
  69. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/dsse.py +0 -0
  70. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/eee_eval_schema.json +0 -0
  71. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/emit.py +0 -0
  72. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/errors.py +0 -0
  73. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/evalclaim.py +0 -0
  74. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/experimental/__init__.py +0 -0
  75. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/experimental/enclave.py +0 -0
  76. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/hf_evals.py +0 -0
  77. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/inspect_hook.py +0 -0
  78. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/intoto.py +0 -0
  79. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/kbjwt.py +0 -0
  80. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/merkle.py +0 -0
  81. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/persample.py +0 -0
  82. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/policies/decision-receipt-template-v1.json +0 -0
  83. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/policies/research-preview-v1.json +0 -0
  84. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/policies/strict-eval-authenticated-root-template-v1.json +0 -0
  85. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/policies/strict-eval-template-v1.json +0 -0
  86. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/policies/strict-prereg-template-v1.json +0 -0
  87. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/policy.py +0 -0
  88. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/policy_profiles.py +0 -0
  89. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/prereg.py +0 -0
  90. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/py.typed +0 -0
  91. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/pytest_plugin.py +0 -0
  92. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/sdjwt.py +0 -0
  93. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/sdjwt_issue.py +0 -0
  94. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/statuslist.py +0 -0
  95. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle/tlogproof.py +0 -0
  96. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle.egg-info/dependency_links.txt +0 -0
  97. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle.egg-info/entry_points.txt +0 -0
  98. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle.egg-info/requires.txt +0 -0
  99. {proofbundle-3.1.3 → proofbundle-3.2.0}/src/proofbundle.egg-info/top_level.txt +0 -0
  100. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_adapters.py +0 -0
  101. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_adversarial.py +0 -0
  102. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_anchor_target_trustedtime.py +0 -0
  103. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_anchors_chia.py +0 -0
  104. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_anchors_chia_claims.py +0 -0
  105. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_anchors_generic.py +0 -0
  106. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_anchors_markovian.py +0 -0
  107. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_anchors_ots.py +0 -0
  108. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_anchors_rfc3161.py +0 -0
  109. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_beacon.py +0 -0
  110. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_branch_base_check.py +0 -0
  111. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_bundle.py +0 -0
  112. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_bundle_robustness.py +0 -0
  113. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_canonical.py +0 -0
  114. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_checkpoint.py +0 -0
  115. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_claims_hygiene.py +0 -0
  116. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_cli.py +0 -0
  117. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_cli_eval.py +0 -0
  118. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_cli_require_anchor.py +0 -0
  119. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_conformance.py +0 -0
  120. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_cosignature.py +0 -0
  121. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_cosignature_mldsa.py +0 -0
  122. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_decision_anchors.py +0 -0
  123. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_decision_cli.py +0 -0
  124. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_decision_evidence.py +0 -0
  125. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_decision_fuzz.py +0 -0
  126. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_decision_hardening.py +0 -0
  127. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_decision_policy.py +0 -0
  128. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_decision_schema.py +0 -0
  129. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_decision_validator_api.py +0 -0
  130. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_decision_verify.py +0 -0
  131. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_demo.py +0 -0
  132. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_docs_truth.py +0 -0
  133. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_dup_key_reject.py +0 -0
  134. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_ed25519_semantics.py +0 -0
  135. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_eee.py +0 -0
  136. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_emit.py +0 -0
  137. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_eval_claim_schema.py +0 -0
  138. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_eval_evidence_class.py +0 -0
  139. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_examples.py +0 -0
  140. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_experimental_enclave.py +0 -0
  141. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_fork_pr_secret_isolation.py +0 -0
  142. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_hf_entry_verify_eee_digest.py +0 -0
  143. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_hf_evals.py +0 -0
  144. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_inspect_hook.py +0 -0
  145. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_intoto.py +0 -0
  146. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_intoto_claims_hygiene.py +0 -0
  147. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_intoto_content_root_migration.py +0 -0
  148. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_intoto_dsse.py +0 -0
  149. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_intoto_eval_result.py +0 -0
  150. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_intoto_examples.py +0 -0
  151. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_intoto_spec_diff.py +0 -0
  152. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_intoto_svr.py +0 -0
  153. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_kbjwt.py +0 -0
  154. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_lens_review_fixes_3_1_3.py +0 -0
  155. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_merkle.py +0 -0
  156. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_merkle_property.py +0 -0
  157. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_ok_semantics.py +0 -0
  158. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_oversized_int_guard.py +0 -0
  159. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_persample.py +0 -0
  160. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_pinned_key_validation.py +0 -0
  161. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_policy_explain_lint.py +0 -0
  162. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_policy_lifecycle_purpose.py +0 -0
  163. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_policy_profiles.py +0 -0
  164. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_policy_templates.py +0 -0
  165. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_predicate_type_enforcement.py +0 -0
  166. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_prereg.py +0 -0
  167. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_promptfoo.py +0 -0
  168. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_provenance.py +0 -0
  169. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_pytest_plugin.py +0 -0
  170. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_rekor_interop.py +0 -0
  171. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_release_integrity_gate.py +0 -0
  172. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_root_authenticity.py +0 -0
  173. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_schema.py +0 -0
  174. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_sdjwt_duplicate_cnf.py +0 -0
  175. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_sdjwt_issue.py +0 -0
  176. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_sdjwt_reference.py +0 -0
  177. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_sdjwt_verify_binding.py +0 -0
  178. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_signature.py +0 -0
  179. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_tlogproof.py +0 -0
  180. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_tree_context_authenticity.py +0 -0
  181. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_trust_policy.py +0 -0
  182. {proofbundle-3.1.3 → proofbundle-3.2.0}/tests/test_verify_matrix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofbundle
3
- Version: 3.1.3
3
+ Version: 3.2.0
4
4
  Summary: Emit and verify portable cryptographic evidence bundles, offline: Ed25519 + RFC 6962 Merkle + optional SD-JWT.
5
5
  Author: Konrad Gruszka
6
6
  License: MIT
@@ -300,6 +300,10 @@ roadmap.
300
300
  a signed outcome is self-assertion. See
301
301
  [docs/predicates/decision-receipt.md](https://github.com/b7n0de/proofbundle/blob/main/docs/predicates/decision-receipt.md)
302
302
  and [ADR 0001](https://github.com/b7n0de/proofbundle/blob/main/docs/adr/0001-decision-receipt-separate-predicate.md).
303
+ That signed outcome is the `action-outcome/v0.1` predicate (EXPERIMENTAL, 3.2.0): an executor signs what was
304
+ actually done, bound by content root to the Decision Receipt, with role separation (executor ≠ decision maker)
305
+ and `execution_proven` only when an effect digest backs an `executed` status. See
306
+ [docs/predicates/action-outcome.md](https://github.com/b7n0de/proofbundle/blob/main/docs/predicates/action-outcome.md).
303
307
 
304
308
  ## Docs
305
309
 
@@ -246,6 +246,10 @@ roadmap.
246
246
  a signed outcome is self-assertion. See
247
247
  [docs/predicates/decision-receipt.md](https://github.com/b7n0de/proofbundle/blob/main/docs/predicates/decision-receipt.md)
248
248
  and [ADR 0001](https://github.com/b7n0de/proofbundle/blob/main/docs/adr/0001-decision-receipt-separate-predicate.md).
249
+ That signed outcome is the `action-outcome/v0.1` predicate (EXPERIMENTAL, 3.2.0): an executor signs what was
250
+ actually done, bound by content root to the Decision Receipt, with role separation (executor ≠ decision maker)
251
+ and `execution_proven` only when an effect digest backs an `executed` status. See
252
+ [docs/predicates/action-outcome.md](https://github.com/b7n0de/proofbundle/blob/main/docs/predicates/action-outcome.md).
249
253
 
250
254
  ## Docs
251
255
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proofbundle"
7
- version = "3.1.3"
7
+ version = "3.2.0"
8
8
  description = "Emit and verify portable cryptographic evidence bundles, offline: Ed25519 + RFC 6962 Merkle + optional SD-JWT."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  from typing import TYPE_CHECKING
15
15
 
16
- __version__ = "3.1.3"
16
+ __version__ = "3.2.0"
17
17
 
18
18
  # The `proofbundle/v0.1` normative spec revision this build implements — kept in sync with the
19
19
  # `Revision:` line at the top of SPEC.md by tests/test_docs_truth.py (WP-B1, closes #28). Bump
@@ -47,6 +47,23 @@ __all__ = [
47
47
  "beacon_audit_challenge",
48
48
  "canonicalize_statement",
49
49
  "statement_content_root",
50
+ "resolve_hash_alg",
51
+ "compute_dual_hash",
52
+ "verify_dual_hash",
53
+ "build_evidence_pack",
54
+ "verify_evidence_pack",
55
+ "ots_upgraded_proof_is_self_contained",
56
+ "build_initial_sequence",
57
+ "renew_timestamp",
58
+ "renew_hashtree",
59
+ "verify_sequence",
60
+ "last_ats",
61
+ "evaluate_renewal_policy",
62
+ "ArchiveTimeStamp",
63
+ "RenewalPolicy",
64
+ "verify_mldsa",
65
+ "verify_slhdsa",
66
+ "verify_hybrid",
50
67
  "VerificationResult",
51
68
  "Check",
52
69
  "ProofBundleError",
@@ -75,6 +92,23 @@ _LAZY = {
75
92
  "beacon_audit_challenge": ".beacon",
76
93
  "canonicalize_statement": ".canonical",
77
94
  "statement_content_root": ".canonical",
95
+ "resolve_hash_alg": ".hashalg",
96
+ "compute_dual_hash": ".hashalg",
97
+ "verify_dual_hash": ".hashalg",
98
+ "build_evidence_pack": ".evidence_pack",
99
+ "verify_evidence_pack": ".evidence_pack",
100
+ "ots_upgraded_proof_is_self_contained": ".evidence_pack",
101
+ "build_initial_sequence": ".renewal",
102
+ "renew_timestamp": ".renewal",
103
+ "renew_hashtree": ".renewal",
104
+ "verify_sequence": ".renewal",
105
+ "last_ats": ".renewal",
106
+ "evaluate_renewal_policy": ".renewal",
107
+ "ArchiveTimeStamp": ".renewal",
108
+ "RenewalPolicy": ".renewal",
109
+ "verify_mldsa": ".pqsig",
110
+ "verify_slhdsa": ".pqsig",
111
+ "verify_hybrid": ".pqsig",
78
112
  }
79
113
 
80
114
  if TYPE_CHECKING: # static analysers + IDEs see the real names/types; runtime stays lazy
@@ -88,6 +122,13 @@ if TYPE_CHECKING: # static analysers + IDEs see the real names/types; runtime s
88
122
  verify_sample_opening)
89
123
  from .beacon import beacon_audit_challenge
90
124
  from .canonical import canonicalize_statement, statement_content_root
125
+ from .hashalg import compute_dual_hash, resolve_hash_alg, verify_dual_hash
126
+ from .evidence_pack import (build_evidence_pack, ots_upgraded_proof_is_self_contained,
127
+ verify_evidence_pack)
128
+ from .renewal import (ArchiveTimeStamp, RenewalPolicy, build_initial_sequence,
129
+ evaluate_renewal_policy, last_ats, renew_hashtree, renew_timestamp,
130
+ verify_sequence)
131
+ from .pqsig import verify_hybrid, verify_mldsa, verify_slhdsa
91
132
  from .prereg import prereg_hash, verify_prereg
92
133
  from .statuslist import verify_status_snapshot
93
134
  from .tlogproof import verify_tlog_proof
@@ -19,13 +19,41 @@ import os
19
19
  import shutil
20
20
  import subprocess
21
21
  import time
22
- from typing import Optional
22
+ from contextlib import contextmanager
23
+ from pathlib import Path
24
+ from typing import Iterator, Optional
23
25
 
24
26
  from .anchors_chia import ANCHOR_TYPE, verify_offline_merkle
25
27
 
26
28
  _CHIA_BIN = os.getenv("CHIA_CLI", shutil.which("chia") or "chia")
27
29
 
28
30
 
31
+ @contextmanager
32
+ def _anchor_in_progress_lock(lock_path: Optional[str]) -> Iterator[None]:
33
+ """Advisory 'anchor in progress' marker held around the on-chain batch_update + confirmation.
34
+
35
+ While an anchor's wallet-using transaction is submitted and confirmed, write a marker FILE at
36
+ ``lock_path`` (or the ``PROOFBUNDLE_ANCHOR_LOCK_PATH`` env). A SEPARATE process — e.g. a wallet-switch
37
+ guard that must not log the wallet out from under the transaction — can treat the file's PRESENCE as
38
+ 'an anchor is running'. Removed on completion; a crash leaves it stale, which a conservative guard
39
+ treats as still-active (fail-safe, never breaks a possibly-live anchor). No-op when no path is set, so
40
+ the default anchor flow is unchanged and proofbundle stays free of any consumer-specific naming."""
41
+ path = lock_path or os.getenv("PROOFBUNDLE_ANCHOR_LOCK_PATH")
42
+ if not path:
43
+ yield
44
+ return
45
+ p = Path(path)
46
+ p.parent.mkdir(parents=True, exist_ok=True)
47
+ p.write_text(str(os.getpid()))
48
+ try:
49
+ yield
50
+ finally:
51
+ try:
52
+ p.unlink()
53
+ except OSError:
54
+ pass
55
+
56
+
29
57
  class ChiaRpcError(RuntimeError):
30
58
  """A Chia RPC/CLI call failed. Fail-closed: the caller aborts, no partial anchor is written."""
31
59
 
@@ -125,11 +153,15 @@ def _wait_confirmed(store_id: str, prev_root: Optional[str], *, timeout: int = 1
125
153
 
126
154
  def anchor_add(canonical_root_hex: str, *, store_id: str, value_digest_hex: Optional[str] = None,
127
155
  target: str = "receipt", network: str = "mainnet", fee: int = 100_000_000,
128
- wait: bool = True) -> dict:
156
+ wait: bool = True, lock_path: Optional[str] = None) -> dict:
129
157
  """Full flow: insert ``key=canonicalRoot`` (value = ``value_digest`` or the canonicalRoot itself) into the
130
158
  store, wait for on-chain confirmation, then export the anchor. Idempotent: a key already present ("Key
131
159
  already present") is treated as "already anchored" and re-exported, not an error. Fail-closed: a network
132
- failure raises ChiaRpcError and writes NOTHING partial (the caller keeps its receipt untouched)."""
160
+ failure raises ChiaRpcError and writes NOTHING partial (the caller keeps its receipt untouched).
161
+
162
+ ``lock_path`` (or the ``PROOFBUNDLE_ANCHOR_LOCK_PATH`` env) holds an advisory 'anchor in progress' marker
163
+ file AUTOMATICALLY around the wallet-using batch_update + confirmation, so a separate wallet-switch guard
164
+ never logs the wallet out mid-anchor. No path → unchanged behaviour."""
133
165
  canonical_root_hex = canonical_root_hex if canonical_root_hex.startswith(("0x", "0X")) else "0x" + canonical_root_hex
134
166
  canonical_root = bytes.fromhex(canonical_root_hex[2:])
135
167
  if len(canonical_root) != 32:
@@ -137,18 +169,19 @@ def anchor_add(canonical_root_hex: str, *, store_id: str, value_digest_hex: Opti
137
169
  value_hex = value_digest_hex or canonical_root_hex
138
170
  value_hex = value_hex if value_hex.startswith(("0x", "0X")) else "0x" + value_hex
139
171
 
140
- prev_root = _rpc("data_layer", "get_root", {"id": store_id}).get("hash")
141
- changelist = [{"action": "insert", "key": canonical_root_hex, "value": value_hex}]
142
- already = False
143
- try:
144
- _rpc("data_layer", "batch_update",
145
- {"id": store_id, "changelist": changelist, "fee": fee, "submit_on_chain": True})
146
- except ChiaRpcError as exc:
147
- if "already present" in str(exc).lower():
148
- already = True # idempotent: the digest is already anchored
149
- else:
150
- raise # any other failure → clean abort, nothing written
151
- if wait and not already:
152
- _wait_confirmed(store_id, prev_root)
153
- return export_anchor(store_id, canonical_root=canonical_root,
154
- target=target, network=network, value=value_hex)
172
+ with _anchor_in_progress_lock(lock_path):
173
+ prev_root = _rpc("data_layer", "get_root", {"id": store_id}).get("hash")
174
+ changelist = [{"action": "insert", "key": canonical_root_hex, "value": value_hex}]
175
+ already = False
176
+ try:
177
+ _rpc("data_layer", "batch_update",
178
+ {"id": store_id, "changelist": changelist, "fee": fee, "submit_on_chain": True})
179
+ except ChiaRpcError as exc:
180
+ if "already present" in str(exc).lower():
181
+ already = True # idempotent: the digest is already anchored
182
+ else:
183
+ raise # any other failure → clean abort, nothing written
184
+ if wait and not already:
185
+ _wait_confirmed(store_id, prev_root)
186
+ return export_anchor(store_id, canonical_root=canonical_root,
187
+ target=target, network=network, value=value_hex)
@@ -1303,6 +1303,127 @@ def _cmd_decision_init(args: argparse.Namespace) -> int:
1303
1303
  return 0
1304
1304
 
1305
1305
 
1306
+ # ── Action Outcome Receipt (3.2.0, action-outcome/v0.1, EXPERIMENTAL) ──────────
1307
+ def _cmd_outcome_init(args: argparse.Namespace) -> int:
1308
+ template = {
1309
+ "schemaVersion": "0.1.0",
1310
+ "outcomeId": "urn:uuid:00000000-0000-0000-0000-000000000000",
1311
+ "decisionRef": {"sha256": "0" * 64},
1312
+ "executor": {"id": "executor://example/runner", "keyId": ""},
1313
+ "requestedActionDigest": {"sha256": "0" * 64},
1314
+ "actualActionDigest": {"sha256": "0" * 64},
1315
+ "responseDigest": {"sha256": "0" * 64},
1316
+ "effectDigest": {"sha256": "0" * 64},
1317
+ "status": "executed",
1318
+ "performedAt": "2026-01-01T00:00:00Z",
1319
+ "policyPurpose": "outcome",
1320
+ "traceContext": {"traceparent": ""},
1321
+ "limitations": [],
1322
+ }
1323
+ out = json.dumps(template, indent=2, ensure_ascii=False)
1324
+ if args.out:
1325
+ with open(args.out, "w", encoding="utf-8") as handle:
1326
+ handle.write(out + "\n")
1327
+ print(f"wrote outcome predicate template {args.out}")
1328
+ else:
1329
+ print(out)
1330
+ return 0
1331
+
1332
+
1333
+ def _cmd_outcome_emit(args: argparse.Namespace) -> int:
1334
+ from .outcome import OutcomeReceiptError, emit_outcome_receipt # noqa: PLC0415
1335
+ signer = _resolve_signer(args)
1336
+ if signer is None:
1337
+ return 2
1338
+ try:
1339
+ with open(args.predicate, encoding="utf-8") as handle:
1340
+ predicate = loads_strict(handle.read()) # WP-C1: a duplicate key must never be signed
1341
+ env = emit_outcome_receipt(predicate, signer, strict=not args.lenient)
1342
+ except (OutcomeReceiptError, ProofBundleError, OSError, ValueError) as exc:
1343
+ print(f"ERROR: {exc}", file=sys.stderr)
1344
+ return 2
1345
+ with open(args.out, "w", encoding="utf-8") as handle:
1346
+ json.dump(env, handle, indent=2)
1347
+ handle.write("\n")
1348
+ print(f"wrote outcome receipt {args.out}")
1349
+ return 0
1350
+
1351
+
1352
+ def _cmd_outcome_verify(args: argparse.Namespace) -> int:
1353
+ import base64 # noqa: PLC0415
1354
+ from .outcome import verify_outcome_receipt # noqa: PLC0415
1355
+ if not args.pub:
1356
+ print("ERROR: --pub <base64 Ed25519 public key> is required", file=sys.stderr)
1357
+ return 2
1358
+ try:
1359
+ with open(args.envelope, encoding="utf-8") as handle:
1360
+ env = loads_strict(handle.read()) # WP-C1: duplicate keys rejected
1361
+ pub = base64.b64decode(args.pub)
1362
+ result = verify_outcome_receipt(
1363
+ env, pub, strict=args.strict,
1364
+ expected_decision_ref=args.expected_decision_ref, decision_maker_id=args.decision_maker_id,
1365
+ expected_audience=args.aud, expected_nonce=args.nonce)
1366
+ except (ProofBundleError, OSError, ValueError) as exc:
1367
+ print(f"ERROR: {exc}", file=sys.stderr)
1368
+ return 2
1369
+ if args.json:
1370
+ report = {k: result[k] for k in (
1371
+ "ok", "crypto_ok", "structure_ok", "predicate_type_ok", "decision_bound", "role_separation_ok",
1372
+ "execution_proven", "audience_ok", "nonce_ok", "warnings", "errors",
1373
+ )}
1374
+ print(json.dumps(report, indent=2))
1375
+ else:
1376
+ print(f"CRYPTO: {'OK' if result['crypto_ok'] else 'FAIL'}")
1377
+ print(f"STRUCTURE: {'OK' if result['structure_ok'] else 'FAIL'}")
1378
+ if result["decision_bound"] is not None:
1379
+ print(f"DECISION_BINDING: {'OK' if result['decision_bound'] else 'MISMATCH'}")
1380
+ if result["role_separation_ok"] is not None:
1381
+ print(f"ROLE_SEPARATION: {'OK' if result['role_separation_ok'] else 'VIOLATED'}")
1382
+ if result["audience_ok"] is not None:
1383
+ print(f"AUDIENCE: {'OK' if result['audience_ok'] else 'MISMATCH'}")
1384
+ if result["nonce_ok"] is not None:
1385
+ print(f"NONCE: {'OK' if result['nonce_ok'] else 'MISMATCH'}")
1386
+ if result["execution_proven"] is False:
1387
+ print("ASSURANCE: status=executed is self-asserted (no effectDigest/actualActionDigest)")
1388
+ for e in result["errors"]:
1389
+ print(f" - {e}", file=sys.stderr)
1390
+ for w in result["warnings"]:
1391
+ print(f" ! {w}", file=sys.stderr)
1392
+ if result["crypto_ok"]:
1393
+ print("\nThis proves who signed what happened, bound to the referenced decision. It does not prove "
1394
+ "the effect was good, correct or desired.")
1395
+ else:
1396
+ print("\nThis receipt did NOT verify (crypto failure); nothing about the outcome is attested.")
1397
+ # Exit contract mirrors decision verify: 1 crypto · 2 malformed/confusion/binding-mismatch · 3 policyPurpose.
1398
+ if not result["crypto_ok"]:
1399
+ return 1
1400
+ if not result["structure_ok"]:
1401
+ return 2
1402
+ if result["decision_bound"] is False or result["role_separation_ok"] is False:
1403
+ return 2
1404
+ if result["audience_ok"] is False or result["nonce_ok"] is False:
1405
+ return 2
1406
+ return 0
1407
+
1408
+
1409
+ def _cmd_outcome_inspect(args: argparse.Namespace) -> int:
1410
+ import base64 # noqa: PLC0415
1411
+ try:
1412
+ with open(args.receipt, encoding="utf-8") as handle:
1413
+ obj = loads_strict(handle.read()) # WP-C1
1414
+ except (ProofBundleError, OSError, ValueError) as exc:
1415
+ print(f"ERROR: {exc}", file=sys.stderr)
1416
+ return 2
1417
+ try:
1418
+ statement = loads_strict(base64.b64decode(obj["payload"])) if isinstance(obj, dict) and "payload" in obj else obj
1419
+ except (ProofBundleError, ValueError, TypeError) as exc:
1420
+ print(f"ERROR: {exc}", file=sys.stderr)
1421
+ return 2
1422
+ predicate = statement.get("predicate", statement) if isinstance(statement, dict) else statement
1423
+ print(json.dumps(predicate, indent=2, ensure_ascii=False))
1424
+ return 0
1425
+
1426
+
1306
1427
  def _cmd_policy_explain(args: argparse.Namespace) -> int:
1307
1428
  from .policy import PolicyError, explain_policy, load_policy, policy_warnings # noqa: PLC0415
1308
1429
  from .policy_profiles import resolve_policy_source # noqa: PLC0415
@@ -1773,6 +1894,45 @@ def build_parser() -> argparse.ArgumentParser:
1773
1894
  d_inspect.add_argument("receipt", help="path to a DSSE receipt or a raw in-toto Statement")
1774
1895
  d_inspect.set_defaults(func=_cmd_decision_inspect)
1775
1896
 
1897
+ # ── Action Outcome Receipt (3.2.0, action-outcome/v0.1, EXPERIMENTAL) ──
1898
+ outcome = sub.add_parser("outcome", help="action-outcome/v0.1 (EXPERIMENTAL): init/emit/verify/inspect an action outcome")
1899
+ osub = outcome.add_subparsers(dest="outcome_command", required=True)
1900
+
1901
+ o_init = osub.add_parser("init", help="print a template outcome predicate (fill in and sign with 'outcome emit')")
1902
+ o_init.add_argument("--out", default=None, help="write the template to a file instead of stdout")
1903
+ o_init.set_defaults(func=_cmd_outcome_init)
1904
+
1905
+ o_emit = osub.add_parser("emit", help="sign an outcome predicate into a DSSE receipt")
1906
+ o_emit.add_argument("predicate", help="path to the outcome predicate JSON")
1907
+ o_emit.add_argument("--out", required=True, help="output path for the signed outcome receipt")
1908
+ o_emit.add_argument("--key", help="load an existing Ed25519 signing key from file")
1909
+ o_emit.add_argument("--new-key", dest="new_key", help="generate a new signing key and write it to file")
1910
+ o_emit.add_argument("--lenient", action="store_true", help="allow non-strict predicates")
1911
+ o_emit.set_defaults(func=_cmd_outcome_emit)
1912
+
1913
+ o_verify = osub.add_parser(
1914
+ "verify", help="verify an outcome receipt (crypto + structure; decision binding + role separation)",
1915
+ description=("Exit codes: 0 OK · 1 crypto/verification failure · 2 malformed / predicateType confusion "
1916
+ "/ decision-binding or role-separation mismatch. --expected-decision-ref binds the outcome "
1917
+ "to a specific decision content root (replay across decisions fails). --decision-maker-id "
1918
+ "enforces role separation (executor must differ from the decision maker). status=executed "
1919
+ "without an effect/actual digest is a self-asserted claim, not proof of the effect."))
1920
+ o_verify.add_argument("envelope", help="path to the DSSE outcome receipt")
1921
+ o_verify.add_argument("--pub", required=True, help="executor Ed25519 public key (base64) to verify against")
1922
+ o_verify.add_argument("--expected-decision-ref", dest="expected_decision_ref", default=None,
1923
+ help="expected decision content root (sha256 hex); a mismatch is a cross-decision replay, fail-closed")
1924
+ o_verify.add_argument("--decision-maker-id", dest="decision_maker_id", default=None,
1925
+ help="the bound decision's maker id; executor==maker violates role separation (fail-closed)")
1926
+ o_verify.add_argument("--json", action="store_true", help="machine readable output")
1927
+ o_verify.add_argument("--strict", action="store_true", help="enforce strict-v0.1 required fields")
1928
+ o_verify.add_argument("--aud", default=None, help="expected audience (checks validity.audience against replay)")
1929
+ o_verify.add_argument("--nonce", default=None, help="expected nonce (checks validity.nonce against replay)")
1930
+ o_verify.set_defaults(func=_cmd_outcome_verify)
1931
+
1932
+ o_inspect = osub.add_parser("inspect", help="print an outcome receipt's predicate (no crypto verification)")
1933
+ o_inspect.add_argument("receipt", help="path to a DSSE receipt or a raw in-toto Statement")
1934
+ o_inspect.set_defaults(func=_cmd_outcome_inspect)
1935
+
1776
1936
  return parser
1777
1937
 
1778
1938
 
@@ -0,0 +1,98 @@
1
+ """B6 evidence-pack hardening for offline long-term verification (EXPERIMENTAL; ADR 0006).
2
+
3
+ Goal: a pack built from a receipt verifies OFFLINE on a second machine from bundled data — no calendar,
4
+ no network. The OTS anchor already verifies offline from a relying-party Bitcoin block header
5
+ (``anchors_ots.verify_opentimestamps``); this module adds the PACK layer around it:
6
+
7
+ * ``ots_upgraded_proof_is_self_contained`` — a proof with a Bitcoin block-header attestation is upgraded
8
+ and self-contained (the calendar is no longer needed to verify existence-in-Bitcoin). A pending-only or
9
+ malformed proof is not.
10
+ * ``build_evidence_pack`` — assemble the self-contained proof + the canonical root + the set of calendars
11
+ it was submitted to (multi-calendar redundancy metadata) and, optionally, a COPY of the Bitcoin block
12
+ headers as EVIDENCE.
13
+ * ``verify_evidence_pack`` — verify the pack with NO network I/O, delegating to the OTS verifier.
14
+
15
+ WP-A1 boundary (never crossed). The header a pack bundles is producer-controlled EVIDENCE, never trust —
16
+ a producer could bundle a self-committed, backdated header. Confirmation therefore still needs a
17
+ RELYING-PARTY header (``rp_trust.bitcoin_block_headers``). That header can be an OFFLINE trusted Bitcoin
18
+ checkpoint the relying party ships, so "needs a relying-party header" does NOT mean "needs the network".
19
+ A pack with a bundled header but no relying-party trust is honestly ``needs_rp_trust``, not a pass.
20
+
21
+ Honesty on scope. The MECHANISM here is complete and tested. Producing a pack from a REAL confirmed
22
+ receipt (submitting to public calendars, waiting for Bitcoin confirmation, ``ots upgrade``) is an
23
+ Owner-gated external anchor submission and is tracked as the OPEN end-to-end step in ADR 0006 — not
24
+ claimed here.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import base64
29
+ from typing import Optional
30
+
31
+ from .anchors_ots import _classify, verify_opentimestamps
32
+
33
+ __all__ = [
34
+ "ots_upgraded_proof_is_self_contained",
35
+ "build_evidence_pack",
36
+ "verify_evidence_pack",
37
+ ]
38
+
39
+
40
+ def ots_upgraded_proof_is_self_contained(proof: bytes) -> bool:
41
+ """True iff ``proof`` is an UPGRADED OTS proof (a Bitcoin block-header attestation) — self-contained,
42
+ so verifying existence-in-Bitcoin no longer needs a calendar. A pending-only or malformed proof is
43
+ False (fail-closed; never over-claim a pending proof as self-contained)."""
44
+ try:
45
+ from opentimestamps.core.serialize import BytesDeserializationContext # noqa: PLC0415
46
+ from opentimestamps.core.timestamp import DetachedTimestampFile # noqa: PLC0415
47
+ except ImportError:
48
+ return False
49
+ try:
50
+ dtf = DetachedTimestampFile.deserialize(BytesDeserializationContext(proof))
51
+ except Exception:
52
+ return False
53
+ has_bitcoin, _heights, _has_pending = _classify(dtf.timestamp)
54
+ return has_bitcoin
55
+
56
+
57
+ def build_evidence_pack(canonical_root: bytes, proof: bytes, *, calendars: list[str],
58
+ bundled_headers: Optional[dict[str, str]] = None) -> dict:
59
+ """Assemble an offline-verifiable evidence pack around an OTS proof.
60
+
61
+ ``calendars`` is the set of calendar servers the stamp was submitted to; recording two or more is the
62
+ multi-calendar redundancy metadata (``calendarRedundancy`` = distinct count). ``bundled_headers`` (a
63
+ ``height -> block merkle-root hex`` map) is copied into the pack as EVIDENCE only (``frozen`` block,
64
+ WP-A1: never trusted by the verifier). The pack never contains a secret."""
65
+ distinct = sorted(set(calendars))
66
+ pack: dict = {
67
+ "type": "opentimestamps-evidence-pack",
68
+ "packVersion": "v0.1",
69
+ "canonicalRoot": base64.b64encode(canonical_root).decode(),
70
+ "proof": base64.b64encode(proof).decode(),
71
+ "selfContained": ots_upgraded_proof_is_self_contained(proof),
72
+ "calendars": distinct,
73
+ "calendarRedundancy": len(distinct),
74
+ "bundledHeaderEvidence": bool(bundled_headers),
75
+ }
76
+ if bundled_headers:
77
+ # producer-controlled EVIDENCE, surfaced in the frozen block; verify_opentimestamps treats it as
78
+ # frozenEvidence and never as trust (WP-A1).
79
+ pack["frozen"] = {"bitcoinBlockHeaderMerkleRootsByHeight": dict(bundled_headers)}
80
+ return pack
81
+
82
+
83
+ def verify_evidence_pack(pack: dict, *, rp_trust: Optional[dict] = None,
84
+ now: Optional[int] = None) -> dict:
85
+ """Verify an evidence pack OFFLINE (no network I/O). Delegates to the OTS verifier with the pack's
86
+ proof + canonical root; the pack's bundled header is passed as ``frozen`` EVIDENCE, and confirmation
87
+ happens only against a relying-party header (``rp_trust``), which may be an offline checkpoint.
88
+
89
+ Returns the OTS verifier's result dict ({ok, detail, warn, status, …}). Fail-closed on a malformed
90
+ pack (missing/!b64 proof or root)."""
91
+ try:
92
+ proof = base64.b64decode(pack["proof"], validate=True)
93
+ canonical_root = base64.b64decode(pack["canonicalRoot"], validate=True)
94
+ except (KeyError, ValueError, TypeError) as exc:
95
+ return {"ok": False, "warn": False, "status": "malformed_pack",
96
+ "detail": f"evidence pack is missing or has a non-base64 proof/canonicalRoot: {exc}"}
97
+ frozen = pack.get("frozen") or {}
98
+ return verify_opentimestamps(proof, canonical_root, frozen=frozen, now=now, rp_trust=rp_trust)
@@ -0,0 +1,177 @@
1
+ """B2 hash-agility — an explicit registry of hash algorithms with fail-closed resolution and a
2
+ dual-hash helper (ADR 0006).
3
+
4
+ Why this module exists. proofbundle's surfaces already DECLARE their hash construction per artifact
5
+ (``merkle.hash_alg = "sha256-rfc6962"``, ``contentRootAlg = "jcs-sha256-v1"``, a checkpoint's
6
+ ``hashAlg``). What was missing for long-term evidence is a single agility layer: one registry that
7
+ says which hash PRIMITIVES are allowed, which are deprecated, and a resolver that NEVER silently
8
+ defaults a missing/unknown/weak algorithm — the exact place an algorithm-confusion attack would hide
9
+ (RFC 7696 §2.1; ADR 0002 §2 warns of the same at the content-root level). The algorithm id maps to the
10
+ RFC 4998 (ERS) ``digestAlgorithm`` OID, so the B3 renewal chain builds directly on this registry.
11
+
12
+ Model: the IANA Named Information Hash Algorithm registry (RFC 6920) — an id, a status
13
+ (``current`` / ``deprecated``), and enough to compute and size the digest. Deprecation follows the NIST
14
+ transition (SHA-1 retired; SHA-256/384/512 and the SHA-3 family current — NIST FIPS 180-4 / 202,
15
+ SP 800-131A).
16
+
17
+ Fail-closed contract:
18
+ * an absent/empty id is ``MissingHashAlgId`` — there is NO implicit SHA-256;
19
+ * an id not in the registry is ``UnknownHashAlg``;
20
+ * a deprecated id is ``DeprecatedHashAlg`` unless the caller explicitly opts into legacy verification
21
+ (``allow_deprecated=True``) — a verifier may need to read an old receipt, but nothing NEW is ever
22
+ produced with a weak hash, and a dual-hash's PASS requires at least one current algorithm.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+ from collections.abc import Mapping, Sequence
28
+ from dataclasses import dataclass
29
+ from typing import Optional
30
+
31
+ from .errors import Check, ProofBundleError, VerificationResult
32
+
33
+ __all__ = [
34
+ "HASH_REGISTRY",
35
+ "HashAlg",
36
+ "HashAlgError",
37
+ "MissingHashAlgId",
38
+ "UnknownHashAlg",
39
+ "DeprecatedHashAlg",
40
+ "resolve_hash_alg",
41
+ "compute_digest",
42
+ "compute_dual_hash",
43
+ "verify_dual_hash",
44
+ ]
45
+
46
+
47
+ class HashAlgError(ProofBundleError):
48
+ """Base class for every hash-agility failure (a subclass of ``ProofBundleError``)."""
49
+
50
+
51
+ class MissingHashAlgId(HashAlgError):
52
+ """No algorithm id was given. Fail-closed: proofbundle never defaults a missing hash to SHA-256."""
53
+
54
+
55
+ class UnknownHashAlg(HashAlgError):
56
+ """The algorithm id is not in ``HASH_REGISTRY``. Fail-closed: an unknown hash is never trusted."""
57
+
58
+
59
+ class DeprecatedHashAlg(HashAlgError):
60
+ """The algorithm is known but deprecated and the caller did not opt into legacy verification."""
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class HashAlg:
65
+ """One registry entry: how to compute the digest, its size, its status, and its ERS OID.
66
+
67
+ ``ers_oid`` is the RFC 4998 ``digestAlgorithm`` ``AlgorithmIdentifier`` OID, so a renewal chain (B3)
68
+ references the same identity this registry defines. ``hashlib_name`` is the ``hashlib.new`` name."""
69
+
70
+ id: str
71
+ hashlib_name: str
72
+ digest_size: int
73
+ status: str # "current" | "deprecated"
74
+ ers_oid: str
75
+
76
+ def new(self) -> "hashlib._Hash":
77
+ return hashlib.new(self.hashlib_name)
78
+
79
+
80
+ # The allowed hash primitives. SHA-256/384/512 (FIPS 180-4) and SHA3-256/512 (FIPS 202) are current;
81
+ # SHA-1 and MD5 are deprecated (kept only so a verifier can READ and clearly reject a legacy receipt).
82
+ # OIDs are the NIST/RFC AlgorithmIdentifier values used by RFC 4998 digestAlgorithm.
83
+ HASH_REGISTRY: dict[str, HashAlg] = {
84
+ "sha256": HashAlg("sha256", "sha256", 32, "current", "2.16.840.1.101.3.4.2.1"),
85
+ "sha384": HashAlg("sha384", "sha384", 48, "current", "2.16.840.1.101.3.4.2.2"),
86
+ "sha512": HashAlg("sha512", "sha512", 64, "current", "2.16.840.1.101.3.4.2.3"),
87
+ "sha3-256": HashAlg("sha3-256", "sha3_256", 32, "current", "2.16.840.1.101.3.4.2.8"),
88
+ "sha3-512": HashAlg("sha3-512", "sha3_512", 64, "current", "2.16.840.1.101.3.4.2.10"),
89
+ "sha1": HashAlg("sha1", "sha1", 20, "deprecated", "1.3.14.3.2.26"),
90
+ "md5": HashAlg("md5", "md5", 16, "deprecated", "1.2.840.113549.2.5"),
91
+ }
92
+
93
+
94
+ def resolve_hash_alg(alg_id: Optional[str], *, allow_deprecated: bool = False) -> HashAlg:
95
+ """Resolve an algorithm id to its registry entry, fail-closed.
96
+
97
+ Raises ``MissingHashAlgId`` for an absent/empty id (no implicit default), ``UnknownHashAlg`` for an
98
+ id not in the registry, and ``DeprecatedHashAlg`` for a weak algorithm unless ``allow_deprecated``.
99
+ """
100
+ if not alg_id or not isinstance(alg_id, str):
101
+ raise MissingHashAlgId(
102
+ "a hash algorithm id is required — proofbundle never defaults a missing hash to SHA-256")
103
+ spec = HASH_REGISTRY.get(alg_id)
104
+ if spec is None:
105
+ raise UnknownHashAlg(
106
+ f"unknown hash algorithm {alg_id!r} — not in the allowed registry "
107
+ f"({', '.join(sorted(HASH_REGISTRY))})")
108
+ if spec.status == "deprecated" and not allow_deprecated:
109
+ raise DeprecatedHashAlg(
110
+ f"hash algorithm {alg_id!r} is deprecated and rejected by default; a legacy verifier must "
111
+ "opt in explicitly (allow_deprecated=True)")
112
+ return spec
113
+
114
+
115
+ def compute_digest(data: bytes, alg_id: str, *, allow_deprecated: bool = False) -> str:
116
+ """Hex digest of ``data`` under ``alg_id`` (fail-closed on missing/unknown/deprecated)."""
117
+ spec = resolve_hash_alg(alg_id, allow_deprecated=allow_deprecated)
118
+ h = spec.new()
119
+ h.update(data)
120
+ return h.hexdigest()
121
+
122
+
123
+ def compute_dual_hash(data: bytes, alg_ids: Sequence[str]) -> dict[str, str]:
124
+ """Digests of ``data`` under two or more DISTINCT CURRENT algorithms — for a NEW receipt.
125
+
126
+ A dual hash lets an old receipt survive the deprecation of one hash: as long as a second,
127
+ independent current hash still binds the same bytes, the evidence keeps its force while a renewal
128
+ (B3) migrates it. A new receipt therefore requires at least two distinct current algorithms; a
129
+ deprecated algorithm is rejected outright (nothing new is produced with a weak hash)."""
130
+ seen: dict[str, HashAlg] = {}
131
+ for alg_id in alg_ids:
132
+ spec = resolve_hash_alg(alg_id) # current-only: no allow_deprecated on the produce path
133
+ if spec.id in seen:
134
+ raise HashAlgError(f"dual hash needs DISTINCT algorithms, {spec.id!r} was given twice")
135
+ seen[spec.id] = spec
136
+ if len(seen) < 2:
137
+ raise HashAlgError(
138
+ "a dual hash needs at least two distinct current algorithms "
139
+ "(e.g. sha256 + sha512 or sha256 + sha3-256)")
140
+ return {alg_id: compute_digest(data, alg_id) for alg_id in seen}
141
+
142
+
143
+ def verify_dual_hash(data: bytes, digests: Mapping[str, str]) -> VerificationResult:
144
+ """Verify that EVERY declared digest binds ``data``, and that at least one is a CURRENT algorithm.
145
+
146
+ Fail-closed: an empty map fails; an unknown algorithm fails; a single mismatching leg fails the
147
+ whole result; a bag of only-deprecated digests fails even when the bytes match (a legacy-only
148
+ receipt has lost its force and must be renewed, not silently accepted). A deprecated leg is checked
149
+ (``allow_deprecated``) so its presence is visible, but it cannot by itself carry a PASS."""
150
+ result = VerificationResult()
151
+ if not isinstance(digests, Mapping) or not digests:
152
+ result.checks.append(Check("hashalg:dual", False,
153
+ "digests must be a non-empty mapping of alg -> hex"))
154
+ return result
155
+
156
+ current_ok = 0
157
+ for alg_id, expected in digests.items():
158
+ try:
159
+ spec = resolve_hash_alg(alg_id, allow_deprecated=True)
160
+ except HashAlgError as exc:
161
+ result.checks.append(Check(f"hashalg:{alg_id}", False, str(exc)))
162
+ continue
163
+ actual = compute_digest(data, alg_id, allow_deprecated=True)
164
+ match = isinstance(expected, str) and actual == expected.lower()
165
+ detail = "digest matches" if match else "digest mismatch"
166
+ if match and spec.status == "deprecated":
167
+ detail = "digest matches but algorithm is deprecated (does not carry a PASS on its own)"
168
+ result.checks.append(Check(f"hashalg:{alg_id}", match, detail))
169
+ if match and spec.status == "current":
170
+ current_ok += 1
171
+
172
+ all_match = all(c.ok for c in result.checks)
173
+ if all_match and current_ok == 0:
174
+ result.checks.append(Check(
175
+ "hashalg:current-binding", False,
176
+ "no CURRENT-algorithm digest binds the payload — a legacy-only receipt must be renewed"))
177
+ return result