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.
- {proofbundle-2.0.0b1/src/proofbundle.egg-info → proofbundle-2.0.0b3}/PKG-INFO +37 -9
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/README.md +31 -8
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/pyproject.toml +12 -1
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/__init__.py +1 -1
- proofbundle-2.0.0b3/src/proofbundle/anchors.py +194 -0
- proofbundle-2.0.0b3/src/proofbundle/anchors_chia.py +196 -0
- proofbundle-2.0.0b3/src/proofbundle/anchors_chia_add.py +154 -0
- proofbundle-2.0.0b3/src/proofbundle/anchors_markovian.py +121 -0
- proofbundle-2.0.0b3/src/proofbundle/anchors_ots.py +91 -0
- proofbundle-2.0.0b3/src/proofbundle/anchors_rfc3161.py +90 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/bundle.py +14 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/cli.py +153 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/evalclaim.py +8 -0
- proofbundle-2.0.0b3/src/proofbundle/intoto.py +468 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3/src/proofbundle.egg-info}/PKG-INFO +37 -9
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/SOURCES.txt +23 -1
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/requires.txt +7 -0
- proofbundle-2.0.0b3/tests/test_anchors_chia.py +222 -0
- proofbundle-2.0.0b3/tests/test_anchors_chia_add.py +168 -0
- proofbundle-2.0.0b3/tests/test_anchors_chia_claims.py +116 -0
- proofbundle-2.0.0b3/tests/test_anchors_generic.py +111 -0
- proofbundle-2.0.0b3/tests/test_anchors_markovian.py +151 -0
- proofbundle-2.0.0b3/tests/test_anchors_ots.py +122 -0
- proofbundle-2.0.0b3/tests/test_anchors_rfc3161.py +115 -0
- proofbundle-2.0.0b3/tests/test_branch_base_check.py +68 -0
- proofbundle-2.0.0b3/tests/test_claims_hygiene.py +48 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_cli_eval.py +25 -0
- proofbundle-2.0.0b3/tests/test_docs_truth.py +46 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_evalclaim.py +23 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_experimental_enclave.py +4 -2
- proofbundle-2.0.0b3/tests/test_intoto_claims_hygiene.py +57 -0
- proofbundle-2.0.0b3/tests/test_intoto_eval_result.py +172 -0
- proofbundle-2.0.0b3/tests/test_intoto_examples.py +86 -0
- proofbundle-2.0.0b3/tests/test_intoto_spec_diff.py +38 -0
- proofbundle-2.0.0b3/tests/test_intoto_svr.py +137 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_kbjwt.py +16 -0
- proofbundle-2.0.0b3/tests/test_verify_matrix.py +82 -0
- proofbundle-2.0.0b1/src/proofbundle/intoto.py +0 -182
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/LICENSE +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/setup.cfg +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/_inspect_registry.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/_integration.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/adapters/__init__.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/adapters/_provenance.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/adapters/eee.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/adapters/inspect_ai.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/adapters/lm_eval.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/adapters/promptfoo.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/adapters/samples.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/beacon.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/checkpoint.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/demo.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/dsse.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/eee_eval_schema.json +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/emit.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/errors.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/experimental/__init__.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/experimental/enclave.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/hf_evals.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/inspect_hook.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/kbjwt.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/merkle.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/persample.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/prereg.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/py.typed +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/pytest_plugin.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/sdjwt.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/sdjwt_issue.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/signature.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/statuslist.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle/tlogproof.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/dependency_links.txt +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/entry_points.txt +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/top_level.txt +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_adapters.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_adversarial.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_beacon.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_bundle.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_bundle_robustness.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_checkpoint.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_cli.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_cosignature.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_cosignature_mldsa.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_demo.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_eee.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_emit.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_eval_claim_schema.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_examples.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_fuzz_parsers.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_hf_evals.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_inspect_hook.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_intoto.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_intoto_dsse.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_merkle.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_merkle_property.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_persample.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_prereg.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_promptfoo.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_provenance.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_pytest_plugin.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_rekor_interop.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_rfc6962_external_vectors.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_schema.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_sdjwt_issue.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_sdjwt_reference.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_signature.py +0 -0
- {proofbundle-2.0.0b1 → proofbundle-2.0.0b3}/tests/test_statuslist.py +0 -0
- {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.
|
|
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
|
-
**
|
|
60
|
-
|
|
61
|
-
|
|
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
|
[](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml)
|
|
71
|
+
[](https://pypi.org/project/proofbundle/)
|
|
72
|
+
[](https://pypi.org/project/proofbundle/)
|
|
64
73
|
[](LICENSE)
|
|
65
74
|
[](https://github.com/astral-sh/ruff)
|
|
66
75
|
[](scripts/mutation_check.py)
|
|
67
|
-
<!--
|
|
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.
|
|
137
|
-
|
|
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,
|
|
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
|
-
**
|
|
11
|
-
|
|
12
|
-
|
|
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
|
[](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml)
|
|
17
|
+
[](https://pypi.org/project/proofbundle/)
|
|
18
|
+
[](https://pypi.org/project/proofbundle/)
|
|
15
19
|
[](LICENSE)
|
|
16
20
|
[](https://github.com/astral-sh/ruff)
|
|
17
21
|
[](scripts/mutation_check.py)
|
|
18
|
-
<!--
|
|
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.
|
|
88
|
-
|
|
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,
|
|
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.
|
|
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
|
|
@@ -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", "")}
|