proofbundle 2.1.0__tar.gz → 3.0.1__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 (139) hide show
  1. {proofbundle-2.1.0/src/proofbundle.egg-info → proofbundle-3.0.1}/PKG-INFO +88 -46
  2. {proofbundle-2.1.0 → proofbundle-3.0.1}/README.md +87 -45
  3. {proofbundle-2.1.0 → proofbundle-3.0.1}/pyproject.toml +4 -1
  4. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/__init__.py +2 -2
  5. proofbundle-3.0.1/src/proofbundle/_strict_json.py +72 -0
  6. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/adapters/eee.py +75 -0
  7. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/anchors.py +66 -9
  8. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/anchors_chia.py +2 -2
  9. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/anchors_markovian.py +29 -12
  10. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/anchors_ots.py +44 -20
  11. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/anchors_rfc3161.py +58 -20
  12. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/bundle.py +91 -8
  13. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/checkpoint.py +2 -2
  14. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/cli.py +262 -41
  15. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/decision.py +101 -14
  16. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/evalclaim.py +21 -12
  17. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/experimental/enclave.py +7 -3
  18. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/hf_evals.py +91 -7
  19. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/intoto.py +69 -29
  20. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/kbjwt.py +12 -3
  21. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/merkle.py +9 -1
  22. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/persample.py +8 -1
  23. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/policy.py +233 -7
  24. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/sdjwt.py +19 -3
  25. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/sdjwt_issue.py +25 -12
  26. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/statuslist.py +10 -3
  27. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/tlogproof.py +5 -0
  28. {proofbundle-2.1.0 → proofbundle-3.0.1/src/proofbundle.egg-info}/PKG-INFO +88 -46
  29. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle.egg-info/SOURCES.txt +14 -0
  30. proofbundle-3.0.1/tests/test_anchor_target_trustedtime.py +209 -0
  31. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_anchors_markovian.py +25 -12
  32. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_anchors_ots.py +27 -17
  33. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_anchors_rfc3161.py +57 -33
  34. proofbundle-3.0.1/tests/test_claims_hygiene.py +194 -0
  35. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_cli_require_anchor.py +60 -0
  36. proofbundle-3.0.1/tests/test_conformance.py +167 -0
  37. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_decision_anchors.py +40 -0
  38. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_decision_policy.py +2 -1
  39. proofbundle-3.0.1/tests/test_decision_validator_api.py +82 -0
  40. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_decision_verify.py +68 -0
  41. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_docs_truth.py +50 -6
  42. proofbundle-3.0.1/tests/test_dup_key_reject.py +319 -0
  43. proofbundle-3.0.1/tests/test_ed25519_semantics.py +99 -0
  44. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_evalclaim.py +17 -0
  45. proofbundle-3.0.1/tests/test_hf_entry_verify_eee_digest.py +195 -0
  46. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_kbjwt.py +11 -6
  47. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_ok_semantics.py +43 -8
  48. proofbundle-3.0.1/tests/test_oversized_int_guard.py +41 -0
  49. proofbundle-3.0.1/tests/test_pinned_key_validation.py +76 -0
  50. proofbundle-3.0.1/tests/test_policy_explain_lint.py +192 -0
  51. proofbundle-3.0.1/tests/test_predicate_type_enforcement.py +102 -0
  52. proofbundle-3.0.1/tests/test_release_integrity_gate.py +88 -0
  53. proofbundle-3.0.1/tests/test_sdjwt_duplicate_cnf.py +181 -0
  54. proofbundle-3.0.1/tests/test_sdjwt_verify_binding.py +168 -0
  55. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_trust_policy.py +7 -4
  56. proofbundle-2.1.0/tests/test_claims_hygiene.py +0 -48
  57. {proofbundle-2.1.0 → proofbundle-3.0.1}/LICENSE +0 -0
  58. {proofbundle-2.1.0 → proofbundle-3.0.1}/setup.cfg +0 -0
  59. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/_inspect_registry.py +0 -0
  60. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/_integration.py +0 -0
  61. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/adapters/__init__.py +0 -0
  62. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/adapters/_provenance.py +0 -0
  63. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/adapters/inspect_ai.py +0 -0
  64. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/adapters/lm_eval.py +0 -0
  65. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/adapters/promptfoo.py +0 -0
  66. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/adapters/samples.py +0 -0
  67. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/anchors_chia_add.py +0 -0
  68. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/beacon.py +0 -0
  69. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/canonical.py +0 -0
  70. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/demo.py +0 -0
  71. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/dsse.py +0 -0
  72. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/eee_eval_schema.json +0 -0
  73. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/emit.py +0 -0
  74. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/errors.py +0 -0
  75. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/experimental/__init__.py +0 -0
  76. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/inspect_hook.py +0 -0
  77. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/prereg.py +0 -0
  78. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/py.typed +0 -0
  79. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/pytest_plugin.py +0 -0
  80. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle/signature.py +0 -0
  81. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle.egg-info/dependency_links.txt +0 -0
  82. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle.egg-info/entry_points.txt +0 -0
  83. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle.egg-info/requires.txt +0 -0
  84. {proofbundle-2.1.0 → proofbundle-3.0.1}/src/proofbundle.egg-info/top_level.txt +0 -0
  85. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_adapters.py +0 -0
  86. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_adversarial.py +0 -0
  87. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_anchors_chia.py +0 -0
  88. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_anchors_chia_add.py +0 -0
  89. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_anchors_chia_claims.py +0 -0
  90. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_anchors_generic.py +0 -0
  91. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_beacon.py +0 -0
  92. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_branch_base_check.py +0 -0
  93. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_bundle.py +0 -0
  94. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_bundle_robustness.py +0 -0
  95. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_canonical.py +0 -0
  96. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_checkpoint.py +0 -0
  97. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_cli.py +0 -0
  98. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_cli_eval.py +0 -0
  99. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_cosignature.py +0 -0
  100. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_cosignature_mldsa.py +0 -0
  101. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_decision_cli.py +0 -0
  102. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_decision_evidence.py +0 -0
  103. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_decision_fuzz.py +0 -0
  104. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_decision_hardening.py +0 -0
  105. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_decision_schema.py +0 -0
  106. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_demo.py +0 -0
  107. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_eee.py +0 -0
  108. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_emit.py +0 -0
  109. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_eval_claim_schema.py +0 -0
  110. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_examples.py +0 -0
  111. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_experimental_enclave.py +0 -0
  112. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_fork_pr_secret_isolation.py +0 -0
  113. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_fuzz_parsers.py +0 -0
  114. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_hf_evals.py +0 -0
  115. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_inspect_hook.py +0 -0
  116. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_intoto.py +0 -0
  117. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_intoto_claims_hygiene.py +0 -0
  118. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_intoto_content_root_migration.py +0 -0
  119. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_intoto_dsse.py +0 -0
  120. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_intoto_eval_result.py +0 -0
  121. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_intoto_examples.py +0 -0
  122. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_intoto_spec_diff.py +0 -0
  123. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_intoto_svr.py +0 -0
  124. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_merkle.py +0 -0
  125. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_merkle_property.py +0 -0
  126. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_persample.py +0 -0
  127. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_prereg.py +0 -0
  128. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_promptfoo.py +0 -0
  129. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_provenance.py +0 -0
  130. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_pytest_plugin.py +0 -0
  131. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_rekor_interop.py +0 -0
  132. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_rfc6962_external_vectors.py +0 -0
  133. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_schema.py +0 -0
  134. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_sdjwt_issue.py +0 -0
  135. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_sdjwt_reference.py +0 -0
  136. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_signature.py +0 -0
  137. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_statuslist.py +0 -0
  138. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_tlogproof.py +0 -0
  139. {proofbundle-2.1.0 → proofbundle-3.0.1}/tests/test_verify_matrix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofbundle
3
- Version: 2.1.0
3
+ Version: 3.0.1
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
@@ -75,12 +75,28 @@ Merkle, one file, no server, no network.
75
75
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21230467.svg)](https://doi.org/10.5281/zenodo.21230467)
76
76
  [![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)
77
77
  [![Mutation tested](https://img.shields.io/badge/tests-mutation_gated-D6248A.svg)](https://github.com/b7n0de/proofbundle/blob/main/scripts/mutation_check.py)
78
- <!-- SLSA / PEP 740 attestation badges follow once the first attested release lands, see RELEASE.md. -->
78
+ [![PyPI attestations](https://img.shields.io/badge/PyPI-PEP_740_attested-D6248A.svg)](https://pypi.org/project/proofbundle/#files)
79
+ [![SLSA provenance](https://img.shields.io/badge/SLSA-build_provenance-D6248A.svg)](https://slsa.dev/spec/v1.0/provenance)
79
80
 
80
81
  **Reviewing this for adoption?** Start with the 30-minute adversarial audit path: **[docs/REVIEWERS.md](https://github.com/b7n0de/proofbundle/blob/main/docs/REVIEWERS.md)**.
81
82
 
82
83
  </div>
83
84
 
85
+ ## Contents
86
+
87
+ - [60-second try](#60-second-try-offline)
88
+ - [Inspect-native?](#inspect-native-metr-task-standard-uk-aisi-ecosystem)
89
+ - [The problem](#the-problem)
90
+ - [What a receipt proves, and what it doesn't](#what-a-receipt-proves-and-what-it-doesnt)
91
+ - [How it fits together](#how-it-fits-together)
92
+ - [Post-quantum posture](#post-quantum-posture-honest-two-layers)
93
+ - [What's in the box](#whats-in-the-box)
94
+ - [Docs](#docs)
95
+ - [Install](#install)
96
+ - [Status, scope and roadmap](#status-scope-and-roadmap)
97
+ - [Contributing](#contributing)
98
+ - [License](#license)
99
+
84
100
  ## 60-second try (offline)
85
101
 
86
102
  ```bash
@@ -91,21 +107,9 @@ git clone https://github.com/b7n0de/proofbundle && cd proofbundle
91
107
  pip install -e ".[eval,inspect]" && make demo # or `make full-demo` for log -> receipt -> verify
92
108
  ```
93
109
 
94
- ## The problem
95
-
96
- Every AI eval number you read a safety benchmark, a capability score, a leaderboard entry — is an
97
- **unverifiable claim**. You trust the lab. There's no portable way to check, offline, that a result
98
- was signed by a stated party, hasn't been altered, and covers the samples it claims.
99
-
100
- proofbundle is that check. It's a small MIT-licensed Python tool (a compact, auditable trusted core,
101
- depends only on [`cryptography`](https://cryptography.io)) that turns a result into a signed
102
- receipt anyone can verify from a single file — and it's honest about the line it does not cross.
103
-
104
- ## What the demo shows
105
-
106
- You'll see an honest receipt verify `=> OK`, then six independent tampers each verify `FAILED`, then
107
- a swapped sample get caught — all in memory. `proofbundle demo` exits non-zero if any tamper slips through,
108
- so it's also a self-test. Full walkthrough: **[docs/DEMO.md](https://github.com/b7n0de/proofbundle/blob/main/docs/DEMO.md)**.
110
+ The demo runs entirely in memory: an honest receipt verifies `=> OK`, six independent tampers each
111
+ verify `FAILED`, and a swapped sample gets caught. `proofbundle demo` exits non-zero if any tamper
112
+ slips through, so it doubles as a self-test. Full walkthrough: **[docs/DEMO.md](https://github.com/b7n0de/proofbundle/blob/main/docs/DEMO.md)**.
109
113
 
110
114
  ```bash
111
115
  # verify a real hosted receipt without writing any code:
@@ -120,7 +124,7 @@ proofbundle verify receipt.json # exit 0 = crypto OK, 1 = crypto/verifica
120
124
  proofbundle verify receipt.json --policy trust_policy.json # POLICY: OK | FAIL (exit 3) | NOT_EVALUATED
121
125
  ```
122
126
 
123
- ## Inspect-native? (METR Task Standard / UK-AISI ecosystem)
127
+ ## Inspect-native? (METR Task Standard, UK-AISI ecosystem)
124
128
 
125
129
  The receipt layer runs directly on [Inspect AI](https://inspect.aisi.org.uk/) — and the proof is
126
130
  reproducible offline in minutes:
@@ -135,7 +139,22 @@ In your own pipeline the end-of-task hook signs every run automatically. Walkthr
135
139
  **[docs/INSPECT_HAPPY_PATH.md](https://github.com/b7n0de/proofbundle/blob/main/docs/INSPECT_HAPPY_PATH.md)** · worked example:
136
140
  **[examples/inspect_receipt.py](https://github.com/b7n0de/proofbundle/blob/main/examples/inspect_receipt.py)**.
137
141
 
138
- ## What a receipt proves — and what it doesn't
142
+ ## The problem
143
+
144
+ Every AI eval number you read — a safety benchmark, a capability score, a leaderboard entry — is an
145
+ **unverifiable claim**. You trust the lab. There's no portable way to check, offline, that a result
146
+ was signed by a stated party, hasn't been altered, and covers the samples it claims.
147
+
148
+ proofbundle is that check. It's a small MIT-licensed Python tool (a compact, auditable trusted core,
149
+ depends only on [`cryptography`](https://cryptography.io)) that turns a result into a signed
150
+ receipt anyone can verify from a single file — and it's honest about the line it does not cross.
151
+
152
+ In plain language: a proofbundle receipt is the cash-register receipt of an AI test result. It shows
153
+ who claimed the number and that nobody quietly changed it afterwards. It does not show the test was
154
+ good — the way a cash-register receipt does not show the meal was good — but without a receipt there
155
+ is nothing to check at all.
156
+
157
+ ## What a receipt proves, and what it doesn't
139
158
 
140
159
  | ✅ It proves | ❌ It does **not** prove |
141
160
  |---|---|
@@ -149,6 +168,21 @@ This boundary is the point, not a weakness. A receipt makes a claim **attributab
149
168
  and — with pre-registration and per-sample auditing — bounded and spot-checkable**. Full detail:
150
169
  **[THREAT_MODEL.md](https://github.com/b7n0de/proofbundle/blob/main/THREAT_MODEL.md)**.
151
170
 
171
+ ## How it fits together
172
+
173
+ *(diagram renders on GitHub — [view it there](https://github.com/b7n0de/proofbundle#how-it-fits-together); PyPI shows the source)*
174
+
175
+ ```mermaid
176
+ flowchart LR
177
+ H["eval harness<br/>inspect_ai · lm-eval · promptfoo · pytest"] --> A["adapter → signed claim<br/>salted commitments · provenance · samples root"]
178
+ A --> R["receipt<br/>one portable file"]
179
+ R --> V{{"proofbundle verify — offline"}}
180
+ V --> C["signature · Merkle inclusion · SD-JWT/KB ·<br/>witness quorum · status list · sample openings"]
181
+ C --> OK(["CRYPTO: OK / FAILED"])
182
+ style V fill:#D6248A,stroke:#D6248A,color:#fff
183
+ style OK fill:#D6248A,stroke:#D6248A,color:#fff
184
+ ```
185
+
152
186
  ## Post-quantum posture (honest, two layers)
153
187
 
154
188
  proofbundle is **not** "quantum-proof" or "quantum-safe" as a whole. It combines two cryptographic layers
@@ -173,28 +207,6 @@ On the witness side, C2SP checkpoints already carry post-quantum **ML-DSA-44** (
173
207
  (`proofbundle[pq]`); a post-quantum *payload* signature — crypto-agility for the receipt itself — is on the
174
208
  roadmap.
175
209
 
176
- ## In plain language
177
-
178
- A proofbundle receipt is the cash-register receipt of an AI test result: it shows who claimed the
179
- number and that nobody quietly changed it afterwards. It does not show the test was good — the way a
180
- cash-register receipt does not show the meal was good — but without a receipt there is nothing to
181
- check at all.
182
-
183
- ## How it fits together
184
-
185
- *(diagram renders on GitHub — [view it there](https://github.com/b7n0de/proofbundle#how-it-fits-together); PyPI shows the source)*
186
-
187
- ```mermaid
188
- flowchart LR
189
- H["eval harness<br/>inspect_ai · lm-eval · promptfoo · pytest"] --> A["adapter → signed claim<br/>salted commitments · provenance · samples root"]
190
- A --> R["receipt<br/>one portable file"]
191
- R --> V{{"proofbundle verify — offline"}}
192
- V --> C["signature · Merkle inclusion · SD-JWT/KB ·<br/>witness quorum · status list · sample openings"]
193
- C --> OK(["CRYPTO: OK / FAILED"])
194
- style V fill:#D6248A,stroke:#D6248A,color:#fff
195
- style OK fill:#D6248A,stroke:#D6248A,color:#fff
196
- ```
197
-
198
210
  ## What's in the box
199
211
 
200
212
  - **Core** — Ed25519 signature + RFC 6962 / 9162 Merkle inclusion, verified fully offline. Checks a
@@ -202,7 +214,8 @@ flowchart LR
202
214
  - **Eval receipts** — a signed claim (`metric ⋈ threshold`, `n`, salted model/dataset commitments,
203
215
  assurance level, provenance) from your run. See [EVAL_CLAIM.md](https://github.com/b7n0de/proofbundle/blob/main/EVAL_CLAIM.md).
204
216
  - **Selective disclosure** — SD-JWT ([RFC 9901](https://datatracker.ietf.org/doc/rfc9901/)) with Key
205
- Binding: prove a threshold while withholding the exact score.
217
+ Binding: prove a threshold while withholding the exact score. Secure-by-default in 3.0.0 (breaking): an
218
+ unsigned SD-JWT, or one whose disclosures do not bind this bundle, now fails verification (was warn-only).
206
219
  - **Transparency-log interop** — C2SP `tlog-checkpoint` / cosignature / `.tlog-proof`, with
207
220
  post-quantum **ML-DSA-44** witness cosignatures. Optional Token-Status-List revocation snapshots.
208
221
  - **Per-sample audit** — commit to every sample; an auditor challenges random indices (with a fresh
@@ -218,12 +231,34 @@ flowchart LR
218
231
  [docs/INSPECT_HAPPY_PATH.md](https://github.com/b7n0de/proofbundle/blob/main/docs/INSPECT_HAPPY_PATH.md) — run an eval, get a receipt, verify it offline.
219
232
  - **External time anchors** *(v2.0 beta, the `[anchors]` extra)* — an optional `anchors[]` layer that
220
233
  attaches external evidence of *when* a commitment or receipt existed, from a party the producer does not
221
- control. Two built-in types verify offline: **RFC 3161** TSA tokens (against a frozen cert chain) and
222
- **OpenTimestamps** Bitcoin proofs (honest pending → confirmed lifecycle). A `register_anchor_type`
234
+ control. Two built-in types verify offline: **RFC 3161** TSA tokens (against a relying-party-supplied TSA
235
+ root, see the 3.0.0 trust note below) and **OpenTimestamps** Bitcoin proofs (honest pending → confirmed lifecycle). A `register_anchor_type`
223
236
  extension interface lets a third party ship its own fail-closed type; two worked examples ship — a
224
237
  first-party **`chia-datalayer/v1`** (offline Merkle inclusion of a canonical root under a published Chia
225
238
  DataLayer root) and a third-party **`markovian-provenance/v1`** (a wallet-attributable, Bitcoin-anchored
226
- stamp). See [docs/ANCHORS.md](https://github.com/b7n0de/proofbundle/blob/main/docs/ANCHORS.md).
239
+ stamp). **Since 2.1:** a `verify --require-anchor` relying-party gate (optionally narrowed by
240
+ `--anchor-type`) turns "no verifying anchor of that type" into a failure layered over the crypto result
241
+ (exit 3, like `--policy`); a pending anchor does not satisfy it unless `--allow-pending`. Plus RFC 3161
242
+ hardening — the frozen cert chain is validated at the token's own `gen_time`, with optional `policyOid`
243
+ pinning. **Breaking in 3.0.0:** an anchor's TRUST now comes only from the relying party — supply a TSA
244
+ root (`--trusted-tsa-root`) or a Bitcoin block header (`--bitcoin-header`), or the equivalent `anchors`
245
+ policy keys; the bundle's producer-controlled `frozen` block is evidence, never a trust source, so
246
+ `--require-anchor` without relying-party trust material is unmet (exit 3). An anchor stays detached from
247
+ the content root, and the `statement` target is RESERVED for decision receipts.
248
+ See [docs/ANCHORS.md](https://github.com/b7n0de/proofbundle/blob/main/docs/ANCHORS.md).
249
+ - **Universal content root** *(2.1, `jcs-sha256-v1`, [ADR 0002](https://github.com/b7n0de/proofbundle/blob/main/docs/adr/0002-universal-content-root.md))* — one shared primitive now underlies both the
250
+ decision-receipt path and the in-toto eval-result / test-result / SVR exports: SHA-256 over the RFC 8785
251
+ (JCS) canonical bytes of the full pre-signature Statement, so a content root survives counter-signing and
252
+ key rotation. The algorithm is a versioned id signed inside the payload (`contentRootAlg`, default
253
+ `jcs-sha256-v1`); a verifier re-serializes with exactly the declared algorithm, never falls back, and an
254
+ unknown algorithm fails closed. Migration is a compatible evolution, not a cutover: absent `contentRootAlg`
255
+ ⇒ the historic `legacy-sortkeys-json-v0` mode, so every already-signed 2.0.0 receipt keeps verifying
256
+ byte-for-byte. This is **not** a completed universal migration — a CLI flag to select the algorithm is still
257
+ deferred. Independent cross-implementation (MarkovianProtocol) interop is now proven for RFC 8785
258
+ canonicalization + content-root binding (see `conformance/decision/crossimpl/`); the same corpus additionally
259
+ verifies a confirmed Bitcoin anchor (block 957504) offline. The external fixture currently reports 12 findings
260
+ against the enforced `decision-receipt/v0.1` validator — recorded as an expected-fail, not hidden — so full
261
+ schema conformance awaits a further schema-conformant regeneration.
227
262
  - **Decision Receipts** *(2.1, vendored `decision-receipt/v0.1` predicate)* — a separate predicate for agent
228
263
  *decisions* (not eval metrics): who decided, the proposed action, the policy boundary, digest-bound evidence,
229
264
  the verdict (`ALLOW`/`DENY`/`REFUSE`/`ESCALATE`/`DEFER`/`OBSERVE`), and explicitly what was *not* checked.
@@ -262,7 +297,7 @@ pip install "proofbundle[pq]" # verify ML-DSA-44 (post-quantum) witnes
262
297
  Requires Python 3.10+. The verify path never rolls its own crypto — Ed25519 comes from
263
298
  `cryptography`; Merkle hashing is RFC 6962.
264
299
 
265
- ## Status & scope
300
+ ## Status, scope and roadmap
266
301
 
267
302
  Beta, SemVer-committed, with a CI test suite behind a mutation gate + property-based parser fuzzing. Correctness
268
303
  is anchored to external RFC 6962 vectors and a real Rekor proof, not just its own bundles. It is
@@ -270,6 +305,13 @@ is anchored to external RFC 6962 vectors and a real Rekor proof, not just its ow
270
305
  by itself — it is the small, offline, standards-native receipt layer between them. Security policy:
271
306
  [SECURITY.md](https://github.com/b7n0de/proofbundle/blob/main/SECURITY.md).
272
307
 
308
+ **Roadmap (stated honestly, all forward-looking):**
309
+
310
+ - A post-quantum *payload* signature (crypto-agility for the receipt itself) is on the roadmap; today the
311
+ post-quantum coverage is witness-side ML-DSA-44 cosignatures only.
312
+ - A CLI flag to select the content-root algorithm is still deferred (`jcs-sha256-v1` is the signed default).
313
+ - **Preview:** a TEE-attestation bridge — see [docs/EXPERIMENTAL_ENCLAVE.md](https://github.com/b7n0de/proofbundle/blob/main/docs/EXPERIMENTAL_ENCLAVE.md).
314
+
273
315
  ## Contributing
274
316
 
275
317
  See [CONTRIBUTING.md](https://github.com/b7n0de/proofbundle/blob/main/CONTRIBUTING.md) and the [Code of Conduct](https://github.com/b7n0de/proofbundle/blob/main/CODE_OF_CONDUCT.md). Good first
@@ -21,12 +21,28 @@ Merkle, one file, no server, no network.
21
21
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21230467.svg)](https://doi.org/10.5281/zenodo.21230467)
22
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
23
  [![Mutation tested](https://img.shields.io/badge/tests-mutation_gated-D6248A.svg)](https://github.com/b7n0de/proofbundle/blob/main/scripts/mutation_check.py)
24
- <!-- SLSA / PEP 740 attestation badges follow once the first attested release lands, see RELEASE.md. -->
24
+ [![PyPI attestations](https://img.shields.io/badge/PyPI-PEP_740_attested-D6248A.svg)](https://pypi.org/project/proofbundle/#files)
25
+ [![SLSA provenance](https://img.shields.io/badge/SLSA-build_provenance-D6248A.svg)](https://slsa.dev/spec/v1.0/provenance)
25
26
 
26
27
  **Reviewing this for adoption?** Start with the 30-minute adversarial audit path: **[docs/REVIEWERS.md](https://github.com/b7n0de/proofbundle/blob/main/docs/REVIEWERS.md)**.
27
28
 
28
29
  </div>
29
30
 
31
+ ## Contents
32
+
33
+ - [60-second try](#60-second-try-offline)
34
+ - [Inspect-native?](#inspect-native-metr-task-standard-uk-aisi-ecosystem)
35
+ - [The problem](#the-problem)
36
+ - [What a receipt proves, and what it doesn't](#what-a-receipt-proves-and-what-it-doesnt)
37
+ - [How it fits together](#how-it-fits-together)
38
+ - [Post-quantum posture](#post-quantum-posture-honest-two-layers)
39
+ - [What's in the box](#whats-in-the-box)
40
+ - [Docs](#docs)
41
+ - [Install](#install)
42
+ - [Status, scope and roadmap](#status-scope-and-roadmap)
43
+ - [Contributing](#contributing)
44
+ - [License](#license)
45
+
30
46
  ## 60-second try (offline)
31
47
 
32
48
  ```bash
@@ -37,21 +53,9 @@ git clone https://github.com/b7n0de/proofbundle && cd proofbundle
37
53
  pip install -e ".[eval,inspect]" && make demo # or `make full-demo` for log -> receipt -> verify
38
54
  ```
39
55
 
40
- ## The problem
41
-
42
- Every AI eval number you read a safety benchmark, a capability score, a leaderboard entry — is an
43
- **unverifiable claim**. You trust the lab. There's no portable way to check, offline, that a result
44
- was signed by a stated party, hasn't been altered, and covers the samples it claims.
45
-
46
- proofbundle is that check. It's a small MIT-licensed Python tool (a compact, auditable trusted core,
47
- depends only on [`cryptography`](https://cryptography.io)) that turns a result into a signed
48
- receipt anyone can verify from a single file — and it's honest about the line it does not cross.
49
-
50
- ## What the demo shows
51
-
52
- You'll see an honest receipt verify `=> OK`, then six independent tampers each verify `FAILED`, then
53
- a swapped sample get caught — all in memory. `proofbundle demo` exits non-zero if any tamper slips through,
54
- so it's also a self-test. Full walkthrough: **[docs/DEMO.md](https://github.com/b7n0de/proofbundle/blob/main/docs/DEMO.md)**.
56
+ The demo runs entirely in memory: an honest receipt verifies `=> OK`, six independent tampers each
57
+ verify `FAILED`, and a swapped sample gets caught. `proofbundle demo` exits non-zero if any tamper
58
+ slips through, so it doubles as a self-test. Full walkthrough: **[docs/DEMO.md](https://github.com/b7n0de/proofbundle/blob/main/docs/DEMO.md)**.
55
59
 
56
60
  ```bash
57
61
  # verify a real hosted receipt without writing any code:
@@ -66,7 +70,7 @@ proofbundle verify receipt.json # exit 0 = crypto OK, 1 = crypto/verifica
66
70
  proofbundle verify receipt.json --policy trust_policy.json # POLICY: OK | FAIL (exit 3) | NOT_EVALUATED
67
71
  ```
68
72
 
69
- ## Inspect-native? (METR Task Standard / UK-AISI ecosystem)
73
+ ## Inspect-native? (METR Task Standard, UK-AISI ecosystem)
70
74
 
71
75
  The receipt layer runs directly on [Inspect AI](https://inspect.aisi.org.uk/) — and the proof is
72
76
  reproducible offline in minutes:
@@ -81,7 +85,22 @@ In your own pipeline the end-of-task hook signs every run automatically. Walkthr
81
85
  **[docs/INSPECT_HAPPY_PATH.md](https://github.com/b7n0de/proofbundle/blob/main/docs/INSPECT_HAPPY_PATH.md)** · worked example:
82
86
  **[examples/inspect_receipt.py](https://github.com/b7n0de/proofbundle/blob/main/examples/inspect_receipt.py)**.
83
87
 
84
- ## What a receipt proves — and what it doesn't
88
+ ## The problem
89
+
90
+ Every AI eval number you read — a safety benchmark, a capability score, a leaderboard entry — is an
91
+ **unverifiable claim**. You trust the lab. There's no portable way to check, offline, that a result
92
+ was signed by a stated party, hasn't been altered, and covers the samples it claims.
93
+
94
+ proofbundle is that check. It's a small MIT-licensed Python tool (a compact, auditable trusted core,
95
+ depends only on [`cryptography`](https://cryptography.io)) that turns a result into a signed
96
+ receipt anyone can verify from a single file — and it's honest about the line it does not cross.
97
+
98
+ In plain language: a proofbundle receipt is the cash-register receipt of an AI test result. It shows
99
+ who claimed the number and that nobody quietly changed it afterwards. It does not show the test was
100
+ good — the way a cash-register receipt does not show the meal was good — but without a receipt there
101
+ is nothing to check at all.
102
+
103
+ ## What a receipt proves, and what it doesn't
85
104
 
86
105
  | ✅ It proves | ❌ It does **not** prove |
87
106
  |---|---|
@@ -95,6 +114,21 @@ This boundary is the point, not a weakness. A receipt makes a claim **attributab
95
114
  and — with pre-registration and per-sample auditing — bounded and spot-checkable**. Full detail:
96
115
  **[THREAT_MODEL.md](https://github.com/b7n0de/proofbundle/blob/main/THREAT_MODEL.md)**.
97
116
 
117
+ ## How it fits together
118
+
119
+ *(diagram renders on GitHub — [view it there](https://github.com/b7n0de/proofbundle#how-it-fits-together); PyPI shows the source)*
120
+
121
+ ```mermaid
122
+ flowchart LR
123
+ H["eval harness<br/>inspect_ai · lm-eval · promptfoo · pytest"] --> A["adapter → signed claim<br/>salted commitments · provenance · samples root"]
124
+ A --> R["receipt<br/>one portable file"]
125
+ R --> V{{"proofbundle verify — offline"}}
126
+ V --> C["signature · Merkle inclusion · SD-JWT/KB ·<br/>witness quorum · status list · sample openings"]
127
+ C --> OK(["CRYPTO: OK / FAILED"])
128
+ style V fill:#D6248A,stroke:#D6248A,color:#fff
129
+ style OK fill:#D6248A,stroke:#D6248A,color:#fff
130
+ ```
131
+
98
132
  ## Post-quantum posture (honest, two layers)
99
133
 
100
134
  proofbundle is **not** "quantum-proof" or "quantum-safe" as a whole. It combines two cryptographic layers
@@ -119,28 +153,6 @@ On the witness side, C2SP checkpoints already carry post-quantum **ML-DSA-44** (
119
153
  (`proofbundle[pq]`); a post-quantum *payload* signature — crypto-agility for the receipt itself — is on the
120
154
  roadmap.
121
155
 
122
- ## In plain language
123
-
124
- A proofbundle receipt is the cash-register receipt of an AI test result: it shows who claimed the
125
- number and that nobody quietly changed it afterwards. It does not show the test was good — the way a
126
- cash-register receipt does not show the meal was good — but without a receipt there is nothing to
127
- check at all.
128
-
129
- ## How it fits together
130
-
131
- *(diagram renders on GitHub — [view it there](https://github.com/b7n0de/proofbundle#how-it-fits-together); PyPI shows the source)*
132
-
133
- ```mermaid
134
- flowchart LR
135
- H["eval harness<br/>inspect_ai · lm-eval · promptfoo · pytest"] --> A["adapter → signed claim<br/>salted commitments · provenance · samples root"]
136
- A --> R["receipt<br/>one portable file"]
137
- R --> V{{"proofbundle verify — offline"}}
138
- V --> C["signature · Merkle inclusion · SD-JWT/KB ·<br/>witness quorum · status list · sample openings"]
139
- C --> OK(["CRYPTO: OK / FAILED"])
140
- style V fill:#D6248A,stroke:#D6248A,color:#fff
141
- style OK fill:#D6248A,stroke:#D6248A,color:#fff
142
- ```
143
-
144
156
  ## What's in the box
145
157
 
146
158
  - **Core** — Ed25519 signature + RFC 6962 / 9162 Merkle inclusion, verified fully offline. Checks a
@@ -148,7 +160,8 @@ flowchart LR
148
160
  - **Eval receipts** — a signed claim (`metric ⋈ threshold`, `n`, salted model/dataset commitments,
149
161
  assurance level, provenance) from your run. See [EVAL_CLAIM.md](https://github.com/b7n0de/proofbundle/blob/main/EVAL_CLAIM.md).
150
162
  - **Selective disclosure** — SD-JWT ([RFC 9901](https://datatracker.ietf.org/doc/rfc9901/)) with Key
151
- Binding: prove a threshold while withholding the exact score.
163
+ Binding: prove a threshold while withholding the exact score. Secure-by-default in 3.0.0 (breaking): an
164
+ unsigned SD-JWT, or one whose disclosures do not bind this bundle, now fails verification (was warn-only).
152
165
  - **Transparency-log interop** — C2SP `tlog-checkpoint` / cosignature / `.tlog-proof`, with
153
166
  post-quantum **ML-DSA-44** witness cosignatures. Optional Token-Status-List revocation snapshots.
154
167
  - **Per-sample audit** — commit to every sample; an auditor challenges random indices (with a fresh
@@ -164,12 +177,34 @@ flowchart LR
164
177
  [docs/INSPECT_HAPPY_PATH.md](https://github.com/b7n0de/proofbundle/blob/main/docs/INSPECT_HAPPY_PATH.md) — run an eval, get a receipt, verify it offline.
165
178
  - **External time anchors** *(v2.0 beta, the `[anchors]` extra)* — an optional `anchors[]` layer that
166
179
  attaches external evidence of *when* a commitment or receipt existed, from a party the producer does not
167
- control. Two built-in types verify offline: **RFC 3161** TSA tokens (against a frozen cert chain) and
168
- **OpenTimestamps** Bitcoin proofs (honest pending → confirmed lifecycle). A `register_anchor_type`
180
+ control. Two built-in types verify offline: **RFC 3161** TSA tokens (against a relying-party-supplied TSA
181
+ root, see the 3.0.0 trust note below) and **OpenTimestamps** Bitcoin proofs (honest pending → confirmed lifecycle). A `register_anchor_type`
169
182
  extension interface lets a third party ship its own fail-closed type; two worked examples ship — a
170
183
  first-party **`chia-datalayer/v1`** (offline Merkle inclusion of a canonical root under a published Chia
171
184
  DataLayer root) and a third-party **`markovian-provenance/v1`** (a wallet-attributable, Bitcoin-anchored
172
- stamp). See [docs/ANCHORS.md](https://github.com/b7n0de/proofbundle/blob/main/docs/ANCHORS.md).
185
+ stamp). **Since 2.1:** a `verify --require-anchor` relying-party gate (optionally narrowed by
186
+ `--anchor-type`) turns "no verifying anchor of that type" into a failure layered over the crypto result
187
+ (exit 3, like `--policy`); a pending anchor does not satisfy it unless `--allow-pending`. Plus RFC 3161
188
+ hardening — the frozen cert chain is validated at the token's own `gen_time`, with optional `policyOid`
189
+ pinning. **Breaking in 3.0.0:** an anchor's TRUST now comes only from the relying party — supply a TSA
190
+ root (`--trusted-tsa-root`) or a Bitcoin block header (`--bitcoin-header`), or the equivalent `anchors`
191
+ policy keys; the bundle's producer-controlled `frozen` block is evidence, never a trust source, so
192
+ `--require-anchor` without relying-party trust material is unmet (exit 3). An anchor stays detached from
193
+ the content root, and the `statement` target is RESERVED for decision receipts.
194
+ See [docs/ANCHORS.md](https://github.com/b7n0de/proofbundle/blob/main/docs/ANCHORS.md).
195
+ - **Universal content root** *(2.1, `jcs-sha256-v1`, [ADR 0002](https://github.com/b7n0de/proofbundle/blob/main/docs/adr/0002-universal-content-root.md))* — one shared primitive now underlies both the
196
+ decision-receipt path and the in-toto eval-result / test-result / SVR exports: SHA-256 over the RFC 8785
197
+ (JCS) canonical bytes of the full pre-signature Statement, so a content root survives counter-signing and
198
+ key rotation. The algorithm is a versioned id signed inside the payload (`contentRootAlg`, default
199
+ `jcs-sha256-v1`); a verifier re-serializes with exactly the declared algorithm, never falls back, and an
200
+ unknown algorithm fails closed. Migration is a compatible evolution, not a cutover: absent `contentRootAlg`
201
+ ⇒ the historic `legacy-sortkeys-json-v0` mode, so every already-signed 2.0.0 receipt keeps verifying
202
+ byte-for-byte. This is **not** a completed universal migration — a CLI flag to select the algorithm is still
203
+ deferred. Independent cross-implementation (MarkovianProtocol) interop is now proven for RFC 8785
204
+ canonicalization + content-root binding (see `conformance/decision/crossimpl/`); the same corpus additionally
205
+ verifies a confirmed Bitcoin anchor (block 957504) offline. The external fixture currently reports 12 findings
206
+ against the enforced `decision-receipt/v0.1` validator — recorded as an expected-fail, not hidden — so full
207
+ schema conformance awaits a further schema-conformant regeneration.
173
208
  - **Decision Receipts** *(2.1, vendored `decision-receipt/v0.1` predicate)* — a separate predicate for agent
174
209
  *decisions* (not eval metrics): who decided, the proposed action, the policy boundary, digest-bound evidence,
175
210
  the verdict (`ALLOW`/`DENY`/`REFUSE`/`ESCALATE`/`DEFER`/`OBSERVE`), and explicitly what was *not* checked.
@@ -208,7 +243,7 @@ pip install "proofbundle[pq]" # verify ML-DSA-44 (post-quantum) witnes
208
243
  Requires Python 3.10+. The verify path never rolls its own crypto — Ed25519 comes from
209
244
  `cryptography`; Merkle hashing is RFC 6962.
210
245
 
211
- ## Status & scope
246
+ ## Status, scope and roadmap
212
247
 
213
248
  Beta, SemVer-committed, with a CI test suite behind a mutation gate + property-based parser fuzzing. Correctness
214
249
  is anchored to external RFC 6962 vectors and a real Rekor proof, not just its own bundles. It is
@@ -216,6 +251,13 @@ is anchored to external RFC 6962 vectors and a real Rekor proof, not just its ow
216
251
  by itself — it is the small, offline, standards-native receipt layer between them. Security policy:
217
252
  [SECURITY.md](https://github.com/b7n0de/proofbundle/blob/main/SECURITY.md).
218
253
 
254
+ **Roadmap (stated honestly, all forward-looking):**
255
+
256
+ - A post-quantum *payload* signature (crypto-agility for the receipt itself) is on the roadmap; today the
257
+ post-quantum coverage is witness-side ML-DSA-44 cosignatures only.
258
+ - A CLI flag to select the content-root algorithm is still deferred (`jcs-sha256-v1` is the signed default).
259
+ - **Preview:** a TEE-attestation bridge — see [docs/EXPERIMENTAL_ENCLAVE.md](https://github.com/b7n0de/proofbundle/blob/main/docs/EXPERIMENTAL_ENCLAVE.md).
260
+
219
261
  ## Contributing
220
262
 
221
263
  See [CONTRIBUTING.md](https://github.com/b7n0de/proofbundle/blob/main/CONTRIBUTING.md) and the [Code of Conduct](https://github.com/b7n0de/proofbundle/blob/main/CODE_OF_CONDUCT.md). Good first
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proofbundle"
7
- version = "2.1.0"
7
+ version = "3.0.1"
8
8
  description = "Emit and verify portable cryptographic evidence bundles, offline: Ed25519 + RFC 6962 Merkle + optional SD-JWT."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -22,6 +22,9 @@ keywords = [
22
22
  "rfc6962",
23
23
  ]
24
24
  classifiers = [
25
+ # Stays Beta until the funded external security review passes (Owner decision E1, 2026-07-12):
26
+ # "stable is evidenced, not asserted". The move to "5 - Production/Stable" is a separate,
27
+ # audit-gated milestone (see docs/GRANT_MILESTONES.md), never claimed pre-audit.
25
28
  "Development Status :: 4 - Beta",
26
29
  "Intended Audience :: Developers",
27
30
  "License :: OSI Approved :: MIT License",
@@ -13,12 +13,12 @@ from __future__ import annotations
13
13
 
14
14
  from typing import TYPE_CHECKING
15
15
 
16
- __version__ = "2.1.0"
16
+ __version__ = "3.0.1"
17
17
 
18
18
  # The `proofbundle/v0.1` normative spec revision this build implements — kept in sync with the
19
19
  # `Revision:` line at the top of SPEC.md by tests/test_docs_truth.py (WP-B1, closes #28). Bump
20
20
  # both together whenever SPEC.md's normative text changes (not on every package release).
21
- SPEC_REVISION = "2026-07-10"
21
+ SPEC_REVISION = "2026-07-11"
22
22
 
23
23
  __all__ = [
24
24
  "__version__",
@@ -0,0 +1,72 @@
1
+ """Duplicate-key-rejecting JSON parsing — the one strict parser for every verify path (WP-C1).
2
+
3
+ ``json.loads`` silently keeps the LAST occurrence of a duplicated key (last-wins). On a verify
4
+ path that is a classic parser differential (Bishop Fox 2021; the exact bug class behind several
5
+ JWT/JOSE CVEs): two implementations parsing the same bytes can disagree about which ``root_b64``
6
+ or ``sig_b64`` they verified, so "cross-verifier consensus" silently stops meaning one thing.
7
+ RFC 8785 (JCS) forbids duplicate keys outright, and the DSSE statement paths already reject them
8
+ INDIRECTLY (a duplicated payload cannot be byte-equal to its own canonical re-serialization) —
9
+ but the native bundle path accepted them silently, and an explicit reject with a clear message
10
+ beats an incidental byte-mismatch everywhere.
11
+
12
+ Converted paths: the native bundle (``load_bundle``, the HF ``pb1.`` token), the DSSE statement
13
+ verifiers (eval-result / test-result / SVR / decision), the trust-policy loader, the per-sample
14
+ disclosure record, the chia-datalayer and markovian anchor envelopes, the status-list token, the
15
+ enclave EAT, and every ``json.load`` in the CLI. Emit-side inputs (a claim/predicate file the
16
+ caller authored) use it too — a duplicate key in something about to be signed is at best an
17
+ authoring bug, at worst an attempted differential.
18
+
19
+ Resolved 2026-07-12 (F12, release-audit): the SD-JWT/KB-JWT payload sites in ``sdjwt.py`` /
20
+ ``kbjwt.py``, ``bundle._issuer_requires_holder_binding``, ``sdjwt_issue._jwt_payload`` and
21
+ ``evalclaim.sd_jwt_hidden_count`` now parse with ``loads_strict`` too, each routed fail-closed —
22
+ a duplicate ``cnf`` is rejected, and ``_issuer_requires_holder_binding`` returns True on a duplicate
23
+ (binding REQUIRED), never the inverted "no holder binding required". Keys that differ only by Unicode normalization
24
+ (NFC/NFD) or a BOM are DISTINCT JSON keys by spec and stay distinct here — normalization games are
25
+ a downstream concern of the field validators, not of the parser.
26
+
27
+ Stdlib-only (``object_pairs_hook``), so the base install keeps rejecting duplicates without any
28
+ extra. The hook fires for every nested object (including objects inside arrays), so duplicates are
29
+ rejected at ANY depth.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ from typing import Any, Union
35
+
36
+ from .errors import BundleFormatError
37
+
38
+ __all__ = ["loads_strict"]
39
+
40
+
41
+ def _reject_duplicate_keys(pairs: list) -> dict:
42
+ obj: dict = {}
43
+ for key, value in pairs:
44
+ if key in obj:
45
+ raise BundleFormatError(
46
+ f"duplicate JSON key {key!r} — rejected fail-closed (a duplicated key parses "
47
+ "differently across JSON implementations; parser-differential guard, WP-C1)")
48
+ obj[key] = value
49
+ return obj
50
+
51
+
52
+ def loads_strict(text: Union[str, bytes]) -> Any:
53
+ """``json.loads`` that rejects duplicate object keys at any nesting depth.
54
+
55
+ Raises :class:`BundleFormatError` for a duplicate key (fail-closed, clear message), maps
56
+ ``RecursionError`` from pathologically deep nesting to the same documented malformed-input
57
+ error, and maps the ``int``/``str`` conversion-limit ``ValueError`` from a JSON integer literal
58
+ with more than ``sys.get_int_max_str_digits()`` digits (CWE-674 / CVE-2020-10735) to it too —
59
+ never a raw traceback — mirroring :func:`proofbundle.bundle.load_bundle`. Ordinary JSON syntax
60
+ errors keep raising ``ValueError`` (``json.JSONDecodeError``) so existing ``except (ValueError,
61
+ ...)`` handling at the call sites stays correct."""
62
+ try:
63
+ return json.loads(text, object_pairs_hook=_reject_duplicate_keys)
64
+ except RecursionError as exc:
65
+ raise BundleFormatError("JSON nesting is too deep") from exc
66
+ except ValueError as exc:
67
+ # The int<->str conversion cap raises a plain ValueError DURING parsing (not a JSONDecodeError),
68
+ # which a pre-auth caller without a broad `except ValueError` would surface as a raw traceback.
69
+ # Map only that specific case; a normal JSONDecodeError keeps raising ValueError as documented.
70
+ if "integer string conversion" in str(exc):
71
+ raise BundleFormatError("JSON integer literal is implausibly long (fail-closed)") from exc
72
+ raise
@@ -22,10 +22,12 @@ has_unknown_level == true means Unknown and is rejected (not silently mapped to
22
22
  from __future__ import annotations
23
23
 
24
24
  import json
25
+ import re
25
26
  from pathlib import Path
26
27
  from typing import Optional, Union
27
28
 
28
29
  from ..evalclaim import build_eval_claim
30
+ from ._provenance import add_provenance
29
31
 
30
32
  _SCHEMA_PATH = Path(__file__).resolve().parent.parent / "eee_eval_schema.json"
31
33
  _SCHEMA_VERSION = "0.2.2"
@@ -95,6 +97,65 @@ def _extract_score(score_details: dict, metric_config: dict) -> str:
95
97
  return _num_to_decimal_str(raw)
96
98
 
97
99
 
100
+ def _model_id_stripped(record: dict) -> dict:
101
+ """A deep copy of the EEE record with the cleartext model identity removed (WP-I3 privacy fix).
102
+ Removes ``model_info.id`` and the id-bearing top-level fields (``evaluation_id`` which embeds the
103
+ id, and ``evaluation_result_id``) so a digest over the result cannot be used as a model-id
104
+ confirmation / enumeration oracle, while still binding every score, timestamp, dataset and metric
105
+ for tamper-evidence.
106
+
107
+ M2 (6-lens review 2026-07-11): ``evaluation_result_id`` was left IN the digest record while the
108
+ run_id provenance path already `_leaks_model_id`-guards it — an asymmetry that kept the digest a
109
+ model-id oracle (the id can correlate to / embed the model). It is a provenance identifier, not a
110
+ scored/content field, so stripping it from the DIGEST removes the oracle without weakening the
111
+ tamper-evidence over the actual result content (it stays available for run_id provenance, which
112
+ applies its own leak guard)."""
113
+ import copy # noqa: PLC0415
114
+ r = copy.deepcopy(record)
115
+ if isinstance(r.get("model_info"), dict):
116
+ r["model_info"].pop("id", None)
117
+ r.pop("evaluation_id", None) # format eval_name/model_id/timestamp — embeds the id
118
+ # M2: strip the per-result ``evaluation_result_id`` (nested in each ``evaluation_results[*]``) —
119
+ # it is a provenance id that can embed/correlate the model id (a digest over it is an oracle).
120
+ for er in r.get("evaluation_results", []) or []:
121
+ if isinstance(er, dict):
122
+ er.pop("evaluation_result_id", None)
123
+ return r
124
+
125
+
126
+ def _record_digest(record: dict) -> str:
127
+ """``"<alg>:<hex>"`` over the canonical EEE record JSON with the cleartext model identity removed
128
+ (WP-I3; six-lens review: an UNSALTED digest over a record embedding ``model_info.id`` is a model
129
+ confirmation/enumeration oracle, so it is stripped first — the digest still binds scores,
130
+ timestamps, dataset and metrics for tamper-evidence). JCS when the ``[eval]`` extra is present,
131
+ else a labeled deterministic sort_keys fallback (the label tells a verifier which normalization
132
+ produced the hex, never a silent difference)."""
133
+ import hashlib # noqa: PLC0415
134
+ stripped = _model_id_stripped(record)
135
+ try:
136
+ import rfc8785 # noqa: PLC0415
137
+ return "sha256-jcs:" + hashlib.sha256(rfc8785.dumps(stripped)).hexdigest()
138
+ except (ImportError, ValueError, TypeError):
139
+ canonical = json.dumps(stripped, sort_keys=True, separators=(",", ":"),
140
+ ensure_ascii=False).encode("utf-8")
141
+ return "sha256-sortkeys:" + hashlib.sha256(canonical).hexdigest()
142
+
143
+
144
+ def _leaks_model_id(text: str, model_id: str) -> bool:
145
+ """True iff ``text`` contains the model id or any of its name components (WP-I3 privacy, six-lens
146
+ review). Case-insensitive; checks the full ``org/name`` id, the bare name after the last '/',
147
+ and slug variants where '-', '_' and '.' are unified — so 'arc/gpt2/run1' is caught for a model
148
+ id whose bare name is 'gpt2'."""
149
+ hay = text.lower()
150
+ hay_norm = re.sub(r"[-_.]+", "", hay)
151
+ tokens = {model_id.lower()}
152
+ bare = model_id.split("/")[-1].lower()
153
+ if bare:
154
+ tokens.add(bare)
155
+ tokens.add(re.sub(r"[-_.]+", "", bare)) # slug-normalized ('gpt-2' -> 'gpt2')
156
+ return any(t and (t in hay or t in hay_norm) for t in tokens)
157
+
158
+
98
159
  def from_eee_dataset(source: Union[str, Path, dict], *, comparator: str, threshold: str,
99
160
  timestamp: Optional[str] = None, eval_index: int = 0, metric_name: Optional[str] = None,
100
161
  model_salt: Optional[bytes] = None, dataset_salt: Optional[bytes] = None,
@@ -149,6 +210,20 @@ def from_eee_dataset(source: Union[str, Path, dict], *, comparator: str, thresho
149
210
  raise EEEAdapterError("no timestamp: pass timestamp= or set retrieved_timestamp/evaluation_timestamp")
150
211
 
151
212
  provenance = {"source": "every_eval_ever", "eee_schema_version": record.get("schema_version") or _SCHEMA_VERSION}
213
+ # WP-I3: bind the receipt to the EXACT source record — the only adapter without a provenance
214
+ # binding. sha256 over the RFC-8785 (JCS) canonical record JSON with the cleartext model identity
215
+ # stripped first (see _record_digest — the digest binds scores/timestamps/dataset for
216
+ # tamper-evidence but is not a model-id oracle). Same "<alg>:<hex>" label scheme as
217
+ # adapters/_provenance.config_hash, but note it uses NO domain-separation tag (the label
218
+ # distinguishes it); it is provenance metadata, never a security commitment.
219
+ provenance["eee_record_sha256"] = _record_digest(record)
220
+ # the RESULT-level id is traceability metadata; the TOP-level evaluation_id embeds the model id
221
+ # in cleartext and stays deliberately excluded. Guard the result id: drop it if ANY model-name
222
+ # component appears (six-lens review: the exact full-repo-id substring test missed the bare
223
+ # name and slug variants — 'arc/gpt2/run1' leaked 'gpt2'). Case-insensitive over a token set.
224
+ _rid = chosen.get("evaluation_result_id")
225
+ if isinstance(_rid, str) and _rid and not _leaks_model_id(_rid, str(model_id)):
226
+ add_provenance(provenance, run_id=_rid)
152
227
  if eval_library.get("name"):
153
228
  provenance["harness"] = str(eval_library["name"])
154
229
  if eval_library.get("version"):