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.
Files changed (107) hide show
  1. {proofbundle-2.0.0b2/src/proofbundle.egg-info → proofbundle-2.0.0b3}/PKG-INFO +14 -2
  2. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/README.md +12 -1
  3. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/pyproject.toml +6 -1
  4. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/__init__.py +1 -1
  5. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/anchors.py +11 -2
  6. proofbundle-2.0.0b3/src/proofbundle/anchors_chia.py +196 -0
  7. proofbundle-2.0.0b3/src/proofbundle/anchors_chia_add.py +154 -0
  8. proofbundle-2.0.0b3/src/proofbundle/anchors_markovian.py +121 -0
  9. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3/src/proofbundle.egg-info}/PKG-INFO +14 -2
  10. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/SOURCES.txt +7 -0
  11. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/requires.txt +2 -0
  12. proofbundle-2.0.0b3/tests/test_anchors_chia.py +222 -0
  13. proofbundle-2.0.0b3/tests/test_anchors_chia_add.py +168 -0
  14. proofbundle-2.0.0b3/tests/test_anchors_chia_claims.py +116 -0
  15. proofbundle-2.0.0b3/tests/test_anchors_markovian.py +151 -0
  16. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_branch_base_check.py +17 -6
  17. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_intoto_claims_hygiene.py +6 -2
  18. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/LICENSE +0 -0
  19. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/setup.cfg +0 -0
  20. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/_inspect_registry.py +0 -0
  21. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/_integration.py +0 -0
  22. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/adapters/__init__.py +0 -0
  23. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/adapters/_provenance.py +0 -0
  24. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/adapters/eee.py +0 -0
  25. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/adapters/inspect_ai.py +0 -0
  26. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/adapters/lm_eval.py +0 -0
  27. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/adapters/promptfoo.py +0 -0
  28. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/adapters/samples.py +0 -0
  29. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/anchors_ots.py +0 -0
  30. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/anchors_rfc3161.py +0 -0
  31. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/beacon.py +0 -0
  32. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/bundle.py +0 -0
  33. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/checkpoint.py +0 -0
  34. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/cli.py +0 -0
  35. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/demo.py +0 -0
  36. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/dsse.py +0 -0
  37. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/eee_eval_schema.json +0 -0
  38. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/emit.py +0 -0
  39. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/errors.py +0 -0
  40. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/evalclaim.py +0 -0
  41. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/experimental/__init__.py +0 -0
  42. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/experimental/enclave.py +0 -0
  43. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/hf_evals.py +0 -0
  44. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/inspect_hook.py +0 -0
  45. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/intoto.py +0 -0
  46. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/kbjwt.py +0 -0
  47. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/merkle.py +0 -0
  48. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/persample.py +0 -0
  49. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/prereg.py +0 -0
  50. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/py.typed +0 -0
  51. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/pytest_plugin.py +0 -0
  52. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/sdjwt.py +0 -0
  53. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/sdjwt_issue.py +0 -0
  54. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/signature.py +0 -0
  55. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/statuslist.py +0 -0
  56. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle/tlogproof.py +0 -0
  57. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/dependency_links.txt +0 -0
  58. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/entry_points.txt +0 -0
  59. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/src/proofbundle.egg-info/top_level.txt +0 -0
  60. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_adapters.py +0 -0
  61. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_adversarial.py +0 -0
  62. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_anchors_generic.py +0 -0
  63. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_anchors_ots.py +0 -0
  64. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_anchors_rfc3161.py +0 -0
  65. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_beacon.py +0 -0
  66. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_bundle.py +0 -0
  67. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_bundle_robustness.py +0 -0
  68. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_checkpoint.py +0 -0
  69. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_claims_hygiene.py +0 -0
  70. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_cli.py +0 -0
  71. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_cli_eval.py +0 -0
  72. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_cosignature.py +0 -0
  73. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_cosignature_mldsa.py +0 -0
  74. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_demo.py +0 -0
  75. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_docs_truth.py +0 -0
  76. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_eee.py +0 -0
  77. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_emit.py +0 -0
  78. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_eval_claim_schema.py +0 -0
  79. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_evalclaim.py +0 -0
  80. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_examples.py +0 -0
  81. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_experimental_enclave.py +0 -0
  82. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_fuzz_parsers.py +0 -0
  83. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_hf_evals.py +0 -0
  84. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_inspect_hook.py +0 -0
  85. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_intoto.py +0 -0
  86. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_intoto_dsse.py +0 -0
  87. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_intoto_eval_result.py +0 -0
  88. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_intoto_examples.py +0 -0
  89. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_intoto_spec_diff.py +0 -0
  90. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_intoto_svr.py +0 -0
  91. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_kbjwt.py +0 -0
  92. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_merkle.py +0 -0
  93. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_merkle_property.py +0 -0
  94. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_persample.py +0 -0
  95. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_prereg.py +0 -0
  96. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_promptfoo.py +0 -0
  97. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_provenance.py +0 -0
  98. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_pytest_plugin.py +0 -0
  99. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_rekor_interop.py +0 -0
  100. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_rfc6962_external_vectors.py +0 -0
  101. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_schema.py +0 -0
  102. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_sdjwt_issue.py +0 -0
  103. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_sdjwt_reference.py +0 -0
  104. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_signature.py +0 -0
  105. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_statuslist.py +0 -0
  106. {proofbundle-2.0.0b2 → proofbundle-2.0.0b3}/tests/test_tlogproof.py +0 -0
  107. {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.0b2
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
  [![CI](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml/badge.svg)](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml)
71
+ [![PyPI](https://img.shields.io/pypi/v/proofbundle.svg)](https://pypi.org/project/proofbundle/)
72
+ [![Python](https://img.shields.io/pypi/pyversions/proofbundle.svg)](https://pypi.org/project/proofbundle/)
70
73
  [![License: MIT](https://img.shields.io/badge/license-MIT-D6248A.svg)](LICENSE)
71
74
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
72
75
  [![Mutation tested](https://img.shields.io/badge/tests-mutation_gated-D6248A.svg)](scripts/mutation_check.py)
73
- <!-- PyPI / Downloads / SLSA / PEP 740 badges are enabled on the first PyPI release see RELEASE.md. -->
76
+ <!-- SLSA / PEP 740 attestation badges follow once the first attested release lands, see RELEASE.md. -->
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
  [![CI](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml/badge.svg)](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml)
17
+ [![PyPI](https://img.shields.io/pypi/v/proofbundle.svg)](https://pypi.org/project/proofbundle/)
18
+ [![Python](https://img.shields.io/pypi/pyversions/proofbundle.svg)](https://pypi.org/project/proofbundle/)
17
19
  [![License: MIT](https://img.shields.io/badge/license-MIT-D6248A.svg)](LICENSE)
18
20
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
19
21
  [![Mutation tested](https://img.shields.io/badge/tests-mutation_gated-D6248A.svg)](scripts/mutation_check.py)
20
- <!-- PyPI / Downloads / SLSA / PEP 740 badges are enabled on the first PyPI release see RELEASE.md. -->
22
+ <!-- SLSA / PEP 740 attestation badges follow once the first attested release lands, see RELEASE.md. -->
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.0b2"
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
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  from typing import TYPE_CHECKING
15
15
 
16
- __version__ = "2.0.0b2"
16
+ __version__ = "2.0.0b3"
17
17
 
18
18
  __all__ = [
19
19
  "__version__",
@@ -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 real verified one
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)