proofbundle 2.0.0b1__tar.gz → 2.0.0b3__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 (108) hide show
  1. {proofbundle-2.0.0b1/src/proofbundle.egg-info → proofbundle-2.0.0b3}/PKG-INFO +37 -9
  2. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/README.md +31 -8
  3. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/pyproject.toml +12 -1
  4. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/__init__.py +1 -1
  5. proofbundle-2.0.0b3/src/proofbundle/anchors.py +194 -0
  6. proofbundle-2.0.0b3/src/proofbundle/anchors_chia.py +196 -0
  7. proofbundle-2.0.0b3/src/proofbundle/anchors_chia_add.py +154 -0
  8. proofbundle-2.0.0b3/src/proofbundle/anchors_markovian.py +121 -0
  9. proofbundle-2.0.0b3/src/proofbundle/anchors_ots.py +91 -0
  10. proofbundle-2.0.0b3/src/proofbundle/anchors_rfc3161.py +90 -0
  11. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/bundle.py +14 -0
  12. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/cli.py +153 -0
  13. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/evalclaim.py +8 -0
  14. proofbundle-2.0.0b3/src/proofbundle/intoto.py +468 -0
  15. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3/src/proofbundle.egg-info}/PKG-INFO +37 -9
  16. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/SOURCES.txt +23 -1
  17. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/requires.txt +7 -0
  18. proofbundle-2.0.0b3/tests/test_anchors_chia.py +222 -0
  19. proofbundle-2.0.0b3/tests/test_anchors_chia_add.py +168 -0
  20. proofbundle-2.0.0b3/tests/test_anchors_chia_claims.py +116 -0
  21. proofbundle-2.0.0b3/tests/test_anchors_generic.py +111 -0
  22. proofbundle-2.0.0b3/tests/test_anchors_markovian.py +151 -0
  23. proofbundle-2.0.0b3/tests/test_anchors_ots.py +122 -0
  24. proofbundle-2.0.0b3/tests/test_anchors_rfc3161.py +115 -0
  25. proofbundle-2.0.0b3/tests/test_branch_base_check.py +68 -0
  26. proofbundle-2.0.0b3/tests/test_claims_hygiene.py +48 -0
  27. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_cli_eval.py +25 -0
  28. proofbundle-2.0.0b3/tests/test_docs_truth.py +46 -0
  29. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_evalclaim.py +23 -0
  30. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_experimental_enclave.py +4 -2
  31. proofbundle-2.0.0b3/tests/test_intoto_claims_hygiene.py +57 -0
  32. proofbundle-2.0.0b3/tests/test_intoto_eval_result.py +172 -0
  33. proofbundle-2.0.0b3/tests/test_intoto_examples.py +86 -0
  34. proofbundle-2.0.0b3/tests/test_intoto_spec_diff.py +38 -0
  35. proofbundle-2.0.0b3/tests/test_intoto_svr.py +137 -0
  36. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_kbjwt.py +16 -0
  37. proofbundle-2.0.0b3/tests/test_verify_matrix.py +82 -0
  38. proofbundle-2.0.0b1/src/proofbundle/intoto.py +0 -182
  39. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/LICENSE +0 -0
  40. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/setup.cfg +0 -0
  41. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/_inspect_registry.py +0 -0
  42. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/_integration.py +0 -0
  43. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/adapters/__init__.py +0 -0
  44. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/adapters/_provenance.py +0 -0
  45. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/adapters/eee.py +0 -0
  46. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/adapters/inspect_ai.py +0 -0
  47. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/adapters/lm_eval.py +0 -0
  48. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/adapters/promptfoo.py +0 -0
  49. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/adapters/samples.py +0 -0
  50. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/beacon.py +0 -0
  51. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/checkpoint.py +0 -0
  52. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/demo.py +0 -0
  53. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/dsse.py +0 -0
  54. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/eee_eval_schema.json +0 -0
  55. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/emit.py +0 -0
  56. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/errors.py +0 -0
  57. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/experimental/__init__.py +0 -0
  58. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/experimental/enclave.py +0 -0
  59. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/hf_evals.py +0 -0
  60. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/inspect_hook.py +0 -0
  61. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/kbjwt.py +0 -0
  62. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/merkle.py +0 -0
  63. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/persample.py +0 -0
  64. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/prereg.py +0 -0
  65. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/py.typed +0 -0
  66. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/pytest_plugin.py +0 -0
  67. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/sdjwt.py +0 -0
  68. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/sdjwt_issue.py +0 -0
  69. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/signature.py +0 -0
  70. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/statuslist.py +0 -0
  71. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/tlogproof.py +0 -0
  72. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/dependency_links.txt +0 -0
  73. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/entry_points.txt +0 -0
  74. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/top_level.txt +0 -0
  75. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_adapters.py +0 -0
  76. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_adversarial.py +0 -0
  77. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_beacon.py +0 -0
  78. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_bundle.py +0 -0
  79. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_bundle_robustness.py +0 -0
  80. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_checkpoint.py +0 -0
  81. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_cli.py +0 -0
  82. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_cosignature.py +0 -0
  83. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_cosignature_mldsa.py +0 -0
  84. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_demo.py +0 -0
  85. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_eee.py +0 -0
  86. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_emit.py +0 -0
  87. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_eval_claim_schema.py +0 -0
  88. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_examples.py +0 -0
  89. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_fuzz_parsers.py +0 -0
  90. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_hf_evals.py +0 -0
  91. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_inspect_hook.py +0 -0
  92. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_intoto.py +0 -0
  93. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_intoto_dsse.py +0 -0
  94. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_merkle.py +0 -0
  95. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_merkle_property.py +0 -0
  96. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_persample.py +0 -0
  97. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_prereg.py +0 -0
  98. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_promptfoo.py +0 -0
  99. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_provenance.py +0 -0
  100. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_pytest_plugin.py +0 -0
  101. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_rekor_interop.py +0 -0
  102. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_rfc6962_external_vectors.py +0 -0
  103. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_schema.py +0 -0
  104. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_sdjwt_issue.py +0 -0
  105. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_sdjwt_reference.py +0 -0
  106. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_signature.py +0 -0
  107. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_statuslist.py +0 -0
  108. {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_tlogproof.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofbundle
3
- Version: 2.0.0b1
3
+ Version: 2.0.0b3
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
@@ -27,7 +27,12 @@ Requires-Dist: cryptography>=42
27
27
  Provides-Extra: sdjwt
28
28
  Provides-Extra: eval
29
29
  Requires-Dist: rfc8785>=0.1.4; extra == "eval"
30
+ Provides-Extra: anchors
31
+ Requires-Dist: rfc3161-client>=1.0.6; extra == "anchors"
32
+ Requires-Dist: opentimestamps>=0.4.5; extra == "anchors"
33
+ Requires-Dist: rfc8785>=0.1.4; extra == "anchors"
30
34
  Provides-Extra: adapters
35
+ Provides-Extra: chia
31
36
  Provides-Extra: experimental
32
37
  Provides-Extra: pq
33
38
  Requires-Dist: cryptography>=48; extra == "pq"
@@ -56,15 +61,19 @@ Dynamic: license-file
56
61
 
57
62
  <h1>proofbundle</h1>
58
63
 
59
- **Turn an AI eval result into one portable, offline-verifiable receipt.**
60
- It proves *who signed these exact bytes* and *that nothing changed since* — not that the number is
61
- true. Ed25519 + RFC 6962 Merkle, one file, no server, no network.
64
+ **AI eval results need receipts.**
65
+
66
+ Turn an AI evaluation result into one portable, offline-verifiable receipt. It proves *who signed
67
+ these exact bytes* and *that nothing changed since* — not that the number is true. Ed25519 + RFC 6962
68
+ Merkle, one file, no server, no network.
62
69
 
63
70
  [![CI](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml/badge.svg)](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml)
71
+ [![PyPI](https://img.shields.io/pypi/v/proofbundle.svg)](https://pypi.org/project/proofbundle/)
72
+ [![Python](https://img.shields.io/pypi/pyversions/proofbundle.svg)](https://pypi.org/project/proofbundle/)
64
73
  [![License: MIT](https://img.shields.io/badge/license-MIT-D6248A.svg)](LICENSE)
65
74
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
66
75
  [![Mutation tested](https://img.shields.io/badge/tests-mutation_gated-D6248A.svg)](scripts/mutation_check.py)
67
- <!-- PyPI / Downloads / SLSA / PEP 740 badges are enabled on the first PyPI release see RELEASE.md. -->
76
+ <!-- SLSA / PEP 740 attestation badges follow once the first attested release lands, see RELEASE.md. -->
68
77
 
69
78
  </div>
70
79
 
@@ -109,6 +118,13 @@ This boundary is the point, not a weakness. A receipt makes a claim **attributab
109
118
  and — with pre-registration and per-sample auditing — bounded and spot-checkable**. Full detail:
110
119
  **[THREAT_MODEL.md](THREAT_MODEL.md)**.
111
120
 
121
+ ## In plain language
122
+
123
+ A proofbundle receipt is the cash-register receipt of an AI test result: it shows who claimed the
124
+ number and that nobody quietly changed it afterwards. It does not show the test was good — the way a
125
+ cash-register receipt does not show the meal was good — but without a receipt there is nothing to
126
+ check at all.
127
+
112
128
  ## How it fits together
113
129
 
114
130
  ```mermaid
@@ -133,13 +149,24 @@ flowchart LR
133
149
  - **Transparency-log interop** — C2SP `tlog-checkpoint` / cosignature / `.tlog-proof`, with
134
150
  post-quantum **ML-DSA-44** witness cosignatures. Optional Token-Status-List revocation snapshots.
135
151
  - **Per-sample audit** — commit to every sample; an auditor challenges random indices (with a fresh
136
- nonce or a **public randomness beacon**, v1.9) and openings must bind to the signed root. Catches
137
- 1% sample-doctoring with 95% confidence at 300 samples, regardless of run size.
152
+ nonce or a **public randomness beacon**, v1.9) and openings must bind to the signed root. With
153
+ such an auditor-supplied or beacon-bound challenge, 300 samples catch 1% sample-doctoring with 95%
154
+ confidence, regardless of run size — a challenge the issuer chose itself does not give this
155
+ guarantee.
138
156
  - **Pre-registration** — `proofbundle prereg <plan>` commits to the protocol before the run, so
139
157
  best-of-many publishing becomes visible.
140
158
  - **Integrations** — opt-in inspect_ai end-of-task hook and pytest plugin (emit only when
141
159
  `PROOFBUNDLE_EMIT=1` / `--proofbundle`), plus a Hugging Face Community Evals bridge. See
142
- [INTEGRATIONS.md](INTEGRATIONS.md).
160
+ [INTEGRATIONS.md](INTEGRATIONS.md), or the end-to-end walkthrough
161
+ [docs/INSPECT_HAPPY_PATH.md](docs/INSPECT_HAPPY_PATH.md) — run an eval, get a receipt, verify it offline.
162
+ - **External time anchors** *(v2.0 beta, the `[anchors]` extra)* — an optional `anchors[]` layer that
163
+ attaches external evidence of *when* a commitment or receipt existed, from a party the producer does not
164
+ control. Two built-in types verify offline: **RFC 3161** TSA tokens (against a frozen cert chain) and
165
+ **OpenTimestamps** Bitcoin proofs (honest pending → confirmed lifecycle). A `register_anchor_type`
166
+ extension interface lets a third party ship its own fail-closed type; two worked examples ship — a
167
+ first-party **`chia-datalayer/v1`** (offline Merkle inclusion of a canonical root under a published Chia
168
+ DataLayer root) and a third-party **`markovian-provenance/v1`** (a wallet-attributable, Bitcoin-anchored
169
+ stamp). See [docs/ANCHORS.md](docs/ANCHORS.md).
143
170
 
144
171
  ## Docs
145
172
 
@@ -154,6 +181,7 @@ flowchart LR
154
181
  | Honest comparison to Rekor / in-toto / OMS / ValiChord | [INTEROP.md](INTEROP.md) |
155
182
  | Regulatory mapping (and what to never claim) | [COMPLIANCE.md](COMPLIANCE.md) |
156
183
  | Funders / role fit | [docs/PROJECT_BRIEF.md](docs/PROJECT_BRIEF.md) |
184
+ | External time anchors + the bring-your-own-type extension interface (v2.0 beta) | [docs/ANCHORS.md](docs/ANCHORS.md) |
157
185
  | **Preview:** TEE-attestation bridge (v2.0 beta) | [docs/EXPERIMENTAL_ENCLAVE.md](docs/EXPERIMENTAL_ENCLAVE.md) |
158
186
 
159
187
  ## Install
@@ -170,7 +198,7 @@ Requires Python 3.10+. The verify path never rolls its own crypto — Ed25519 co
170
198
 
171
199
  ## Status & scope
172
200
 
173
- Beta, SemVer-committed, 303 tests + a CI mutation gate + property-based parser fuzzing. Correctness
201
+ Beta, SemVer-committed, with a CI test suite behind a mutation gate + property-based parser fuzzing. Correctness
174
202
  is anchored to external RFC 6962 vectors and a real Rekor proof, not just its own bundles. It is
175
203
  **not** a log service, a full in-toto client, a TEE, a consensus network, or a compliance product
176
204
  by itself — it is the small, offline, standards-native receipt layer between them. Security policy:
@@ -7,15 +7,19 @@
7
7
 
8
8
  <h1>proofbundle</h1>
9
9
 
10
- **Turn an AI eval result into one portable, offline-verifiable receipt.**
11
- It proves *who signed these exact bytes* and *that nothing changed since* — not that the number is
12
- true. Ed25519 + RFC 6962 Merkle, one file, no server, no network.
10
+ **AI eval results need receipts.**
11
+
12
+ Turn an AI evaluation result into one portable, offline-verifiable receipt. It proves *who signed
13
+ these exact bytes* and *that nothing changed since* — not that the number is true. Ed25519 + RFC 6962
14
+ Merkle, one file, no server, no network.
13
15
 
14
16
  [![CI](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml/badge.svg)](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml)
17
+ [![PyPI](https://img.shields.io/pypi/v/proofbundle.svg)](https://pypi.org/project/proofbundle/)
18
+ [![Python](https://img.shields.io/pypi/pyversions/proofbundle.svg)](https://pypi.org/project/proofbundle/)
15
19
  [![License: MIT](https://img.shields.io/badge/license-MIT-D6248A.svg)](LICENSE)
16
20
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
17
21
  [![Mutation tested](https://img.shields.io/badge/tests-mutation_gated-D6248A.svg)](scripts/mutation_check.py)
18
- <!-- PyPI / Downloads / SLSA / PEP 740 badges are enabled on the first PyPI release see RELEASE.md. -->
22
+ <!-- SLSA / PEP 740 attestation badges follow once the first attested release lands, see RELEASE.md. -->
19
23
 
20
24
  </div>
21
25
 
@@ -60,6 +64,13 @@ This boundary is the point, not a weakness. A receipt makes a claim **attributab
60
64
  and — with pre-registration and per-sample auditing — bounded and spot-checkable**. Full detail:
61
65
  **[THREAT_MODEL.md](THREAT_MODEL.md)**.
62
66
 
67
+ ## In plain language
68
+
69
+ A proofbundle receipt is the cash-register receipt of an AI test result: it shows who claimed the
70
+ number and that nobody quietly changed it afterwards. It does not show the test was good — the way a
71
+ cash-register receipt does not show the meal was good — but without a receipt there is nothing to
72
+ check at all.
73
+
63
74
  ## How it fits together
64
75
 
65
76
  ```mermaid
@@ -84,13 +95,24 @@ flowchart LR
84
95
  - **Transparency-log interop** — C2SP `tlog-checkpoint` / cosignature / `.tlog-proof`, with
85
96
  post-quantum **ML-DSA-44** witness cosignatures. Optional Token-Status-List revocation snapshots.
86
97
  - **Per-sample audit** — commit to every sample; an auditor challenges random indices (with a fresh
87
- nonce or a **public randomness beacon**, v1.9) and openings must bind to the signed root. Catches
88
- 1% sample-doctoring with 95% confidence at 300 samples, regardless of run size.
98
+ nonce or a **public randomness beacon**, v1.9) and openings must bind to the signed root. With
99
+ such an auditor-supplied or beacon-bound challenge, 300 samples catch 1% sample-doctoring with 95%
100
+ confidence, regardless of run size — a challenge the issuer chose itself does not give this
101
+ guarantee.
89
102
  - **Pre-registration** — `proofbundle prereg <plan>` commits to the protocol before the run, so
90
103
  best-of-many publishing becomes visible.
91
104
  - **Integrations** — opt-in inspect_ai end-of-task hook and pytest plugin (emit only when
92
105
  `PROOFBUNDLE_EMIT=1` / `--proofbundle`), plus a Hugging Face Community Evals bridge. See
93
- [INTEGRATIONS.md](INTEGRATIONS.md).
106
+ [INTEGRATIONS.md](INTEGRATIONS.md), or the end-to-end walkthrough
107
+ [docs/INSPECT_HAPPY_PATH.md](docs/INSPECT_HAPPY_PATH.md) — run an eval, get a receipt, verify it offline.
108
+ - **External time anchors** *(v2.0 beta, the `[anchors]` extra)* — an optional `anchors[]` layer that
109
+ attaches external evidence of *when* a commitment or receipt existed, from a party the producer does not
110
+ control. Two built-in types verify offline: **RFC 3161** TSA tokens (against a frozen cert chain) and
111
+ **OpenTimestamps** Bitcoin proofs (honest pending → confirmed lifecycle). A `register_anchor_type`
112
+ extension interface lets a third party ship its own fail-closed type; two worked examples ship — a
113
+ first-party **`chia-datalayer/v1`** (offline Merkle inclusion of a canonical root under a published Chia
114
+ DataLayer root) and a third-party **`markovian-provenance/v1`** (a wallet-attributable, Bitcoin-anchored
115
+ stamp). See [docs/ANCHORS.md](docs/ANCHORS.md).
94
116
 
95
117
  ## Docs
96
118
 
@@ -105,6 +127,7 @@ flowchart LR
105
127
  | Honest comparison to Rekor / in-toto / OMS / ValiChord | [INTEROP.md](INTEROP.md) |
106
128
  | Regulatory mapping (and what to never claim) | [COMPLIANCE.md](COMPLIANCE.md) |
107
129
  | Funders / role fit | [docs/PROJECT_BRIEF.md](docs/PROJECT_BRIEF.md) |
130
+ | External time anchors + the bring-your-own-type extension interface (v2.0 beta) | [docs/ANCHORS.md](docs/ANCHORS.md) |
108
131
  | **Preview:** TEE-attestation bridge (v2.0 beta) | [docs/EXPERIMENTAL_ENCLAVE.md](docs/EXPERIMENTAL_ENCLAVE.md) |
109
132
 
110
133
  ## Install
@@ -121,7 +144,7 @@ Requires Python 3.10+. The verify path never rolls its own crypto — Ed25519 co
121
144
 
122
145
  ## Status & scope
123
146
 
124
- Beta, SemVer-committed, 303 tests + a CI mutation gate + property-based parser fuzzing. Correctness
147
+ Beta, SemVer-committed, with a CI test suite behind a mutation gate + property-based parser fuzzing. Correctness
125
148
  is anchored to external RFC 6962 vectors and a real Rekor proof, not just its own bundles. It is
126
149
  **not** a log service, a full in-toto client, a TEE, a consensus network, or a compliance product
127
150
  by itself — it is the small, offline, standards-native receipt layer between them. Security policy:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proofbundle"
7
- version = "2.0.0b1"
7
+ version = "2.0.0b3"
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"
@@ -44,8 +44,19 @@ sdjwt = []
44
44
  # path (verify_bundle / decode_eval_claim) never canonicalizes — it checks stored bytes — so the
45
45
  # verifier stays dependency-free. `pip install proofbundle[eval]` adds emit-side canonicalization.
46
46
  eval = ["rfc8785>=0.1.4"]
47
+ # EXPERIMENTAL: external time anchors (proofbundle.anchors) — RFC 3161 TSA + OpenTimestamps. Opt-in and
48
+ # clearly experimental (API/format may change). rfc3161-client (Trail of Bits) is deliberately offline —
49
+ # it verifies an RFC 3161 token against bundled roots, no network in the verify path. opentimestamps is
50
+ # the OTS proof library. rfc8785 gives the receipt its canonical (JCS) root. The base install stays
51
+ # anchor-free (only cryptography); a receipt with no anchors verifies unchanged.
52
+ anchors = ["rfc3161-client>=1.0.6", "opentimestamps>=0.4.5", "rfc8785>=0.1.4"]
47
53
  # The lm-eval adapter reads exported results.json (no import) → pure stdlib.
48
54
  adapters = []
55
+ # chia-datalayer/v1 anchor WRITER (anchors_chia_add). Needs no extra Python package — it drives the
56
+ # stable `chia rpc data_layer|full_node` CLI of a locally installed, cert-authed Chia node (the DataLayer
57
+ # node is the real dependency, not a wheel). Declared so `pip install proofbundle[chia]` is a documented,
58
+ # discoverable install target. VERIFYING an anchor offline (anchors_chia) needs neither this nor a node.
59
+ chia = []
49
60
  # EXPERIMENTAL (v2.0 preview): the TEE-attestation bridge (proofbundle.experimental.enclave).
50
61
  # Opt-in and clearly unstable — API/wire-format may change or be removed without deprecation.
51
62
  # No new runtime dependency (pure stdlib + the core `cryptography`); the extra exists to signal
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  from typing import TYPE_CHECKING
15
15
 
16
- __version__ = "2.0.0b1"
16
+ __version__ = "2.0.0b3"
17
17
 
18
18
  __all__ = [
19
19
  "__version__",
@@ -0,0 +1,194 @@
1
+ """Generic external time-anchor layer for proofbundle receipts (EXPERIMENTAL; `[anchors]` extra).
2
+
3
+ An **anchor** is external evidence that a target existed at (or before) a time — something the receipt's
4
+ own Ed25519 + Merkle structure cannot establish on its own, because a self-emitted timestamp is only
5
+ producer-clock testimony. Two targets, **never mixed**:
6
+
7
+ * ``preRegistration`` — "the commitment existed BEFORE the run" (backdating protection; the point raised
8
+ in in-toto/attestation#565).
9
+ * ``receipt`` — "the receipt existed from time T" (publication proof).
10
+
11
+ Each ``anchors[]`` entry is ``{type, target, canonicalRoot, proof, anchoredAt}``:
12
+
13
+ * ``type`` — ``rfc3161-tsa`` | ``opentimestamps`` | ``<extension>/vN``.
14
+ * ``target`` — ``receipt`` | ``preRegistration`` (see above).
15
+ * ``canonicalRoot`` — base64 of the canonical root of the target: for ``receipt`` the RFC 8785 (JCS)
16
+ sha256 of the receipt bundle; for ``preRegistration`` the sha256 of the raw protocol bytes (the
17
+ receipt's ``prereg_sha256``). The anchor timestamps THIS root.
18
+ * ``proof`` — base64 of the type-specific proof (an RFC 3161 token, an OpenTimestamps proof, ...).
19
+ * ``anchoredAt`` — RFC 3339 Z, INFORMATIVE only (the trusted time comes from the proof, not this field).
20
+
21
+ **Verify contract (fail-closed).** Missing/empty ``anchors`` → SKIP (never FAIL — consistent with
22
+ in-toto's Monotonic Principle: deny only when an attestation is present and wrong). Present → fail-closed:
23
+ a root mismatch, an unknown type, or a broken proof is a FAIL, never silent. ``require`` (CLI
24
+ ``--require-anchor <type|any>``) turns "no verifying anchor" into a FAIL.
25
+
26
+ **Cross-target safety.** ``canonicalRoot`` is compared to the root of the anchor's OWN ``target`` — a
27
+ ``preRegistration`` anchor can never validate a ``receipt`` target and vice versa (the roots differ).
28
+
29
+ **Lean core.** This module is pure dispatch + schema; the RFC 3161 / OpenTimestamps verifiers lazy-import
30
+ their libraries and are only needed with the ``[anchors]`` extra. The base install pulls only
31
+ ``cryptography``; a bundle with no anchors verifies unchanged. Anchoring writes a NEW file — a network
32
+ error while stamping never corrupts the local receipt.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import base64
37
+ import binascii
38
+ import hashlib
39
+ from typing import Callable, Optional
40
+
41
+ from .errors import BundleFormatError
42
+
43
+ ANCHOR_TARGETS = ("receipt", "preRegistration")
44
+ _ANCHOR_KEYS = {"type", "target", "canonicalRoot", "proof", "anchoredAt", "frozen"}
45
+
46
+ # type name -> verifier callable:
47
+ # (proof: bytes, canonical_root: bytes, *, frozen: dict, now: Optional[int]) -> {"ok": bool, "detail": str}
48
+ _VERIFIERS: dict[str, Callable] = {}
49
+
50
+
51
+ def register_anchor_type(type_name: str, verifier: Callable) -> None:
52
+ """Register a verifier for an anchor ``type``. A third party ships its own type this way (see
53
+ docs/ANCHORS.md). The verifier MUST be fail-closed: return ``{"ok": False, ...}`` on any doubt,
54
+ never raise for an ordinary bad proof."""
55
+ if not type_name or not isinstance(type_name, str) or not callable(verifier):
56
+ raise BundleFormatError("register_anchor_type needs a non-empty name and a callable verifier")
57
+ _VERIFIERS[type_name] = verifier
58
+
59
+
60
+ def registered_anchor_types() -> tuple:
61
+ _ensure_builtin_types()
62
+ return tuple(sorted(_VERIFIERS))
63
+
64
+
65
+ def _ensure_builtin_types() -> None:
66
+ """Lazily register the built-in anchor verifiers (rfc3161-tsa, opentimestamps). Each needs the
67
+ ``[anchors]`` extra; if a library is absent the type stays UNREGISTERED — which the verify path
68
+ treats as an unknown type → FAIL (fail-closed), exactly the behaviour we want without the extra."""
69
+ if "rfc3161-tsa" not in _VERIFIERS:
70
+ try:
71
+ from . import anchors_rfc3161 # noqa: PLC0415
72
+ _VERIFIERS["rfc3161-tsa"] = anchors_rfc3161.verify_rfc3161
73
+ except Exception: # extra missing / import failure → leave unregistered (fail-closed)
74
+ pass
75
+ if "opentimestamps" not in _VERIFIERS:
76
+ try:
77
+ from . import anchors_ots # noqa: PLC0415
78
+ _VERIFIERS["opentimestamps"] = anchors_ots.verify_opentimestamps
79
+ except Exception:
80
+ pass
81
+ # chia-datalayer/v1: the first FIRST-PARTY extension anchor. Its offline Merkle verifier (level i) is
82
+ # PURE SHA-256 — no Chia software, no extra — so it always registers (writing an anchor via anchor-add
83
+ # needs the [chia] extra + a node, but VERIFYING one offline does not).
84
+ if "chia-datalayer/v1" not in _VERIFIERS:
85
+ try:
86
+ from . import anchors_chia # noqa: PLC0415
87
+ _VERIFIERS[anchors_chia.ANCHOR_TYPE] = anchors_chia.verify_chia_datalayer
88
+ except Exception: # pragma: no cover - pure module, import should not fail
89
+ pass
90
+
91
+
92
+ def _b64d(value, field: str) -> bytes:
93
+ if not isinstance(value, str):
94
+ raise BundleFormatError(f"anchor {field} must be a base64 string")
95
+ try:
96
+ return base64.b64decode(value, validate=True)
97
+ except (ValueError, binascii.Error) as exc:
98
+ raise BundleFormatError(f"anchor {field} is not valid base64") from exc
99
+
100
+
101
+ def receipt_canonical_root(bundle: dict) -> bytes:
102
+ """The RFC 8785 (JCS) sha256 of the receipt bundle — the canonical root a ``receipt`` anchor stamps.
103
+ Uses a real RFC 8785 canonicalizer (the ``[anchors]``/``[eval]`` extra); never a home-grown sort."""
104
+ try:
105
+ import rfc8785 # noqa: PLC0415
106
+ except ImportError as exc: # pragma: no cover - guarded by the extra
107
+ raise BundleFormatError(
108
+ "receipt anchoring needs the RFC 8785 canonicalizer — install proofbundle[anchors]") from exc
109
+ return hashlib.sha256(rfc8785.dumps(bundle)).digest()
110
+
111
+
112
+ def prereg_canonical_root(prereg_sha256_hex: str) -> bytes:
113
+ """The canonical root a ``preRegistration`` anchor stamps: the sha256 (raw bytes) of the eval
114
+ protocol file, i.e. the receipt's ``prereg_sha256``."""
115
+ if not isinstance(prereg_sha256_hex, str) or len(prereg_sha256_hex) != 64:
116
+ raise BundleFormatError("prereg canonical root needs a 64-char hex sha256")
117
+ try:
118
+ return bytes.fromhex(prereg_sha256_hex)
119
+ except ValueError as exc:
120
+ raise BundleFormatError("prereg_sha256 is not valid hex") from exc
121
+
122
+
123
+ def verify_anchor(anchor: dict, *, target_roots: dict, now: Optional[int] = None) -> dict:
124
+ """Verify ONE anchor entry, fail-closed. ``target_roots`` maps a target name to its canonical root
125
+ bytes (only the targets that exist for this receipt). Returns ``{ok, type, target, detail}``."""
126
+ _ensure_builtin_types()
127
+ if not isinstance(anchor, dict):
128
+ raise BundleFormatError("each anchor must be a JSON object")
129
+ unknown = set(anchor) - _ANCHOR_KEYS
130
+ if unknown:
131
+ raise BundleFormatError(f"anchor has unknown field(s) {sorted(unknown)}")
132
+ atype = anchor.get("type")
133
+ target = anchor.get("target")
134
+ out = {"ok": False, "warn": False, "status": "fail", "type": atype, "target": target, "detail": ""}
135
+ if target not in ANCHOR_TARGETS:
136
+ out["detail"] = f"anchor target must be one of {ANCHOR_TARGETS}"
137
+ return out
138
+ if not isinstance(atype, str) or atype not in _VERIFIERS:
139
+ # Unknown type is a FAIL, not a SKIP — an anchor we cannot check must never pass silently.
140
+ out["detail"] = (f"no verifier registered for anchor type {atype!r} "
141
+ "(install proofbundle[anchors] or register the extension type)")
142
+ return out
143
+ expected_root = target_roots.get(target)
144
+ if expected_root is None:
145
+ out["detail"] = f"the receipt has no {target} target to anchor against"
146
+ return out
147
+ canonical_root = _b64d(anchor.get("canonicalRoot"), "canonicalRoot")
148
+ if canonical_root != expected_root:
149
+ # cross-target safety: a preRegistration anchor's root never equals the receipt root, and v.v.
150
+ out["detail"] = f"canonicalRoot does not match the {target} root (cross-target or tampered)"
151
+ return out
152
+ proof = _b64d(anchor.get("proof"), "proof")
153
+ try:
154
+ res = _VERIFIERS[atype](proof, canonical_root, frozen=anchor.get("frozen") or {}, now=now)
155
+ except Exception as exc: # a verifier must be fail-closed; if it raises, treat as FAIL, never pass
156
+ out["detail"] = f"anchor verifier error (fail-closed): {exc}"
157
+ return out
158
+ out["ok"] = bool(res.get("ok"))
159
+ out["warn"] = bool(res.get("warn"))
160
+ out["status"] = res.get("status") or ("pass" if out["ok"] else ("warn" if out["warn"] else "fail"))
161
+ out["detail"] = res.get("detail", "")
162
+ return out
163
+
164
+
165
+ def verify_anchors(anchors, *, target_roots: dict, require: Optional[str] = None,
166
+ now: Optional[int] = None) -> dict:
167
+ """Verify a receipt's ``anchors``. Missing/empty → SKIP (unless ``require`` is set → FAIL). Present →
168
+ fail-closed PASS/FAIL over every entry. ``require`` is ``None`` | ``'any'`` | a type string; when set,
169
+ at least one anchor of that type (or any) must verify. Returns ``{status, detail, results}`` with
170
+ ``status`` in {PASS, FAIL, SKIP}."""
171
+ if not anchors:
172
+ if require:
173
+ return {"status": "FAIL", "detail": f"--require-anchor {require} set but the receipt has no anchors",
174
+ "results": []}
175
+ return {"status": "SKIP", "detail": "no external time anchors present", "results": []}
176
+ if not isinstance(anchors, list):
177
+ raise BundleFormatError("anchors must be a list")
178
+ results = [verify_anchor(a, target_roots=target_roots, now=now) for a in anchors]
179
+ if require: # a warn/pending/inclusion-only anchor never SATISFIES a requirement — only a full one
180
+ want = None if require == "any" else require
181
+ matched = [r for r in results if r["ok"] and not r["warn"] and (want is None or r["type"] == want)]
182
+ if not matched:
183
+ return {"status": "FAIL",
184
+ "detail": f"--require-anchor {require}: no verifying anchor of that type",
185
+ "results": results}
186
+ hard_fail = any(not r["ok"] and not r["warn"] for r in results)
187
+ if hard_fail:
188
+ status = "FAIL" # a broken/unbound/unknown anchor is never silent
189
+ elif any(r["warn"] for r in results):
190
+ status = "WARN" # e.g. a PENDING OpenTimestamps proof — not a full anchor yet
191
+ else:
192
+ status = "PASS"
193
+ detail = f"{sum(r['ok'] for r in results)}/{len(results)} anchor(s) verified"
194
+ return {"status": status, "detail": detail, "results": results}
@@ -0,0 +1,196 @@
1
+ """Chia DataLayer anchor (``chia-datalayer/v1``) — the first first-party extension anchor type.
2
+
3
+ This is the **offline Merkle verifier** (level i of the three-level honesty documented in ``docs/ANCHORS.md``):
4
+ it proves, with **pure SHA-256 and NO Chia software**, that a key/value pair is included under a published
5
+ DataLayer root. It does NOT prove the chain binding (that the ``published_root`` is actually an unspent
6
+ singleton on the heaviest chain) — that is level ii (light wallet) / level iii (own full node), which need
7
+ Chia software and are reported separately, never conflated here.
8
+
9
+ Wire facts (Chia 2.7.x / chia_rs datalayer, pinned in the adapter spec; re-check against the live version
10
+ before trusting — Chia moves modules): a DataLayer ``get_proof`` yields, per key:
11
+
12
+ key_clvm_hash = CLVM tree hash of the key atom = sha256(0x01 ‖ key_atom)
13
+ value_clvm_hash= CLVM tree hash of the value atom = sha256(0x01 ‖ value_atom)
14
+ node_hash (leaf) = sha256(0x02 ‖ key_clvm_hash ‖ value_clvm_hash)
15
+ internal node = sha256(0x02 ‖ left ‖ right)
16
+ ascent: each layer carries other_hash_side (0=LEFT sibling / 1=RIGHT sibling), other_hash, combined_hash
17
+ root = layers[-1].combined_hash (or node_hash when layers == [])
18
+
19
+ The anchor's ``proof`` field (base64 in the bundle, decoded to bytes by ``verify_anchor``) is the **UTF-8 JSON**
20
+ of a proof object with those hex fields; ``canonical_root`` is the ``value_digest`` the anchor stamps.
21
+
22
+ Fail-closed: any structural problem, hash mismatch, or ascent inconsistency returns ``{"ok": False, ...}``.
23
+ Never raises for an ordinary bad proof.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import json
30
+ from typing import Optional
31
+
32
+ ANCHOR_TYPE = "chia-datalayer/v1"
33
+
34
+ # CLVM tree-hash / node-hash domain-separation prefixes (Chia DataLayer Merkle set).
35
+ _ATOM_PREFIX = b"\x01" # sha256(0x01 ‖ atom) = CLVM tree hash of an atom
36
+ _NODE_PREFIX = b"\x02" # sha256(0x02 ‖ left ‖ right) = internal/leaf node hash
37
+
38
+ _HASH_LEN = 32 # sha256 digest length
39
+ _MAX_LAYERS = 256 # a DataLayer tree of 2**256 leaves is absurd; bound the ascent (DoS guard)
40
+ _MAX_PROOF_BYTES = 131072 # real chia-datalayer proofs are ~2.5 KB; cap the input before json.loads (DoS guard)
41
+
42
+
43
+ def _h(*parts: bytes) -> bytes:
44
+ m = hashlib.sha256()
45
+ for p in parts:
46
+ m.update(p)
47
+ return m.digest()
48
+
49
+
50
+ def _hexatom(value, field: str) -> bytes:
51
+ """Decode an arbitrary-length hex string (optionally 0x-prefixed) to bytes. DataLayer keys/values are
52
+ atoms of any length — only the HASHES are fixed at 32 bytes (see _hexbytes)."""
53
+ if not isinstance(value, str):
54
+ raise ValueError(f"{field} must be a hex string")
55
+ s = value[2:] if value[:2] in ("0x", "0X") else value
56
+ return bytes.fromhex(s) # raises ValueError on non-hex / odd length
57
+
58
+
59
+ def _hexbytes(value, field: str) -> bytes:
60
+ """Decode a 32-byte hash hex string (optionally 0x-prefixed) to bytes, else raise ValueError."""
61
+ b = _hexatom(value, field)
62
+ if len(b) != _HASH_LEN:
63
+ raise ValueError(f"{field} must be {_HASH_LEN} bytes (got {len(b)})")
64
+ return b
65
+
66
+
67
+ def clvm_atom_hash(atom: bytes) -> bytes:
68
+ """CLVM tree hash of a raw atom: sha256(0x01 ‖ atom). Public helper (Paket 1 anchor-add reuses it)."""
69
+ return _h(_ATOM_PREFIX, atom)
70
+
71
+
72
+ def leaf_node_hash(key_clvm_hash: bytes, value_clvm_hash: bytes) -> bytes:
73
+ """DataLayer leaf hash: sha256(0x02 ‖ key_clvm_hash ‖ value_clvm_hash)."""
74
+ return _h(_NODE_PREFIX, key_clvm_hash, value_clvm_hash)
75
+
76
+
77
+ def merkle_root_from_layers(node_hash: bytes, inclusion_layers: list) -> bytes:
78
+ """Recompute the DataLayer root by ascending ``inclusion_layers`` from ``node_hash``.
79
+
80
+ Each layer is ``{"other_hash_side": 0|1, "other_hash": hex, "combined_hash": hex}``. ``other_hash_side``
81
+ is the side of the SIBLING: 0 = sibling on the LEFT → sha256(0x02 ‖ other ‖ cur); 1 = sibling on the
82
+ RIGHT → sha256(0x02 ‖ cur ‖ other). Each layer's declared ``combined_hash`` MUST equal the recomputed
83
+ value (fail-closed) — this is what makes the proof self-consistent, not just plausible.
84
+
85
+ Returns the root bytes. Raises ValueError on any structural/consistency problem.
86
+ """
87
+ if not isinstance(inclusion_layers, list):
88
+ raise ValueError("inclusion_layers must be a list")
89
+ if len(inclusion_layers) > _MAX_LAYERS:
90
+ raise ValueError("inclusion_layers too deep")
91
+ cur = node_hash
92
+ for i, layer in enumerate(inclusion_layers):
93
+ if not isinstance(layer, dict):
94
+ raise ValueError(f"layer {i} must be an object")
95
+ side = layer.get("other_hash_side")
96
+ if side not in (0, 1):
97
+ raise ValueError(f"layer {i} other_hash_side must be 0 (LEFT) or 1 (RIGHT)")
98
+ other = _hexbytes(layer.get("other_hash"), f"layer {i} other_hash")
99
+ declared = _hexbytes(layer.get("combined_hash"), f"layer {i} combined_hash")
100
+ combined = _h(_NODE_PREFIX, other, cur) if side == 0 else _h(_NODE_PREFIX, cur, other)
101
+ if combined != declared:
102
+ raise ValueError(f"layer {i} combined_hash inconsistent with the recomputed hash")
103
+ cur = combined
104
+ return cur
105
+
106
+
107
+ def verify_offline_merkle(proof_obj: dict, canonical_root: bytes) -> dict:
108
+ """Pure offline verification (level i). ``proof_obj`` is the decoded chia-datalayer proof dict.
109
+
110
+ The BINDING to the target is via the DataLayer KEY: the anchor's ``canonicalRoot`` IS the key stored in
111
+ the store (``key = canonicalRoot``), so the proof must carry the raw ``key``, it must EQUAL
112
+ ``canonical_root``, and it must be the atom that hashes (``sha256(0x01‖key)``) to ``key_clvm_hash`` — whose
113
+ leaf ascends through ``inclusion_layers`` to ``published_root``. This ties ``canonical_root``
114
+ cryptographically to the Merkle path; an unrelated (even genuine) proof for a DIFFERENT key can no longer
115
+ be relabelled to this target by swapping a decoupled digest field.
116
+
117
+ Checks, all fail-closed:
118
+ 1. raw ``key`` present AND == ``canonical_root`` AND ``sha256(0x01‖key) == key_clvm_hash``
119
+ 2. (optional) raw ``value`` → ``sha256(0x01‖value) == value_clvm_hash``
120
+ 3. leaf ``node_hash == sha256(0x02‖key_clvm‖value_clvm)`` (when declared)
121
+ 4. ascending ``inclusion_layers`` reproduces ``published_root`` (each layer's combined_hash self-consistent)
122
+
123
+ Proves ONLY that ``canonical_root`` is a key included under ``published_root``; NOT that ``published_root``
124
+ is on-chain (that is level ii/iii, needs Chia software — see docs/ANCHORS.md).
125
+ """
126
+ try:
127
+ key_clvm = _hexbytes(proof_obj.get("key_clvm_hash"), "key_clvm_hash")
128
+ value_clvm = _hexbytes(proof_obj.get("value_clvm_hash"), "value_clvm_hash")
129
+ published_root = _hexbytes(proof_obj.get("published_root"), "published_root")
130
+ layers = proof_obj.get("inclusion_layers")
131
+
132
+ # (1) BINDING — the raw DataLayer key IS the target canonicalRoot, and it is the atom under key_clvm_hash.
133
+ raw_key = proof_obj.get("key")
134
+ if not isinstance(raw_key, str) or not raw_key:
135
+ return {"ok": False, "detail": "proof missing the raw key (required to bind canonicalRoot to the leaf)"}
136
+ key_bytes = _hexatom(raw_key, "key")
137
+ if key_bytes != canonical_root:
138
+ return {"ok": False, "detail": "the anchored DataLayer key does not equal the target canonicalRoot (cross-target/tampered/forged)"}
139
+ if clvm_atom_hash(key_bytes) != key_clvm:
140
+ return {"ok": False, "detail": "key_clvm_hash does not match sha256(0x01 || key)"}
141
+
142
+ # (2) optional: raw value atom (ANY length) → value_clvm_hash
143
+ raw_value = proof_obj.get("value")
144
+ if isinstance(raw_value, str) and raw_value:
145
+ if clvm_atom_hash(_hexatom(raw_value, "value")) != value_clvm:
146
+ return {"ok": False, "detail": "value_clvm_hash does not match sha256(0x01 || value)"}
147
+
148
+ # (3) leaf hash
149
+ leaf = leaf_node_hash(key_clvm, value_clvm)
150
+ declared_node = proof_obj.get("node_hash")
151
+ if declared_node is not None and _hexbytes(declared_node, "node_hash") != leaf:
152
+ return {"ok": False, "detail": "node_hash does not match sha256(0x02 || key_clvm || value_clvm)"}
153
+
154
+ # (4) ascend to the root
155
+ root = merkle_root_from_layers(leaf, layers if layers is not None else [])
156
+ if root != published_root:
157
+ return {"ok": False, "detail": "inclusion_layers do not reproduce published_root (not included)"}
158
+
159
+ except ValueError as exc:
160
+ return {"ok": False, "detail": f"malformed chia-datalayer proof: {exc}"}
161
+ return {"ok": True, "detail": "chia-datalayer merkle: canonicalRoot (as DataLayer key) included under published_root (level i, offline; chain binding NOT checked here)"}
162
+
163
+
164
+ def verify_chia_datalayer(proof: bytes, canonical_root: bytes, *, frozen: Optional[dict] = None,
165
+ now: Optional[int] = None) -> dict:
166
+ """Registered anchor verifier for ``chia-datalayer/v1`` (see ``register_anchor_type``).
167
+
168
+ ``proof`` is the UTF-8 JSON of the proof object; ``canonical_root`` is the target's root bytes. Returns
169
+ ``{"ok", "warn", "status", "detail"}``, fail-closed. LEVEL i ONLY: proves the Merkle inclusion under the
170
+ published root offline; it deliberately does NOT assert the chain binding (an anchor that is Merkle-valid
171
+ but whose published_root was never on-chain would pass HERE — the honest, documented boundary; a relying
172
+ party who needs the chain binding runs level ii/iii with Chia software, see docs/ANCHORS.md).
173
+ """
174
+ if not isinstance(proof, (bytes, bytearray)):
175
+ return {"ok": False, "warn": False, "status": "fail", "detail": "chia-datalayer proof must be bytes"}
176
+ if len(proof) > _MAX_PROOF_BYTES:
177
+ return {"ok": False, "warn": False, "status": "fail", "detail": f"chia-datalayer proof too large (> {_MAX_PROOF_BYTES} bytes)"}
178
+ # Unconditional fail-closed backstop: a verifier must NEVER crash its caller on a hostile proof. Enumerating
179
+ # exception types (ValueError, ...) misses RecursionError (deeply nested JSON), MemoryError, or a TypeError
180
+ # from a non-bytes canonical_root — all of which a caller-controlled proof could trigger. Catch everything.
181
+ try:
182
+ proof_obj = json.loads(bytes(proof).decode("utf-8"))
183
+ if not isinstance(proof_obj, dict):
184
+ return {"ok": False, "warn": False, "status": "fail", "detail": "chia-datalayer proof JSON must be an object"}
185
+ res = verify_offline_merkle(proof_obj, bytes(canonical_root))
186
+ except Exception as exc: # noqa: BLE001 - deliberate fail-closed backstop for ANY hostile input
187
+ return {"ok": False, "warn": False, "status": "fail", "detail": f"chia-datalayer proof rejected (fail-closed): {type(exc).__name__}"}
188
+ if not bool(res.get("ok")):
189
+ return {"ok": False, "warn": False, "status": "fail", "detail": res.get("detail", "")}
190
+ # LEVEL i is proven (offline Merkle inclusion) → ok=True. But level i is NOT external time / chain-binding
191
+ # evidence: a self-fabricated tree with a never-on-chain published_root passes here (the documented
192
+ # boundary). So this is marked warn=True (status 'warn'), exactly like an un-upgraded OpenTimestamps
193
+ # PENDING proof — it must NOT satisfy --require-anchor and it aggregates as WARN, never a clean PASS that
194
+ # would read as a full external-time anchor. A relying party who needs the chain binding runs level
195
+ # ii/iii with Chia software (see docs/ANCHORS.md); the require-matcher gates on ok AND NOT warn.
196
+ return {"ok": True, "warn": True, "status": "warn", "detail": res.get("detail", "")}