proofbundle 2.1.0__tar.gz → 3.0.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 (138) hide show
  1. {proofbundle-2.1.0/src/proofbundle.egg-info → proofbundle-3.0.0}/PKG-INFO +28 -5
  2. {proofbundle-2.1.0 → proofbundle-3.0.0}/README.md +27 -4
  3. {proofbundle-2.1.0 → proofbundle-3.0.0}/pyproject.toml +4 -1
  4. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/__init__.py +2 -2
  5. proofbundle-3.0.0/src/proofbundle/_strict_json.py +72 -0
  6. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/adapters/eee.py +62 -0
  7. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/anchors.py +66 -9
  8. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/anchors_chia.py +2 -2
  9. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/anchors_markovian.py +29 -12
  10. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/anchors_ots.py +44 -20
  11. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/anchors_rfc3161.py +58 -20
  12. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/bundle.py +91 -8
  13. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/checkpoint.py +2 -2
  14. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/cli.py +262 -41
  15. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/decision.py +101 -14
  16. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/evalclaim.py +21 -12
  17. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/experimental/enclave.py +7 -3
  18. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/hf_evals.py +91 -7
  19. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/intoto.py +69 -29
  20. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/kbjwt.py +12 -3
  21. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/merkle.py +9 -1
  22. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/persample.py +8 -1
  23. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/policy.py +233 -7
  24. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/sdjwt.py +19 -3
  25. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/sdjwt_issue.py +25 -12
  26. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/statuslist.py +10 -3
  27. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/tlogproof.py +5 -0
  28. {proofbundle-2.1.0 → proofbundle-3.0.0/src/proofbundle.egg-info}/PKG-INFO +28 -5
  29. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle.egg-info/SOURCES.txt +13 -0
  30. proofbundle-3.0.0/tests/test_anchor_target_trustedtime.py +209 -0
  31. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_anchors_markovian.py +25 -12
  32. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_anchors_ots.py +27 -17
  33. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_anchors_rfc3161.py +57 -33
  34. proofbundle-3.0.0/tests/test_claims_hygiene.py +194 -0
  35. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_cli_require_anchor.py +60 -0
  36. proofbundle-3.0.0/tests/test_conformance.py +167 -0
  37. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_decision_anchors.py +40 -0
  38. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_decision_policy.py +2 -1
  39. proofbundle-3.0.0/tests/test_decision_validator_api.py +82 -0
  40. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_decision_verify.py +68 -0
  41. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_docs_truth.py +50 -6
  42. proofbundle-3.0.0/tests/test_dup_key_reject.py +319 -0
  43. proofbundle-3.0.0/tests/test_ed25519_semantics.py +99 -0
  44. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_evalclaim.py +17 -0
  45. proofbundle-3.0.0/tests/test_hf_entry_verify_eee_digest.py +187 -0
  46. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_kbjwt.py +11 -6
  47. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_ok_semantics.py +43 -8
  48. proofbundle-3.0.0/tests/test_oversized_int_guard.py +41 -0
  49. proofbundle-3.0.0/tests/test_pinned_key_validation.py +76 -0
  50. proofbundle-3.0.0/tests/test_policy_explain_lint.py +192 -0
  51. proofbundle-3.0.0/tests/test_predicate_type_enforcement.py +102 -0
  52. proofbundle-3.0.0/tests/test_sdjwt_duplicate_cnf.py +181 -0
  53. proofbundle-3.0.0/tests/test_sdjwt_verify_binding.py +168 -0
  54. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_trust_policy.py +7 -4
  55. proofbundle-2.1.0/tests/test_claims_hygiene.py +0 -48
  56. {proofbundle-2.1.0 → proofbundle-3.0.0}/LICENSE +0 -0
  57. {proofbundle-2.1.0 → proofbundle-3.0.0}/setup.cfg +0 -0
  58. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/_inspect_registry.py +0 -0
  59. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/_integration.py +0 -0
  60. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/adapters/__init__.py +0 -0
  61. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/adapters/_provenance.py +0 -0
  62. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/adapters/inspect_ai.py +0 -0
  63. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/adapters/lm_eval.py +0 -0
  64. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/adapters/promptfoo.py +0 -0
  65. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/adapters/samples.py +0 -0
  66. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/anchors_chia_add.py +0 -0
  67. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/beacon.py +0 -0
  68. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/canonical.py +0 -0
  69. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/demo.py +0 -0
  70. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/dsse.py +0 -0
  71. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/eee_eval_schema.json +0 -0
  72. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/emit.py +0 -0
  73. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/errors.py +0 -0
  74. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/experimental/__init__.py +0 -0
  75. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/inspect_hook.py +0 -0
  76. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/prereg.py +0 -0
  77. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/py.typed +0 -0
  78. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/pytest_plugin.py +0 -0
  79. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle/signature.py +0 -0
  80. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle.egg-info/dependency_links.txt +0 -0
  81. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle.egg-info/entry_points.txt +0 -0
  82. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle.egg-info/requires.txt +0 -0
  83. {proofbundle-2.1.0 → proofbundle-3.0.0}/src/proofbundle.egg-info/top_level.txt +0 -0
  84. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_adapters.py +0 -0
  85. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_adversarial.py +0 -0
  86. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_anchors_chia.py +0 -0
  87. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_anchors_chia_add.py +0 -0
  88. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_anchors_chia_claims.py +0 -0
  89. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_anchors_generic.py +0 -0
  90. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_beacon.py +0 -0
  91. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_branch_base_check.py +0 -0
  92. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_bundle.py +0 -0
  93. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_bundle_robustness.py +0 -0
  94. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_canonical.py +0 -0
  95. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_checkpoint.py +0 -0
  96. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_cli.py +0 -0
  97. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_cli_eval.py +0 -0
  98. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_cosignature.py +0 -0
  99. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_cosignature_mldsa.py +0 -0
  100. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_decision_cli.py +0 -0
  101. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_decision_evidence.py +0 -0
  102. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_decision_fuzz.py +0 -0
  103. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_decision_hardening.py +0 -0
  104. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_decision_schema.py +0 -0
  105. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_demo.py +0 -0
  106. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_eee.py +0 -0
  107. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_emit.py +0 -0
  108. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_eval_claim_schema.py +0 -0
  109. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_examples.py +0 -0
  110. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_experimental_enclave.py +0 -0
  111. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_fork_pr_secret_isolation.py +0 -0
  112. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_fuzz_parsers.py +0 -0
  113. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_hf_evals.py +0 -0
  114. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_inspect_hook.py +0 -0
  115. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_intoto.py +0 -0
  116. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_intoto_claims_hygiene.py +0 -0
  117. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_intoto_content_root_migration.py +0 -0
  118. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_intoto_dsse.py +0 -0
  119. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_intoto_eval_result.py +0 -0
  120. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_intoto_examples.py +0 -0
  121. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_intoto_spec_diff.py +0 -0
  122. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_intoto_svr.py +0 -0
  123. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_merkle.py +0 -0
  124. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_merkle_property.py +0 -0
  125. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_persample.py +0 -0
  126. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_prereg.py +0 -0
  127. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_promptfoo.py +0 -0
  128. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_provenance.py +0 -0
  129. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_pytest_plugin.py +0 -0
  130. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_rekor_interop.py +0 -0
  131. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_rfc6962_external_vectors.py +0 -0
  132. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_schema.py +0 -0
  133. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_sdjwt_issue.py +0 -0
  134. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_sdjwt_reference.py +0 -0
  135. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_signature.py +0 -0
  136. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_statuslist.py +0 -0
  137. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_tlogproof.py +0 -0
  138. {proofbundle-2.1.0 → proofbundle-3.0.0}/tests/test_verify_matrix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofbundle
3
- Version: 2.1.0
3
+ Version: 3.0.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
@@ -202,7 +202,8 @@ flowchart LR
202
202
  - **Eval receipts** — a signed claim (`metric ⋈ threshold`, `n`, salted model/dataset commitments,
203
203
  assurance level, provenance) from your run. See [EVAL_CLAIM.md](https://github.com/b7n0de/proofbundle/blob/main/EVAL_CLAIM.md).
204
204
  - **Selective disclosure** — SD-JWT ([RFC 9901](https://datatracker.ietf.org/doc/rfc9901/)) with Key
205
- Binding: prove a threshold while withholding the exact score.
205
+ Binding: prove a threshold while withholding the exact score. Secure-by-default in 3.0.0 (breaking): an
206
+ unsigned SD-JWT, or one whose disclosures do not bind this bundle, now fails verification (was warn-only).
206
207
  - **Transparency-log interop** — C2SP `tlog-checkpoint` / cosignature / `.tlog-proof`, with
207
208
  post-quantum **ML-DSA-44** witness cosignatures. Optional Token-Status-List revocation snapshots.
208
209
  - **Per-sample audit** — commit to every sample; an auditor challenges random indices (with a fresh
@@ -218,12 +219,34 @@ flowchart LR
218
219
  [docs/INSPECT_HAPPY_PATH.md](https://github.com/b7n0de/proofbundle/blob/main/docs/INSPECT_HAPPY_PATH.md) — run an eval, get a receipt, verify it offline.
219
220
  - **External time anchors** *(v2.0 beta, the `[anchors]` extra)* — an optional `anchors[]` layer that
220
221
  attaches external evidence of *when* a commitment or receipt existed, from a party the producer does not
221
- control. Two built-in types verify offline: **RFC 3161** TSA tokens (against a frozen cert chain) and
222
- **OpenTimestamps** Bitcoin proofs (honest pending → confirmed lifecycle). A `register_anchor_type`
222
+ control. Two built-in types verify offline: **RFC 3161** TSA tokens (against a relying-party-supplied TSA
223
+ root, see the 3.0.0 trust note below) and **OpenTimestamps** Bitcoin proofs (honest pending → confirmed lifecycle). A `register_anchor_type`
223
224
  extension interface lets a third party ship its own fail-closed type; two worked examples ship — a
224
225
  first-party **`chia-datalayer/v1`** (offline Merkle inclusion of a canonical root under a published Chia
225
226
  DataLayer root) and a third-party **`markovian-provenance/v1`** (a wallet-attributable, Bitcoin-anchored
226
- stamp). See [docs/ANCHORS.md](https://github.com/b7n0de/proofbundle/blob/main/docs/ANCHORS.md).
227
+ stamp). **Since 2.1:** a `verify --require-anchor` relying-party gate (optionally narrowed by
228
+ `--anchor-type`) turns "no verifying anchor of that type" into a failure layered over the crypto result
229
+ (exit 3, like `--policy`); a pending anchor does not satisfy it unless `--allow-pending`. Plus RFC 3161
230
+ hardening — the frozen cert chain is validated at the token's own `gen_time`, with optional `policyOid`
231
+ pinning. **Breaking in 3.0.0:** an anchor's TRUST now comes only from the relying party — supply a TSA
232
+ root (`--trusted-tsa-root`) or a Bitcoin block header (`--bitcoin-header`), or the equivalent `anchors`
233
+ policy keys; the bundle's producer-controlled `frozen` block is evidence, never a trust source, so
234
+ `--require-anchor` without relying-party trust material is unmet (exit 3). An anchor stays detached from
235
+ the content root, and the `statement` target is RESERVED for decision receipts.
236
+ See [docs/ANCHORS.md](https://github.com/b7n0de/proofbundle/blob/main/docs/ANCHORS.md).
237
+ - **Universal content root** *(2.1, `jcs-sha256-v1`, [ADR 0002](https://github.com/b7n0de/proofbundle/blob/main/docs/adr/0002-universal-content-root.md))* — one shared primitive now underlies both the
238
+ decision-receipt path and the in-toto eval-result / test-result / SVR exports: SHA-256 over the RFC 8785
239
+ (JCS) canonical bytes of the full pre-signature Statement, so a content root survives counter-signing and
240
+ key rotation. The algorithm is a versioned id signed inside the payload (`contentRootAlg`, default
241
+ `jcs-sha256-v1`); a verifier re-serializes with exactly the declared algorithm, never falls back, and an
242
+ unknown algorithm fails closed. Migration is a compatible evolution, not a cutover: absent `contentRootAlg`
243
+ ⇒ the historic `legacy-sortkeys-json-v0` mode, so every already-signed 2.0.0 receipt keeps verifying
244
+ byte-for-byte. This is **not** a completed universal migration — a CLI flag to select the algorithm is still
245
+ deferred. Independent cross-implementation (MarkovianProtocol) interop is now proven for RFC 8785
246
+ canonicalization + content-root binding (see `conformance/decision/crossimpl/`); the same corpus additionally
247
+ verifies a confirmed Bitcoin anchor (block 957504) offline. The external fixture currently reports 12 findings
248
+ against the enforced `decision-receipt/v0.1` validator — recorded as an expected-fail, not hidden — so full
249
+ schema conformance awaits a further schema-conformant regeneration.
227
250
  - **Decision Receipts** *(2.1, vendored `decision-receipt/v0.1` predicate)* — a separate predicate for agent
228
251
  *decisions* (not eval metrics): who decided, the proposed action, the policy boundary, digest-bound evidence,
229
252
  the verdict (`ALLOW`/`DENY`/`REFUSE`/`ESCALATE`/`DEFER`/`OBSERVE`), and explicitly what was *not* checked.
@@ -148,7 +148,8 @@ flowchart LR
148
148
  - **Eval receipts** — a signed claim (`metric ⋈ threshold`, `n`, salted model/dataset commitments,
149
149
  assurance level, provenance) from your run. See [EVAL_CLAIM.md](https://github.com/b7n0de/proofbundle/blob/main/EVAL_CLAIM.md).
150
150
  - **Selective disclosure** — SD-JWT ([RFC 9901](https://datatracker.ietf.org/doc/rfc9901/)) with Key
151
- Binding: prove a threshold while withholding the exact score.
151
+ Binding: prove a threshold while withholding the exact score. Secure-by-default in 3.0.0 (breaking): an
152
+ unsigned SD-JWT, or one whose disclosures do not bind this bundle, now fails verification (was warn-only).
152
153
  - **Transparency-log interop** — C2SP `tlog-checkpoint` / cosignature / `.tlog-proof`, with
153
154
  post-quantum **ML-DSA-44** witness cosignatures. Optional Token-Status-List revocation snapshots.
154
155
  - **Per-sample audit** — commit to every sample; an auditor challenges random indices (with a fresh
@@ -164,12 +165,34 @@ flowchart LR
164
165
  [docs/INSPECT_HAPPY_PATH.md](https://github.com/b7n0de/proofbundle/blob/main/docs/INSPECT_HAPPY_PATH.md) — run an eval, get a receipt, verify it offline.
165
166
  - **External time anchors** *(v2.0 beta, the `[anchors]` extra)* — an optional `anchors[]` layer that
166
167
  attaches external evidence of *when* a commitment or receipt existed, from a party the producer does not
167
- control. Two built-in types verify offline: **RFC 3161** TSA tokens (against a frozen cert chain) and
168
- **OpenTimestamps** Bitcoin proofs (honest pending → confirmed lifecycle). A `register_anchor_type`
168
+ control. Two built-in types verify offline: **RFC 3161** TSA tokens (against a relying-party-supplied TSA
169
+ root, see the 3.0.0 trust note below) and **OpenTimestamps** Bitcoin proofs (honest pending → confirmed lifecycle). A `register_anchor_type`
169
170
  extension interface lets a third party ship its own fail-closed type; two worked examples ship — a
170
171
  first-party **`chia-datalayer/v1`** (offline Merkle inclusion of a canonical root under a published Chia
171
172
  DataLayer root) and a third-party **`markovian-provenance/v1`** (a wallet-attributable, Bitcoin-anchored
172
- stamp). See [docs/ANCHORS.md](https://github.com/b7n0de/proofbundle/blob/main/docs/ANCHORS.md).
173
+ stamp). **Since 2.1:** a `verify --require-anchor` relying-party gate (optionally narrowed by
174
+ `--anchor-type`) turns "no verifying anchor of that type" into a failure layered over the crypto result
175
+ (exit 3, like `--policy`); a pending anchor does not satisfy it unless `--allow-pending`. Plus RFC 3161
176
+ hardening — the frozen cert chain is validated at the token's own `gen_time`, with optional `policyOid`
177
+ pinning. **Breaking in 3.0.0:** an anchor's TRUST now comes only from the relying party — supply a TSA
178
+ root (`--trusted-tsa-root`) or a Bitcoin block header (`--bitcoin-header`), or the equivalent `anchors`
179
+ policy keys; the bundle's producer-controlled `frozen` block is evidence, never a trust source, so
180
+ `--require-anchor` without relying-party trust material is unmet (exit 3). An anchor stays detached from
181
+ the content root, and the `statement` target is RESERVED for decision receipts.
182
+ See [docs/ANCHORS.md](https://github.com/b7n0de/proofbundle/blob/main/docs/ANCHORS.md).
183
+ - **Universal content root** *(2.1, `jcs-sha256-v1`, [ADR 0002](https://github.com/b7n0de/proofbundle/blob/main/docs/adr/0002-universal-content-root.md))* — one shared primitive now underlies both the
184
+ decision-receipt path and the in-toto eval-result / test-result / SVR exports: SHA-256 over the RFC 8785
185
+ (JCS) canonical bytes of the full pre-signature Statement, so a content root survives counter-signing and
186
+ key rotation. The algorithm is a versioned id signed inside the payload (`contentRootAlg`, default
187
+ `jcs-sha256-v1`); a verifier re-serializes with exactly the declared algorithm, never falls back, and an
188
+ unknown algorithm fails closed. Migration is a compatible evolution, not a cutover: absent `contentRootAlg`
189
+ ⇒ the historic `legacy-sortkeys-json-v0` mode, so every already-signed 2.0.0 receipt keeps verifying
190
+ byte-for-byte. This is **not** a completed universal migration — a CLI flag to select the algorithm is still
191
+ deferred. Independent cross-implementation (MarkovianProtocol) interop is now proven for RFC 8785
192
+ canonicalization + content-root binding (see `conformance/decision/crossimpl/`); the same corpus additionally
193
+ verifies a confirmed Bitcoin anchor (block 957504) offline. The external fixture currently reports 12 findings
194
+ against the enforced `decision-receipt/v0.1` validator — recorded as an expected-fail, not hidden — so full
195
+ schema conformance awaits a further schema-conformant regeneration.
173
196
  - **Decision Receipts** *(2.1, vendored `decision-receipt/v0.1` predicate)* — a separate predicate for agent
174
197
  *decisions* (not eval metrics): who decided, the proposed action, the policy boundary, digest-bound evidence,
175
198
  the verdict (`ALLOW`/`DENY`/`REFUSE`/`ESCALATE`/`DEFER`/`OBSERVE`), and explicitly what was *not* checked.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proofbundle"
7
- version = "2.1.0"
7
+ version = "3.0.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"
@@ -22,6 +22,9 @@ keywords = [
22
22
  "rfc6962",
23
23
  ]
24
24
  classifiers = [
25
+ # Stays Beta until the funded external security review passes (Owner decision E1, 2026-07-12):
26
+ # "stable is evidenced, not asserted". The move to "5 - Production/Stable" is a separate,
27
+ # audit-gated milestone (see docs/GRANT_MILESTONES.md), never claimed pre-audit.
25
28
  "Development Status :: 4 - Beta",
26
29
  "Intended Audience :: Developers",
27
30
  "License :: OSI Approved :: MIT License",
@@ -13,12 +13,12 @@ from __future__ import annotations
13
13
 
14
14
  from typing import TYPE_CHECKING
15
15
 
16
- __version__ = "2.1.0"
16
+ __version__ = "3.0.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
20
20
  # both together whenever SPEC.md's normative text changes (not on every package release).
21
- SPEC_REVISION = "2026-07-10"
21
+ SPEC_REVISION = "2026-07-11"
22
22
 
23
23
  __all__ = [
24
24
  "__version__",
@@ -0,0 +1,72 @@
1
+ """Duplicate-key-rejecting JSON parsing — the one strict parser for every verify path (WP-C1).
2
+
3
+ ``json.loads`` silently keeps the LAST occurrence of a duplicated key (last-wins). On a verify
4
+ path that is a classic parser differential (Bishop Fox 2021; the exact bug class behind several
5
+ JWT/JOSE CVEs): two implementations parsing the same bytes can disagree about which ``root_b64``
6
+ or ``sig_b64`` they verified, so "cross-verifier consensus" silently stops meaning one thing.
7
+ RFC 8785 (JCS) forbids duplicate keys outright, and the DSSE statement paths already reject them
8
+ INDIRECTLY (a duplicated payload cannot be byte-equal to its own canonical re-serialization) —
9
+ but the native bundle path accepted them silently, and an explicit reject with a clear message
10
+ beats an incidental byte-mismatch everywhere.
11
+
12
+ Converted paths: the native bundle (``load_bundle``, the HF ``pb1.`` token), the DSSE statement
13
+ verifiers (eval-result / test-result / SVR / decision), the trust-policy loader, the per-sample
14
+ disclosure record, the chia-datalayer and markovian anchor envelopes, the status-list token, the
15
+ enclave EAT, and every ``json.load`` in the CLI. Emit-side inputs (a claim/predicate file the
16
+ caller authored) use it too — a duplicate key in something about to be signed is at best an
17
+ authoring bug, at worst an attempted differential.
18
+
19
+ Resolved 2026-07-12 (F12, release-audit): the SD-JWT/KB-JWT payload sites in ``sdjwt.py`` /
20
+ ``kbjwt.py``, ``bundle._issuer_requires_holder_binding``, ``sdjwt_issue._jwt_payload`` and
21
+ ``evalclaim.sd_jwt_hidden_count`` now parse with ``loads_strict`` too, each routed fail-closed —
22
+ a duplicate ``cnf`` is rejected, and ``_issuer_requires_holder_binding`` returns True on a duplicate
23
+ (binding REQUIRED), never the inverted "no holder binding required". Keys that differ only by Unicode normalization
24
+ (NFC/NFD) or a BOM are DISTINCT JSON keys by spec and stay distinct here — normalization games are
25
+ a downstream concern of the field validators, not of the parser.
26
+
27
+ Stdlib-only (``object_pairs_hook``), so the base install keeps rejecting duplicates without any
28
+ extra. The hook fires for every nested object (including objects inside arrays), so duplicates are
29
+ rejected at ANY depth.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ from typing import Any, Union
35
+
36
+ from .errors import BundleFormatError
37
+
38
+ __all__ = ["loads_strict"]
39
+
40
+
41
+ def _reject_duplicate_keys(pairs: list) -> dict:
42
+ obj: dict = {}
43
+ for key, value in pairs:
44
+ if key in obj:
45
+ raise BundleFormatError(
46
+ f"duplicate JSON key {key!r} — rejected fail-closed (a duplicated key parses "
47
+ "differently across JSON implementations; parser-differential guard, WP-C1)")
48
+ obj[key] = value
49
+ return obj
50
+
51
+
52
+ def loads_strict(text: Union[str, bytes]) -> Any:
53
+ """``json.loads`` that rejects duplicate object keys at any nesting depth.
54
+
55
+ Raises :class:`BundleFormatError` for a duplicate key (fail-closed, clear message), maps
56
+ ``RecursionError`` from pathologically deep nesting to the same documented malformed-input
57
+ error, and maps the ``int``/``str`` conversion-limit ``ValueError`` from a JSON integer literal
58
+ with more than ``sys.get_int_max_str_digits()`` digits (CWE-674 / CVE-2020-10735) to it too —
59
+ never a raw traceback — mirroring :func:`proofbundle.bundle.load_bundle`. Ordinary JSON syntax
60
+ errors keep raising ``ValueError`` (``json.JSONDecodeError``) so existing ``except (ValueError,
61
+ ...)`` handling at the call sites stays correct."""
62
+ try:
63
+ return json.loads(text, object_pairs_hook=_reject_duplicate_keys)
64
+ except RecursionError as exc:
65
+ raise BundleFormatError("JSON nesting is too deep") from exc
66
+ except ValueError as exc:
67
+ # The int<->str conversion cap raises a plain ValueError DURING parsing (not a JSONDecodeError),
68
+ # which a pre-auth caller without a broad `except ValueError` would surface as a raw traceback.
69
+ # Map only that specific case; a normal JSONDecodeError keeps raising ValueError as documented.
70
+ if "integer string conversion" in str(exc):
71
+ raise BundleFormatError("JSON integer literal is implausibly long (fail-closed)") from exc
72
+ raise
@@ -22,10 +22,12 @@ has_unknown_level == true means Unknown and is rejected (not silently mapped to
22
22
  from __future__ import annotations
23
23
 
24
24
  import json
25
+ import re
25
26
  from pathlib import Path
26
27
  from typing import Optional, Union
27
28
 
28
29
  from ..evalclaim import build_eval_claim
30
+ from ._provenance import add_provenance
29
31
 
30
32
  _SCHEMA_PATH = Path(__file__).resolve().parent.parent / "eee_eval_schema.json"
31
33
  _SCHEMA_VERSION = "0.2.2"
@@ -95,6 +97,52 @@ def _extract_score(score_details: dict, metric_config: dict) -> str:
95
97
  return _num_to_decimal_str(raw)
96
98
 
97
99
 
100
+ def _model_id_stripped(record: dict) -> dict:
101
+ """A deep copy of the EEE record with the cleartext model identity removed (WP-I3 privacy fix).
102
+ Removes ``model_info.id`` and the top-level ``evaluation_id`` (which embeds the id) so a digest
103
+ over the result cannot be used as a model-id confirmation / enumeration oracle, while still
104
+ binding every score, timestamp, dataset and metric for tamper-evidence."""
105
+ import copy # noqa: PLC0415
106
+ r = copy.deepcopy(record)
107
+ if isinstance(r.get("model_info"), dict):
108
+ r["model_info"].pop("id", None)
109
+ r.pop("evaluation_id", None) # format eval_name/model_id/timestamp — embeds the id
110
+ return r
111
+
112
+
113
+ def _record_digest(record: dict) -> str:
114
+ """``"<alg>:<hex>"`` over the canonical EEE record JSON with the cleartext model identity removed
115
+ (WP-I3; six-lens review: an UNSALTED digest over a record embedding ``model_info.id`` is a model
116
+ confirmation/enumeration oracle, so it is stripped first — the digest still binds scores,
117
+ timestamps, dataset and metrics for tamper-evidence). JCS when the ``[eval]`` extra is present,
118
+ else a labeled deterministic sort_keys fallback (the label tells a verifier which normalization
119
+ produced the hex, never a silent difference)."""
120
+ import hashlib # noqa: PLC0415
121
+ stripped = _model_id_stripped(record)
122
+ try:
123
+ import rfc8785 # noqa: PLC0415
124
+ return "sha256-jcs:" + hashlib.sha256(rfc8785.dumps(stripped)).hexdigest()
125
+ except (ImportError, ValueError, TypeError):
126
+ canonical = json.dumps(stripped, sort_keys=True, separators=(",", ":"),
127
+ ensure_ascii=False).encode("utf-8")
128
+ return "sha256-sortkeys:" + hashlib.sha256(canonical).hexdigest()
129
+
130
+
131
+ def _leaks_model_id(text: str, model_id: str) -> bool:
132
+ """True iff ``text`` contains the model id or any of its name components (WP-I3 privacy, six-lens
133
+ review). Case-insensitive; checks the full ``org/name`` id, the bare name after the last '/',
134
+ and slug variants where '-', '_' and '.' are unified — so 'arc/gpt2/run1' is caught for a model
135
+ id whose bare name is 'gpt2'."""
136
+ hay = text.lower()
137
+ hay_norm = re.sub(r"[-_.]+", "", hay)
138
+ tokens = {model_id.lower()}
139
+ bare = model_id.split("/")[-1].lower()
140
+ if bare:
141
+ tokens.add(bare)
142
+ tokens.add(re.sub(r"[-_.]+", "", bare)) # slug-normalized ('gpt-2' -> 'gpt2')
143
+ return any(t and (t in hay or t in hay_norm) for t in tokens)
144
+
145
+
98
146
  def from_eee_dataset(source: Union[str, Path, dict], *, comparator: str, threshold: str,
99
147
  timestamp: Optional[str] = None, eval_index: int = 0, metric_name: Optional[str] = None,
100
148
  model_salt: Optional[bytes] = None, dataset_salt: Optional[bytes] = None,
@@ -149,6 +197,20 @@ def from_eee_dataset(source: Union[str, Path, dict], *, comparator: str, thresho
149
197
  raise EEEAdapterError("no timestamp: pass timestamp= or set retrieved_timestamp/evaluation_timestamp")
150
198
 
151
199
  provenance = {"source": "every_eval_ever", "eee_schema_version": record.get("schema_version") or _SCHEMA_VERSION}
200
+ # WP-I3: bind the receipt to the EXACT source record — the only adapter without a provenance
201
+ # binding. sha256 over the RFC-8785 (JCS) canonical record JSON with the cleartext model identity
202
+ # stripped first (see _record_digest — the digest binds scores/timestamps/dataset for
203
+ # tamper-evidence but is not a model-id oracle). Same "<alg>:<hex>" label scheme as
204
+ # adapters/_provenance.config_hash, but note it uses NO domain-separation tag (the label
205
+ # distinguishes it); it is provenance metadata, never a security commitment.
206
+ provenance["eee_record_sha256"] = _record_digest(record)
207
+ # the RESULT-level id is traceability metadata; the TOP-level evaluation_id embeds the model id
208
+ # in cleartext and stays deliberately excluded. Guard the result id: drop it if ANY model-name
209
+ # component appears (six-lens review: the exact full-repo-id substring test missed the bare
210
+ # name and slug variants — 'arc/gpt2/run1' leaked 'gpt2'). Case-insensitive over a token set.
211
+ _rid = chosen.get("evaluation_result_id")
212
+ if isinstance(_rid, str) and _rid and not _leaks_model_id(_rid, str(model_id)):
213
+ add_provenance(provenance, run_id=_rid)
152
214
  if eval_library.get("name"):
153
215
  provenance["harness"] = str(eval_library["name"])
154
216
  if eval_library.get("version"):
@@ -144,9 +144,29 @@ def statement_content_root(payload_bytes: bytes) -> bytes:
144
144
  return canonical.statement_content_root(bytes(payload_bytes))
145
145
 
146
146
 
147
- def verify_anchor(anchor: dict, *, target_roots: dict, now: Optional[int] = None) -> dict:
147
+ def _call_verifier(fn: Callable, proof: bytes, canonical_root: bytes, *,
148
+ frozen: dict, now: Optional[int], rp_trust: Optional[dict]) -> dict:
149
+ """Dispatch to an anchor verifier, backward-compatibly. WP-A1 added the ``rp_trust`` kwarg (relying-
150
+ party trust material); a third-party verifier registered before A-1 accepts only ``(proof, root, *,
151
+ frozen, now)``. Pass ``rp_trust`` only when the verifier's signature accepts it (or takes ``**kwargs``),
152
+ so pre-A1 extension verifiers keep working — they simply never see RP trust (their own trust model)."""
153
+ import inspect # noqa: PLC0415
154
+ kw: dict = {"frozen": frozen, "now": now}
155
+ try:
156
+ params = inspect.signature(fn).parameters
157
+ if "rp_trust" in params or any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()):
158
+ kw["rp_trust"] = rp_trust
159
+ except (ValueError, TypeError): # a builtin/C callable with no introspectable signature
160
+ pass
161
+ return fn(proof, canonical_root, **kw)
162
+
163
+
164
+ def verify_anchor(anchor: dict, *, target_roots: dict, now: Optional[int] = None,
165
+ rp_trust: Optional[dict] = None) -> dict:
148
166
  """Verify ONE anchor entry, fail-closed. ``target_roots`` maps a target name to its canonical root
149
- bytes (only the targets that exist for this receipt). Returns ``{ok, type, target, detail}``."""
167
+ bytes (only the targets that exist for this receipt). ``rp_trust`` (WP-A1) is the relying-party trust
168
+ material (TSA roots, Bitcoin block headers) — the ONLY source of trust for a confirmed time anchor;
169
+ the bundle's own ``frozen`` block is evidence, never trust. Returns ``{ok, type, target, detail}``."""
150
170
  _ensure_builtin_types()
151
171
  if not isinstance(anchor, dict):
152
172
  raise BundleFormatError("each anchor must be a JSON object")
@@ -159,6 +179,13 @@ def verify_anchor(anchor: dict, *, target_roots: dict, now: Optional[int] = None
159
179
  if target not in ANCHOR_TARGETS:
160
180
  out["detail"] = f"anchor target must be one of {ANCHOR_TARGETS}"
161
181
  return out
182
+ anchored_at = anchor.get("anchoredAt")
183
+ if anchored_at is not None and not isinstance(anchored_at, str):
184
+ # WP-A7: anchoredAt is INFORMATIVE, but a non-string value is malformed input, not a
185
+ # display nicety — fail closed like every other schema violation (detached anchors have no
186
+ # JSON-schema layer in front of them).
187
+ out["detail"] = "anchor anchoredAt must be an RFC 3339 string or null (informative only)"
188
+ return out
162
189
  if not isinstance(atype, str) or atype not in _VERIFIERS:
163
190
  # Unknown type is a FAIL, not a SKIP — an anchor we cannot check must never pass silently.
164
191
  out["detail"] = (f"no verifier registered for anchor type {atype!r} "
@@ -175,7 +202,8 @@ def verify_anchor(anchor: dict, *, target_roots: dict, now: Optional[int] = None
175
202
  return out
176
203
  proof = _b64d(anchor.get("proof"), "proof")
177
204
  try:
178
- res = _VERIFIERS[atype](proof, canonical_root, frozen=anchor.get("frozen") or {}, now=now)
205
+ res = _call_verifier(_VERIFIERS[atype], proof, canonical_root,
206
+ frozen=anchor.get("frozen") or {}, now=now, rp_trust=rp_trust)
179
207
  except Exception as exc: # a verifier must be fail-closed; if it raises, treat as FAIL, never pass
180
208
  out["detail"] = f"anchor verifier error (fail-closed): {exc}"
181
209
  return out
@@ -183,11 +211,26 @@ def verify_anchor(anchor: dict, *, target_roots: dict, now: Optional[int] = None
183
211
  out["warn"] = bool(res.get("warn"))
184
212
  out["status"] = res.get("status") or ("pass" if out["ok"] else ("warn" if out["warn"] else "fail"))
185
213
  out["detail"] = res.get("detail", "")
214
+ # WP-A1: surface the trust provenance so the relying party can see WHY (and the require gate can only
215
+ # count RP-trusted anchors). `rp_trusted` True → verified against RP-supplied trust material;
216
+ # `needs_rp_trust` True → the proof exists but confirming it needs RP material (frozen is not trust);
217
+ # `frozenEvidence` True → the bundle carried frozen material, reported but never trusted.
218
+ for _f in ("rp_trusted", "needs_rp_trust", "frozenEvidence"):
219
+ if _f in res:
220
+ out[_f] = bool(res.get(_f))
221
+ # WP-A2: structured trusted time, carried VERBATIM from the type verifier — present only when
222
+ # the proof genuinely carries it (rfc3161 gen_time; a confirmed Bitcoin height). NEVER guessed,
223
+ # NEVER derived from the informative anchoredAt field.
224
+ tt = res.get("trustedTime")
225
+ if isinstance(tt, dict) and tt.get("source"):
226
+ out["trustedTime"] = tt
186
227
  return out
187
228
 
188
229
 
189
230
  def verify_anchors(anchors, *, target_roots: dict, require: Optional[str] = None,
190
- allow_pending: bool = False, now: Optional[int] = None) -> dict:
231
+ require_target: Optional[str] = None,
232
+ allow_pending: bool = False, now: Optional[int] = None,
233
+ rp_trust: Optional[dict] = None) -> dict:
191
234
  """Verify a receipt's ``anchors``. Missing/empty → SKIP (unless ``require`` is set → FAIL). Present →
192
235
  fail-closed PASS/FAIL over every entry. ``require`` is ``None`` | ``'any'`` | a type string; when set,
193
236
  at least one anchor of that type (or any) must verify. Returns ``{status, detail, results}`` with
@@ -209,6 +252,11 @@ def verify_anchors(anchors, *, target_roots: dict, require: Optional[str] = None
209
252
  ``allow_pending=True`` (CLI ``--require-anchor … --allow-pending``) a pending anchor also satisfies
210
253
  the requirement — weaker, and the relying party opted into it explicitly. It never turns a broken
211
254
  anchor into a pass: a hard-failing anchor still aggregates to FAIL."""
255
+ if require_target is not None and require_target not in ANCHOR_TARGETS:
256
+ raise BundleFormatError(
257
+ f"require_target must be one of {ANCHOR_TARGETS}, got {require_target!r}")
258
+ if require_target is not None and not require:
259
+ require = "any" # a target requirement IS an anchor requirement (mirrors --anchor-type)
212
260
  if not anchors:
213
261
  if require:
214
262
  return {"status": "FAIL", "require_met": False,
@@ -217,19 +265,28 @@ def verify_anchors(anchors, *, target_roots: dict, require: Optional[str] = None
217
265
  return {"status": "SKIP", "detail": "no external time anchors present", "results": []}
218
266
  if not isinstance(anchors, list):
219
267
  raise BundleFormatError("anchors must be a list")
220
- results = [verify_anchor(a, target_roots=target_roots, now=now) for a in anchors]
268
+ results = [verify_anchor(a, target_roots=target_roots, now=now, rp_trust=rp_trust) for a in anchors]
221
269
  if require: # a warn/pending/inclusion-only anchor never SATISFIES a requirement — only a full one
222
270
  want = None if require == "any" else require
271
+ # WP-A1: matched = ok ∧ ¬warn ∧ type ∧ TARGET. Matching the type alone was a backdating
272
+ # hole: a relying party demanding pre-registration evidence (--anchor-target
273
+ # preRegistration) was satisfied by a RECEIPT anchor stamped today — existence-now proves
274
+ # nothing about existence-before-the-run.
275
+ def _target_ok(r):
276
+ return require_target is None or r["target"] == require_target
223
277
  if allow_pending:
224
278
  matched = [r for r in results
225
- if (r["ok"] or r["warn"]) and (want is None or r["type"] == want)]
279
+ if (r["ok"] or r["warn"]) and (want is None or r["type"] == want)
280
+ and _target_ok(r)]
226
281
  else:
227
282
  matched = [r for r in results
228
- if r["ok"] and not r["warn"] and (want is None or r["type"] == want)]
283
+ if r["ok"] and not r["warn"] and (want is None or r["type"] == want)
284
+ and _target_ok(r)]
229
285
  if not matched:
230
- detail = (f"--require-anchor {require} (--allow-pending): no verifying or pending anchor of that type"
286
+ tgt = f" with target {require_target!r}" if require_target is not None else ""
287
+ detail = (f"--require-anchor {require}{tgt} (--allow-pending): no verifying or pending anchor of that type/target"
231
288
  if allow_pending else
232
- f"--require-anchor {require}: no verifying anchor of that type")
289
+ f"--require-anchor {require}{tgt}: no verifying anchor of that type/target")
233
290
  return {"status": "FAIL", "require_met": False, "detail": detail, "results": results}
234
291
  hard_fail = any(not r["ok"] and not r["warn"] for r in results)
235
292
  if hard_fail:
@@ -26,7 +26,6 @@ Never raises for an ordinary bad proof.
26
26
  from __future__ import annotations
27
27
 
28
28
  import hashlib
29
- import json
30
29
  from typing import Optional
31
30
 
32
31
  ANCHOR_TYPE = "chia-datalayer/v1"
@@ -179,7 +178,8 @@ def verify_chia_datalayer(proof: bytes, canonical_root: bytes, *, frozen: Option
179
178
  # exception types (ValueError, ...) misses RecursionError (deeply nested JSON), MemoryError, or a TypeError
180
179
  # from a non-bytes canonical_root — all of which a caller-controlled proof could trigger. Catch everything.
181
180
  try:
182
- proof_obj = json.loads(bytes(proof).decode("utf-8"))
181
+ from ._strict_json import loads_strict # noqa: PLC0415 — WP-C1: dup keys fail-closed
182
+ proof_obj = loads_strict(bytes(proof).decode("utf-8"))
183
183
  if not isinstance(proof_obj, dict):
184
184
  return {"ok": False, "warn": False, "status": "fail", "detail": "chia-datalayer proof JSON must be an object"}
185
185
  res = verify_offline_merkle(proof_obj, bytes(canonical_root))
@@ -8,8 +8,8 @@ NOT wired into the built-in set (``_ensure_builtin_types``) — a third-party ty
8
8
  **What it proves.** A ``markovian-provenance/v1`` stamp is an *issuer-attributable* commitment: it binds
9
9
  the committed data to a wallet via ``merkle_root = sha256(data_hash ":" salt ":" wallet)``, and the data
10
10
  itself is Bitcoin-anchored with OpenTimestamps. A verified anchor therefore says: *the target canonical
11
- root was committed by wallet W and existed by the OTS-attested Bitcoin block time.* The time is trustless
12
- (Bitcoin); the issuer binding is self-consistent inside the stamp envelope.
11
+ root was committed by wallet W and existed by the OTS-attested Bitcoin block time.* The time is
12
+ trust-minimized (Bitcoin PoW time); the issuer binding is self-consistent inside the stamp envelope.
13
13
 
14
14
  **Composition, not reinvention.** The Bitcoin time proof is delegated verbatim to the built-in
15
15
  ``opentimestamps`` verifier (:func:`proofbundle.anchors_ots.verify_opentimestamps`), inheriting its exact
@@ -31,7 +31,6 @@ from __future__ import annotations
31
31
  import base64
32
32
  import binascii
33
33
  import hashlib
34
- import json
35
34
  from typing import Optional
36
35
 
37
36
  ANCHOR_TYPE = "markovian-provenance/v1"
@@ -42,7 +41,7 @@ def _fail(status: str, detail: str) -> dict:
42
41
 
43
42
 
44
43
  def verify_markovian(proof: bytes, canonical_root: bytes, *, frozen: dict,
45
- now: Optional[int] = None) -> dict:
44
+ now: Optional[int] = None, rp_trust: Optional[dict] = None) -> dict:
46
45
  """Fail-closed verifier for a ``markovian-provenance/v1`` anchor. Returns {ok, warn, status, detail}.
47
46
 
48
47
  Steps (any doubt -> not ok; never raises for an ordinary bad proof):
@@ -54,13 +53,17 @@ def verify_markovian(proof: bytes, canonical_root: bytes, *, frozen: dict,
54
53
  The final status/warn mirror the OTS verifier (pending / upgraded_unverified / confirmed); a PASS also
55
54
  names the committing wallet and Markovian chain height.
56
55
  """
57
- # 1. parse
56
+ # 1. parse (WP-C1: strict — a duplicated key in the envelope is a parser differential over
57
+ # which wallet/merkle_root was committed; BundleFormatError keeps the never-raise contract)
58
58
  try:
59
- env = json.loads(proof.decode("utf-8"))
59
+ from ._strict_json import loads_strict # noqa: PLC0415
60
+ env = loads_strict(proof.decode("utf-8"))
60
61
  if not isinstance(env, dict):
61
62
  raise ValueError("envelope is not a JSON object")
62
63
  except (UnicodeDecodeError, ValueError) as exc:
63
64
  return _fail("malformed", f"markovian proof is not valid JSON: {exc}")
65
+ except Exception as exc: # BundleFormatError (dup key) → clean fail, never a raise
66
+ return _fail("malformed", f"markovian proof rejected: {exc}")
64
67
 
65
68
  # 2. schema
66
69
  if env.get("schema") != ANCHOR_TYPE:
@@ -102,17 +105,31 @@ def verify_markovian(proof: bytes, canonical_root: bytes, *, frozen: dict,
102
105
  return _fail("no_lib",
103
106
  "markovian anchor needs proofbundle[anchors] (opentimestamps) for the Bitcoin proof")
104
107
 
105
- ots_res = verify_opentimestamps(ots_proof, canonical_root, frozen=frozen, now=now)
108
+ # WP-A1: forward the relying-party trust material so the composed Bitcoin proof is confirmed only
109
+ # against RP-supplied block headers, never the bundle's frozen ones.
110
+ ots_res = verify_opentimestamps(ots_proof, canonical_root, frozen=frozen, now=now, rp_trust=rp_trust)
106
111
  who = f"Markovian wallet {wallet}"
107
112
  chain = env.get("block_height")
108
113
  chain_part = f", Markovian chain block {chain}" if chain is not None else ""
114
+ # WP-A1: carry the composed OTS proof's trust-provenance fields verbatim so a relying party keying on
115
+ # rp_trusted / needs_rp_trust / frozenEvidence sees the same truth for a markovian anchor.
116
+ def _with_provenance(out: dict) -> dict:
117
+ for _f in ("rp_trusted", "needs_rp_trust", "frozenEvidence"):
118
+ if _f in ots_res:
119
+ out[_f] = bool(ots_res.get(_f))
120
+ return out
121
+
109
122
  if ots_res.get("ok"):
110
- return {"ok": True, "warn": False, "status": "confirmed",
111
- "detail": f"canonical root committed by {who}{chain_part}; {ots_res.get('detail', '')}"}
123
+ out = {"ok": True, "warn": False, "status": "confirmed",
124
+ "detail": f"canonical root committed by {who}{chain_part}; {ots_res.get('detail', '')}"}
125
+ if isinstance(ots_res.get("trustedTime"), dict): # WP-A2: delegate verbatim (compose)
126
+ out["trustedTime"] = ots_res["trustedTime"]
127
+ return _with_provenance(out)
112
128
  # not a full anchor yet — carry the OTS lifecycle status/warn verbatim, framed as Markovian
113
- return {"ok": False, "warn": bool(ots_res.get("warn")), "status": ots_res.get("status", "fail"),
114
- "detail": f"markovian stamp envelope valid ({who}{chain_part}) but Bitcoin proof not verified: "
115
- f"{ots_res.get('detail', '')}"}
129
+ return _with_provenance(
130
+ {"ok": False, "warn": bool(ots_res.get("warn")), "status": ots_res.get("status", "fail"),
131
+ "detail": f"markovian stamp envelope valid ({who}{chain_part}) but Bitcoin proof not verified: "
132
+ f"{ots_res.get('detail', '')}"})
116
133
 
117
134
 
118
135
  def register() -> None:
@@ -34,11 +34,18 @@ def _classify(timestamp):
34
34
 
35
35
 
36
36
  def verify_opentimestamps(proof: bytes, canonical_root: bytes, *, frozen: dict,
37
- now: Optional[int] = None) -> dict:
37
+ now: Optional[int] = None, rp_trust: Optional[dict] = None) -> dict:
38
38
  """Fail-closed OTS verify. Returns {ok, detail, warn, status}. A pending proof is warn (status
39
- 'pending'); an upgraded proof with no supplied block header is not-ok-but-not-warn honest report
40
- (status 'upgraded_unverified'); an upgraded proof verified against a supplied header is ok (status
41
- 'confirmed')."""
39
+ 'pending'); an upgraded proof with no RELYING-PARTY block header is not-ok-but-not-warn honest report
40
+ (status 'needs_rp_trust'); an upgraded proof verified against an RP-supplied header is ok (status
41
+ 'confirmed').
42
+
43
+ WP-A1 (Owner-GO, trust from the relying party): the Bitcoin block header that turns an upgraded proof
44
+ into a CONFIRMED anchor is trust material and MUST come from the relying party (``rp_trust`` — CLI
45
+ ``--bitcoin-header`` / policy ``anchors.bitcoin_block_headers``), NEVER from the bundle's own ``frozen``
46
+ block (which the producer controls and could backdate with a self-committed header). A frozen header is
47
+ reported as EVIDENCE (``frozenEvidence``) but is never trusted. Without RP trust material an upgraded
48
+ proof is honestly ``needs_rp_trust`` (ok=False), so ``--require-anchor`` is unmet → exit 3."""
42
49
  try:
43
50
  from opentimestamps.core.serialize import BytesDeserializationContext # noqa: PLC0415
44
51
  from opentimestamps.core.timestamp import DetachedTimestampFile # noqa: PLC0415
@@ -62,30 +69,47 @@ def verify_opentimestamps(proof: bytes, canonical_root: bytes, *, frozen: dict,
62
69
  "run `ots upgrade`; not a full anchor yet"}
63
70
  return {"ok": False, "warn": False, "status": "empty",
64
71
  "detail": "OTS proof has no Bitcoin or pending attestation"}
65
- # upgraded: to verify offline we need the block's Merkle root for the attested height, supplied by a
66
- # trusted (local pruned) Bitcoin node proofbundle never fetches it. BitcoinBlockHeaderAttestation's
67
- # own check is exactly `attestation_message == block_header.hashMerkleRoot`; we do that comparison
68
- # directly against the supplied root (equivalent, and avoids reconstructing a full CBlockHeader).
69
- headers = frozen.get("bitcoinBlockHeaderMerkleRootsByHeight") or {}
72
+ # upgraded: to verify offline we need the block's Merkle root for the attested height. WP-A1: that root
73
+ # is TRUST material it must come from the RELYING PARTY (rp_trust.bitcoin_block_headers, i.e. their
74
+ # own trusted/pruned Bitcoin node), NEVER from the producer-controlled `frozen` block. A frozen header
75
+ # is surfaced as evidence only. BitcoinBlockHeaderAttestation's own check is exactly
76
+ # `attestation_message == block_header.hashMerkleRoot`; we do that comparison directly against the
77
+ # RP-supplied root (equivalent, and avoids reconstructing a full CBlockHeader).
78
+ rp_headers = (rp_trust or {}).get("bitcoin_block_headers") or {}
79
+ frozen_headers = frozen.get("bitcoinBlockHeaderMerkleRootsByHeight") or {}
80
+ if not rp_headers:
81
+ # no RP trust material → cannot confirm. Frozen alone is NOT trust (it could be a self-committed
82
+ # backdated header). Honest not-ok report; --require-anchor stays unmet (exit 3).
83
+ return {"ok": False, "warn": False, "status": "needs_rp_trust", "needs_rp_trust": True,
84
+ "frozenEvidence": bool(frozen_headers),
85
+ "detail": f"OTS proof is upgraded (Bitcoin height {heights}) but confirming it needs a "
86
+ "relying-party-supplied Bitcoin block header (--bitcoin-header / policy "
87
+ "anchors.bitcoin_block_headers). The bundle's own frozen header is producer-"
88
+ "controlled evidence, not trust; not claiming a pass"}
70
89
  for msg, att in dtf.timestamp.all_attestations():
71
90
  height = getattr(att, "height", None)
72
91
  if height is None:
73
92
  continue
74
- merkle_root_hex = headers.get(str(height))
93
+ merkle_root_hex = rp_headers.get(str(height))
75
94
  if not merkle_root_hex:
76
95
  continue
77
96
  try:
78
97
  expected = bytes.fromhex(merkle_root_hex)
79
98
  except ValueError:
80
- return {"ok": False, "warn": False, "status": "bad_header",
81
- "detail": f"supplied Bitcoin block merkle root for height {height} is not valid hex"}
99
+ return {"ok": False, "warn": False, "status": "bad_header", "rp_trusted": True,
100
+ "detail": f"relying-party Bitcoin block merkle root for height {height} is not valid hex"}
82
101
  if msg == expected:
83
- return {"ok": True, "warn": False, "status": "confirmed",
102
+ return {"ok": True, "warn": False, "status": "confirmed", "rp_trusted": True,
103
+ # WP-A2: a Bitcoin HEIGHT is the proof's native trusted-time unit — reported
104
+ # structured, never converted to a wall-clock guess (the header time is not
105
+ # part of the supplied material).
106
+ "trustedTime": {"source": "bitcoin_block", "height": height},
84
107
  "detail": f"OTS proof confirmed: committed in the Bitcoin block at height {height} "
85
- "(merkle root supplied by a trusted node)"}
86
- return {"ok": False, "warn": False, "status": "block_mismatch",
87
- "detail": f"OTS Bitcoin attestation at height {height} does not match the supplied "
88
- "block merkle root"}
89
- return {"ok": False, "warn": False, "status": "upgraded_unverified",
90
- "detail": f"OTS proof is upgraded (Bitcoin height {heights}) but no block header was supplied "
91
- " offline verification needs a local (pruned) Bitcoin node; not claiming a pass"}
108
+ "(merkle root supplied by the relying party)"}
109
+ return {"ok": False, "warn": False, "status": "block_mismatch", "rp_trusted": True,
110
+ "detail": f"OTS Bitcoin attestation at height {height} does not match the relying-party "
111
+ "block merkle root (present-and-wrong)"}
112
+ return {"ok": False, "warn": False, "status": "upgraded_unverified", "needs_rp_trust": True,
113
+ "frozenEvidence": bool(frozen_headers),
114
+ "detail": f"OTS proof is upgraded (Bitcoin height {heights}) but the relying party supplied no "
115
+ "block header for that height; not claiming a pass"}