proofbundle 2.0.0b2__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.0b2/src/proofbundle.egg-info → proofbundle-2.0.0b3}/PKG-INFO +14 -2
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/README.md +12 -1
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/pyproject.toml +6 -1
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/__init__.py +1 -1
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/anchors.py +11 -2
- 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.0b2 → proofbundle-2.0.0b3/src/proofbundle.egg-info}/PKG-INFO +14 -2
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/SOURCES.txt +7 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/requires.txt +2 -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_markovian.py +151 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_branch_base_check.py +17 -6
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_intoto_claims_hygiene.py +6 -2
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/LICENSE +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/setup.cfg +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/_inspect_registry.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/_integration.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/adapters/__init__.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/adapters/_provenance.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/adapters/eee.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/adapters/inspect_ai.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/adapters/lm_eval.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/adapters/promptfoo.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/adapters/samples.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/anchors_ots.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/anchors_rfc3161.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/beacon.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/bundle.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/checkpoint.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/cli.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/demo.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/dsse.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/eee_eval_schema.json +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/emit.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/errors.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/evalclaim.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/experimental/__init__.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/experimental/enclave.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/hf_evals.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/inspect_hook.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/intoto.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/kbjwt.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/merkle.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/persample.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/prereg.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/py.typed +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/pytest_plugin.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/sdjwt.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/sdjwt_issue.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/signature.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/statuslist.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/tlogproof.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/dependency_links.txt +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/entry_points.txt +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/top_level.txt +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_adapters.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_adversarial.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_anchors_generic.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_anchors_ots.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_anchors_rfc3161.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_beacon.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_bundle.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_bundle_robustness.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_checkpoint.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_claims_hygiene.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_cli.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_cli_eval.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_cosignature.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_cosignature_mldsa.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_demo.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_docs_truth.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_eee.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_emit.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_eval_claim_schema.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_evalclaim.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_examples.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_experimental_enclave.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_fuzz_parsers.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_hf_evals.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_inspect_hook.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_intoto.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_intoto_dsse.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_intoto_eval_result.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_intoto_examples.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_intoto_spec_diff.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_intoto_svr.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_kbjwt.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_merkle.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_merkle_property.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_persample.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_prereg.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_promptfoo.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_provenance.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_pytest_plugin.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_rekor_interop.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_rfc6962_external_vectors.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_schema.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_sdjwt_issue.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_sdjwt_reference.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_signature.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_statuslist.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_tlogproof.py +0 -0
- {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_verify_matrix.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
|
|
@@ -32,6 +32,7 @@ Requires-Dist: rfc3161-client>=1.0.6; extra == "anchors"
|
|
|
32
32
|
Requires-Dist: opentimestamps>=0.4.5; extra == "anchors"
|
|
33
33
|
Requires-Dist: rfc8785>=0.1.4; extra == "anchors"
|
|
34
34
|
Provides-Extra: adapters
|
|
35
|
+
Provides-Extra: chia
|
|
35
36
|
Provides-Extra: experimental
|
|
36
37
|
Provides-Extra: pq
|
|
37
38
|
Requires-Dist: cryptography>=48; extra == "pq"
|
|
@@ -67,10 +68,12 @@ these exact bytes* and *that nothing changed since* — not that the number is t
|
|
|
67
68
|
Merkle, one file, no server, no network.
|
|
68
69
|
|
|
69
70
|
[](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml)
|
|
71
|
+
[](https://pypi.org/project/proofbundle/)
|
|
72
|
+
[](https://pypi.org/project/proofbundle/)
|
|
70
73
|
[](LICENSE)
|
|
71
74
|
[](https://github.com/astral-sh/ruff)
|
|
72
75
|
[](scripts/mutation_check.py)
|
|
73
|
-
<!--
|
|
76
|
+
<!-- SLSA / PEP 740 attestation badges follow once the first attested release lands, see RELEASE.md. -->
|
|
74
77
|
|
|
75
78
|
</div>
|
|
76
79
|
|
|
@@ -156,6 +159,14 @@ flowchart LR
|
|
|
156
159
|
`PROOFBUNDLE_EMIT=1` / `--proofbundle`), plus a Hugging Face Community Evals bridge. See
|
|
157
160
|
[INTEGRATIONS.md](INTEGRATIONS.md), or the end-to-end walkthrough
|
|
158
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).
|
|
159
170
|
|
|
160
171
|
## Docs
|
|
161
172
|
|
|
@@ -170,6 +181,7 @@ flowchart LR
|
|
|
170
181
|
| Honest comparison to Rekor / in-toto / OMS / ValiChord | [INTEROP.md](INTEROP.md) |
|
|
171
182
|
| Regulatory mapping (and what to never claim) | [COMPLIANCE.md](COMPLIANCE.md) |
|
|
172
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) |
|
|
173
185
|
| **Preview:** TEE-attestation bridge (v2.0 beta) | [docs/EXPERIMENTAL_ENCLAVE.md](docs/EXPERIMENTAL_ENCLAVE.md) |
|
|
174
186
|
|
|
175
187
|
## Install
|
|
@@ -14,10 +14,12 @@ these exact bytes* and *that nothing changed since* — not that the number is t
|
|
|
14
14
|
Merkle, one file, no server, no network.
|
|
15
15
|
|
|
16
16
|
[](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml)
|
|
17
|
+
[](https://pypi.org/project/proofbundle/)
|
|
18
|
+
[](https://pypi.org/project/proofbundle/)
|
|
17
19
|
[](LICENSE)
|
|
18
20
|
[](https://github.com/astral-sh/ruff)
|
|
19
21
|
[](scripts/mutation_check.py)
|
|
20
|
-
<!--
|
|
22
|
+
<!-- SLSA / PEP 740 attestation badges follow once the first attested release lands, see RELEASE.md. -->
|
|
21
23
|
|
|
22
24
|
</div>
|
|
23
25
|
|
|
@@ -103,6 +105,14 @@ flowchart LR
|
|
|
103
105
|
`PROOFBUNDLE_EMIT=1` / `--proofbundle`), plus a Hugging Face Community Evals bridge. See
|
|
104
106
|
[INTEGRATIONS.md](INTEGRATIONS.md), or the end-to-end walkthrough
|
|
105
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).
|
|
106
116
|
|
|
107
117
|
## Docs
|
|
108
118
|
|
|
@@ -117,6 +127,7 @@ flowchart LR
|
|
|
117
127
|
| Honest comparison to Rekor / in-toto / OMS / ValiChord | [INTEROP.md](INTEROP.md) |
|
|
118
128
|
| Regulatory mapping (and what to never claim) | [COMPLIANCE.md](COMPLIANCE.md) |
|
|
119
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) |
|
|
120
131
|
| **Preview:** TEE-attestation bridge (v2.0 beta) | [docs/EXPERIMENTAL_ENCLAVE.md](docs/EXPERIMENTAL_ENCLAVE.md) |
|
|
121
132
|
|
|
122
133
|
## Install
|
|
@@ -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"
|
|
@@ -52,6 +52,11 @@ eval = ["rfc8785>=0.1.4"]
|
|
|
52
52
|
anchors = ["rfc3161-client>=1.0.6", "opentimestamps>=0.4.5", "rfc8785>=0.1.4"]
|
|
53
53
|
# The lm-eval adapter reads exported results.json (no import) → pure stdlib.
|
|
54
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 = []
|
|
55
60
|
# EXPERIMENTAL (v2.0 preview): the TEE-attestation bridge (proofbundle.experimental.enclave).
|
|
56
61
|
# Opt-in and clearly unstable — API/wire-format may change or be removed without deprecation.
|
|
57
62
|
# No new runtime dependency (pure stdlib + the core `cryptography`); the extra exists to signal
|
|
@@ -78,6 +78,15 @@ def _ensure_builtin_types() -> None:
|
|
|
78
78
|
_VERIFIERS["opentimestamps"] = anchors_ots.verify_opentimestamps
|
|
79
79
|
except Exception:
|
|
80
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
|
|
81
90
|
|
|
82
91
|
|
|
83
92
|
def _b64d(value, field: str) -> bytes:
|
|
@@ -167,9 +176,9 @@ def verify_anchors(anchors, *, target_roots: dict, require: Optional[str] = None
|
|
|
167
176
|
if not isinstance(anchors, list):
|
|
168
177
|
raise BundleFormatError("anchors must be a list")
|
|
169
178
|
results = [verify_anchor(a, target_roots=target_roots, now=now) for a in anchors]
|
|
170
|
-
if require: # a warn/pending anchor never SATISFIES a requirement — only a
|
|
179
|
+
if require: # a warn/pending/inclusion-only anchor never SATISFIES a requirement — only a full one
|
|
171
180
|
want = None if require == "any" else require
|
|
172
|
-
matched = [r for r in results if r["ok"] and (want is None or r["type"] == want)]
|
|
181
|
+
matched = [r for r in results if r["ok"] and not r["warn"] and (want is None or r["type"] == want)]
|
|
173
182
|
if not matched:
|
|
174
183
|
return {"status": "FAIL",
|
|
175
184
|
"detail": f"--require-anchor {require}: no verifying anchor of that type",
|
|
@@ -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", "")}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""chia-datalayer/v1 anchor WRITER / EXPORTER (Paket 1) — needs the ``[chia]`` extra + a reachable Chia
|
|
2
|
+
DataLayer node (this is the ONLY chia-dependent part; VERIFYING an anchor offline needs neither, see
|
|
3
|
+
``anchors_chia.py``).
|
|
4
|
+
|
|
5
|
+
``export_anchor`` turns an existing (store, key) into a portable ``chia-datalayer/v1`` anchor dict whose
|
|
6
|
+
offline proof ``verify_chia_datalayer`` accepts. ``anchor_add`` is the full flow: insert the digest, wait
|
|
7
|
+
for on-chain confirmation, then export. A NETWORK/NODE FAILURE NEVER DAMAGES ANYTHING — ``anchor_add``
|
|
8
|
+
returns the new anchor or raises cleanly; it never writes a partial/half-anchor.
|
|
9
|
+
|
|
10
|
+
Access is via the stable ``chia rpc data_layer|full_node <method> '<json>'`` subprocess surface (the Python
|
|
11
|
+
imports move between Chia versions; the JSON-RPC wire is stable). RPC is local + cert-authed; never exposed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import time
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from .anchors_chia import ANCHOR_TYPE, verify_offline_merkle
|
|
25
|
+
|
|
26
|
+
_CHIA_BIN = os.getenv("CHIA_CLI", shutil.which("chia") or "chia")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ChiaRpcError(RuntimeError):
|
|
30
|
+
"""A Chia RPC/CLI call failed. Fail-closed: the caller aborts, no partial anchor is written."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _rpc(service: str, method: str, payload: dict, *, timeout: int = 60) -> dict:
|
|
34
|
+
"""Call ``chia rpc <service> <method> '<json>'`` and return the parsed dict. Raises ChiaRpcError on
|
|
35
|
+
any failure (missing binary, timeout, non-zero exit, non-JSON, ``success:false``)."""
|
|
36
|
+
try:
|
|
37
|
+
proc = subprocess.run(
|
|
38
|
+
[_CHIA_BIN, "rpc", service, method, json.dumps(payload)],
|
|
39
|
+
capture_output=True, text=True, timeout=timeout, check=False,
|
|
40
|
+
)
|
|
41
|
+
except FileNotFoundError as exc:
|
|
42
|
+
raise ChiaRpcError(f"chia binary not found ({_CHIA_BIN}); install proofbundle[chia] + a node") from exc
|
|
43
|
+
except subprocess.TimeoutExpired as exc:
|
|
44
|
+
raise ChiaRpcError(f"chia rpc {service} {method} timed out") from exc
|
|
45
|
+
if proc.returncode != 0:
|
|
46
|
+
raise ChiaRpcError(f"chia rpc {service} {method} exit {proc.returncode}: {(proc.stderr or proc.stdout)[:200]}")
|
|
47
|
+
try:
|
|
48
|
+
data = json.loads(proc.stdout)
|
|
49
|
+
except ValueError as exc:
|
|
50
|
+
raise ChiaRpcError(f"chia rpc {service} {method}: non-JSON response") from exc
|
|
51
|
+
if isinstance(data, dict) and data.get("success") is False:
|
|
52
|
+
raise ChiaRpcError(f"chia rpc {service} {method}: {data.get('error', 'success=false')}")
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _hx(b: bytes) -> str:
|
|
57
|
+
return "0x" + b.hex()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def export_anchor(store_id: str, *, canonical_root: bytes, target: str = "receipt",
|
|
61
|
+
network: str = "mainnet", value: Optional[str] = None) -> dict:
|
|
62
|
+
"""Build a ``chia-datalayer/v1`` anchor from live ``get_proof`` + ``get_root`` (+ best-effort
|
|
63
|
+
``get_coin_record`` for height/timestamp). The DataLayer KEY IS the target's ``canonical_root`` (that is
|
|
64
|
+
the whole binding — see anchors_chia.verify_offline_merkle), so the key is derived here, never passed in
|
|
65
|
+
independently. Returns the anchor dict (self-verifying offline before emit). Fail-closed.
|
|
66
|
+
"""
|
|
67
|
+
key = _hx(bytes(canonical_root)) # key == canonicalRoot: the binding the verifier enforces
|
|
68
|
+
gp = _rpc("data_layer", "get_proof", {"store_id": store_id, "keys": [key]})
|
|
69
|
+
proofs = ((gp.get("proof") or {}).get("store_proofs") or {}).get("proofs") or []
|
|
70
|
+
if not proofs:
|
|
71
|
+
raise ChiaRpcError("get_proof returned no proof for the key (not in the store / not confirmed)")
|
|
72
|
+
pr = proofs[0]
|
|
73
|
+
root_resp = _rpc("data_layer", "get_root", {"id": store_id})
|
|
74
|
+
published_root = root_resp.get("hash")
|
|
75
|
+
if not published_root or set(published_root.replace("0x", "")) == {"0"}:
|
|
76
|
+
raise ChiaRpcError("store has no confirmed published root yet")
|
|
77
|
+
|
|
78
|
+
coin_id = (gp.get("proof") or {}).get("coin_id")
|
|
79
|
+
inner_puzzle_hash = (gp.get("proof") or {}).get("inner_puzzle_hash")
|
|
80
|
+
block_height = None
|
|
81
|
+
root_timestamp = None
|
|
82
|
+
if coin_id: # best-effort: height + timestamp from the full node (Stufe iii material)
|
|
83
|
+
try:
|
|
84
|
+
cr = _rpc("full_node", "get_coin_record_by_name", {"name": coin_id}, timeout=20)
|
|
85
|
+
rec = cr.get("coin_record") or {}
|
|
86
|
+
block_height = rec.get("confirmed_block_index")
|
|
87
|
+
root_timestamp = rec.get("timestamp")
|
|
88
|
+
except ChiaRpcError:
|
|
89
|
+
pass # height/timestamp are Stufe-iii extras; absence is not fatal
|
|
90
|
+
|
|
91
|
+
proof_obj = {
|
|
92
|
+
"store_id": store_id, "key": key, # key == canonicalRoot (the binding)
|
|
93
|
+
"key_clvm_hash": pr["key_clvm_hash"], "value_clvm_hash": pr["value_clvm_hash"],
|
|
94
|
+
"node_hash": pr["node_hash"], "inclusion_layers": pr.get("layers", []),
|
|
95
|
+
"published_root": published_root,
|
|
96
|
+
"coin_id": coin_id, "inner_puzzle_hash": inner_puzzle_hash,
|
|
97
|
+
"block_height": block_height, "root_timestamp": root_timestamp, "network": network,
|
|
98
|
+
}
|
|
99
|
+
if value is not None:
|
|
100
|
+
proof_obj["value"] = value if value.startswith(("0x", "0X")) else "0x" + value
|
|
101
|
+
|
|
102
|
+
# self-check BEFORE returning: an exported anchor MUST verify offline, else we never emit it (No-Fake).
|
|
103
|
+
chk = verify_offline_merkle(proof_obj, bytes(canonical_root))
|
|
104
|
+
if not chk.get("ok"):
|
|
105
|
+
raise ChiaRpcError(f"exported anchor failed its own offline verification: {chk.get('detail')}")
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"type": ANCHOR_TYPE, "target": target,
|
|
109
|
+
"canonicalRoot": base64.b64encode(bytes(canonical_root)).decode(),
|
|
110
|
+
"proof": base64.b64encode(json.dumps(proof_obj).encode()).decode(),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _wait_confirmed(store_id: str, prev_root: Optional[str], *, timeout: int = 180, poll: int = 5) -> None:
|
|
115
|
+
"""Block until the store's published root advances past ``prev_root`` (the batch_update confirmed).
|
|
116
|
+
Raises ChiaRpcError on timeout. ``poll`` uses monotonic sleep; no busy loop."""
|
|
117
|
+
deadline = time.monotonic() + timeout
|
|
118
|
+
while time.monotonic() < deadline:
|
|
119
|
+
root = _rpc("data_layer", "get_root", {"id": store_id}).get("hash")
|
|
120
|
+
if root and root != prev_root and set(root.replace("0x", "")) != {"0"}:
|
|
121
|
+
return
|
|
122
|
+
time.sleep(poll)
|
|
123
|
+
raise ChiaRpcError("timed out waiting for the batch_update root to confirm on-chain")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def anchor_add(canonical_root_hex: str, *, store_id: str, value_digest_hex: Optional[str] = None,
|
|
127
|
+
target: str = "receipt", network: str = "mainnet", fee: int = 100_000_000,
|
|
128
|
+
wait: bool = True) -> dict:
|
|
129
|
+
"""Full flow: insert ``key=canonicalRoot`` (value = ``value_digest`` or the canonicalRoot itself) into the
|
|
130
|
+
store, wait for on-chain confirmation, then export the anchor. Idempotent: a key already present ("Key
|
|
131
|
+
already present") is treated as "already anchored" and re-exported, not an error. Fail-closed: a network
|
|
132
|
+
failure raises ChiaRpcError and writes NOTHING partial (the caller keeps its receipt untouched)."""
|
|
133
|
+
canonical_root_hex = canonical_root_hex if canonical_root_hex.startswith(("0x", "0X")) else "0x" + canonical_root_hex
|
|
134
|
+
canonical_root = bytes.fromhex(canonical_root_hex[2:])
|
|
135
|
+
if len(canonical_root) != 32:
|
|
136
|
+
raise ValueError("canonical_root must be a 32-byte hex digest")
|
|
137
|
+
value_hex = value_digest_hex or canonical_root_hex
|
|
138
|
+
value_hex = value_hex if value_hex.startswith(("0x", "0X")) else "0x" + value_hex
|
|
139
|
+
|
|
140
|
+
prev_root = _rpc("data_layer", "get_root", {"id": store_id}).get("hash")
|
|
141
|
+
changelist = [{"action": "insert", "key": canonical_root_hex, "value": value_hex}]
|
|
142
|
+
already = False
|
|
143
|
+
try:
|
|
144
|
+
_rpc("data_layer", "batch_update",
|
|
145
|
+
{"id": store_id, "changelist": changelist, "fee": fee, "submit_on_chain": True})
|
|
146
|
+
except ChiaRpcError as exc:
|
|
147
|
+
if "already present" in str(exc).lower():
|
|
148
|
+
already = True # idempotent: the digest is already anchored
|
|
149
|
+
else:
|
|
150
|
+
raise # any other failure → clean abort, nothing written
|
|
151
|
+
if wait and not already:
|
|
152
|
+
_wait_confirmed(store_id, prev_root)
|
|
153
|
+
return export_anchor(store_id, canonical_root=canonical_root,
|
|
154
|
+
target=target, network=network, value=value_hex)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Markovian provenance anchor (EXPERIMENTAL; third-party extension example for docs/ANCHORS.md).
|
|
2
|
+
|
|
3
|
+
A worked example of a *third-party* anchor ``type`` registered through
|
|
4
|
+
:func:`proofbundle.anchors.register_anchor_type`. It shows how an outside protocol plugs its own
|
|
5
|
+
evidence into the generic ``anchors[]`` layer without touching proofbundle's core, and it is deliberately
|
|
6
|
+
NOT wired into the built-in set (``_ensure_builtin_types``) — a third-party type is opt-in by design.
|
|
7
|
+
|
|
8
|
+
**What it proves.** A ``markovian-provenance/v1`` stamp is an *issuer-attributable* commitment: it binds
|
|
9
|
+
the committed data to a wallet via ``merkle_root = sha256(data_hash ":" salt ":" wallet)``, and the data
|
|
10
|
+
itself is Bitcoin-anchored with OpenTimestamps. A verified anchor therefore says: *the target canonical
|
|
11
|
+
root was committed by wallet W and existed by the OTS-attested Bitcoin block time.* The time is trustless
|
|
12
|
+
(Bitcoin); the issuer binding is self-consistent inside the stamp envelope.
|
|
13
|
+
|
|
14
|
+
**Composition, not reinvention.** The Bitcoin time proof is delegated verbatim to the built-in
|
|
15
|
+
``opentimestamps`` verifier (:func:`proofbundle.anchors_ots.verify_opentimestamps`), inheriting its exact
|
|
16
|
+
fail-closed lifecycle (PENDING -> WARN; upgraded-without-header -> honest not-pass; upgraded + supplied
|
|
17
|
+
block header -> confirmed). This type only adds the Markovian envelope checks on top; it never
|
|
18
|
+
re-implements the Bitcoin discipline.
|
|
19
|
+
|
|
20
|
+
``proof`` is UTF-8 JSON: ``{schema, data_hash, salt, wallet, merkle_root, zk_commitment?, block_height?,
|
|
21
|
+
stamped_at?, ots}`` where ``ots`` is base64 of the detached OpenTimestamps proof over the canonical root.
|
|
22
|
+
``canonicalRoot`` is the exact bytes the stamp committed (the stamp's ``data_hash``).
|
|
23
|
+
|
|
24
|
+
Register it explicitly (that it is not a core built-in is the point of a third-party type)::
|
|
25
|
+
|
|
26
|
+
from proofbundle.anchors_markovian import register
|
|
27
|
+
register() # anchors[] entries with type "markovian-provenance/v1" now verify
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import base64
|
|
32
|
+
import binascii
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
from typing import Optional
|
|
36
|
+
|
|
37
|
+
ANCHOR_TYPE = "markovian-provenance/v1"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _fail(status: str, detail: str) -> dict:
|
|
41
|
+
return {"ok": False, "warn": False, "status": status, "detail": detail}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def verify_markovian(proof: bytes, canonical_root: bytes, *, frozen: dict,
|
|
45
|
+
now: Optional[int] = None) -> dict:
|
|
46
|
+
"""Fail-closed verifier for a ``markovian-provenance/v1`` anchor. Returns {ok, warn, status, detail}.
|
|
47
|
+
|
|
48
|
+
Steps (any doubt -> not ok; never raises for an ordinary bad proof):
|
|
49
|
+
1. parse the JSON envelope;
|
|
50
|
+
2. schema is ``markovian-provenance/v1``;
|
|
51
|
+
3. binding: ``data_hash`` equals the target ``canonical_root`` (hex);
|
|
52
|
+
4. envelope integrity: ``merkle_root == sha256(data_hash ":" salt ":" wallet)`` (wallet bound to data);
|
|
53
|
+
5. Bitcoin time: delegate the embedded OTS proof to the built-in opentimestamps verifier.
|
|
54
|
+
The final status/warn mirror the OTS verifier (pending / upgraded_unverified / confirmed); a PASS also
|
|
55
|
+
names the committing wallet and Markovian chain height.
|
|
56
|
+
"""
|
|
57
|
+
# 1. parse
|
|
58
|
+
try:
|
|
59
|
+
env = json.loads(proof.decode("utf-8"))
|
|
60
|
+
if not isinstance(env, dict):
|
|
61
|
+
raise ValueError("envelope is not a JSON object")
|
|
62
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
63
|
+
return _fail("malformed", f"markovian proof is not valid JSON: {exc}")
|
|
64
|
+
|
|
65
|
+
# 2. schema
|
|
66
|
+
if env.get("schema") != ANCHOR_TYPE:
|
|
67
|
+
return _fail("bad_schema", f"markovian proof schema must be {ANCHOR_TYPE!r}")
|
|
68
|
+
|
|
69
|
+
data_hash = env.get("data_hash")
|
|
70
|
+
salt = env.get("salt")
|
|
71
|
+
wallet = env.get("wallet")
|
|
72
|
+
merkle_root = env.get("merkle_root")
|
|
73
|
+
ots_b64 = env.get("ots")
|
|
74
|
+
for name, val in (("data_hash", data_hash), ("salt", salt), ("wallet", wallet),
|
|
75
|
+
("merkle_root", merkle_root), ("ots", ots_b64)):
|
|
76
|
+
if not isinstance(val, str) or not val:
|
|
77
|
+
return _fail("bad_fields", f"markovian envelope field {name!r} missing or not a string")
|
|
78
|
+
# the loop above guarantees these are non-empty str; narrow explicitly so the type checker follows
|
|
79
|
+
assert isinstance(data_hash, str) and isinstance(ots_b64, str)
|
|
80
|
+
|
|
81
|
+
# 3. binding: the stamp must commit to EXACTLY the target canonical root
|
|
82
|
+
try:
|
|
83
|
+
if bytes.fromhex(data_hash) != canonical_root:
|
|
84
|
+
return _fail("unbound", "markovian data_hash does not match the target canonical root")
|
|
85
|
+
except ValueError:
|
|
86
|
+
return _fail("bad_fields", "markovian data_hash is not valid hex")
|
|
87
|
+
|
|
88
|
+
# 4. envelope integrity: the wallet is bound to the data inside the stamp
|
|
89
|
+
recomputed = hashlib.sha256(f"{data_hash}:{salt}:{wallet}".encode("utf-8")).hexdigest()
|
|
90
|
+
if recomputed != merkle_root:
|
|
91
|
+
return _fail("envelope_mismatch",
|
|
92
|
+
"markovian merkle_root != sha256(data_hash:salt:wallet) — envelope inconsistent/tampered")
|
|
93
|
+
|
|
94
|
+
# 5. Bitcoin time: delegate to the built-in OpenTimestamps verifier (compose, don't reinvent)
|
|
95
|
+
try:
|
|
96
|
+
ots_proof = base64.b64decode(ots_b64, validate=True)
|
|
97
|
+
except (ValueError, binascii.Error):
|
|
98
|
+
return _fail("bad_fields", "markovian ots field is not valid base64")
|
|
99
|
+
try:
|
|
100
|
+
from .anchors_ots import verify_opentimestamps # noqa: PLC0415
|
|
101
|
+
except ImportError:
|
|
102
|
+
return _fail("no_lib",
|
|
103
|
+
"markovian anchor needs proofbundle[anchors] (opentimestamps) for the Bitcoin proof")
|
|
104
|
+
|
|
105
|
+
ots_res = verify_opentimestamps(ots_proof, canonical_root, frozen=frozen, now=now)
|
|
106
|
+
who = f"Markovian wallet {wallet}"
|
|
107
|
+
chain = env.get("block_height")
|
|
108
|
+
chain_part = f", Markovian chain block {chain}" if chain is not None else ""
|
|
109
|
+
if ots_res.get("ok"):
|
|
110
|
+
return {"ok": True, "warn": False, "status": "confirmed",
|
|
111
|
+
"detail": f"canonical root committed by {who}{chain_part}; {ots_res.get('detail', '')}"}
|
|
112
|
+
# not a full anchor yet — carry the OTS lifecycle status/warn verbatim, framed as Markovian
|
|
113
|
+
return {"ok": False, "warn": bool(ots_res.get("warn")), "status": ots_res.get("status", "fail"),
|
|
114
|
+
"detail": f"markovian stamp envelope valid ({who}{chain_part}) but Bitcoin proof not verified: "
|
|
115
|
+
f"{ots_res.get('detail', '')}"}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def register() -> None:
|
|
119
|
+
"""Register this third-party type so ``anchors[]`` entries of type ``markovian-provenance/v1`` verify."""
|
|
120
|
+
from .anchors import register_anchor_type # noqa: PLC0415
|
|
121
|
+
register_anchor_type(ANCHOR_TYPE, verify_markovian)
|