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.
- {proofbundle-1.0.0/src/proofbundle.egg-info → proofbundle-1.2.0}/PKG-INFO +92 -22
- {proofbundle-1.0.0 → proofbundle-1.2.0}/README.md +91 -21
- {proofbundle-1.0.0 → proofbundle-1.2.0}/pyproject.toml +1 -1
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/__init__.py +7 -2
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/bundle.py +65 -1
- proofbundle-1.2.0/src/proofbundle/checkpoint.py +325 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/cli.py +24 -3
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/evalclaim.py +104 -5
- proofbundle-1.2.0/src/proofbundle/kbjwt.py +206 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/sdjwt.py +7 -3
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/sdjwt_issue.py +35 -1
- {proofbundle-1.0.0 → proofbundle-1.2.0/src/proofbundle.egg-info}/PKG-INFO +92 -22
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/SOURCES.txt +4 -0
- proofbundle-1.2.0/tests/test_adversarial.py +95 -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.0.0/src/proofbundle/checkpoint.py +0 -157
- proofbundle-1.0.0/tests/test_cli.py +0 -40
- {proofbundle-1.0.0 → proofbundle-1.2.0}/LICENSE +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/setup.cfg +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/_inspect_registry.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/_integration.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/adapters/__init__.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/adapters/eee.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/adapters/inspect_ai.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/adapters/lm_eval.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/dsse.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/eee_eval_schema.json +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/emit.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/errors.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/inspect_hook.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/intoto.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/merkle.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/py.typed +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/pytest_plugin.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle/signature.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/dependency_links.txt +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/entry_points.txt +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/requires.txt +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/src/proofbundle.egg-info/top_level.txt +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_adapters.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_bundle.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_bundle_robustness.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_checkpoint.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_cli_eval.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_eee.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_emit.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_eval_claim_schema.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_evalclaim.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_examples.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_inspect_hook.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_intoto.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_intoto_dsse.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_merkle.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_merkle_property.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_pytest_plugin.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_rekor_interop.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_rfc6962_external_vectors.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_schema.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_sdjwt_issue.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.2.0}/tests/test_sdjwt_reference.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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
|
|
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,
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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,
|
|
381
|
-
emits a valid [C2SP](https://github.com/C2SP/C2SP/blob/main/tlog-checkpoint.md)
|
|
382
|
-
RFC 6962 Merkle root
|
|
383
|
-
|
|
384
|
-
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
|
412
|
-
|
|
413
|
-
- **
|
|
414
|
-
|
|
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.
|
|
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
|
|
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,
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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,
|
|
336
|
-
emits a valid [C2SP](https://github.com/C2SP/C2SP/blob/main/tlog-checkpoint.md)
|
|
337
|
-
RFC 6962 Merkle root
|
|
338
|
-
|
|
339
|
-
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
367
|
-
|
|
368
|
-
- **
|
|
369
|
-
|
|
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.
|
|
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": ""}
|