proofbundle 1.0.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.0.0/src/proofbundle.egg-info → proofbundle-1.2.0}/PKG-INFO +92 -22
  2. {proofbundle-1.0.0 → proofbundle-1.2.0}/README.md +91 -21
  3. {proofbundle-1.0.0 → proofbundle-1.2.0}/pyproject.toml +1 -1
  4. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/__init__.py +7 -2
  5. {proofbundle-1.0.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.0.0 → proofbundle-1.2.0}/src/proofbundle/cli.py +24 -3
  8. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/evalclaim.py +104 -5
  9. proofbundle-1.2.0/src/proofbundle/kbjwt.py +206 -0
  10. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/sdjwt.py +7 -3
  11. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/sdjwt_issue.py +35 -1
  12. {proofbundle-1.0.0 → proofbundle-1.2.0/src/proofbundle.egg-info}/PKG-INFO +92 -22
  13. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/SOURCES.txt +4 -0
  14. proofbundle-1.2.0/tests/test_adversarial.py +95 -0
  15. proofbundle-1.2.0/tests/test_cli.py +91 -0
  16. proofbundle-1.2.0/tests/test_cosignature.py +162 -0
  17. proofbundle-1.2.0/tests/test_kbjwt.py +263 -0
  18. proofbundle-1.0.0/src/proofbundle/checkpoint.py +0 -157
  19. proofbundle-1.0.0/tests/test_cli.py +0 -40
  20. {proofbundle-1.0.0 → proofbundle-1.2.0}/LICENSE +0 -0
  21. {proofbundle-1.0.0 → proofbundle-1.2.0}/setup.cfg +0 -0
  22. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/_inspect_registry.py +0 -0
  23. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/_integration.py +0 -0
  24. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/adapters/__init__.py +0 -0
  25. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/adapters/eee.py +0 -0
  26. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/adapters/inspect_ai.py +0 -0
  27. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/adapters/lm_eval.py +0 -0
  28. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/dsse.py +0 -0
  29. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/eee_eval_schema.json +0 -0
  30. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/emit.py +0 -0
  31. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/errors.py +0 -0
  32. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/inspect_hook.py +0 -0
  33. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/intoto.py +0 -0
  34. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/merkle.py +0 -0
  35. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/py.typed +0 -0
  36. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/pytest_plugin.py +0 -0
  37. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/signature.py +0 -0
  38. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/dependency_links.txt +0 -0
  39. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/entry_points.txt +0 -0
  40. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/requires.txt +0 -0
  41. {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/top_level.txt +0 -0
  42. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_adapters.py +0 -0
  43. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_bundle.py +0 -0
  44. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_bundle_robustness.py +0 -0
  45. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_checkpoint.py +0 -0
  46. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_cli_eval.py +0 -0
  47. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_eee.py +0 -0
  48. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_emit.py +0 -0
  49. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_eval_claim_schema.py +0 -0
  50. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_evalclaim.py +0 -0
  51. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_examples.py +0 -0
  52. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_inspect_hook.py +0 -0
  53. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_intoto.py +0 -0
  54. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_intoto_dsse.py +0 -0
  55. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_merkle.py +0 -0
  56. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_merkle_property.py +0 -0
  57. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_pytest_plugin.py +0 -0
  58. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_rekor_interop.py +0 -0
  59. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_rfc6962_external_vectors.py +0 -0
  60. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_schema.py +0 -0
  61. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_sdjwt_issue.py +0 -0
  62. {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_sdjwt_reference.py +0 -0
  63. {proofbundle-1.0.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.0.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
 
@@ -111,6 +111,33 @@ disclosable receipt. The verifier shipped first, small and correct, so it could
111
111
  be reviewed and trusted on its own; `emit_bundle` now creates bundles that
112
112
  `verify_bundle` accepts, fully offline on both sides.
113
113
 
114
+ ## What a receipt proves (and what it does not)
115
+
116
+ A receipt is a **tamper-evident, signed statement of authorship and integrity** over an eval or test result —
117
+ not a proof that the number is *true* or that the evaluation was well designed. Hold these apart:
118
+
119
+ - **It proves:** the payload was signed by the stated issuer (authorship), no byte changed since (integrity,
120
+ Ed25519 + RFC 6962), the model/dataset behind salted commitments, and — since v1.1 — the **assurance level**
121
+ is signed in — tamper-evident and bound to the issuer, so a third party cannot alter it. `show-eval`
122
+ displays the level, warns on the weakest combination (self_attested with no pre-registration), and shows
123
+ withheld SD-JWT fields + receipt age; the `verify_commitment` library call (the holder presents the
124
+ identifier + salt out of band) makes a model-swap visible.
125
+ - **It does not prove:** that a *self-attested* issuer is honest. The level is issuer-DECLARED: a dishonest
126
+ issuer can sign `reproduced` on a self-run eval — the signature binds *who claimed it* to them, it does not
127
+ make the claim true (same as the score). The warning catches the honest self_attested case; a higher level
128
+ is only as trustworthy as the process behind it.
129
+ - **Also not proven:** that a result was not cherry-picked from many runs without pre-registration, or that
130
+ the suite measures what it claims. Those need a pre-registered protocol or independent reproduction.
131
+
132
+ | assurance_level | meaning |
133
+ |---|---|
134
+ | `self_attested` | issuer ran + signed it (default); trust rests on the issuer |
135
+ | `third_party` | a third party checked before signing |
136
+ | `reproduced` | independently re-run and matched |
137
+ | `enclave_attested` | produced in an attested trusted execution environment |
138
+
139
+ Full detail: **[THREAT_MODEL.md](THREAT_MODEL.md)** — what `verify` catches and what it structurally cannot.
140
+
114
141
  ## What it verifies
115
142
 
116
143
  A bundle is a single JSON document. `proofbundle` checks, offline:
@@ -121,6 +148,12 @@ A bundle is a single JSON document. `proofbundle` checks, offline:
121
148
  Certificate Transparency)
122
149
  3. **sd-jwt** (optional) — an embedded SD-JWT selective-disclosure credential is
123
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.
124
157
 
125
158
  The verifier treats the payload as opaque bytes. It proves that these exact
126
159
  bytes were signed and anchored, not what they mean. That is on purpose: it keeps
@@ -133,16 +166,18 @@ flowchart LR
133
166
  P["payload bytes"]
134
167
  P -->|"Ed25519 sign"| S["signature"]
135
168
  P -->|"RFC 6962 anchor"| M["Merkle inclusion proof"]
136
- SD["SD-JWT VC (optional)"] -.-> B
169
+ SD["SD-JWT VC + KB-JWT (optional)"] -.-> B
137
170
  S --> B["bundle.json"]
138
171
  M --> B
139
172
  B --> V{{"proofbundle verify"}}
140
173
  V --> C1["ed25519-signature"]
141
174
  V --> C2["merkle-inclusion"]
142
175
  V --> C3["sd-jwt (optional)"]
176
+ V --> C4["key binding (optional)"]
143
177
  C1 --> R{"all checks pass?"}
144
178
  C2 --> R
145
179
  C3 --> R
180
+ C4 --> R
146
181
  R -->|yes| OK(["=> OK   exit 0"])
147
182
  R -->|no| FAIL(["=> FAILED   exit 1"])
148
183
 
@@ -188,6 +223,14 @@ Machine-readable output and a non-zero exit code on failure:
188
223
  proofbundle verify --json bundle.json # exit 0 = ok, 1 = failed, 2 = malformed
189
224
  ```
190
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
+
191
234
  Emit a bundle of your own (v0.2): sign a payload with a fresh key and anchor it,
192
235
  then verify it anywhere, offline.
193
236
 
@@ -262,6 +305,13 @@ vectors vendored from
262
305
  [transparency-dev/merkle](https://github.com/transparency-dev/merkle) (see
263
306
  `tests/fixtures/`), plus Hypothesis property tests.
264
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
+
265
315
  ## Bundle format (`proofbundle/v0.1`)
266
316
 
267
317
  The format is specified normatively in [SPEC.md](SPEC.md) (fields, encodings,
@@ -285,23 +335,32 @@ RFC 6962 hashing, verification order) with a machine-readable JSON Schema at
285
335
  ```
286
336
 
287
337
  `sd_jwt_vc` is optional. Base64 fields are standard base64; the SD-JWT compact
288
- 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).
289
340
 
290
341
  ## Security notes and scope, stated honestly
291
342
 
292
343
  The scope is deliberately narrow. It does exactly what it says and no more:
293
344
 
294
- - Ed25519 signatures only, for both the payload and the optional SD-JWT issuer
295
- signature.
345
+ - Ed25519 signatures only, for the payload, the optional SD-JWT issuer
346
+ signature, and the optional KB-JWT holder signature.
296
347
  - SD-JWT: the SD-JWT core is now [RFC 9901](https://datatracker.ietf.org/doc/rfc9901/)
297
348
  (November 2025); this verifies that every presented disclosure is committed in the
298
- issuer-signed payload, and the issuer signature (EdDSA) if a key is supplied. It
299
- does **not** verify a Key Binding JWT, an X.509 or trust-list chain, status
300
- lists, or `vct` type metadata. **SD-JWT VC** (the credential-type profile) is
301
- 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/));
302
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.
303
362
  - The verifier does not fetch anything. Trust anchors (the signer key, the
304
- expected root) are inputs you supply out of band.
363
+ expected root, witness keys) are inputs you supply out of band.
305
364
  - No custom cryptography. Ed25519 comes from `cryptography`; Merkle hashing is
306
365
  RFC 6962.
307
366
 
@@ -377,13 +436,17 @@ threshold was met without revealing the model or the data. See [INTEROP.md](INTE
377
436
  in-toto verifier understands it. Alongside the self-hosted-predicate `to_intoto_statement` (see
378
437
  [PREDICATE.md](PREDICATE.md)). Metric details live in `annotations` (test-result has no native metric
379
438
  field); the model/dataset stay salted commitments, never `sha256`.
380
- - **C2SP tlog-checkpoint** (v0.9) — `proofbundle.checkpoint.sign_checkpoint(origin, tree_size, root, …)`
381
- emits a valid [C2SP](https://github.com/C2SP/C2SP/blob/main/tlog-checkpoint.md) signed note over the
382
- RFC 6962 Merkle root, making a receipt witness-network / transparency-log compatible. Pure serialization
383
- over the Ed25519 key already in use no new crypto.
384
- - **SD-JWT issuance** (RFC 9901, verified Nov 2025) `proofbundle.sdjwt_issue.issue_sd_jwt(claim, signer,
385
- root_b64=…, exact_score=…)` issues the receipt so a holder can disclose `passed` +
386
- `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
387
450
  RFC 9901 §4.2.3 (base64url of SHA-256 over the base64url-encoded Disclosure), cross-checked against the
388
451
  `sd-jwt-python` reference.
389
452
  The signed bundle payload is always the source of truth; the SD-JWT and the in-toto export are derived,
@@ -408,10 +471,17 @@ attestation — see [SECURITY.md](SECURITY.md).
408
471
  a sharpened honesty guardrail (authenticity/integrity, not computation-correctness), and outreach drafts.
409
472
  - **v0.9** — the standards moat: a DSSE-signed in-toto `test-result` export, a C2SP tlog-checkpoint over
410
473
  the RFC 6962 root, an Every Eval Ever converter, and standards-native repositioning.
411
- - **v1.0 (current release)** — distribution: opt-in framework integrations that auto-emit a signed receipt
412
- of an inspect_ai eval (end-of-task hook) or a pytest run (pytest11 plugin), plus a composite GitHub Action.
413
- - **Deferred** (explicitly not yet built) SD-JWT VC conformance + `vct` metadata,
414
- Key-Binding JWT, status lists / revocation, an official in-toto PR, DSSE / a full in-toto client.
474
+ - **v1.0** — distribution: opt-in framework integrations that auto-emit a signed receipt of an inspect_ai
475
+ eval (end-of-task hook) or a pytest run (pytest11 plugin), plus a composite GitHub Action.
476
+ - **v1.1** trust hardening: a signed `assurance_level`, a THREAT_MODEL, a self_attested-
477
+ without-prereg warning, model-swap + replay + withheld-field checks, and an adversarial No-Fake-PASS suite.
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.
415
485
 
416
486
  ## Contributing
417
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
 
@@ -66,6 +66,33 @@ disclosable receipt. The verifier shipped first, small and correct, so it could
66
66
  be reviewed and trusted on its own; `emit_bundle` now creates bundles that
67
67
  `verify_bundle` accepts, fully offline on both sides.
68
68
 
69
+ ## What a receipt proves (and what it does not)
70
+
71
+ A receipt is a **tamper-evident, signed statement of authorship and integrity** over an eval or test result —
72
+ not a proof that the number is *true* or that the evaluation was well designed. Hold these apart:
73
+
74
+ - **It proves:** the payload was signed by the stated issuer (authorship), no byte changed since (integrity,
75
+ Ed25519 + RFC 6962), the model/dataset behind salted commitments, and — since v1.1 — the **assurance level**
76
+ is signed in — tamper-evident and bound to the issuer, so a third party cannot alter it. `show-eval`
77
+ displays the level, warns on the weakest combination (self_attested with no pre-registration), and shows
78
+ withheld SD-JWT fields + receipt age; the `verify_commitment` library call (the holder presents the
79
+ identifier + salt out of band) makes a model-swap visible.
80
+ - **It does not prove:** that a *self-attested* issuer is honest. The level is issuer-DECLARED: a dishonest
81
+ issuer can sign `reproduced` on a self-run eval — the signature binds *who claimed it* to them, it does not
82
+ make the claim true (same as the score). The warning catches the honest self_attested case; a higher level
83
+ is only as trustworthy as the process behind it.
84
+ - **Also not proven:** that a result was not cherry-picked from many runs without pre-registration, or that
85
+ the suite measures what it claims. Those need a pre-registered protocol or independent reproduction.
86
+
87
+ | assurance_level | meaning |
88
+ |---|---|
89
+ | `self_attested` | issuer ran + signed it (default); trust rests on the issuer |
90
+ | `third_party` | a third party checked before signing |
91
+ | `reproduced` | independently re-run and matched |
92
+ | `enclave_attested` | produced in an attested trusted execution environment |
93
+
94
+ Full detail: **[THREAT_MODEL.md](THREAT_MODEL.md)** — what `verify` catches and what it structurally cannot.
95
+
69
96
  ## What it verifies
70
97
 
71
98
  A bundle is a single JSON document. `proofbundle` checks, offline:
@@ -76,6 +103,12 @@ A bundle is a single JSON document. `proofbundle` checks, offline:
76
103
  Certificate Transparency)
77
104
  3. **sd-jwt** (optional) — an embedded SD-JWT selective-disclosure credential is
78
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.
79
112
 
80
113
  The verifier treats the payload as opaque bytes. It proves that these exact
81
114
  bytes were signed and anchored, not what they mean. That is on purpose: it keeps
@@ -88,16 +121,18 @@ flowchart LR
88
121
  P["payload bytes"]
89
122
  P -->|"Ed25519 sign"| S["signature"]
90
123
  P -->|"RFC 6962 anchor"| M["Merkle inclusion proof"]
91
- SD["SD-JWT VC (optional)"] -.-> B
124
+ SD["SD-JWT VC + KB-JWT (optional)"] -.-> B
92
125
  S --> B["bundle.json"]
93
126
  M --> B
94
127
  B --> V{{"proofbundle verify"}}
95
128
  V --> C1["ed25519-signature"]
96
129
  V --> C2["merkle-inclusion"]
97
130
  V --> C3["sd-jwt (optional)"]
131
+ V --> C4["key binding (optional)"]
98
132
  C1 --> R{"all checks pass?"}
99
133
  C2 --> R
100
134
  C3 --> R
135
+ C4 --> R
101
136
  R -->|yes| OK(["=> OK   exit 0"])
102
137
  R -->|no| FAIL(["=> FAILED   exit 1"])
103
138
 
@@ -143,6 +178,14 @@ Machine-readable output and a non-zero exit code on failure:
143
178
  proofbundle verify --json bundle.json # exit 0 = ok, 1 = failed, 2 = malformed
144
179
  ```
145
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
+
146
189
  Emit a bundle of your own (v0.2): sign a payload with a fresh key and anchor it,
147
190
  then verify it anywhere, offline.
148
191
 
@@ -217,6 +260,13 @@ vectors vendored from
217
260
  [transparency-dev/merkle](https://github.com/transparency-dev/merkle) (see
218
261
  `tests/fixtures/`), plus Hypothesis property tests.
219
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
+
220
270
  ## Bundle format (`proofbundle/v0.1`)
221
271
 
222
272
  The format is specified normatively in [SPEC.md](SPEC.md) (fields, encodings,
@@ -240,23 +290,32 @@ RFC 6962 hashing, verification order) with a machine-readable JSON Schema at
240
290
  ```
241
291
 
242
292
  `sd_jwt_vc` is optional. Base64 fields are standard base64; the SD-JWT compact
243
- 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).
244
295
 
245
296
  ## Security notes and scope, stated honestly
246
297
 
247
298
  The scope is deliberately narrow. It does exactly what it says and no more:
248
299
 
249
- - Ed25519 signatures only, for both the payload and the optional SD-JWT issuer
250
- signature.
300
+ - Ed25519 signatures only, for the payload, the optional SD-JWT issuer
301
+ signature, and the optional KB-JWT holder signature.
251
302
  - SD-JWT: the SD-JWT core is now [RFC 9901](https://datatracker.ietf.org/doc/rfc9901/)
252
303
  (November 2025); this verifies that every presented disclosure is committed in the
253
- issuer-signed payload, and the issuer signature (EdDSA) if a key is supplied. It
254
- does **not** verify a Key Binding JWT, an X.509 or trust-list chain, status
255
- lists, or `vct` type metadata. **SD-JWT VC** (the credential-type profile) is
256
- 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/));
257
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.
258
317
  - The verifier does not fetch anything. Trust anchors (the signer key, the
259
- expected root) are inputs you supply out of band.
318
+ expected root, witness keys) are inputs you supply out of band.
260
319
  - No custom cryptography. Ed25519 comes from `cryptography`; Merkle hashing is
261
320
  RFC 6962.
262
321
 
@@ -332,13 +391,17 @@ threshold was met without revealing the model or the data. See [INTEROP.md](INTE
332
391
  in-toto verifier understands it. Alongside the self-hosted-predicate `to_intoto_statement` (see
333
392
  [PREDICATE.md](PREDICATE.md)). Metric details live in `annotations` (test-result has no native metric
334
393
  field); the model/dataset stay salted commitments, never `sha256`.
335
- - **C2SP tlog-checkpoint** (v0.9) — `proofbundle.checkpoint.sign_checkpoint(origin, tree_size, root, …)`
336
- emits a valid [C2SP](https://github.com/C2SP/C2SP/blob/main/tlog-checkpoint.md) signed note over the
337
- RFC 6962 Merkle root, making a receipt witness-network / transparency-log compatible. Pure serialization
338
- over the Ed25519 key already in use no new crypto.
339
- - **SD-JWT issuance** (RFC 9901, verified Nov 2025) `proofbundle.sdjwt_issue.issue_sd_jwt(claim, signer,
340
- root_b64=…, exact_score=…)` issues the receipt so a holder can disclose `passed` +
341
- `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
342
405
  RFC 9901 §4.2.3 (base64url of SHA-256 over the base64url-encoded Disclosure), cross-checked against the
343
406
  `sd-jwt-python` reference.
344
407
  The signed bundle payload is always the source of truth; the SD-JWT and the in-toto export are derived,
@@ -363,10 +426,17 @@ attestation — see [SECURITY.md](SECURITY.md).
363
426
  a sharpened honesty guardrail (authenticity/integrity, not computation-correctness), and outreach drafts.
364
427
  - **v0.9** — the standards moat: a DSSE-signed in-toto `test-result` export, a C2SP tlog-checkpoint over
365
428
  the RFC 6962 root, an Every Eval Ever converter, and standards-native repositioning.
366
- - **v1.0 (current release)** — distribution: opt-in framework integrations that auto-emit a signed receipt
367
- of an inspect_ai eval (end-of-task hook) or a pytest run (pytest11 plugin), plus a composite GitHub Action.
368
- - **Deferred** (explicitly not yet built) SD-JWT VC conformance + `vct` metadata,
369
- Key-Binding JWT, status lists / revocation, an official in-toto PR, DSSE / a full in-toto client.
429
+ - **v1.0** — distribution: opt-in framework integrations that auto-emit a signed receipt of an inspect_ai
430
+ eval (end-of-task hook) or a pytest run (pytest11 plugin), plus a composite GitHub Action.
431
+ - **v1.1** trust hardening: a signed `assurance_level`, a THREAT_MODEL, a self_attested-
432
+ without-prereg warning, model-swap + replay + withheld-field checks, and an adversarial No-Fake-PASS suite.
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.
370
440
 
371
441
  ## Contributing
372
442
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proofbundle"
7
- version = "1.0.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.0.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": ""}