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.
- {proofbundle-1.1.0/src/proofbundle.egg-info → proofbundle-1.2.0}/PKG-INFO +62 -21
- {proofbundle-1.1.0 → proofbundle-1.2.0}/README.md +61 -20
- {proofbundle-1.1.0 → proofbundle-1.2.0}/pyproject.toml +1 -1
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/__init__.py +7 -2
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/bundle.py +65 -1
- proofbundle-1.2.0/src/proofbundle/checkpoint.py +325 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/cli.py +12 -2
- proofbundle-1.2.0/src/proofbundle/kbjwt.py +206 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/sdjwt.py +7 -3
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/sdjwt_issue.py +35 -1
- {proofbundle-1.1.0 → proofbundle-1.2.0/src/proofbundle.egg-info}/PKG-INFO +62 -21
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/SOURCES.txt +3 -0
- proofbundle-1.2.0/tests/test_cli.py +91 -0
- proofbundle-1.2.0/tests/test_cosignature.py +162 -0
- proofbundle-1.2.0/tests/test_kbjwt.py +263 -0
- proofbundle-1.1.0/src/proofbundle/checkpoint.py +0 -157
- proofbundle-1.1.0/tests/test_cli.py +0 -40
- {proofbundle-1.1.0 → proofbundle-1.2.0}/LICENSE +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/setup.cfg +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/_inspect_registry.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/_integration.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/adapters/__init__.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/adapters/eee.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/adapters/inspect_ai.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/adapters/lm_eval.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/dsse.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/eee_eval_schema.json +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/emit.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/errors.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/evalclaim.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/inspect_hook.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/intoto.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/merkle.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/py.typed +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/pytest_plugin.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle/signature.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/dependency_links.txt +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/entry_points.txt +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/requires.txt +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/top_level.txt +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_adapters.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_adversarial.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_bundle.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_bundle_robustness.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_checkpoint.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_cli_eval.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_eee.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_emit.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_eval_claim_schema.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_evalclaim.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_examples.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_inspect_hook.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_intoto.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_intoto_dsse.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_merkle.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_merkle_property.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_pytest_plugin.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_rekor_interop.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_rfc6962_external_vectors.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_schema.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_sdjwt_issue.py +0 -0
- {proofbundle-1.1.0 → proofbundle-1.2.0}/tests/test_sdjwt_reference.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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,
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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,
|
|
408
|
-
emits a valid [C2SP](https://github.com/C2SP/C2SP/blob/main/tlog-checkpoint.md)
|
|
409
|
-
RFC 6962 Merkle root
|
|
410
|
-
|
|
411
|
-
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
|
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
|
-
- **
|
|
443
|
-
|
|
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.
|
|
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
|
|
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,
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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,
|
|
363
|
-
emits a valid [C2SP](https://github.com/C2SP/C2SP/blob/main/tlog-checkpoint.md)
|
|
364
|
-
RFC 6962 Merkle root
|
|
365
|
-
|
|
366
|
-
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
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
|
-
- **
|
|
398
|
-
|
|
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.
|
|
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.
|
|
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": ""}
|