proofbundle 2.0.0b1__tar.gz → 2.0.0b2__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 (101) hide show
  1. {proofbundle-2.0.0b1/src/proofbundle.egg-info → proofbundle-2.0.0b2}/PKG-INFO +24 -8
  2. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/README.md +19 -7
  3. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/pyproject.toml +7 -1
  4. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/__init__.py +1 -1
  5. proofbundle-2.0.0b2/src/proofbundle/anchors.py +185 -0
  6. proofbundle-2.0.0b2/src/proofbundle/anchors_ots.py +91 -0
  7. proofbundle-2.0.0b2/src/proofbundle/anchors_rfc3161.py +90 -0
  8. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/bundle.py +14 -0
  9. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/cli.py +153 -0
  10. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/evalclaim.py +8 -0
  11. proofbundle-2.0.0b2/src/proofbundle/intoto.py +468 -0
  12. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2/src/proofbundle.egg-info}/PKG-INFO +24 -8
  13. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/SOURCES.txt +16 -1
  14. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/requires.txt +5 -0
  15. proofbundle-2.0.0b2/tests/test_anchors_generic.py +111 -0
  16. proofbundle-2.0.0b2/tests/test_anchors_ots.py +122 -0
  17. proofbundle-2.0.0b2/tests/test_anchors_rfc3161.py +115 -0
  18. proofbundle-2.0.0b2/tests/test_branch_base_check.py +57 -0
  19. proofbundle-2.0.0b2/tests/test_claims_hygiene.py +48 -0
  20. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_cli_eval.py +25 -0
  21. proofbundle-2.0.0b2/tests/test_docs_truth.py +46 -0
  22. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_evalclaim.py +23 -0
  23. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_experimental_enclave.py +4 -2
  24. proofbundle-2.0.0b2/tests/test_intoto_claims_hygiene.py +53 -0
  25. proofbundle-2.0.0b2/tests/test_intoto_eval_result.py +172 -0
  26. proofbundle-2.0.0b2/tests/test_intoto_examples.py +86 -0
  27. proofbundle-2.0.0b2/tests/test_intoto_spec_diff.py +38 -0
  28. proofbundle-2.0.0b2/tests/test_intoto_svr.py +137 -0
  29. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_kbjwt.py +16 -0
  30. proofbundle-2.0.0b2/tests/test_verify_matrix.py +82 -0
  31. proofbundle-2.0.0b1/src/proofbundle/intoto.py +0 -182
  32. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/LICENSE +0 -0
  33. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/setup.cfg +0 -0
  34. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/_inspect_registry.py +0 -0
  35. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/_integration.py +0 -0
  36. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/adapters/__init__.py +0 -0
  37. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/adapters/_provenance.py +0 -0
  38. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/adapters/eee.py +0 -0
  39. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/adapters/inspect_ai.py +0 -0
  40. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/adapters/lm_eval.py +0 -0
  41. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/adapters/promptfoo.py +0 -0
  42. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/adapters/samples.py +0 -0
  43. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/beacon.py +0 -0
  44. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/checkpoint.py +0 -0
  45. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/demo.py +0 -0
  46. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/dsse.py +0 -0
  47. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/eee_eval_schema.json +0 -0
  48. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/emit.py +0 -0
  49. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/errors.py +0 -0
  50. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/experimental/__init__.py +0 -0
  51. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/experimental/enclave.py +0 -0
  52. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/hf_evals.py +0 -0
  53. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/inspect_hook.py +0 -0
  54. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/kbjwt.py +0 -0
  55. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/merkle.py +0 -0
  56. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/persample.py +0 -0
  57. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/prereg.py +0 -0
  58. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/py.typed +0 -0
  59. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/pytest_plugin.py +0 -0
  60. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/sdjwt.py +0 -0
  61. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/sdjwt_issue.py +0 -0
  62. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/signature.py +0 -0
  63. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/statuslist.py +0 -0
  64. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle/tlogproof.py +0 -0
  65. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/dependency_links.txt +0 -0
  66. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/entry_points.txt +0 -0
  67. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/src/proofbundle.egg-info/top_level.txt +0 -0
  68. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_adapters.py +0 -0
  69. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_adversarial.py +0 -0
  70. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_beacon.py +0 -0
  71. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_bundle.py +0 -0
  72. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_bundle_robustness.py +0 -0
  73. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_checkpoint.py +0 -0
  74. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_cli.py +0 -0
  75. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_cosignature.py +0 -0
  76. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_cosignature_mldsa.py +0 -0
  77. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_demo.py +0 -0
  78. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_eee.py +0 -0
  79. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_emit.py +0 -0
  80. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_eval_claim_schema.py +0 -0
  81. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_examples.py +0 -0
  82. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_fuzz_parsers.py +0 -0
  83. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_hf_evals.py +0 -0
  84. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_inspect_hook.py +0 -0
  85. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_intoto.py +0 -0
  86. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_intoto_dsse.py +0 -0
  87. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_merkle.py +0 -0
  88. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_merkle_property.py +0 -0
  89. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_persample.py +0 -0
  90. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_prereg.py +0 -0
  91. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_promptfoo.py +0 -0
  92. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_provenance.py +0 -0
  93. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_pytest_plugin.py +0 -0
  94. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_rekor_interop.py +0 -0
  95. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_rfc6962_external_vectors.py +0 -0
  96. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_schema.py +0 -0
  97. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_sdjwt_issue.py +0 -0
  98. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_sdjwt_reference.py +0 -0
  99. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_signature.py +0 -0
  100. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_statuslist.py +0 -0
  101. {proofbundle-2.0.0b1 → proofbundle-2.0.0b2}/tests/test_tlogproof.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofbundle
3
- Version: 2.0.0b1
3
+ Version: 2.0.0b2
4
4
  Summary: Emit and verify portable cryptographic evidence bundles, offline: Ed25519 + RFC 6962 Merkle + optional SD-JWT.
5
5
  Author: Konrad Gruszka
6
6
  License: MIT
@@ -27,6 +27,10 @@ Requires-Dist: cryptography>=42
27
27
  Provides-Extra: sdjwt
28
28
  Provides-Extra: eval
29
29
  Requires-Dist: rfc8785>=0.1.4; extra == "eval"
30
+ Provides-Extra: anchors
31
+ Requires-Dist: rfc3161-client>=1.0.6; extra == "anchors"
32
+ Requires-Dist: opentimestamps>=0.4.5; extra == "anchors"
33
+ Requires-Dist: rfc8785>=0.1.4; extra == "anchors"
30
34
  Provides-Extra: adapters
31
35
  Provides-Extra: experimental
32
36
  Provides-Extra: pq
@@ -56,9 +60,11 @@ Dynamic: license-file
56
60
 
57
61
  <h1>proofbundle</h1>
58
62
 
59
- **Turn an AI eval result into one portable, offline-verifiable receipt.**
60
- It proves *who signed these exact bytes* and *that nothing changed since* — not that the number is
61
- true. Ed25519 + RFC 6962 Merkle, one file, no server, no network.
63
+ **AI eval results need receipts.**
64
+
65
+ Turn an AI evaluation result into one portable, offline-verifiable receipt. It proves *who signed
66
+ these exact bytes* and *that nothing changed since* — not that the number is true. Ed25519 + RFC 6962
67
+ Merkle, one file, no server, no network.
62
68
 
63
69
  [![CI](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml/badge.svg)](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml)
64
70
  [![License: MIT](https://img.shields.io/badge/license-MIT-D6248A.svg)](LICENSE)
@@ -109,6 +115,13 @@ This boundary is the point, not a weakness. A receipt makes a claim **attributab
109
115
  and — with pre-registration and per-sample auditing — bounded and spot-checkable**. Full detail:
110
116
  **[THREAT_MODEL.md](THREAT_MODEL.md)**.
111
117
 
118
+ ## In plain language
119
+
120
+ A proofbundle receipt is the cash-register receipt of an AI test result: it shows who claimed the
121
+ number and that nobody quietly changed it afterwards. It does not show the test was good — the way a
122
+ cash-register receipt does not show the meal was good — but without a receipt there is nothing to
123
+ check at all.
124
+
112
125
  ## How it fits together
113
126
 
114
127
  ```mermaid
@@ -133,13 +146,16 @@ flowchart LR
133
146
  - **Transparency-log interop** — C2SP `tlog-checkpoint` / cosignature / `.tlog-proof`, with
134
147
  post-quantum **ML-DSA-44** witness cosignatures. Optional Token-Status-List revocation snapshots.
135
148
  - **Per-sample audit** — commit to every sample; an auditor challenges random indices (with a fresh
136
- nonce or a **public randomness beacon**, v1.9) and openings must bind to the signed root. Catches
137
- 1% sample-doctoring with 95% confidence at 300 samples, regardless of run size.
149
+ nonce or a **public randomness beacon**, v1.9) and openings must bind to the signed root. With
150
+ such an auditor-supplied or beacon-bound challenge, 300 samples catch 1% sample-doctoring with 95%
151
+ confidence, regardless of run size — a challenge the issuer chose itself does not give this
152
+ guarantee.
138
153
  - **Pre-registration** — `proofbundle prereg <plan>` commits to the protocol before the run, so
139
154
  best-of-many publishing becomes visible.
140
155
  - **Integrations** — opt-in inspect_ai end-of-task hook and pytest plugin (emit only when
141
156
  `PROOFBUNDLE_EMIT=1` / `--proofbundle`), plus a Hugging Face Community Evals bridge. See
142
- [INTEGRATIONS.md](INTEGRATIONS.md).
157
+ [INTEGRATIONS.md](INTEGRATIONS.md), or the end-to-end walkthrough
158
+ [docs/INSPECT_HAPPY_PATH.md](docs/INSPECT_HAPPY_PATH.md) — run an eval, get a receipt, verify it offline.
143
159
 
144
160
  ## Docs
145
161
 
@@ -170,7 +186,7 @@ Requires Python 3.10+. The verify path never rolls its own crypto — Ed25519 co
170
186
 
171
187
  ## Status & scope
172
188
 
173
- Beta, SemVer-committed, 303 tests + a CI mutation gate + property-based parser fuzzing. Correctness
189
+ Beta, SemVer-committed, with a CI test suite behind a mutation gate + property-based parser fuzzing. Correctness
174
190
  is anchored to external RFC 6962 vectors and a real Rekor proof, not just its own bundles. It is
175
191
  **not** a log service, a full in-toto client, a TEE, a consensus network, or a compliance product
176
192
  by itself — it is the small, offline, standards-native receipt layer between them. Security policy:
@@ -7,9 +7,11 @@
7
7
 
8
8
  <h1>proofbundle</h1>
9
9
 
10
- **Turn an AI eval result into one portable, offline-verifiable receipt.**
11
- It proves *who signed these exact bytes* and *that nothing changed since* — not that the number is
12
- true. Ed25519 + RFC 6962 Merkle, one file, no server, no network.
10
+ **AI eval results need receipts.**
11
+
12
+ Turn an AI evaluation result into one portable, offline-verifiable receipt. It proves *who signed
13
+ these exact bytes* and *that nothing changed since* — not that the number is true. Ed25519 + RFC 6962
14
+ Merkle, one file, no server, no network.
13
15
 
14
16
  [![CI](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml/badge.svg)](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml)
15
17
  [![License: MIT](https://img.shields.io/badge/license-MIT-D6248A.svg)](LICENSE)
@@ -60,6 +62,13 @@ This boundary is the point, not a weakness. A receipt makes a claim **attributab
60
62
  and — with pre-registration and per-sample auditing — bounded and spot-checkable**. Full detail:
61
63
  **[THREAT_MODEL.md](THREAT_MODEL.md)**.
62
64
 
65
+ ## In plain language
66
+
67
+ A proofbundle receipt is the cash-register receipt of an AI test result: it shows who claimed the
68
+ number and that nobody quietly changed it afterwards. It does not show the test was good — the way a
69
+ cash-register receipt does not show the meal was good — but without a receipt there is nothing to
70
+ check at all.
71
+
63
72
  ## How it fits together
64
73
 
65
74
  ```mermaid
@@ -84,13 +93,16 @@ flowchart LR
84
93
  - **Transparency-log interop** — C2SP `tlog-checkpoint` / cosignature / `.tlog-proof`, with
85
94
  post-quantum **ML-DSA-44** witness cosignatures. Optional Token-Status-List revocation snapshots.
86
95
  - **Per-sample audit** — commit to every sample; an auditor challenges random indices (with a fresh
87
- nonce or a **public randomness beacon**, v1.9) and openings must bind to the signed root. Catches
88
- 1% sample-doctoring with 95% confidence at 300 samples, regardless of run size.
96
+ nonce or a **public randomness beacon**, v1.9) and openings must bind to the signed root. With
97
+ such an auditor-supplied or beacon-bound challenge, 300 samples catch 1% sample-doctoring with 95%
98
+ confidence, regardless of run size — a challenge the issuer chose itself does not give this
99
+ guarantee.
89
100
  - **Pre-registration** — `proofbundle prereg <plan>` commits to the protocol before the run, so
90
101
  best-of-many publishing becomes visible.
91
102
  - **Integrations** — opt-in inspect_ai end-of-task hook and pytest plugin (emit only when
92
103
  `PROOFBUNDLE_EMIT=1` / `--proofbundle`), plus a Hugging Face Community Evals bridge. See
93
- [INTEGRATIONS.md](INTEGRATIONS.md).
104
+ [INTEGRATIONS.md](INTEGRATIONS.md), or the end-to-end walkthrough
105
+ [docs/INSPECT_HAPPY_PATH.md](docs/INSPECT_HAPPY_PATH.md) — run an eval, get a receipt, verify it offline.
94
106
 
95
107
  ## Docs
96
108
 
@@ -121,7 +133,7 @@ Requires Python 3.10+. The verify path never rolls its own crypto — Ed25519 co
121
133
 
122
134
  ## Status & scope
123
135
 
124
- Beta, SemVer-committed, 303 tests + a CI mutation gate + property-based parser fuzzing. Correctness
136
+ Beta, SemVer-committed, with a CI test suite behind a mutation gate + property-based parser fuzzing. Correctness
125
137
  is anchored to external RFC 6962 vectors and a real Rekor proof, not just its own bundles. It is
126
138
  **not** a log service, a full in-toto client, a TEE, a consensus network, or a compliance product
127
139
  by itself — it is the small, offline, standards-native receipt layer between them. Security policy:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proofbundle"
7
- version = "2.0.0b1"
7
+ version = "2.0.0b2"
8
8
  description = "Emit and verify portable cryptographic evidence bundles, offline: Ed25519 + RFC 6962 Merkle + optional SD-JWT."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -44,6 +44,12 @@ sdjwt = []
44
44
  # path (verify_bundle / decode_eval_claim) never canonicalizes — it checks stored bytes — so the
45
45
  # verifier stays dependency-free. `pip install proofbundle[eval]` adds emit-side canonicalization.
46
46
  eval = ["rfc8785>=0.1.4"]
47
+ # EXPERIMENTAL: external time anchors (proofbundle.anchors) — RFC 3161 TSA + OpenTimestamps. Opt-in and
48
+ # clearly experimental (API/format may change). rfc3161-client (Trail of Bits) is deliberately offline —
49
+ # it verifies an RFC 3161 token against bundled roots, no network in the verify path. opentimestamps is
50
+ # the OTS proof library. rfc8785 gives the receipt its canonical (JCS) root. The base install stays
51
+ # anchor-free (only cryptography); a receipt with no anchors verifies unchanged.
52
+ anchors = ["rfc3161-client>=1.0.6", "opentimestamps>=0.4.5", "rfc8785>=0.1.4"]
47
53
  # The lm-eval adapter reads exported results.json (no import) → pure stdlib.
48
54
  adapters = []
49
55
  # EXPERIMENTAL (v2.0 preview): the TEE-attestation bridge (proofbundle.experimental.enclave).
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  from typing import TYPE_CHECKING
15
15
 
16
- __version__ = "2.0.0b1"
16
+ __version__ = "2.0.0b2"
17
17
 
18
18
  __all__ = [
19
19
  "__version__",
@@ -0,0 +1,185 @@
1
+ """Generic external time-anchor layer for proofbundle receipts (EXPERIMENTAL; `[anchors]` extra).
2
+
3
+ An **anchor** is external evidence that a target existed at (or before) a time — something the receipt's
4
+ own Ed25519 + Merkle structure cannot establish on its own, because a self-emitted timestamp is only
5
+ producer-clock testimony. Two targets, **never mixed**:
6
+
7
+ * ``preRegistration`` — "the commitment existed BEFORE the run" (backdating protection; the point raised
8
+ in in-toto/attestation#565).
9
+ * ``receipt`` — "the receipt existed from time T" (publication proof).
10
+
11
+ Each ``anchors[]`` entry is ``{type, target, canonicalRoot, proof, anchoredAt}``:
12
+
13
+ * ``type`` — ``rfc3161-tsa`` | ``opentimestamps`` | ``<extension>/vN``.
14
+ * ``target`` — ``receipt`` | ``preRegistration`` (see above).
15
+ * ``canonicalRoot`` — base64 of the canonical root of the target: for ``receipt`` the RFC 8785 (JCS)
16
+ sha256 of the receipt bundle; for ``preRegistration`` the sha256 of the raw protocol bytes (the
17
+ receipt's ``prereg_sha256``). The anchor timestamps THIS root.
18
+ * ``proof`` — base64 of the type-specific proof (an RFC 3161 token, an OpenTimestamps proof, ...).
19
+ * ``anchoredAt`` — RFC 3339 Z, INFORMATIVE only (the trusted time comes from the proof, not this field).
20
+
21
+ **Verify contract (fail-closed).** Missing/empty ``anchors`` → SKIP (never FAIL — consistent with
22
+ in-toto's Monotonic Principle: deny only when an attestation is present and wrong). Present → fail-closed:
23
+ a root mismatch, an unknown type, or a broken proof is a FAIL, never silent. ``require`` (CLI
24
+ ``--require-anchor <type|any>``) turns "no verifying anchor" into a FAIL.
25
+
26
+ **Cross-target safety.** ``canonicalRoot`` is compared to the root of the anchor's OWN ``target`` — a
27
+ ``preRegistration`` anchor can never validate a ``receipt`` target and vice versa (the roots differ).
28
+
29
+ **Lean core.** This module is pure dispatch + schema; the RFC 3161 / OpenTimestamps verifiers lazy-import
30
+ their libraries and are only needed with the ``[anchors]`` extra. The base install pulls only
31
+ ``cryptography``; a bundle with no anchors verifies unchanged. Anchoring writes a NEW file — a network
32
+ error while stamping never corrupts the local receipt.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import base64
37
+ import binascii
38
+ import hashlib
39
+ from typing import Callable, Optional
40
+
41
+ from .errors import BundleFormatError
42
+
43
+ ANCHOR_TARGETS = ("receipt", "preRegistration")
44
+ _ANCHOR_KEYS = {"type", "target", "canonicalRoot", "proof", "anchoredAt", "frozen"}
45
+
46
+ # type name -> verifier callable:
47
+ # (proof: bytes, canonical_root: bytes, *, frozen: dict, now: Optional[int]) -> {"ok": bool, "detail": str}
48
+ _VERIFIERS: dict[str, Callable] = {}
49
+
50
+
51
+ def register_anchor_type(type_name: str, verifier: Callable) -> None:
52
+ """Register a verifier for an anchor ``type``. A third party ships its own type this way (see
53
+ docs/ANCHORS.md). The verifier MUST be fail-closed: return ``{"ok": False, ...}`` on any doubt,
54
+ never raise for an ordinary bad proof."""
55
+ if not type_name or not isinstance(type_name, str) or not callable(verifier):
56
+ raise BundleFormatError("register_anchor_type needs a non-empty name and a callable verifier")
57
+ _VERIFIERS[type_name] = verifier
58
+
59
+
60
+ def registered_anchor_types() -> tuple:
61
+ _ensure_builtin_types()
62
+ return tuple(sorted(_VERIFIERS))
63
+
64
+
65
+ def _ensure_builtin_types() -> None:
66
+ """Lazily register the built-in anchor verifiers (rfc3161-tsa, opentimestamps). Each needs the
67
+ ``[anchors]`` extra; if a library is absent the type stays UNREGISTERED — which the verify path
68
+ treats as an unknown type → FAIL (fail-closed), exactly the behaviour we want without the extra."""
69
+ if "rfc3161-tsa" not in _VERIFIERS:
70
+ try:
71
+ from . import anchors_rfc3161 # noqa: PLC0415
72
+ _VERIFIERS["rfc3161-tsa"] = anchors_rfc3161.verify_rfc3161
73
+ except Exception: # extra missing / import failure → leave unregistered (fail-closed)
74
+ pass
75
+ if "opentimestamps" not in _VERIFIERS:
76
+ try:
77
+ from . import anchors_ots # noqa: PLC0415
78
+ _VERIFIERS["opentimestamps"] = anchors_ots.verify_opentimestamps
79
+ except Exception:
80
+ pass
81
+
82
+
83
+ def _b64d(value, field: str) -> bytes:
84
+ if not isinstance(value, str):
85
+ raise BundleFormatError(f"anchor {field} must be a base64 string")
86
+ try:
87
+ return base64.b64decode(value, validate=True)
88
+ except (ValueError, binascii.Error) as exc:
89
+ raise BundleFormatError(f"anchor {field} is not valid base64") from exc
90
+
91
+
92
+ def receipt_canonical_root(bundle: dict) -> bytes:
93
+ """The RFC 8785 (JCS) sha256 of the receipt bundle — the canonical root a ``receipt`` anchor stamps.
94
+ Uses a real RFC 8785 canonicalizer (the ``[anchors]``/``[eval]`` extra); never a home-grown sort."""
95
+ try:
96
+ import rfc8785 # noqa: PLC0415
97
+ except ImportError as exc: # pragma: no cover - guarded by the extra
98
+ raise BundleFormatError(
99
+ "receipt anchoring needs the RFC 8785 canonicalizer — install proofbundle[anchors]") from exc
100
+ return hashlib.sha256(rfc8785.dumps(bundle)).digest()
101
+
102
+
103
+ def prereg_canonical_root(prereg_sha256_hex: str) -> bytes:
104
+ """The canonical root a ``preRegistration`` anchor stamps: the sha256 (raw bytes) of the eval
105
+ protocol file, i.e. the receipt's ``prereg_sha256``."""
106
+ if not isinstance(prereg_sha256_hex, str) or len(prereg_sha256_hex) != 64:
107
+ raise BundleFormatError("prereg canonical root needs a 64-char hex sha256")
108
+ try:
109
+ return bytes.fromhex(prereg_sha256_hex)
110
+ except ValueError as exc:
111
+ raise BundleFormatError("prereg_sha256 is not valid hex") from exc
112
+
113
+
114
+ def verify_anchor(anchor: dict, *, target_roots: dict, now: Optional[int] = None) -> dict:
115
+ """Verify ONE anchor entry, fail-closed. ``target_roots`` maps a target name to its canonical root
116
+ bytes (only the targets that exist for this receipt). Returns ``{ok, type, target, detail}``."""
117
+ _ensure_builtin_types()
118
+ if not isinstance(anchor, dict):
119
+ raise BundleFormatError("each anchor must be a JSON object")
120
+ unknown = set(anchor) - _ANCHOR_KEYS
121
+ if unknown:
122
+ raise BundleFormatError(f"anchor has unknown field(s) {sorted(unknown)}")
123
+ atype = anchor.get("type")
124
+ target = anchor.get("target")
125
+ out = {"ok": False, "warn": False, "status": "fail", "type": atype, "target": target, "detail": ""}
126
+ if target not in ANCHOR_TARGETS:
127
+ out["detail"] = f"anchor target must be one of {ANCHOR_TARGETS}"
128
+ return out
129
+ if not isinstance(atype, str) or atype not in _VERIFIERS:
130
+ # Unknown type is a FAIL, not a SKIP — an anchor we cannot check must never pass silently.
131
+ out["detail"] = (f"no verifier registered for anchor type {atype!r} "
132
+ "(install proofbundle[anchors] or register the extension type)")
133
+ return out
134
+ expected_root = target_roots.get(target)
135
+ if expected_root is None:
136
+ out["detail"] = f"the receipt has no {target} target to anchor against"
137
+ return out
138
+ canonical_root = _b64d(anchor.get("canonicalRoot"), "canonicalRoot")
139
+ if canonical_root != expected_root:
140
+ # cross-target safety: a preRegistration anchor's root never equals the receipt root, and v.v.
141
+ out["detail"] = f"canonicalRoot does not match the {target} root (cross-target or tampered)"
142
+ return out
143
+ proof = _b64d(anchor.get("proof"), "proof")
144
+ try:
145
+ res = _VERIFIERS[atype](proof, canonical_root, frozen=anchor.get("frozen") or {}, now=now)
146
+ except Exception as exc: # a verifier must be fail-closed; if it raises, treat as FAIL, never pass
147
+ out["detail"] = f"anchor verifier error (fail-closed): {exc}"
148
+ return out
149
+ out["ok"] = bool(res.get("ok"))
150
+ out["warn"] = bool(res.get("warn"))
151
+ out["status"] = res.get("status") or ("pass" if out["ok"] else ("warn" if out["warn"] else "fail"))
152
+ out["detail"] = res.get("detail", "")
153
+ return out
154
+
155
+
156
+ def verify_anchors(anchors, *, target_roots: dict, require: Optional[str] = None,
157
+ now: Optional[int] = None) -> dict:
158
+ """Verify a receipt's ``anchors``. Missing/empty → SKIP (unless ``require`` is set → FAIL). Present →
159
+ fail-closed PASS/FAIL over every entry. ``require`` is ``None`` | ``'any'`` | a type string; when set,
160
+ at least one anchor of that type (or any) must verify. Returns ``{status, detail, results}`` with
161
+ ``status`` in {PASS, FAIL, SKIP}."""
162
+ if not anchors:
163
+ if require:
164
+ return {"status": "FAIL", "detail": f"--require-anchor {require} set but the receipt has no anchors",
165
+ "results": []}
166
+ return {"status": "SKIP", "detail": "no external time anchors present", "results": []}
167
+ if not isinstance(anchors, list):
168
+ raise BundleFormatError("anchors must be a list")
169
+ 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
171
+ 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)]
173
+ if not matched:
174
+ return {"status": "FAIL",
175
+ "detail": f"--require-anchor {require}: no verifying anchor of that type",
176
+ "results": results}
177
+ hard_fail = any(not r["ok"] and not r["warn"] for r in results)
178
+ if hard_fail:
179
+ status = "FAIL" # a broken/unbound/unknown anchor is never silent
180
+ elif any(r["warn"] for r in results):
181
+ status = "WARN" # e.g. a PENDING OpenTimestamps proof — not a full anchor yet
182
+ else:
183
+ status = "PASS"
184
+ detail = f"{sum(r['ok'] for r in results)}/{len(results)} anchor(s) verified"
185
+ return {"status": status, "detail": detail, "results": results}
@@ -0,0 +1,91 @@
1
+ """OpenTimestamps anchor (EXPERIMENTAL; the `[anchors]` extra).
2
+
3
+ Honest lifecycle (this is where OTS is easy to over-claim):
4
+
5
+ * A fresh stamp is submitted to public calendars and is **PENDING** — it commits your root but is not
6
+ yet anchored in Bitcoin. A pending proof is a WARN, never a full-strength anchor.
7
+ * ``ots upgrade`` embeds the Bitcoin block-header path; only then is the proof **upgraded** and
8
+ self-contained (no calendar needed to verify).
9
+ * Verifying an upgraded proof still needs the Bitcoin **block header** for the attested height — per the
10
+ documented client path, a local (pruned) Bitcoin node. There is no documented "header file instead of
11
+ a node" mode, and we do not claim one. If the caller supplies the block header (its Merkle root) in the
12
+ anchor's ``frozen`` block, we verify against it offline; otherwise we report, honestly, that the proof
13
+ is upgraded but Bitcoin verification needs a node/header — we never silently PASS it.
14
+
15
+ ``proof`` is the serialized detached OTS proof; ``canonicalRoot`` is the exact bytes that were stamped.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from typing import Optional
20
+
21
+
22
+ def _classify(timestamp):
23
+ """Return (has_bitcoin, bitcoin_heights, has_pending) over all attestations in the proof."""
24
+ from opentimestamps.core.notary import ( # noqa: PLC0415
25
+ BitcoinBlockHeaderAttestation, PendingAttestation,
26
+ )
27
+ heights, has_pending = [], False
28
+ for _msg, att in timestamp.all_attestations():
29
+ if isinstance(att, BitcoinBlockHeaderAttestation):
30
+ heights.append(att.height)
31
+ elif isinstance(att, PendingAttestation):
32
+ has_pending = True
33
+ return (bool(heights), heights, has_pending)
34
+
35
+
36
+ def verify_opentimestamps(proof: bytes, canonical_root: bytes, *, frozen: dict,
37
+ now: Optional[int] = None) -> dict:
38
+ """Fail-closed OTS verify. Returns {ok, detail, warn, status}. A pending proof is warn (status
39
+ 'pending'); an upgraded proof with no supplied block header is not-ok-but-not-warn honest report
40
+ (status 'upgraded_unverified'); an upgraded proof verified against a supplied header is ok (status
41
+ 'confirmed')."""
42
+ try:
43
+ from opentimestamps.core.serialize import BytesDeserializationContext # noqa: PLC0415
44
+ from opentimestamps.core.timestamp import DetachedTimestampFile # noqa: PLC0415
45
+ except ImportError:
46
+ return {"ok": False, "warn": False, "status": "no_lib",
47
+ "detail": "opentimestamps anchor needs proofbundle[anchors] (opentimestamps)"}
48
+ try:
49
+ dtf = DetachedTimestampFile.deserialize(BytesDeserializationContext(proof))
50
+ except Exception as exc: # any malformed proof → FAIL (fail-closed)
51
+ return {"ok": False, "warn": False, "status": "malformed",
52
+ "detail": f"OTS proof did not deserialize: {exc}"}
53
+ # structural binding: the proof must commit to EXACTLY the canonical root
54
+ if dtf.file_digest != canonical_root:
55
+ return {"ok": False, "warn": False, "status": "unbound",
56
+ "detail": "OTS proof does not commit to the target canonical root"}
57
+ has_bitcoin, heights, has_pending = _classify(dtf.timestamp)
58
+ if not has_bitcoin:
59
+ if has_pending:
60
+ return {"ok": False, "warn": True, "status": "pending",
61
+ "detail": "OTS proof is PENDING (submitted to calendars, not yet on Bitcoin) — "
62
+ "run `ots upgrade`; not a full anchor yet"}
63
+ return {"ok": False, "warn": False, "status": "empty",
64
+ "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, supplied by a
66
+ # trusted (local pruned) Bitcoin node — proofbundle never fetches it. BitcoinBlockHeaderAttestation's
67
+ # own check is exactly `attestation_message == block_header.hashMerkleRoot`; we do that comparison
68
+ # directly against the supplied root (equivalent, and avoids reconstructing a full CBlockHeader).
69
+ headers = frozen.get("bitcoinBlockHeaderMerkleRootsByHeight") or {}
70
+ for msg, att in dtf.timestamp.all_attestations():
71
+ height = getattr(att, "height", None)
72
+ if height is None:
73
+ continue
74
+ merkle_root_hex = headers.get(str(height))
75
+ if not merkle_root_hex:
76
+ continue
77
+ try:
78
+ expected = bytes.fromhex(merkle_root_hex)
79
+ except ValueError:
80
+ return {"ok": False, "warn": False, "status": "bad_header",
81
+ "detail": f"supplied Bitcoin block merkle root for height {height} is not valid hex"}
82
+ if msg == expected:
83
+ return {"ok": True, "warn": False, "status": "confirmed",
84
+ "detail": f"OTS proof confirmed: committed in the Bitcoin block at height {height} "
85
+ "(merkle root supplied by a trusted node)"}
86
+ return {"ok": False, "warn": False, "status": "block_mismatch",
87
+ "detail": f"OTS Bitcoin attestation at height {height} does not match the supplied "
88
+ "block merkle root"}
89
+ return {"ok": False, "warn": False, "status": "upgraded_unverified",
90
+ "detail": f"OTS proof is upgraded (Bitcoin height {heights}) but no block header was supplied "
91
+ "— offline verification needs a local (pruned) Bitcoin node; not claiming a pass"}
@@ -0,0 +1,90 @@
1
+ """RFC 3161 Time-Stamp Authority anchor (EXPERIMENTAL; the `[anchors]` extra).
2
+
3
+ Verification is OFFLINE (Trail of Bits ``rfc3161-client`` — deliberately no network in verify): an RFC
4
+ 3161 token is checked against the TSA certificate chain **frozen into the anchor at emit time**. Freezing
5
+ matters because a TSA can rotate its certificate (FreeTSA rotated in March 2026); an old token is then
6
+ only re-verifiable against the chain that was current when it was issued. The frozen material lives in the
7
+ anchor's ``frozen`` block as base64 DER, so no PEM files travel with a receipt.
8
+
9
+ ``proof`` is the base64 DER of the RFC 3161 response; ``canonicalRoot`` is the exact bytes that were
10
+ timestamped (the anchor layer has already matched it to the receipt's target root). Creating an anchor
11
+ (``create_rfc3161_anchor``) does the network call to the TSA and writes a NEW anchor object; a network
12
+ error there never touches the local receipt.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ from typing import Optional
18
+
19
+
20
+ def _load_der_cert(b64: str):
21
+ from cryptography import x509 # noqa: PLC0415
22
+ return x509.load_der_x509_certificate(base64.b64decode(b64))
23
+
24
+
25
+ def verify_rfc3161(proof: bytes, canonical_root: bytes, *, frozen: dict, now: Optional[int] = None) -> dict:
26
+ """Fail-closed offline verify of an RFC 3161 token against the frozen chain. Returns {ok, detail}."""
27
+ try:
28
+ import rfc3161_client as tsp # noqa: PLC0415
29
+ except ImportError:
30
+ return {"ok": False, "detail": "rfc3161-tsa anchor needs proofbundle[anchors] (rfc3161-client)"}
31
+ roots = frozen.get("rootCertsDerB64") or []
32
+ if not roots:
33
+ return {"ok": False, "detail": "frozen chain is missing rootCertsDerB64 (cannot verify offline)"}
34
+ try:
35
+ response = tsp.decode_timestamp_response(proof)
36
+ builder = tsp.VerifierBuilder()
37
+ for rb in roots:
38
+ builder = builder.add_root_certificate(_load_der_cert(rb))
39
+ for ib in frozen.get("intermediateCertsDerB64", []) or []:
40
+ builder = builder.add_intermediate_certificate(_load_der_cert(ib))
41
+ tsa_b64 = frozen.get("tsaCertDerB64")
42
+ if tsa_b64:
43
+ builder = builder.tsa_certificate(_load_der_cert(tsa_b64))
44
+ builder.build().verify_message(response, canonical_root)
45
+ except Exception as exc: # any verify failure is a FAIL, never a silent pass (fail-closed)
46
+ return {"ok": False, "detail": f"RFC 3161 token did not verify against the frozen chain: {exc}"}
47
+ return {"ok": True, "detail": "RFC 3161 token verified offline against the frozen TSA chain"}
48
+
49
+
50
+ def create_rfc3161_anchor(canonical_root: bytes, target: str, *, tsa_url: str,
51
+ root_certs_der: list, tsa_cert_der: Optional[bytes] = None,
52
+ intermediate_certs_der: Optional[list] = None,
53
+ anchored_at: Optional[str] = None, timeout: int = 30) -> dict:
54
+ """Stamp ``canonical_root`` at ``tsa_url`` and return a NEW anchor object with the chain frozen in.
55
+
56
+ Network call (POST an RFC 3161 query). The caller supplies the TSA's root cert(s) (and, for TSAs that
57
+ do not embed it, the TSA cert) so the chain can be frozen for offline re-verification. This function
58
+ only builds and returns the anchor dict — writing it into a receipt is the caller's job, so a network
59
+ failure here never corrupts the local receipt.
60
+ """
61
+ import urllib.request # noqa: PLC0415
62
+
63
+ import rfc3161_client as tsp # noqa: PLC0415
64
+ request = tsp.TimestampRequestBuilder().data(canonical_root).cert_request().build()
65
+ http = urllib.request.Request(
66
+ tsa_url, data=request.as_bytes(), method="POST",
67
+ headers={"Content-Type": "application/timestamp-query",
68
+ "Accept": "application/timestamp-reply"})
69
+ with urllib.request.urlopen(http, timeout=timeout) as resp:
70
+ token = resp.read()
71
+ # sanity: the response must be granted and verify against the supplied chain before we freeze it
72
+ frozen: dict = {
73
+ "rootCertsDerB64": [base64.b64encode(c).decode("ascii") for c in root_certs_der],
74
+ }
75
+ if tsa_cert_der:
76
+ frozen["tsaCertDerB64"] = base64.b64encode(tsa_cert_der).decode("ascii")
77
+ if intermediate_certs_der:
78
+ frozen["intermediateCertsDerB64"] = [base64.b64encode(c).decode("ascii")
79
+ for c in intermediate_certs_der]
80
+ check = verify_rfc3161(token, canonical_root, frozen=frozen)
81
+ if not check["ok"]:
82
+ raise RuntimeError(f"refusing to build anchor: fresh token did not verify — {check['detail']}")
83
+ return {
84
+ "type": "rfc3161-tsa",
85
+ "target": target,
86
+ "canonicalRoot": base64.b64encode(canonical_root).decode("ascii"),
87
+ "proof": base64.b64encode(token).decode("ascii"),
88
+ "anchoredAt": anchored_at,
89
+ "frozen": frozen,
90
+ }
@@ -153,6 +153,7 @@ def verify_bundle(bundle: Union[dict, str], *, expected_aud=None, expected_nonce
153
153
 
154
154
  # 3. optional SD-JWT selective disclosure credential
155
155
  sd = bundle.get("sd_jwt_vc")
156
+ kb_binding_checked = False # F4: did a KB-JWT (the aud/nonce carrier) actually get verified?
156
157
  if sd is not None:
157
158
  sd = _require_dict(sd, "sd_jwt_vc")
158
159
  _reject_unknown(sd, _SD_KEYS, "sd_jwt_vc")
@@ -186,6 +187,7 @@ def verify_bundle(bundle: Union[dict, str], *, expected_aud=None, expected_nonce
186
187
  if kb is not None:
187
188
  kb_res = verify_key_binding(compact, expected_aud=expected_aud, expected_nonce=expected_nonce)
188
189
  result.add("sd-jwt-key-binding", kb_res["ok"], kb_res["detail"])
190
+ kb_binding_checked = True
189
191
  elif _issuer_requires_holder_binding(sd_part):
190
192
  result.add(
191
193
  "sd-jwt-key-binding", False,
@@ -206,6 +208,18 @@ def verify_bundle(bundle: Union[dict, str], *, expected_aud=None, expected_nonce
206
208
  "binding is unverifiable, refusing (fail-closed; supply "
207
209
  "sd_jwt_vc.issuer_public_key_b64)")
208
210
 
211
+ # F4 (v1.9.2, fail-closed): supplying expected_aud/expected_nonce asks for RFC 9901 §7.3
212
+ # replay/audience binding. A bundle with no verifiable KB-JWT (no sd_jwt_vc at all, or an
213
+ # sd_jwt_vc without a Key Binding JWT) carries nothing to bind to — returning OK anyway is a
214
+ # downgrade trap: the verifier believes the presentation was bound to its aud/nonce when it was
215
+ # not. Refuse the binding it asked for but cannot be enforced. Verifiers that pass no expected_*
216
+ # are unaffected (backward-compatible: the check only fires when a binding was actually requested).
217
+ if (expected_aud is not None or expected_nonce is not None) and not kb_binding_checked:
218
+ result.add(
219
+ "sd-jwt-key-binding", False,
220
+ "expected_aud/expected_nonce were supplied but the bundle carries no verifiable Key "
221
+ "Binding JWT — the requested replay/audience binding cannot be enforced (fail-closed)")
222
+
209
223
  return result
210
224
 
211
225