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