proofbundle 1.4.0__tar.gz → 1.8.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.
Files changed (80) hide show
  1. {proofbundle-1.4.0/src/proofbundle.egg-info → proofbundle-1.8.0}/PKG-INFO +36 -12
  2. {proofbundle-1.4.0 → proofbundle-1.8.0}/README.md +34 -10
  3. {proofbundle-1.4.0 → proofbundle-1.8.0}/pyproject.toml +2 -2
  4. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/__init__.py +16 -1
  5. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/adapters/__init__.py +3 -1
  6. proofbundle-1.8.0/src/proofbundle/adapters/_provenance.py +63 -0
  7. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/adapters/eee.py +1 -1
  8. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/adapters/inspect_ai.py +9 -0
  9. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/adapters/lm_eval.py +10 -0
  10. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/adapters/promptfoo.py +7 -0
  11. proofbundle-1.8.0/src/proofbundle/adapters/samples.py +82 -0
  12. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/bundle.py +20 -4
  13. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/cli.py +123 -4
  14. proofbundle-1.8.0/src/proofbundle/demo.py +153 -0
  15. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/evalclaim.py +70 -7
  16. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/hf_evals.py +43 -1
  17. proofbundle-1.8.0/src/proofbundle/persample.py +270 -0
  18. proofbundle-1.8.0/src/proofbundle/prereg.py +58 -0
  19. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/statuslist.py +18 -6
  20. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/tlogproof.py +3 -1
  21. {proofbundle-1.4.0 → proofbundle-1.8.0/src/proofbundle.egg-info}/PKG-INFO +36 -12
  22. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle.egg-info/SOURCES.txt +10 -0
  23. proofbundle-1.8.0/tests/test_demo.py +54 -0
  24. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_evalclaim.py +43 -0
  25. proofbundle-1.8.0/tests/test_fuzz_parsers.py +88 -0
  26. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_hf_evals.py +35 -0
  27. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_kbjwt.py +24 -6
  28. proofbundle-1.8.0/tests/test_persample.py +418 -0
  29. proofbundle-1.8.0/tests/test_prereg.py +122 -0
  30. proofbundle-1.8.0/tests/test_provenance.py +77 -0
  31. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_statuslist.py +36 -0
  32. {proofbundle-1.4.0 → proofbundle-1.8.0}/LICENSE +0 -0
  33. {proofbundle-1.4.0 → proofbundle-1.8.0}/setup.cfg +0 -0
  34. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/_inspect_registry.py +0 -0
  35. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/_integration.py +0 -0
  36. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/checkpoint.py +0 -0
  37. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/dsse.py +0 -0
  38. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/eee_eval_schema.json +0 -0
  39. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/emit.py +0 -0
  40. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/errors.py +0 -0
  41. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/inspect_hook.py +0 -0
  42. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/intoto.py +0 -0
  43. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/kbjwt.py +0 -0
  44. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/merkle.py +0 -0
  45. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/py.typed +0 -0
  46. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/pytest_plugin.py +0 -0
  47. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/sdjwt.py +0 -0
  48. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/sdjwt_issue.py +0 -0
  49. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle/signature.py +0 -0
  50. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle.egg-info/dependency_links.txt +0 -0
  51. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle.egg-info/entry_points.txt +0 -0
  52. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle.egg-info/requires.txt +0 -0
  53. {proofbundle-1.4.0 → proofbundle-1.8.0}/src/proofbundle.egg-info/top_level.txt +0 -0
  54. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_adapters.py +0 -0
  55. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_adversarial.py +0 -0
  56. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_bundle.py +0 -0
  57. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_bundle_robustness.py +0 -0
  58. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_checkpoint.py +0 -0
  59. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_cli.py +0 -0
  60. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_cli_eval.py +0 -0
  61. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_cosignature.py +0 -0
  62. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_cosignature_mldsa.py +0 -0
  63. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_eee.py +0 -0
  64. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_emit.py +0 -0
  65. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_eval_claim_schema.py +0 -0
  66. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_examples.py +0 -0
  67. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_inspect_hook.py +0 -0
  68. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_intoto.py +0 -0
  69. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_intoto_dsse.py +0 -0
  70. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_merkle.py +0 -0
  71. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_merkle_property.py +0 -0
  72. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_promptfoo.py +0 -0
  73. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_pytest_plugin.py +0 -0
  74. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_rekor_interop.py +0 -0
  75. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_rfc6962_external_vectors.py +0 -0
  76. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_schema.py +0 -0
  77. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_sdjwt_issue.py +0 -0
  78. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_sdjwt_reference.py +0 -0
  79. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_signature.py +0 -0
  80. {proofbundle-1.4.0 → proofbundle-1.8.0}/tests/test_tlogproof.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofbundle
3
- Version: 1.4.0
3
+ Version: 1.8.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
@@ -10,7 +10,7 @@ Project-URL: Issues, https://github.com/b7n0de/proofbundle/issues
10
10
  Project-URL: Changelog, https://github.com/b7n0de/proofbundle/blob/main/CHANGELOG.md
11
11
  Project-URL: Documentation, https://github.com/b7n0de/proofbundle#readme
12
12
  Keywords: cryptography,merkle,transparency-log,ed25519,sd-jwt,verifiable-credentials,attestation,provenance,rfc6962
13
- Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Development Status :: 4 - Beta
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: License :: OSI Approved :: MIT License
16
16
  Classifier: Programming Language :: Python :: 3
@@ -63,13 +63,16 @@ revocation snapshots, aligned to the in-toto test-result predicate. One portable
63
63
  file, no server, no network.**
64
64
 
65
65
  [![CI](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml/badge.svg)](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml)
66
+ [![License: MIT](https://img.shields.io/badge/license-MIT-D6248A.svg)](LICENSE)
67
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
68
+ <!-- Badges below are enabled AFTER the first PyPI release (they render broken/false before it,
69
+ which reads as abandonment). Un-comment on first publish — see RELEASE.md:
66
70
  [![PyPI](https://img.shields.io/pypi/v/proofbundle.svg?color=D6248A&cacheSeconds=3600)](https://pypi.org/project/proofbundle/)
67
71
  [![Python](https://img.shields.io/pypi/pyversions/proofbundle.svg?color=D6248A&cacheSeconds=3600)](https://pypi.org/project/proofbundle/)
68
72
  [![Downloads](https://static.pepy.tech/badge/proofbundle)](https://pepy.tech/project/proofbundle)
69
- [![License: MIT](https://img.shields.io/badge/license-MIT-D6248A.svg)](LICENSE)
70
- [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
71
73
  [![SLSA build provenance](https://img.shields.io/badge/SLSA-build_provenance-D6248A.svg)](https://slsa.dev)
72
74
  [![PyPI attestations](https://img.shields.io/badge/PyPI-attestations_(PEP_740)-D6248A.svg)](https://pypi.org/project/proofbundle/)
75
+ -->
73
76
  [![Post-quantum witnesses](https://img.shields.io/badge/PQ-ML--DSA--44_cosignatures-D6248A.svg)](https://github.com/C2SP/C2SP/blob/main/tlog-cosignature.md)
74
77
  [![Mutation tested](https://img.shields.io/badge/tests-mutation_gated-D6248A.svg)](scripts/mutation_check.py)
75
78
  [![C2SP tlog-proof](https://img.shields.io/badge/C2SP-tlog--proof-D6248A.svg)](https://github.com/C2SP/C2SP/blob/main/tlog-proof.md)
@@ -81,7 +84,7 @@ file, no server, no network.**
81
84
 
82
85
  **At a glance:** `proofbundle emit` signs and anchors a payload; `proofbundle
83
86
  verify` checks one self-contained `bundle.json` with three offline cryptographic
84
- checks → `OK` or `FAILED`. No network, no daemon, no own crypto. 209 tests + a CI mutation gate.
87
+ checks → `OK` or `FAILED`. No network, no daemon, no own crypto. 289 tests + a CI mutation gate.
85
88
 
86
89
  ## Contents
87
90
 
@@ -146,6 +149,8 @@ not a proof that the number is *true* or that the evaluation was well designed.
146
149
  | `reproduced` | independently re-run and matched |
147
150
  | `enclave_attested` | produced in an attested trusted execution environment |
148
151
 
152
+ Since v1.6 `decode_eval_claim(bundle, expected_context=...)` enforces the signed `context_binding` (cross-context replay guard), and verify-side re-checks the `samples.n == n` invariant — guarantees no longer live only in the emitter.
153
+
149
154
  Full detail: **[THREAT_MODEL.md](THREAT_MODEL.md)** — what `verify` catches and what it structurally cannot.
150
155
 
151
156
  ## What it verifies
@@ -223,7 +228,7 @@ pip install "proofbundle[sdjwt]"
223
228
  ## Quickstart
224
229
 
225
230
  ```bash
226
- # generate a real example bundle with throwaway keys
231
+ # from a git checkout (examples/ ships in the repo, not the wheel):
227
232
  python examples/make_example.py
228
233
 
229
234
  # verify it
@@ -234,6 +239,15 @@ proofbundle verify examples/example_bundle.json
234
239
  <img src="assets/demo.svg" alt="proofbundle verify output: PASS checks and OK" width="680">
235
240
  </div>
236
241
 
242
+ See the whole trust story in five seconds — pip-only, offline, in memory (honest receipt verifies, six tampers fail, a swapped sample is caught):
243
+
244
+ ```bash
245
+ pip install proofbundle
246
+ proofbundle demo
247
+ ```
248
+
249
+ Full walkthrough incl. the forced-random-sample audit: **[docs/DEMO.md](docs/DEMO.md)**.
250
+
237
251
  Machine-readable output and a non-zero exit code on failure:
238
252
 
239
253
  ```bash
@@ -327,7 +341,7 @@ Since v1.2 proofbundle also speaks the witness layer:
327
341
  (Ed25519 cosignature/v1) — `verify_witnessed_checkpoint` checks a checkpoint is
328
342
  both log-signed **and** cosigned by a quorum of distinct witnesses, offline,
329
343
  which rules out a split view by the log operator. This is the same
330
- witnessed-checkpoint pattern Rekor v2 (GA October 2025) institutionalizes.
344
+ witnessed-checkpoint pattern Rekor v2 (GA October 2025) is integrating.
331
345
 
332
346
  Since v1.3 the witness layer is complete: **ML-DSA-44 cosignatures** (C2SP type
333
347
  0x06, FIPS 204 — post-quantum, the spec's SHOULD for new witness deployments;
@@ -551,15 +565,25 @@ ISO/IEC DIS 24970), including the anti-patterns no one should claim.
551
565
  Status List** snapshot verification (offline revocation), SD-JWT VC markers (`dc+sd-jwt`,
552
566
  `vct`, `status`), COMPLIANCE.md (EU AI Act Art. 12 / NIST AI RMF mapping), and the mutation
553
567
  suite as a CI gate.
554
- - **v1.4 (current release)** — distribution: a **promptfoo** adapter (results.json v3 →
568
+ - **v1.4** — distribution: a **promptfoo** adapter (results.json v3 →
555
569
  pass_rate receipt), the **Hugging Face Community Evals bridge** (`pb1.` receipt tokens for the
556
570
  `.eval_results` `verifyToken` field + a strict YAML entry emitter — proofbundle-verifiable,
557
571
  explicitly NOT the HF-internal badge token), `hf-token` CLI, and the **Python 3.10+ floor**
558
572
  (3.9 is EOL since 2025-10).
559
- - **v2.0 (next major, research differentiation)** — **per-sample Merkle receipts**: the payload carries a
560
- Merkle root over *individual* sample results, so a verifier can force a random-sample re-check instead of
561
- trusting only the reported aggregate. This is the THREAT_MODEL's one named structural gap ("forced random
562
- sub-sampling"), and closing it is the road from *authorship* to *sampled correctness*.
573
+ - **v1.5** — per-sample receipts: a signed **Merkle commitment over every
574
+ individual sample** (`samples` claim field, SPEC §7g), salted per-leaf disclosures (RFC 9901
575
+ mechanic), **sample openings** with replay guards, and a **spot-check audit protocol**
576
+ (`audit-challenge` / `verify-opening` CLI; auditor-nonce, beacon, and documented-grindable
577
+ self-challenge modes; PoR soundness 1−(1−m)^k) — narrowing the THREAT_MODEL's sub-sampling gap.
578
+ Extractors for lm-eval `--log_samples` JSONL and promptfoo rows.
579
+ - **v1.6** — external principal-security review fixes: verify-side sample/`leaf_alg`/root invariants
580
+ and signed `context_binding` enforcement (was signed but unchecked), status-list freshness
581
+ (`exp`/`ttl` None-safe), `merkle.hash_alg` now required; Beta classifier; 26-operator mutation gate.
582
+ - **v1.6.1** — developer experience: `proofbundle demo` (pip-only, fail-closed tamper smoke test), a
583
+ runnable per-sample example, `docs/DEMO.md`.
584
+ - **v1.7 (current release)** — release supply-chain hardening: `release.yml` builds once and
585
+ publishes the **attested** bytes (sha256 gate: attested == published), a reviewer-gated `pypi`
586
+ environment, least-privilege permissions, SHA-pinned actions, `RELEASE.md` + `docs/REVIEWERS.md`.
563
587
  - **Deferred** (explicitly not yet built, stated honestly) — full SD-JWT VC conformance + `vct`
564
588
  type-metadata resolution (pre-IESG), an official in-toto eval predicate (proposal path via
565
589
  OpenSSF/CoSAI), and a full in-toto client.
@@ -15,13 +15,16 @@ revocation snapshots, aligned to the in-toto test-result predicate. One portable
15
15
  file, no server, no network.**
16
16
 
17
17
  [![CI](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml/badge.svg)](https://github.com/b7n0de/proofbundle/actions/workflows/ci.yml)
18
+ [![License: MIT](https://img.shields.io/badge/license-MIT-D6248A.svg)](LICENSE)
19
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
20
+ <!-- Badges below are enabled AFTER the first PyPI release (they render broken/false before it,
21
+ which reads as abandonment). Un-comment on first publish — see RELEASE.md:
18
22
  [![PyPI](https://img.shields.io/pypi/v/proofbundle.svg?color=D6248A&cacheSeconds=3600)](https://pypi.org/project/proofbundle/)
19
23
  [![Python](https://img.shields.io/pypi/pyversions/proofbundle.svg?color=D6248A&cacheSeconds=3600)](https://pypi.org/project/proofbundle/)
20
24
  [![Downloads](https://static.pepy.tech/badge/proofbundle)](https://pepy.tech/project/proofbundle)
21
- [![License: MIT](https://img.shields.io/badge/license-MIT-D6248A.svg)](LICENSE)
22
- [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
23
25
  [![SLSA build provenance](https://img.shields.io/badge/SLSA-build_provenance-D6248A.svg)](https://slsa.dev)
24
26
  [![PyPI attestations](https://img.shields.io/badge/PyPI-attestations_(PEP_740)-D6248A.svg)](https://pypi.org/project/proofbundle/)
27
+ -->
25
28
  [![Post-quantum witnesses](https://img.shields.io/badge/PQ-ML--DSA--44_cosignatures-D6248A.svg)](https://github.com/C2SP/C2SP/blob/main/tlog-cosignature.md)
26
29
  [![Mutation tested](https://img.shields.io/badge/tests-mutation_gated-D6248A.svg)](scripts/mutation_check.py)
27
30
  [![C2SP tlog-proof](https://img.shields.io/badge/C2SP-tlog--proof-D6248A.svg)](https://github.com/C2SP/C2SP/blob/main/tlog-proof.md)
@@ -33,7 +36,7 @@ file, no server, no network.**
33
36
 
34
37
  **At a glance:** `proofbundle emit` signs and anchors a payload; `proofbundle
35
38
  verify` checks one self-contained `bundle.json` with three offline cryptographic
36
- checks → `OK` or `FAILED`. No network, no daemon, no own crypto. 209 tests + a CI mutation gate.
39
+ checks → `OK` or `FAILED`. No network, no daemon, no own crypto. 289 tests + a CI mutation gate.
37
40
 
38
41
  ## Contents
39
42
 
@@ -98,6 +101,8 @@ not a proof that the number is *true* or that the evaluation was well designed.
98
101
  | `reproduced` | independently re-run and matched |
99
102
  | `enclave_attested` | produced in an attested trusted execution environment |
100
103
 
104
+ Since v1.6 `decode_eval_claim(bundle, expected_context=...)` enforces the signed `context_binding` (cross-context replay guard), and verify-side re-checks the `samples.n == n` invariant — guarantees no longer live only in the emitter.
105
+
101
106
  Full detail: **[THREAT_MODEL.md](THREAT_MODEL.md)** — what `verify` catches and what it structurally cannot.
102
107
 
103
108
  ## What it verifies
@@ -175,7 +180,7 @@ pip install "proofbundle[sdjwt]"
175
180
  ## Quickstart
176
181
 
177
182
  ```bash
178
- # generate a real example bundle with throwaway keys
183
+ # from a git checkout (examples/ ships in the repo, not the wheel):
179
184
  python examples/make_example.py
180
185
 
181
186
  # verify it
@@ -186,6 +191,15 @@ proofbundle verify examples/example_bundle.json
186
191
  <img src="assets/demo.svg" alt="proofbundle verify output: PASS checks and OK" width="680">
187
192
  </div>
188
193
 
194
+ See the whole trust story in five seconds — pip-only, offline, in memory (honest receipt verifies, six tampers fail, a swapped sample is caught):
195
+
196
+ ```bash
197
+ pip install proofbundle
198
+ proofbundle demo
199
+ ```
200
+
201
+ Full walkthrough incl. the forced-random-sample audit: **[docs/DEMO.md](docs/DEMO.md)**.
202
+
189
203
  Machine-readable output and a non-zero exit code on failure:
190
204
 
191
205
  ```bash
@@ -279,7 +293,7 @@ Since v1.2 proofbundle also speaks the witness layer:
279
293
  (Ed25519 cosignature/v1) — `verify_witnessed_checkpoint` checks a checkpoint is
280
294
  both log-signed **and** cosigned by a quorum of distinct witnesses, offline,
281
295
  which rules out a split view by the log operator. This is the same
282
- witnessed-checkpoint pattern Rekor v2 (GA October 2025) institutionalizes.
296
+ witnessed-checkpoint pattern Rekor v2 (GA October 2025) is integrating.
283
297
 
284
298
  Since v1.3 the witness layer is complete: **ML-DSA-44 cosignatures** (C2SP type
285
299
  0x06, FIPS 204 — post-quantum, the spec's SHOULD for new witness deployments;
@@ -503,15 +517,25 @@ ISO/IEC DIS 24970), including the anti-patterns no one should claim.
503
517
  Status List** snapshot verification (offline revocation), SD-JWT VC markers (`dc+sd-jwt`,
504
518
  `vct`, `status`), COMPLIANCE.md (EU AI Act Art. 12 / NIST AI RMF mapping), and the mutation
505
519
  suite as a CI gate.
506
- - **v1.4 (current release)** — distribution: a **promptfoo** adapter (results.json v3 →
520
+ - **v1.4** — distribution: a **promptfoo** adapter (results.json v3 →
507
521
  pass_rate receipt), the **Hugging Face Community Evals bridge** (`pb1.` receipt tokens for the
508
522
  `.eval_results` `verifyToken` field + a strict YAML entry emitter — proofbundle-verifiable,
509
523
  explicitly NOT the HF-internal badge token), `hf-token` CLI, and the **Python 3.10+ floor**
510
524
  (3.9 is EOL since 2025-10).
511
- - **v2.0 (next major, research differentiation)** — **per-sample Merkle receipts**: the payload carries a
512
- Merkle root over *individual* sample results, so a verifier can force a random-sample re-check instead of
513
- trusting only the reported aggregate. This is the THREAT_MODEL's one named structural gap ("forced random
514
- sub-sampling"), and closing it is the road from *authorship* to *sampled correctness*.
525
+ - **v1.5** — per-sample receipts: a signed **Merkle commitment over every
526
+ individual sample** (`samples` claim field, SPEC §7g), salted per-leaf disclosures (RFC 9901
527
+ mechanic), **sample openings** with replay guards, and a **spot-check audit protocol**
528
+ (`audit-challenge` / `verify-opening` CLI; auditor-nonce, beacon, and documented-grindable
529
+ self-challenge modes; PoR soundness 1−(1−m)^k) — narrowing the THREAT_MODEL's sub-sampling gap.
530
+ Extractors for lm-eval `--log_samples` JSONL and promptfoo rows.
531
+ - **v1.6** — external principal-security review fixes: verify-side sample/`leaf_alg`/root invariants
532
+ and signed `context_binding` enforcement (was signed but unchecked), status-list freshness
533
+ (`exp`/`ttl` None-safe), `merkle.hash_alg` now required; Beta classifier; 26-operator mutation gate.
534
+ - **v1.6.1** — developer experience: `proofbundle demo` (pip-only, fail-closed tamper smoke test), a
535
+ runnable per-sample example, `docs/DEMO.md`.
536
+ - **v1.7 (current release)** — release supply-chain hardening: `release.yml` builds once and
537
+ publishes the **attested** bytes (sha256 gate: attested == published), a reviewer-gated `pypi`
538
+ environment, least-privilege permissions, SHA-pinned actions, `RELEASE.md` + `docs/REVIEWERS.md`.
515
539
  - **Deferred** (explicitly not yet built, stated honestly) — full SD-JWT VC conformance + `vct`
516
540
  type-metadata resolution (pre-IESG), an official in-toto eval predicate (proposal path via
517
541
  OpenSSF/CoSAI), and a full in-toto client.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proofbundle"
7
- version = "1.4.0"
7
+ version = "1.8.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,7 +22,7 @@ keywords = [
22
22
  "rfc6962",
23
23
  ]
24
24
  classifiers = [
25
- "Development Status :: 3 - Alpha",
25
+ "Development Status :: 4 - Beta",
26
26
  "Intended Audience :: Developers",
27
27
  "License :: OSI Approved :: MIT License",
28
28
  "Programming Language :: Python :: 3",
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  from typing import TYPE_CHECKING
15
15
 
16
- __version__ = "1.4.0"
16
+ __version__ = "1.8.0"
17
17
 
18
18
  __all__ = [
19
19
  "__version__",
@@ -32,6 +32,12 @@ __all__ = [
32
32
  "verify_status_snapshot",
33
33
  "receipt_token",
34
34
  "verify_receipt_token",
35
+ "build_sample_tree",
36
+ "sample_opening",
37
+ "verify_sample_opening",
38
+ "audit_challenge",
39
+ "prereg_hash",
40
+ "verify_prereg",
35
41
  "VerificationResult",
36
42
  "Check",
37
43
  "ProofBundleError",
@@ -51,6 +57,12 @@ _LAZY = {
51
57
  "verify_status_snapshot": ".statuslist",
52
58
  "receipt_token": ".hf_evals",
53
59
  "verify_receipt_token": ".hf_evals",
60
+ "build_sample_tree": ".persample",
61
+ "sample_opening": ".persample",
62
+ "verify_sample_opening": ".persample",
63
+ "audit_challenge": ".persample",
64
+ "prereg_hash": ".prereg",
65
+ "verify_prereg": ".prereg",
54
66
  }
55
67
 
56
68
  if TYPE_CHECKING: # static analysers + IDEs see the real names/types; runtime stays lazy
@@ -60,6 +72,9 @@ if TYPE_CHECKING: # static analysers + IDEs see the real names/types; runtime s
60
72
  from .checkpoint import verify_cosignature, verify_witnessed_checkpoint
61
73
  from .kbjwt import verify_key_binding
62
74
  from .hf_evals import receipt_token, verify_receipt_token
75
+ from .persample import (audit_challenge, build_sample_tree, sample_opening,
76
+ verify_sample_opening)
77
+ from .prereg import prereg_hash, verify_prereg
63
78
  from .statuslist import verify_status_snapshot
64
79
  from .tlogproof import verify_tlog_proof
65
80
  from .merkle import verify_consistency, verify_inclusion
@@ -8,6 +8,8 @@ from .inspect_ai import from_inspect_ai_log
8
8
  from .eee import from_eee_dataset
9
9
  from .lm_eval import from_lm_eval_results
10
10
  from .promptfoo import from_promptfoo_results
11
+ from .samples import samples_from_lm_eval_jsonl, samples_from_promptfoo_results
11
12
 
12
13
  __all__ = ["from_lm_eval_results", "from_inspect_ai_log", "from_eee_dataset",
13
- "from_promptfoo_results"]
14
+ "from_promptfoo_results",
15
+ "samples_from_lm_eval_jsonl", "samples_from_promptfoo_results"]
@@ -0,0 +1,63 @@
1
+ """Shared provenance helpers for adapters (v1.8).
2
+
3
+ The external review found that run-id and config-hash were missing from nearly every adapter and
4
+ that the two flagship adapters took the timestamp from the caller rather than the eval log. These
5
+ helpers close that: each adapter now records, where the framework exposes it, a stable RUN id, a
6
+ CONFIG hash, and the LOG-NATIVE timestamp — so a receipt is traceable back to the exact run.
7
+
8
+ Design notes (verified against framework source, 2026-07):
9
+ - No framework ships a canonical config hash, so we compute our own. Config is an in-memory
10
+ object re-serialized non-deterministically, so it MUST be canonicalized before hashing —
11
+ RFC 8785 JCS via the same `rfc8785` extra the emit path already needs. If that extra is
12
+ absent we fall back to a deterministic `json.dumps(sort_keys=True)` and LABEL the hash
13
+ algorithm accordingly, so a verifier is never misled about how the hash was formed.
14
+ - The hash is over the config's JSON, prefixed with a domain tag, hex sha256. It is provenance
15
+ metadata (traceability), NOT a security commitment — it is not salted and reveals structure;
16
+ it exists so two receipts from the same config are linkable and a changed config is visible.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ from typing import Optional
24
+
25
+ _CONFIG_DOMAIN = b"proofbundle/v1.8/config-hash\x00"
26
+
27
+
28
+ def config_hash(config) -> Optional[str]:
29
+ """Return ``"<alg>:<hex>"`` over the canonical JSON of a config object, or None if it is
30
+ empty/None. ``<alg>`` is ``sha256-jcs`` when RFC 8785 is available, else ``sha256-sortkeys``
31
+ (both deterministic; the label tells a verifier which normalization produced the hex)."""
32
+ if config is None or config == {} or config == []:
33
+ return None
34
+ try:
35
+ import rfc8785 # noqa: PLC0415 — same optional dep as the emit path
36
+ canonical = rfc8785.dumps(config)
37
+ alg = "sha256-jcs"
38
+ except (ImportError, ValueError, TypeError):
39
+ # rfc8785 rejects non-JCS-able values (e.g. floats it deems unsafe); fall back to a
40
+ # deterministic stdlib serialization and label it so the difference is never hidden.
41
+ try:
42
+ canonical = json.dumps(config, sort_keys=True, separators=(",", ":"),
43
+ ensure_ascii=False).encode("utf-8")
44
+ except (TypeError, ValueError):
45
+ return None
46
+ alg = "sha256-sortkeys"
47
+ return f"{alg}:{hashlib.sha256(_CONFIG_DOMAIN + canonical).hexdigest()}"
48
+
49
+
50
+ def add_provenance(provenance: dict, *, run_id=None, config=None, log_timestamp=None,
51
+ config_hash_value: Optional[str] = None) -> dict:
52
+ """Merge the standard traceability fields into a provenance dict, skipping absent ones.
53
+
54
+ ``config_hash_value`` lets a caller pass a precomputed hash (e.g. over already-canonical
55
+ material) instead of a config object; otherwise ``config`` is hashed here."""
56
+ if run_id:
57
+ provenance["run_id"] = str(run_id)
58
+ if log_timestamp is not None:
59
+ provenance["run_timestamp"] = str(log_timestamp)
60
+ ch = config_hash_value if config_hash_value is not None else config_hash(config)
61
+ if ch:
62
+ provenance["config_hash"] = ch
63
+ return provenance
@@ -5,7 +5,7 @@ it has no cryptography. This converter is strictly additive: it reads an EEE agg
5
5
  signed, selectively-disclosable proofbundle receipt from it.
6
6
 
7
7
  IMPORTANT: `every_eval_ever` is NOT imported at runtime — it requires Python 3.12+ (pydantic/numpy/pandas/
8
- duckdb), while proofbundle stays 3.9+. We parse the EEE JSON directly and OPTIONALLY validate it against the
8
+ duckdb), while proofbundle stays 3.10+. We parse the EEE JSON directly and OPTIONALLY validate it against the
9
9
  vendored `eee_eval_schema.json` (schema version 0.2.2, MIT) using `jsonschema` if available.
10
10
 
11
11
  Field mapping (verified 2026-07 against schemas/eval.schema.json v0.2.2):
@@ -90,6 +90,15 @@ def from_inspect_ai_log(path, metric: str, *, comparator: str, threshold: str, t
90
90
  if tv is not None:
91
91
  provenance["task_version"] = str(tv)
92
92
 
93
+ # v1.8 (external review): run-id + config-hash + LOG-NATIVE timestamp so a receipt traces back
94
+ # to the exact run. inspect_ai: eval.run_id (unique run id), eval.created (UTC datetime string),
95
+ # eval.task_args (the config material — no native config hash exists, so we compute one).
96
+ from ._provenance import add_provenance # noqa: PLC0415
97
+ task_args = getattr(ev, "task_args", None)
98
+ add_provenance(provenance, run_id=getattr(ev, "run_id", None),
99
+ config=task_args if isinstance(task_args, dict) else None,
100
+ log_timestamp=getattr(ev, "created", None))
101
+
93
102
  return build_eval_claim(
94
103
  suite=suite, suite_version=str(getattr(ev, "task_version", "1")),
95
104
  metric=metric, comparator=comparator, threshold=threshold, score=_score_str(value),
@@ -71,6 +71,16 @@ def from_lm_eval_results(path, task: str, metric: str, *, comparator: str, thres
71
71
  if stderr is not None:
72
72
  provenance["stderr"] = repr(stderr) if not isinstance(stderr, str) else stderr
73
73
 
74
+ # v1.8 (external review): config-hash + LOG-NATIVE timestamp. lm-eval has no dedicated run-id;
75
+ # its `date` is a Unix float (distinct from the ISO filename stamp). `config` is the run config
76
+ # block (model/args/seeds/gen_kwargs); no native hash exists, so we compute one.
77
+ from ._provenance import add_provenance # noqa: PLC0415
78
+ add_provenance(provenance, config=cfg if isinstance(cfg, dict) and cfg else None,
79
+ log_timestamp=data.get("date"))
80
+ task_hashes = data.get("task_hashes", {})
81
+ if isinstance(task_hashes, dict) and task_hashes.get(task):
82
+ provenance["task_hash"] = str(task_hashes[task]) # lm-eval's native per-task sample hash
83
+
74
84
  return build_eval_claim(
75
85
  suite=task, suite_version=str(data.get("versions", {}).get(task, "lm-eval")),
76
86
  metric=metric, comparator=comparator, threshold=threshold, score=str(score), n=n,
@@ -124,6 +124,13 @@ def from_promptfoo_results(path, *, comparator: str, threshold: str, timestamp:
124
124
  if summary.get("timestamp"):
125
125
  provenance["run_timestamp"] = str(summary["timestamp"])
126
126
 
127
+ # v1.8 (external review): a uniform run_id key across adapters + a config-hash over the FULL
128
+ # resolved suite config (providers/prompts/tests/…), not just the tests-derived dataset id.
129
+ from ._provenance import add_provenance # noqa: PLC0415
130
+ add_provenance(provenance, run_id=eval_id,
131
+ config=config if isinstance(config, dict) and config else None,
132
+ log_timestamp=metadata.get("evaluationCreatedAt"))
133
+
127
134
  return build_eval_claim(
128
135
  suite=suite, suite_version=f"promptfoo-summary-v{version}",
129
136
  metric="pass_rate", comparator=comparator, threshold=threshold, score=score, n=n,
@@ -0,0 +1,82 @@
1
+ """Per-sample record extractors for the v1.5 sample tree (file-based, no framework imports).
2
+
3
+ Each extractor maps a framework's per-sample export into small, canonical leaf records for
4
+ :func:`proofbundle.persample.build_sample_tree`. Records are returned in canonical order
5
+ (sorted by the framework's stable sample identity) — the tree builder assigns and embeds `idx`.
6
+
7
+ Privacy layering (two-layer hiding, per the v1.5 design): records carry the framework's
8
+ CONTENT HASHES (or compact result fields), never benchmark plaintext — so opening a sample for
9
+ audit reveals the model's result without necessarily revealing the benchmark item's text.
10
+ lm-evaluation-harness conveniently already emits per-doc `doc_hash`/`prompt_hash`/`target_hash`
11
+ (SHA-256, verified against lm_eval/evaluator.py) — note these are UNSALTED upstream hashes and
12
+ therefore linkable on their own; hiding comes from the salted disclosure wrapping them, which is
13
+ why they go INSIDE the leaf, never beside it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from pathlib import Path
20
+ from typing import List
21
+
22
+
23
+ def samples_from_lm_eval_jsonl(path) -> List[dict]:
24
+ """Read an lm-evaluation-harness ``--log_samples`` JSONL (samples_<task>_*.jsonl) into leaf
25
+ records: (doc_id, filter, doc/prompt/target hashes, filtered responses, metric values).
26
+
27
+ Sorted by (doc_id, filter). Metric values are stringified (leaves are transport artifacts;
28
+ numeric canonicalization stays out of the commitment)."""
29
+ records = []
30
+ for line_no, line in enumerate(Path(path).read_text(encoding="utf-8").splitlines(), 1):
31
+ if not line.strip():
32
+ continue
33
+ try:
34
+ row = json.loads(line)
35
+ except json.JSONDecodeError as exc:
36
+ raise ValueError(f"line {line_no}: not valid JSON") from exc
37
+ if not isinstance(row, dict) or "doc_id" not in row:
38
+ raise ValueError(f"line {line_no}: not an lm-eval sample row")
39
+ doc_id = row.get("doc_id")
40
+ if isinstance(doc_id, bool) or not isinstance(doc_id, int):
41
+ raise ValueError(f"line {line_no}: doc_id missing or not an integer")
42
+ metrics = row.get("metrics") or []
43
+ rec = {"id": doc_id, "epoch": 1, "filter": str(row.get("filter", "none")),
44
+ "doc_hash": str(row.get("doc_hash", "")),
45
+ "prompt_hash": str(row.get("prompt_hash", "")),
46
+ "target_hash": str(row.get("target_hash", "")),
47
+ "filtered_resps": [str(r) for r in (row.get("filtered_resps") or [])],
48
+ "metrics": {m: str(row[m]) for m in metrics if m in row}}
49
+ records.append(rec)
50
+ if not records:
51
+ raise ValueError("no sample rows found")
52
+ records.sort(key=lambda r: (r["id"], r["filter"]))
53
+ return records
54
+
55
+
56
+ def samples_from_promptfoo_results(path) -> List[dict]:
57
+ """Read a promptfoo results.json (summary v3) into leaf records:
58
+ (testIdx, promptIdx, provider, success, score). Sorted by (testIdx, promptIdx, provider)."""
59
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
60
+ summary = data.get("results")
61
+ if not isinstance(summary, dict) or summary.get("version") != 3:
62
+ raise ValueError("not a promptfoo v3 output file (see adapters.promptfoo)")
63
+ records = []
64
+ for i, row in enumerate(summary.get("results") or []):
65
+ if not isinstance(row, dict):
66
+ raise ValueError(f"results[{i}] is not an object")
67
+ test_idx, prompt_idx = row.get("testIdx"), row.get("promptIdx")
68
+ for name, val in (("testIdx", test_idx), ("promptIdx", prompt_idx)):
69
+ if isinstance(val, bool) or not isinstance(val, int):
70
+ raise ValueError(f"results[{i}].{name} missing or not an integer")
71
+ success = row.get("success")
72
+ if not isinstance(success, bool):
73
+ raise ValueError(f"results[{i}].success missing or not a boolean")
74
+ prov = row.get("provider")
75
+ provider = str(prov.get("id")) if isinstance(prov, dict) else str(prov or "unknown")
76
+ records.append({"id": test_idx, "epoch": 1, "prompt_idx": prompt_idx,
77
+ "provider": provider, "success": success,
78
+ "score": str(row.get("score", ""))})
79
+ if not records:
80
+ raise ValueError("no result rows found")
81
+ records.sort(key=lambda r: (r["id"], r["prompt_idx"], r["provider"]))
82
+ return records
@@ -132,7 +132,9 @@ def verify_bundle(bundle: Union[dict, str], *, expected_aud=None, expected_nonce
132
132
  # 2. merkle inclusion of the payload
133
133
  mk = _require_dict(_require(bundle, "merkle", "merkle"), "merkle")
134
134
  _reject_unknown(mk, _MERKLE_KEYS, "merkle")
135
- hash_alg = mk.get("hash_alg", "sha256-rfc6962")
135
+ # v1.6 (external review): hash_alg is REQUIRED — the emitter always writes it, and a
136
+ # silent default is exactly where a future multi-alg version would hide an alg-confusion.
137
+ hash_alg = _require(mk, "hash_alg", "merkle.hash_alg")
136
138
  if hash_alg != "sha256-rfc6962":
137
139
  raise UnsupportedError(f"merkle hash_alg {hash_alg!r} not supported in v0.1")
138
140
  leaf_index = _require_int(mk, "leaf_index", "merkle.leaf_index")
@@ -189,6 +191,20 @@ def verify_bundle(bundle: Union[dict, str], *, expected_aud=None, expected_nonce
189
191
  "sd-jwt-key-binding", False,
190
192
  "issuer bound a holder key (cnf) but the presentation carries NO Key Binding JWT — "
191
193
  "required proof-of-possession is missing (bearer downgrade, RFC 9901 §4.3)")
194
+ elif not sd_res.get("sig_checked"):
195
+ # v1.6 fail-closed (external review, CRITICAL): gating holder-binding on issuer
196
+ # verification opened a KEY-OMISSION downgrade — strip the KB-JWT AND drop
197
+ # issuer_public_key_b64, and a cnf-bound credential silently passed as a bearer
198
+ # token. A `cnf`-carrying SD-JWT whose issuer cannot be verified is REFUSED, never
199
+ # "structure-only passed". SD-JWTs without `cnf` keep the documented no-key
200
+ # backward-compat path unchanged.
201
+ sd_part, _ = split_key_binding(compact)
202
+ if _issuer_requires_holder_binding(sd_part):
203
+ result.add(
204
+ "sd-jwt-key-binding", False,
205
+ "SD-JWT declares a cnf holder key but NO issuer key was supplied — holder "
206
+ "binding is unverifiable, refusing (fail-closed; supply "
207
+ "sd_jwt_vc.issuer_public_key_b64)")
192
208
 
193
209
  return result
194
210
 
@@ -208,9 +224,9 @@ def recompute_merkle_root_b64(bundle: Union[dict, str]) -> dict:
208
224
  raise BundleFormatError("bundle must be a JSON object")
209
225
  payload = _b64d(_require(bundle, "payload_b64", "payload_b64"), "payload_b64")
210
226
  mk = _require_dict(_require(bundle, "merkle", "merkle"), "merkle")
211
- # Validate hash_alg the same way verify_bundle does — recompute must not apply SHA-256/RFC-6962 primitives
212
- # to a bundle that declares a different algorithm (audit LOW #11/#14).
213
- hash_alg = mk.get("hash_alg", "sha256-rfc6962")
227
+ # Validate hash_alg the SAME way verify_bundle does — REQUIRED, not silently defaulted (release-review #13:
228
+ # the docstring claimed strict-as-verify_bundle but this defaulted a missing hash_alg; verify_bundle _require's it).
229
+ hash_alg = _require(mk, "hash_alg", "merkle.hash_alg")
214
230
  if hash_alg != "sha256-rfc6962":
215
231
  raise UnsupportedError(f"merkle hash_alg {hash_alg!r} not supported in v0.1")
216
232
  leaf_index = _require_int(mk, "leaf_index", "merkle.leaf_index")