proofbundle 1.4.0__tar.gz → 1.7.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 (75) hide show
  1. {proofbundle-1.4.0/src/proofbundle.egg-info → proofbundle-1.7.0}/PKG-INFO +36 -12
  2. {proofbundle-1.4.0 → proofbundle-1.7.0}/README.md +34 -10
  3. {proofbundle-1.4.0 → proofbundle-1.7.0}/pyproject.toml +2 -2
  4. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/__init__.py +11 -1
  5. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/adapters/__init__.py +3 -1
  6. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/adapters/eee.py +1 -1
  7. proofbundle-1.7.0/src/proofbundle/adapters/samples.py +82 -0
  8. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/bundle.py +20 -4
  9. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/cli.py +83 -4
  10. proofbundle-1.7.0/src/proofbundle/demo.py +153 -0
  11. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/evalclaim.py +60 -6
  12. proofbundle-1.7.0/src/proofbundle/persample.py +270 -0
  13. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/statuslist.py +18 -6
  14. {proofbundle-1.4.0 → proofbundle-1.7.0/src/proofbundle.egg-info}/PKG-INFO +36 -12
  15. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle.egg-info/SOURCES.txt +5 -0
  16. proofbundle-1.7.0/tests/test_demo.py +54 -0
  17. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_evalclaim.py +30 -0
  18. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_kbjwt.py +24 -6
  19. proofbundle-1.7.0/tests/test_persample.py +418 -0
  20. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_statuslist.py +36 -0
  21. {proofbundle-1.4.0 → proofbundle-1.7.0}/LICENSE +0 -0
  22. {proofbundle-1.4.0 → proofbundle-1.7.0}/setup.cfg +0 -0
  23. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/_inspect_registry.py +0 -0
  24. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/_integration.py +0 -0
  25. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/adapters/inspect_ai.py +0 -0
  26. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/adapters/lm_eval.py +0 -0
  27. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/adapters/promptfoo.py +0 -0
  28. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/checkpoint.py +0 -0
  29. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/dsse.py +0 -0
  30. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/eee_eval_schema.json +0 -0
  31. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/emit.py +0 -0
  32. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/errors.py +0 -0
  33. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/hf_evals.py +0 -0
  34. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/inspect_hook.py +0 -0
  35. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/intoto.py +0 -0
  36. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/kbjwt.py +0 -0
  37. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/merkle.py +0 -0
  38. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/py.typed +0 -0
  39. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/pytest_plugin.py +0 -0
  40. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/sdjwt.py +0 -0
  41. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/sdjwt_issue.py +0 -0
  42. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/signature.py +0 -0
  43. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle/tlogproof.py +0 -0
  44. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle.egg-info/dependency_links.txt +0 -0
  45. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle.egg-info/entry_points.txt +0 -0
  46. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle.egg-info/requires.txt +0 -0
  47. {proofbundle-1.4.0 → proofbundle-1.7.0}/src/proofbundle.egg-info/top_level.txt +0 -0
  48. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_adapters.py +0 -0
  49. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_adversarial.py +0 -0
  50. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_bundle.py +0 -0
  51. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_bundle_robustness.py +0 -0
  52. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_checkpoint.py +0 -0
  53. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_cli.py +0 -0
  54. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_cli_eval.py +0 -0
  55. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_cosignature.py +0 -0
  56. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_cosignature_mldsa.py +0 -0
  57. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_eee.py +0 -0
  58. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_emit.py +0 -0
  59. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_eval_claim_schema.py +0 -0
  60. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_examples.py +0 -0
  61. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_hf_evals.py +0 -0
  62. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_inspect_hook.py +0 -0
  63. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_intoto.py +0 -0
  64. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_intoto_dsse.py +0 -0
  65. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_merkle.py +0 -0
  66. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_merkle_property.py +0 -0
  67. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_promptfoo.py +0 -0
  68. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_pytest_plugin.py +0 -0
  69. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_rekor_interop.py +0 -0
  70. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_rfc6962_external_vectors.py +0 -0
  71. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_schema.py +0 -0
  72. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_sdjwt_issue.py +0 -0
  73. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_sdjwt_reference.py +0 -0
  74. {proofbundle-1.4.0 → proofbundle-1.7.0}/tests/test_signature.py +0 -0
  75. {proofbundle-1.4.0 → proofbundle-1.7.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.7.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. 257 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. 257 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.7.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.7.0"
17
17
 
18
18
  __all__ = [
19
19
  "__version__",
@@ -32,6 +32,10 @@ __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",
35
39
  "VerificationResult",
36
40
  "Check",
37
41
  "ProofBundleError",
@@ -51,6 +55,10 @@ _LAZY = {
51
55
  "verify_status_snapshot": ".statuslist",
52
56
  "receipt_token": ".hf_evals",
53
57
  "verify_receipt_token": ".hf_evals",
58
+ "build_sample_tree": ".persample",
59
+ "sample_opening": ".persample",
60
+ "verify_sample_opening": ".persample",
61
+ "audit_challenge": ".persample",
54
62
  }
55
63
 
56
64
  if TYPE_CHECKING: # static analysers + IDEs see the real names/types; runtime stays lazy
@@ -60,6 +68,8 @@ if TYPE_CHECKING: # static analysers + IDEs see the real names/types; runtime s
60
68
  from .checkpoint import verify_cosignature, verify_witnessed_checkpoint
61
69
  from .kbjwt import verify_key_binding
62
70
  from .hf_evals import receipt_token, verify_receipt_token
71
+ from .persample import (audit_challenge, build_sample_tree, sample_opening,
72
+ verify_sample_opening)
63
73
  from .statuslist import verify_status_snapshot
64
74
  from .tlogproof import verify_tlog_proof
65
75
  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"]
@@ -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):
@@ -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")
@@ -48,11 +48,15 @@ def _cmd_emit_eval(args: argparse.Namespace) -> int:
48
48
 
49
49
 
50
50
  def _cmd_show_eval(args: argparse.Namespace) -> int:
51
+ from .bundle import load_bundle # noqa: PLC0415
51
52
  from .evalclaim import ( # noqa: PLC0415
52
53
  DEFAULT_ASSURANCE, check_freshness, claim_warnings, decode_eval_claim, sd_jwt_hidden_count,
53
54
  )
54
55
  try:
55
- claim = decode_eval_claim(args.receipt)
56
+ # Resolve the path to a dict ONCE and pass that object to every reader — a second per-function re-read of
57
+ # the same path would reopen a TOCTOU window (CWE-367) between the reads. Release-review fix 2026-07-02.
58
+ bundle = load_bundle(args.receipt)
59
+ claim = decode_eval_claim(bundle, expected_context=getattr(args, "context", None))
56
60
  except (OSError, ValueError, ProofBundleError) as exc: # missing/invalid receipt file → clean exit, not a traceback
57
61
  print(f"ERROR: {exc}", file=sys.stderr)
58
62
  return 2
@@ -67,7 +71,7 @@ def _cmd_show_eval(args: argparse.Namespace) -> int:
67
71
  print(f"dataset commit {claim['dataset_id_commit']}")
68
72
  print(f"issuer {claim['issuer']}")
69
73
  print(f"timestamp {claim['timestamp']}")
70
- hidden = sd_jwt_hidden_count(args.receipt)
74
+ hidden = sd_jwt_hidden_count(bundle)
71
75
  if hidden is not None:
72
76
  print(f"sd-jwt {hidden} field(s) withheld (selective disclosure)")
73
77
  fresh = check_freshness(claim)
@@ -80,10 +84,14 @@ def _cmd_show_eval(args: argparse.Namespace) -> int:
80
84
 
81
85
 
82
86
  def _cmd_verify(args: argparse.Namespace) -> int:
87
+ from .bundle import load_bundle # noqa: PLC0415
83
88
  try:
84
- result = verify_bundle(args.bundle, expected_aud=getattr(args, "aud", None),
89
+ # Resolve the path to a dict ONCE and pass it to both verify_bundle and recompute — a second per-function
90
+ # re-read of the same path reopens a TOCTOU window (release-review consistency fix, mirrors show-eval).
91
+ bundle = load_bundle(args.bundle)
92
+ result = verify_bundle(bundle, expected_aud=getattr(args, "aud", None),
85
93
  expected_nonce=getattr(args, "nonce", None))
86
- roots = recompute_merkle_root_b64(args.bundle) if args.verbose else None
94
+ roots = recompute_merkle_root_b64(bundle) if args.verbose else None
87
95
  except (ProofBundleError, OSError, ValueError) as exc: # file/JSON/format errors → clean exit, never a raw traceback
88
96
  if args.json:
89
97
  print(json.dumps({"ok": False, "error": str(exc)}))
@@ -177,6 +185,50 @@ def _cmd_hf_token(args: argparse.Namespace) -> int:
177
185
  return 2
178
186
 
179
187
 
188
+ def _cmd_audit_challenge(args: argparse.Namespace) -> int:
189
+ from .persample import audit_challenge # noqa: PLC0415
190
+ try:
191
+ nonce = bytes.fromhex(args.nonce) if args.nonce else b""
192
+ indices = audit_challenge(args.root, args.n, args.k, nonce)
193
+ except (ProofBundleError, ValueError) as exc:
194
+ print(f"ERROR: {exc}", file=sys.stderr)
195
+ return 2
196
+ if args.json:
197
+ print(json.dumps({"indices": indices, "n": args.n, "k": args.k,
198
+ "mode": "auditor-nonce" if args.nonce else "self-challenge"}))
199
+ else:
200
+ if not args.nonce:
201
+ print("WARNING: self-challenge mode (no --nonce) is a sanity check only — "
202
+ "a producer can grind by re-salting; real audits supply a fresh nonce",
203
+ file=sys.stderr)
204
+ print(" ".join(str(i) for i in indices))
205
+ return 0
206
+
207
+
208
+ def _cmd_verify_opening(args: argparse.Namespace) -> int:
209
+ from .persample import verify_sample_opening # noqa: PLC0415
210
+ try:
211
+ with open(args.opening, encoding="utf-8") as handle:
212
+ opening = json.load(handle)
213
+ res = verify_sample_opening(opening, args.root, args.n)
214
+ except (ProofBundleError, OSError, ValueError) as exc:
215
+ print(f"ERROR: {exc}", file=sys.stderr)
216
+ return 2
217
+ if args.json:
218
+ print(json.dumps(res))
219
+ else:
220
+ print(f"[{'PASS' if res['ok'] else 'FAIL'}] sample-opening: {res['detail']}")
221
+ if res["ok"]:
222
+ print(json.dumps(res["record"], indent=2))
223
+ print("=> OK" if res["ok"] else "=> FAILED")
224
+ return 0 if res["ok"] else 1
225
+
226
+
227
+ def _cmd_demo(args: argparse.Namespace) -> int:
228
+ from .demo import run_demo # noqa: PLC0415
229
+ return run_demo(as_json=args.json)
230
+
231
+
180
232
  def build_parser() -> argparse.ArgumentParser:
181
233
  parser = argparse.ArgumentParser(
182
234
  prog="proofbundle",
@@ -213,6 +265,8 @@ def build_parser() -> argparse.ArgumentParser:
213
265
 
214
266
  show_eval = sub.add_parser("show-eval", help="verify an eval receipt and print the claim")
215
267
  show_eval.add_argument("receipt", help="path to the eval receipt bundle JSON")
268
+ show_eval.add_argument("--context", dest="context", default=None,
269
+ help="require the receipt's signed context_binding to equal this (cross-context replay guard)")
216
270
  show_eval.set_defaults(func=_cmd_show_eval)
217
271
 
218
272
  verify_proof = sub.add_parser(
@@ -238,6 +292,31 @@ def build_parser() -> argparse.ArgumentParser:
238
292
  help="verify a pb1. token instead of emitting one")
239
293
  hf_token.set_defaults(func=_cmd_hf_token)
240
294
 
295
+ challenge = sub.add_parser(
296
+ "audit-challenge",
297
+ help="derive k audit indices from a samples root (v1.5; supply --nonce for real audits)")
298
+ challenge.add_argument("root", help="the receipt's samples root (base64)")
299
+ challenge.add_argument("n", type=int, help="committed sample count")
300
+ challenge.add_argument("k", type=int, help="number of samples to challenge")
301
+ challenge.add_argument("--nonce", help="fresh auditor nonce (hex, >=32 hex chars recommended)")
302
+ challenge.add_argument("--json", action="store_true", help="machine readable output")
303
+ challenge.set_defaults(func=_cmd_audit_challenge)
304
+
305
+ verify_opening = sub.add_parser(
306
+ "verify-opening", help="verify one sample opening against a samples root (v1.5)")
307
+ verify_opening.add_argument("opening", help="opening JSON file (index/disclosure/proof_b64)")
308
+ verify_opening.add_argument("--root", required=True, help="the receipt's samples root (base64)")
309
+ verify_opening.add_argument("--n", required=True, type=int, help="committed sample count")
310
+ verify_opening.add_argument("--json", action="store_true", help="machine readable output")
311
+ verify_opening.set_defaults(func=_cmd_verify_opening)
312
+
313
+ demo = sub.add_parser(
314
+ "demo",
315
+ help="run the whole trust story in memory (pip-only, offline): honest receipt verifies, "
316
+ "six tampers fail, a swapped sample is caught")
317
+ demo.add_argument("--json", action="store_true", help="machine readable output")
318
+ demo.set_defaults(func=_cmd_demo)
319
+
241
320
  return parser
242
321
 
243
322