proofbundle 1.1.0__tar.gz → 1.2.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 (63) hide show
  1. {proofbundle-1.1.0/src/proofbundle.egg-info → proofbundle-1.2.0}/PKG-INFO +62 -21
  2. {proofbundle-1.1.0 → proofbundle-1.2.0}/README.md +61 -20
  3. {proofbundle-1.1.0 → proofbundle-1.2.0}/pyproject.toml +1 -1
  4. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/__init__.py +7 -2
  5. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/bundle.py +65 -1
  6. proofbundle-1.2.0/src/proofbundle/checkpoint.py +325 -0
  7. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/cli.py +12 -2
  8. proofbundle-1.2.0/src/proofbundle/kbjwt.py +206 -0
  9. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/sdjwt.py +7 -3
  10. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/sdjwt_issue.py +35 -1
  11. {proofbundle-1.1.0 → proofbundle-1.2.0/src/proofbundle.egg-info}/PKG-INFO +62 -21
  12. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/SOURCES.txt +3 -0
  13. proofbundle-1.2.0/tests/test_cli.py +91 -0
  14. proofbundle-1.2.0/tests/test_cosignature.py +162 -0
  15. proofbundle-1.2.0/tests/test_kbjwt.py +263 -0
  16. proofbundle-1.1.0/src/proofbundle/checkpoint.py +0 -157
  17. proofbundle-1.1.0/tests/test_cli.py +0 -40
  18. {proofbundle-1.1.0 → proofbundle-1.2.0}/LICENSE +0 -0
  19. {proofbundle-1.1.0 → proofbundle-1.2.0}/setup.cfg +0 -0
  20. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/_inspect_registry.py +0 -0
  21. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/_integration.py +0 -0
  22. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/adapters/__init__.py +0 -0
  23. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/adapters/eee.py +0 -0
  24. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/adapters/inspect_ai.py +0 -0
  25. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/adapters/lm_eval.py +0 -0
  26. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/dsse.py +0 -0
  27. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/eee_eval_schema.json +0 -0
  28. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/emit.py +0 -0
  29. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/errors.py +0 -0
  30. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/evalclaim.py +0 -0
  31. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/inspect_hook.py +0 -0
  32. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/intoto.py +0 -0
  33. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/merkle.py +0 -0
  34. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/py.typed +0 -0
  35. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/pytest_plugin.py +0 -0
  36. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/signature.py +0 -0
  37. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/dependency_links.txt +0 -0
  38. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/entry_points.txt +0 -0
  39. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/requires.txt +0 -0
  40. {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/top_level.txt +0 -0
  41. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_adapters.py +0 -0
  42. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_adversarial.py +0 -0
  43. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_bundle.py +0 -0
  44. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_bundle_robustness.py +0 -0
  45. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_checkpoint.py +0 -0
  46. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_cli_eval.py +0 -0
  47. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_eee.py +0 -0
  48. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_emit.py +0 -0
  49. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_eval_claim_schema.py +0 -0
  50. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_evalclaim.py +0 -0
  51. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_examples.py +0 -0
  52. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_inspect_hook.py +0 -0
  53. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_intoto.py +0 -0
  54. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_intoto_dsse.py +0 -0
  55. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_merkle.py +0 -0
  56. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_merkle_property.py +0 -0
  57. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_pytest_plugin.py +0 -0
  58. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_rekor_interop.py +0 -0
  59. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_rfc6962_external_vectors.py +0 -0
  60. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_schema.py +0 -0
  61. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_sdjwt_issue.py +0 -0
  62. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_sdjwt_reference.py +0 -0
  63. {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_signature.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofbundle
3
- Version: 1.1.0
3
+ Version: 1.2.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
@@ -73,7 +73,7 @@ no server, no network.**
73
73
 
74
74
  **At a glance:** `proofbundle emit` signs and anchors a payload; `proofbundle
75
75
  verify` checks one self-contained `bundle.json` with three offline cryptographic
76
- checks → `OK` or `FAILED`. No network, no daemon, no own crypto. 102 tests.
76
+ checks → `OK` or `FAILED`. No network, no daemon, no own crypto. 148 tests.
77
77
 
78
78
  ## Contents
79
79
 
@@ -148,6 +148,12 @@ A bundle is a single JSON document. `proofbundle` checks, offline:
148
148
  Certificate Transparency)
149
149
  3. **sd-jwt** (optional) — an embedded SD-JWT selective-disclosure credential is
150
150
  well formed, and if an issuer key is given, correctly issuer-signed
151
+ 4. **sd-jwt-key-binding** (optional, v1.2) — if the SD-JWT carries a Key Binding
152
+ JWT (RFC 9901 §4.3), it is verified **fail-closed**: `typ` is `kb+jwt`,
153
+ `iat`/`aud`/`nonce`/`sd_hash` are present, `sd_hash` binds the exact presented
154
+ disclosure set, and the signature verifies under the issuer-bound `cnf.jwk`
155
+ holder key. A present-but-broken KB-JWT fails the bundle — it is never
156
+ silently ignored.
151
157
 
152
158
  The verifier treats the payload as opaque bytes. It proves that these exact
153
159
  bytes were signed and anchored, not what they mean. That is on purpose: it keeps
@@ -160,16 +166,18 @@ flowchart LR
160
166
  P["payload bytes"]
161
167
  P -->|"Ed25519 sign"| S["signature"]
162
168
  P -->|"RFC 6962 anchor"| M["Merkle inclusion proof"]
163
- SD["SD-JWT VC (optional)"] -.-> B
169
+ SD["SD-JWT VC + KB-JWT (optional)"] -.-> B
164
170
  S --> B["bundle.json"]
165
171
  M --> B
166
172
  B --> V{{"proofbundle verify"}}
167
173
  V --> C1["ed25519-signature"]
168
174
  V --> C2["merkle-inclusion"]
169
175
  V --> C3["sd-jwt (optional)"]
176
+ V --> C4["key binding (optional)"]
170
177
  C1 --> R{"all checks pass?"}
171
178
  C2 --> R
172
179
  C3 --> R
180
+ C4 --> R
173
181
  R -->|yes| OK(["=> OK   exit 0"])
174
182
  R -->|no| FAIL(["=> FAILED   exit 1"])
175
183
 
@@ -215,6 +223,14 @@ Machine-readable output and a non-zero exit code on failure:
215
223
  proofbundle verify --json bundle.json # exit 0 = ok, 1 = failed, 2 = malformed
216
224
  ```
217
225
 
226
+ Debugging an inclusion proof (v1.2): `--verbose` prints the recomputed Merkle
227
+ root next to the stated root, so a `FAIL` shows *which* root your payload
228
+ actually anchors to:
229
+
230
+ ```bash
231
+ proofbundle verify --verbose bundle.json
232
+ ```
233
+
218
234
  Emit a bundle of your own (v0.2): sign a payload with a fresh key and anchor it,
219
235
  then verify it anywhere, offline.
220
236
 
@@ -289,6 +305,13 @@ vectors vendored from
289
305
  [transparency-dev/merkle](https://github.com/transparency-dev/merkle) (see
290
306
  `tests/fixtures/`), plus Hypothesis property tests.
291
307
 
308
+ Since v1.2 proofbundle also speaks the witness layer:
309
+ [C2SP tlog-cosignature](https://github.com/C2SP/C2SP/blob/main/tlog-cosignature.md)
310
+ (Ed25519 cosignature/v1) — `verify_witnessed_checkpoint` checks a checkpoint is
311
+ both log-signed **and** cosigned by a quorum of distinct witnesses, offline,
312
+ which rules out a split view by the log operator. This is the same
313
+ witnessed-checkpoint pattern Rekor v2 (GA October 2025) institutionalizes.
314
+
292
315
  ## Bundle format (`proofbundle/v0.1`)
293
316
 
294
317
  The format is specified normatively in [SPEC.md](SPEC.md) (fields, encodings,
@@ -312,23 +335,32 @@ RFC 6962 hashing, verification order) with a machine-readable JSON Schema at
312
335
  ```
313
336
 
314
337
  `sd_jwt_vc` is optional. Base64 fields are standard base64; the SD-JWT compact
315
- string uses base64url as per the spec.
338
+ string uses base64url as per the spec. The compact string MAY end in a Key
339
+ Binding JWT (instead of the trailing `~`), which is then verified (v1.2).
316
340
 
317
341
  ## Security notes and scope, stated honestly
318
342
 
319
343
  The scope is deliberately narrow. It does exactly what it says and no more:
320
344
 
321
- - Ed25519 signatures only, for both the payload and the optional SD-JWT issuer
322
- signature.
345
+ - Ed25519 signatures only, for the payload, the optional SD-JWT issuer
346
+ signature, and the optional KB-JWT holder signature.
323
347
  - SD-JWT: the SD-JWT core is now [RFC 9901](https://datatracker.ietf.org/doc/rfc9901/)
324
348
  (November 2025); this verifies that every presented disclosure is committed in the
325
- issuer-signed payload, and the issuer signature (EdDSA) if a key is supplied. It
326
- does **not** verify a Key Binding JWT, an X.509 or trust-list chain, status
327
- lists, or `vct` type metadata. **SD-JWT VC** (the credential-type profile) is
328
- still an IETF draft ([draft-ietf-oauth-sd-jwt-vc](https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/));
349
+ issuer-signed payload, the issuer signature (EdDSA) if a key is supplied, and — since
350
+ v1.2, fail-closed a Key Binding JWT if one is attached (RFC 9901 §4.3: `kb+jwt`
351
+ typ, required `iat`/`aud`/`nonce`/`sd_hash`, `sd_hash` over the presented disclosure
352
+ set, holder signature under the issuer-bound `cnf.jwk`). `aud`/`nonce` *values* and
353
+ `iat` freshness are relying-party policy (an offline verifier has no trusted clock);
354
+ pass `expected_aud`/`expected_nonce` to `verify_key_binding` to enforce them. It does
355
+ **not** verify an X.509 or trust-list chain, status lists, or `vct` type metadata.
356
+ **SD-JWT VC** (the credential-type profile) is still an IETF draft
357
+ ([draft-ietf-oauth-sd-jwt-vc](https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/));
329
358
  full VC conformance is on the roadmap.
359
+ - Witness cosignatures (v1.2) prove consistency *observations* by the listed
360
+ witnesses; real split-view resistance additionally requires the witnesses to be
361
+ operationally independent — a deployment property no file format can supply.
330
362
  - The verifier does not fetch anything. Trust anchors (the signer key, the
331
- expected root) are inputs you supply out of band.
363
+ expected root, witness keys) are inputs you supply out of band.
332
364
  - No custom cryptography. Ed25519 comes from `cryptography`; Merkle hashing is
333
365
  RFC 6962.
334
366
 
@@ -404,13 +436,17 @@ threshold was met without revealing the model or the data. See [INTEROP.md](INTE
404
436
  in-toto verifier understands it. Alongside the self-hosted-predicate `to_intoto_statement` (see
405
437
  [PREDICATE.md](PREDICATE.md)). Metric details live in `annotations` (test-result has no native metric
406
438
  field); the model/dataset stay salted commitments, never `sha256`.
407
- - **C2SP tlog-checkpoint** (v0.9) — `proofbundle.checkpoint.sign_checkpoint(origin, tree_size, root, …)`
408
- emits a valid [C2SP](https://github.com/C2SP/C2SP/blob/main/tlog-checkpoint.md) signed note over the
409
- RFC 6962 Merkle root, making a receipt witness-network / transparency-log compatible. Pure serialization
410
- over the Ed25519 key already in use no new crypto.
411
- - **SD-JWT issuance** (RFC 9901, verified Nov 2025) `proofbundle.sdjwt_issue.issue_sd_jwt(claim, signer,
412
- root_b64=…, exact_score=…)` issues the receipt so a holder can disclose `passed` +
413
- `threshold` while **withholding the exact score** and the identifier openings. The digest mechanic is
439
+ - **C2SP tlog-checkpoint + cosignatures** (v0.9/v1.2) — `proofbundle.checkpoint.sign_checkpoint(origin,
440
+ tree_size, root, …)` emits a valid [C2SP](https://github.com/C2SP/C2SP/blob/main/tlog-checkpoint.md)
441
+ signed note over the RFC 6962 Merkle root; `cosign_checkpoint` / `verify_witnessed_checkpoint` add and
442
+ check Ed25519 cosignature/v1 witness cosignatures with a quorum threshold, making a receipt
443
+ witness-network / transparency-log compatible. Pure serialization over the Ed25519 keys already in use
444
+ no new crypto.
445
+ - **SD-JWT issuance + Key Binding** (RFC 9901) `proofbundle.sdjwt_issue.issue_sd_jwt(claim, signer,
446
+ root_b64=…, exact_score=…, holder_public_key=…)` issues the receipt so a holder can disclose `passed` +
447
+ `threshold` while **withholding the exact score** and the identifier openings, optionally binding a
448
+ holder key via `cnf.jwk`; `present_with_key_binding` builds the holder presentation and
449
+ `proofbundle.kbjwt.verify_key_binding` verifies it (v1.2). The digest mechanic is
414
450
  RFC 9901 §4.2.3 (base64url of SHA-256 over the base64url-encoded Disclosure), cross-checked against the
415
451
  `sd-jwt-python` reference.
416
452
  The signed bundle payload is always the source of truth; the SD-JWT and the in-toto export are derived,
@@ -437,10 +473,15 @@ attestation — see [SECURITY.md](SECURITY.md).
437
473
  the RFC 6962 root, an Every Eval Ever converter, and standards-native repositioning.
438
474
  - **v1.0** — distribution: opt-in framework integrations that auto-emit a signed receipt of an inspect_ai
439
475
  eval (end-of-task hook) or a pytest run (pytest11 plugin), plus a composite GitHub Action.
440
- - **v1.1 (current release)** — trust hardening: a signed `assurance_level`, a THREAT_MODEL, a self_attested-
476
+ - **v1.1** — trust hardening: a signed `assurance_level`, a THREAT_MODEL, a self_attested-
441
477
  without-prereg warning, model-swap + replay + withheld-field checks, and an adversarial No-Fake-PASS suite.
442
- - **Deferred** (explicitly not yet built) — SD-JWT VC conformance + `vct` metadata,
443
- Key-Binding JWT, status lists / revocation, an official in-toto PR, DSSE / a full in-toto client.
478
+ - **v1.2 (current release)**holder binding + witness quorum: **Key Binding JWT** verification
479
+ (RFC 9901 §4.3, fail-closed; closes #1) with `cnf.jwk` issuance and holder presentation, **C2SP
480
+ tlog-cosignature** verification (Ed25519 cosignature/v1, witness quorum, split-view resistance), and
481
+ `verify --verbose` with the recomputed Merkle root (closes #2).
482
+ - **Deferred** (explicitly not yet built) — SD-JWT VC conformance + `vct` metadata (not an RFC yet),
483
+ Token Status List verification (draft in the RFC-Editor queue; planned as a bundled snapshot to stay
484
+ offline), ML-DSA-44 cosignatures, an official in-toto PR, a full in-toto client.
444
485
 
445
486
  ## Contributing
446
487
 
@@ -28,7 +28,7 @@ no server, no network.**
28
28
 
29
29
  **At a glance:** `proofbundle emit` signs and anchors a payload; `proofbundle
30
30
  verify` checks one self-contained `bundle.json` with three offline cryptographic
31
- checks → `OK` or `FAILED`. No network, no daemon, no own crypto. 102 tests.
31
+ checks → `OK` or `FAILED`. No network, no daemon, no own crypto. 148 tests.
32
32
 
33
33
  ## Contents
34
34
 
@@ -103,6 +103,12 @@ A bundle is a single JSON document. `proofbundle` checks, offline:
103
103
  Certificate Transparency)
104
104
  3. **sd-jwt** (optional) — an embedded SD-JWT selective-disclosure credential is
105
105
  well formed, and if an issuer key is given, correctly issuer-signed
106
+ 4. **sd-jwt-key-binding** (optional, v1.2) — if the SD-JWT carries a Key Binding
107
+ JWT (RFC 9901 §4.3), it is verified **fail-closed**: `typ` is `kb+jwt`,
108
+ `iat`/`aud`/`nonce`/`sd_hash` are present, `sd_hash` binds the exact presented
109
+ disclosure set, and the signature verifies under the issuer-bound `cnf.jwk`
110
+ holder key. A present-but-broken KB-JWT fails the bundle — it is never
111
+ silently ignored.
106
112
 
107
113
  The verifier treats the payload as opaque bytes. It proves that these exact
108
114
  bytes were signed and anchored, not what they mean. That is on purpose: it keeps
@@ -115,16 +121,18 @@ flowchart LR
115
121
  P["payload bytes"]
116
122
  P -->|"Ed25519 sign"| S["signature"]
117
123
  P -->|"RFC 6962 anchor"| M["Merkle inclusion proof"]
118
- SD["SD-JWT VC (optional)"] -.-> B
124
+ SD["SD-JWT VC + KB-JWT (optional)"] -.-> B
119
125
  S --> B["bundle.json"]
120
126
  M --> B
121
127
  B --> V{{"proofbundle verify"}}
122
128
  V --> C1["ed25519-signature"]
123
129
  V --> C2["merkle-inclusion"]
124
130
  V --> C3["sd-jwt (optional)"]
131
+ V --> C4["key binding (optional)"]
125
132
  C1 --> R{"all checks pass?"}
126
133
  C2 --> R
127
134
  C3 --> R
135
+ C4 --> R
128
136
  R -->|yes| OK(["=> OK   exit 0"])
129
137
  R -->|no| FAIL(["=> FAILED   exit 1"])
130
138
 
@@ -170,6 +178,14 @@ Machine-readable output and a non-zero exit code on failure:
170
178
  proofbundle verify --json bundle.json # exit 0 = ok, 1 = failed, 2 = malformed
171
179
  ```
172
180
 
181
+ Debugging an inclusion proof (v1.2): `--verbose` prints the recomputed Merkle
182
+ root next to the stated root, so a `FAIL` shows *which* root your payload
183
+ actually anchors to:
184
+
185
+ ```bash
186
+ proofbundle verify --verbose bundle.json
187
+ ```
188
+
173
189
  Emit a bundle of your own (v0.2): sign a payload with a fresh key and anchor it,
174
190
  then verify it anywhere, offline.
175
191
 
@@ -244,6 +260,13 @@ vectors vendored from
244
260
  [transparency-dev/merkle](https://github.com/transparency-dev/merkle) (see
245
261
  `tests/fixtures/`), plus Hypothesis property tests.
246
262
 
263
+ Since v1.2 proofbundle also speaks the witness layer:
264
+ [C2SP tlog-cosignature](https://github.com/C2SP/C2SP/blob/main/tlog-cosignature.md)
265
+ (Ed25519 cosignature/v1) — `verify_witnessed_checkpoint` checks a checkpoint is
266
+ both log-signed **and** cosigned by a quorum of distinct witnesses, offline,
267
+ which rules out a split view by the log operator. This is the same
268
+ witnessed-checkpoint pattern Rekor v2 (GA October 2025) institutionalizes.
269
+
247
270
  ## Bundle format (`proofbundle/v0.1`)
248
271
 
249
272
  The format is specified normatively in [SPEC.md](SPEC.md) (fields, encodings,
@@ -267,23 +290,32 @@ RFC 6962 hashing, verification order) with a machine-readable JSON Schema at
267
290
  ```
268
291
 
269
292
  `sd_jwt_vc` is optional. Base64 fields are standard base64; the SD-JWT compact
270
- string uses base64url as per the spec.
293
+ string uses base64url as per the spec. The compact string MAY end in a Key
294
+ Binding JWT (instead of the trailing `~`), which is then verified (v1.2).
271
295
 
272
296
  ## Security notes and scope, stated honestly
273
297
 
274
298
  The scope is deliberately narrow. It does exactly what it says and no more:
275
299
 
276
- - Ed25519 signatures only, for both the payload and the optional SD-JWT issuer
277
- signature.
300
+ - Ed25519 signatures only, for the payload, the optional SD-JWT issuer
301
+ signature, and the optional KB-JWT holder signature.
278
302
  - SD-JWT: the SD-JWT core is now [RFC 9901](https://datatracker.ietf.org/doc/rfc9901/)
279
303
  (November 2025); this verifies that every presented disclosure is committed in the
280
- issuer-signed payload, and the issuer signature (EdDSA) if a key is supplied. It
281
- does **not** verify a Key Binding JWT, an X.509 or trust-list chain, status
282
- lists, or `vct` type metadata. **SD-JWT VC** (the credential-type profile) is
283
- still an IETF draft ([draft-ietf-oauth-sd-jwt-vc](https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/));
304
+ issuer-signed payload, the issuer signature (EdDSA) if a key is supplied, and — since
305
+ v1.2, fail-closed a Key Binding JWT if one is attached (RFC 9901 §4.3: `kb+jwt`
306
+ typ, required `iat`/`aud`/`nonce`/`sd_hash`, `sd_hash` over the presented disclosure
307
+ set, holder signature under the issuer-bound `cnf.jwk`). `aud`/`nonce` *values* and
308
+ `iat` freshness are relying-party policy (an offline verifier has no trusted clock);
309
+ pass `expected_aud`/`expected_nonce` to `verify_key_binding` to enforce them. It does
310
+ **not** verify an X.509 or trust-list chain, status lists, or `vct` type metadata.
311
+ **SD-JWT VC** (the credential-type profile) is still an IETF draft
312
+ ([draft-ietf-oauth-sd-jwt-vc](https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/));
284
313
  full VC conformance is on the roadmap.
314
+ - Witness cosignatures (v1.2) prove consistency *observations* by the listed
315
+ witnesses; real split-view resistance additionally requires the witnesses to be
316
+ operationally independent — a deployment property no file format can supply.
285
317
  - The verifier does not fetch anything. Trust anchors (the signer key, the
286
- expected root) are inputs you supply out of band.
318
+ expected root, witness keys) are inputs you supply out of band.
287
319
  - No custom cryptography. Ed25519 comes from `cryptography`; Merkle hashing is
288
320
  RFC 6962.
289
321
 
@@ -359,13 +391,17 @@ threshold was met without revealing the model or the data. See [INTEROP.md](INTE
359
391
  in-toto verifier understands it. Alongside the self-hosted-predicate `to_intoto_statement` (see
360
392
  [PREDICATE.md](PREDICATE.md)). Metric details live in `annotations` (test-result has no native metric
361
393
  field); the model/dataset stay salted commitments, never `sha256`.
362
- - **C2SP tlog-checkpoint** (v0.9) — `proofbundle.checkpoint.sign_checkpoint(origin, tree_size, root, …)`
363
- emits a valid [C2SP](https://github.com/C2SP/C2SP/blob/main/tlog-checkpoint.md) signed note over the
364
- RFC 6962 Merkle root, making a receipt witness-network / transparency-log compatible. Pure serialization
365
- over the Ed25519 key already in use no new crypto.
366
- - **SD-JWT issuance** (RFC 9901, verified Nov 2025) `proofbundle.sdjwt_issue.issue_sd_jwt(claim, signer,
367
- root_b64=…, exact_score=…)` issues the receipt so a holder can disclose `passed` +
368
- `threshold` while **withholding the exact score** and the identifier openings. The digest mechanic is
394
+ - **C2SP tlog-checkpoint + cosignatures** (v0.9/v1.2) — `proofbundle.checkpoint.sign_checkpoint(origin,
395
+ tree_size, root, …)` emits a valid [C2SP](https://github.com/C2SP/C2SP/blob/main/tlog-checkpoint.md)
396
+ signed note over the RFC 6962 Merkle root; `cosign_checkpoint` / `verify_witnessed_checkpoint` add and
397
+ check Ed25519 cosignature/v1 witness cosignatures with a quorum threshold, making a receipt
398
+ witness-network / transparency-log compatible. Pure serialization over the Ed25519 keys already in use
399
+ no new crypto.
400
+ - **SD-JWT issuance + Key Binding** (RFC 9901) `proofbundle.sdjwt_issue.issue_sd_jwt(claim, signer,
401
+ root_b64=…, exact_score=…, holder_public_key=…)` issues the receipt so a holder can disclose `passed` +
402
+ `threshold` while **withholding the exact score** and the identifier openings, optionally binding a
403
+ holder key via `cnf.jwk`; `present_with_key_binding` builds the holder presentation and
404
+ `proofbundle.kbjwt.verify_key_binding` verifies it (v1.2). The digest mechanic is
369
405
  RFC 9901 §4.2.3 (base64url of SHA-256 over the base64url-encoded Disclosure), cross-checked against the
370
406
  `sd-jwt-python` reference.
371
407
  The signed bundle payload is always the source of truth; the SD-JWT and the in-toto export are derived,
@@ -392,10 +428,15 @@ attestation — see [SECURITY.md](SECURITY.md).
392
428
  the RFC 6962 root, an Every Eval Ever converter, and standards-native repositioning.
393
429
  - **v1.0** — distribution: opt-in framework integrations that auto-emit a signed receipt of an inspect_ai
394
430
  eval (end-of-task hook) or a pytest run (pytest11 plugin), plus a composite GitHub Action.
395
- - **v1.1 (current release)** — trust hardening: a signed `assurance_level`, a THREAT_MODEL, a self_attested-
431
+ - **v1.1** — trust hardening: a signed `assurance_level`, a THREAT_MODEL, a self_attested-
396
432
  without-prereg warning, model-swap + replay + withheld-field checks, and an adversarial No-Fake-PASS suite.
397
- - **Deferred** (explicitly not yet built) — SD-JWT VC conformance + `vct` metadata,
398
- Key-Binding JWT, status lists / revocation, an official in-toto PR, DSSE / a full in-toto client.
433
+ - **v1.2 (current release)**holder binding + witness quorum: **Key Binding JWT** verification
434
+ (RFC 9901 §4.3, fail-closed; closes #1) with `cnf.jwk` issuance and holder presentation, **C2SP
435
+ tlog-cosignature** verification (Ed25519 cosignature/v1, witness quorum, split-view resistance), and
436
+ `verify --verbose` with the recomputed Merkle root (closes #2).
437
+ - **Deferred** (explicitly not yet built) — SD-JWT VC conformance + `vct` metadata (not an RFC yet),
438
+ Token Status List verification (draft in the RFC-Editor queue; planned as a bundled snapshot to stay
439
+ offline), ML-DSA-44 cosignatures, an official in-toto PR, a full in-toto client.
399
440
 
400
441
  ## Contributing
401
442
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proofbundle"
7
- version = "1.1.0"
7
+ version = "1.2.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.9"
@@ -13,17 +13,19 @@ from __future__ import annotations
13
13
 
14
14
  from typing import TYPE_CHECKING
15
15
 
16
- __version__ = "1.1.0"
16
+ __version__ = "1.2.0"
17
17
 
18
18
  __all__ = [
19
19
  "__version__",
20
20
  "SCHEMA",
21
21
  "verify_bundle",
22
22
  "load_bundle",
23
+ "recompute_merkle_root_b64",
23
24
  "emit_bundle",
24
25
  "generate_signer",
25
26
  "verify_inclusion",
26
27
  "verify_consistency",
28
+ "verify_key_binding",
27
29
  "VerificationResult",
28
30
  "Check",
29
31
  "ProofBundleError",
@@ -32,15 +34,18 @@ __all__ = [
32
34
  # name → backing submodule (relative). Loaded on first attribute access.
33
35
  _LAZY = {
34
36
  "SCHEMA": ".bundle", "load_bundle": ".bundle", "verify_bundle": ".bundle",
37
+ "recompute_merkle_root_b64": ".bundle",
35
38
  "emit_bundle": ".emit", "generate_signer": ".emit",
36
39
  "Check": ".errors", "ProofBundleError": ".errors", "VerificationResult": ".errors",
37
40
  "verify_consistency": ".merkle", "verify_inclusion": ".merkle",
41
+ "verify_key_binding": ".kbjwt",
38
42
  }
39
43
 
40
44
  if TYPE_CHECKING: # static analysers + IDEs see the real names/types; runtime stays lazy
41
- from .bundle import SCHEMA, load_bundle, verify_bundle
45
+ from .bundle import SCHEMA, load_bundle, recompute_merkle_root_b64, verify_bundle
42
46
  from .emit import emit_bundle, generate_signer
43
47
  from .errors import Check, ProofBundleError, VerificationResult
48
+ from .kbjwt import verify_key_binding
44
49
  from .merkle import verify_consistency, verify_inclusion
45
50
 
46
51
 
@@ -27,10 +27,24 @@ from typing import Union
27
27
 
28
28
  from . import merkle
29
29
  from .errors import BundleFormatError, UnsupportedError, VerificationResult
30
+ from .kbjwt import holder_key_from_cnf, split_key_binding, verify_key_binding
30
31
  from .signature import verify_ed25519
31
32
  from .sdjwt import verify_sd_jwt
32
33
 
33
- __all__ = ["SCHEMA", "verify_bundle", "load_bundle"]
34
+ __all__ = ["SCHEMA", "verify_bundle", "load_bundle", "recompute_merkle_root_b64"]
35
+
36
+
37
+ def _issuer_requires_holder_binding(sd_part: str) -> bool:
38
+ """True iff the issuer-signed SD-JWT payload carries a usable ``cnf`` holder key (RFC 7800) — i.e. the
39
+ issuer REQUIRES proof-of-possession. A presentation without a valid Key Binding JWT is then a bearer
40
+ downgrade and MUST fail. Malformed/absent → False (no cnf ⇒ no binding required, backward-compatible)."""
41
+ try:
42
+ issuer_jwt = sd_part.split("~", 1)[0]
43
+ payload_b64 = issuer_jwt.split(".")[1].encode("ascii")
44
+ payload = json.loads(base64.urlsafe_b64decode(payload_b64 + b"=" * (-len(payload_b64) % 4)))
45
+ return isinstance(payload, dict) and holder_key_from_cnf(payload) is not None
46
+ except Exception:
47
+ return False
34
48
 
35
49
  SCHEMA = "proofbundle/v0.1"
36
50
 
@@ -145,5 +159,55 @@ def verify_bundle(bundle: Union[dict, str]) -> VerificationResult:
145
159
  sd_res["sig_ok"],
146
160
  "issuer signature valid" if sd_res["sig_ok"] else "issuer signature invalid",
147
161
  )
162
+ # v1.2, fail-closed: a KB-JWT that is PRESENT must verify (RFC 9901 §4.3). Before
163
+ # v1.2 a trailing KB-JWT was silently ignored — a downgrade risk. Bundles without
164
+ # a KB-JWT are untouched ONLY when the issuer did NOT bind a holder key: a v0.9/v1.0/
165
+ # v1.1 bundle with no ``cnf`` verifies exactly as before.
166
+ # CRITICAL fix (release review 2026-07-02): if the issuer-signed payload DOES carry a
167
+ # ``cnf`` holder key (proof-of-possession REQUIRED by the issuer), a presentation with
168
+ # NO Key Binding JWT is a bearer downgrade — anyone who sees the disclosed SD-JWT could
169
+ # replay it. That MUST fail, not silently pass.
170
+ if isinstance(compact, str):
171
+ sd_part, kb = split_key_binding(compact)
172
+ if kb is not None:
173
+ kb_res = verify_key_binding(compact)
174
+ result.add("sd-jwt-key-binding", kb_res["ok"], kb_res["detail"])
175
+ elif _issuer_requires_holder_binding(sd_part):
176
+ result.add(
177
+ "sd-jwt-key-binding", False,
178
+ "issuer bound a holder key (cnf) but the presentation carries NO Key Binding JWT — "
179
+ "required proof-of-possession is missing (bearer downgrade, RFC 9901 §4.3)")
148
180
 
149
181
  return result
182
+
183
+
184
+ def recompute_merkle_root_b64(bundle: Union[dict, str]) -> dict:
185
+ """Recompute the Merkle root from the bundle's own payload + inclusion proof (v1.2, issue #2).
186
+
187
+ Debugging aid for ``proofbundle verify --verbose``: returns
188
+ ``{"stated_b64": ..., "recomputed_b64": ...}`` where ``recomputed_b64`` is None when the
189
+ proof cannot be evaluated (e.g. index out of range, proof too short/long). Performs the
190
+ same strict format validation as :func:`verify_bundle` — malformed input raises
191
+ ``BundleFormatError``, never a raw traceback.
192
+ """
193
+ if isinstance(bundle, str):
194
+ bundle = load_bundle(bundle)
195
+ if not isinstance(bundle, dict):
196
+ raise BundleFormatError("bundle must be a JSON object")
197
+ payload = _b64d(_require(bundle, "payload_b64", "payload_b64"), "payload_b64")
198
+ mk = _require_dict(_require(bundle, "merkle", "merkle"), "merkle")
199
+ leaf_index = _require_int(mk, "leaf_index", "merkle.leaf_index")
200
+ tree_size = _require_int(mk, "tree_size", "merkle.tree_size")
201
+ proof_list = _require(mk, "inclusion_proof_b64", "merkle.inclusion_proof_b64")
202
+ if not isinstance(proof_list, list):
203
+ raise BundleFormatError("field merkle.inclusion_proof_b64 must be a list")
204
+ proof = [_b64d(p, "merkle.inclusion_proof_b64[]") for p in proof_list]
205
+ stated_b64 = _require(mk, "root_b64", "merkle.root_b64")
206
+ _b64d(stated_b64, "merkle.root_b64") # validate encoding
207
+ try:
208
+ recomputed = merkle.root_from_inclusion(
209
+ leaf_index, tree_size, merkle.leaf_hash(payload), proof)
210
+ recomputed_b64 = base64.b64encode(recomputed).decode("ascii")
211
+ except ValueError as exc:
212
+ return {"stated_b64": stated_b64, "recomputed_b64": None, "detail": str(exc)}
213
+ return {"stated_b64": stated_b64, "recomputed_b64": recomputed_b64, "detail": ""}