proofbundle 1.0.0__tar.gz → 1.1.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.1.0}/PKG-INFO +32 -3
- {proofbundle-1.0.0 → proofbundle-1.1.0}/README.md +31 -2
- {proofbundle-1.0.0 → proofbundle-1.1.0}/pyproject.toml +1 -1
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/__init__.py +1 -1
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/cli.py +12 -1
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/evalclaim.py +104 -5
- {proofbundle-1.0.0 → proofbundle-1.1.0/src/proofbundle.egg-info}/PKG-INFO +32 -3
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle.egg-info/SOURCES.txt +1 -0
- proofbundle-1.1.0/tests/test_adversarial.py +95 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/LICENSE +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/setup.cfg +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/_inspect_registry.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/_integration.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/adapters/__init__.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/adapters/eee.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/adapters/inspect_ai.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/adapters/lm_eval.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/bundle.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/checkpoint.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/dsse.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/eee_eval_schema.json +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/emit.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/errors.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/inspect_hook.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/intoto.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/merkle.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/py.typed +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/pytest_plugin.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/sdjwt.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/sdjwt_issue.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/signature.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle.egg-info/dependency_links.txt +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle.egg-info/entry_points.txt +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle.egg-info/requires.txt +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle.egg-info/top_level.txt +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_adapters.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_bundle.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_bundle_robustness.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_checkpoint.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_cli.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_cli_eval.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_eee.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_emit.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_eval_claim_schema.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_evalclaim.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_examples.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_inspect_hook.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_intoto.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_intoto_dsse.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_merkle.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_merkle_property.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_pytest_plugin.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_rekor_interop.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_rfc6962_external_vectors.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_schema.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_sdjwt_issue.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_sdjwt_reference.py +0 -0
- {proofbundle-1.0.0 → proofbundle-1.1.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.1.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
|
|
@@ -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:
|
|
@@ -408,8 +435,10 @@ attestation — see [SECURITY.md](SECURITY.md).
|
|
|
408
435
|
a sharpened honesty guardrail (authenticity/integrity, not computation-correctness), and outreach drafts.
|
|
409
436
|
- **v0.9** — the standards moat: a DSSE-signed in-toto `test-result` export, a C2SP tlog-checkpoint over
|
|
410
437
|
the RFC 6962 root, an Every Eval Ever converter, and standards-native repositioning.
|
|
411
|
-
- **v1.0
|
|
412
|
-
|
|
438
|
+
- **v1.0** — distribution: opt-in framework integrations that auto-emit a signed receipt of an inspect_ai
|
|
439
|
+
eval (end-of-task hook) or a pytest run (pytest11 plugin), plus a composite GitHub Action.
|
|
440
|
+
- **v1.1 (current release)** — trust hardening: a signed `assurance_level`, a THREAT_MODEL, a self_attested-
|
|
441
|
+
without-prereg warning, model-swap + replay + withheld-field checks, and an adversarial No-Fake-PASS suite.
|
|
413
442
|
- **Deferred** (explicitly not yet built) — SD-JWT VC conformance + `vct` metadata,
|
|
414
443
|
Key-Binding JWT, status lists / revocation, an official in-toto PR, DSSE / a full in-toto client.
|
|
415
444
|
|
|
@@ -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:
|
|
@@ -363,8 +390,10 @@ attestation — see [SECURITY.md](SECURITY.md).
|
|
|
363
390
|
a sharpened honesty guardrail (authenticity/integrity, not computation-correctness), and outreach drafts.
|
|
364
391
|
- **v0.9** — the standards moat: a DSSE-signed in-toto `test-result` export, a C2SP tlog-checkpoint over
|
|
365
392
|
the RFC 6962 root, an Every Eval Ever converter, and standards-native repositioning.
|
|
366
|
-
- **v1.0
|
|
367
|
-
|
|
393
|
+
- **v1.0** — distribution: opt-in framework integrations that auto-emit a signed receipt of an inspect_ai
|
|
394
|
+
eval (end-of-task hook) or a pytest run (pytest11 plugin), plus a composite GitHub Action.
|
|
395
|
+
- **v1.1 (current release)** — trust hardening: a signed `assurance_level`, a THREAT_MODEL, a self_attested-
|
|
396
|
+
without-prereg warning, model-swap + replay + withheld-field checks, and an adversarial No-Fake-PASS suite.
|
|
368
397
|
- **Deferred** (explicitly not yet built) — SD-JWT VC conformance + `vct` metadata,
|
|
369
398
|
Key-Binding JWT, status lists / revocation, an official in-toto PR, DSSE / a full in-toto client.
|
|
370
399
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proofbundle"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.1.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"
|
|
@@ -48,7 +48,9 @@ def _cmd_emit_eval(args: argparse.Namespace) -> int:
|
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def _cmd_show_eval(args: argparse.Namespace) -> int:
|
|
51
|
-
from .evalclaim import
|
|
51
|
+
from .evalclaim import ( # noqa: PLC0415
|
|
52
|
+
DEFAULT_ASSURANCE, check_freshness, claim_warnings, decode_eval_claim, sd_jwt_hidden_count,
|
|
53
|
+
)
|
|
52
54
|
claim = decode_eval_claim(args.receipt)
|
|
53
55
|
if claim is None:
|
|
54
56
|
print("=> FAILED: not a valid, issuer-bound eval receipt", file=sys.stderr)
|
|
@@ -56,10 +58,19 @@ def _cmd_show_eval(args: argparse.Namespace) -> int:
|
|
|
56
58
|
print(f"suite {claim['suite']} ({claim['suite_version']})")
|
|
57
59
|
print(f"metric {claim['metric']} {claim['comparator']} {claim['threshold']}")
|
|
58
60
|
print(f"passed {claim['passed']} (n={claim['n']})")
|
|
61
|
+
print(f"assurance {claim.get('assurance_level', DEFAULT_ASSURANCE)}")
|
|
59
62
|
print(f"model commit {claim['model_id_commit']}")
|
|
60
63
|
print(f"dataset commit {claim['dataset_id_commit']}")
|
|
61
64
|
print(f"issuer {claim['issuer']}")
|
|
62
65
|
print(f"timestamp {claim['timestamp']}")
|
|
66
|
+
hidden = sd_jwt_hidden_count(args.receipt)
|
|
67
|
+
if hidden is not None:
|
|
68
|
+
print(f"sd-jwt {hidden} field(s) withheld (selective disclosure)")
|
|
69
|
+
fresh = check_freshness(claim)
|
|
70
|
+
if fresh["parsed"]:
|
|
71
|
+
print(f"age {fresh['age_seconds']}s")
|
|
72
|
+
for w in claim_warnings(claim):
|
|
73
|
+
print(f"WARNING {w}")
|
|
63
74
|
print("=> OK")
|
|
64
75
|
return 0
|
|
65
76
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Eval receipts (v0.4): sign + Merkle-anchor a canonical eval CLAIM.
|
|
2
2
|
|
|
3
|
-
A receipt
|
|
4
|
-
passed=…* — carrying only SALTED commitments to the model and dataset identifiers,
|
|
3
|
+
A receipt is tamper-evident signed evidence of exactly one thing — *suite S scored `comparator` threshold
|
|
4
|
+
T, passed=…* — carrying only SALTED commitments to the model and dataset identifiers,
|
|
5
5
|
never the weights, the data, or the plaintext names. A third party verifies the
|
|
6
6
|
threshold was met, offline, from one file, without ever seeing the model or dataset.
|
|
7
7
|
|
|
@@ -37,14 +37,22 @@ _COMPARATORS = {">=", ">", "<=", "<"}
|
|
|
37
37
|
_MAX_SAFE_INT = 2 ** 53 - 1
|
|
38
38
|
# The published eval-claim schema's decimal pattern for threshold/score (no exponent, no sign+, no spaces).
|
|
39
39
|
_DECIMAL_RE = re.compile(r"^-?[0-9]+(\.[0-9]+)?$")
|
|
40
|
+
# Assurance level (v1.1): how much a PASS is worth. Signed into the claim (tamper-evident + bound to the
|
|
41
|
+
# issuer, so a third party cannot alter it) — but issuer-DECLARED: a dishonest issuer can sign a higher level,
|
|
42
|
+
# the signature attributes that claim to them, it does not make it true. Ordered weakest→strongest. Default
|
|
43
|
+
# self_attested — the 1.0 integrations emit self-attested, and claiming more would be dishonest.
|
|
44
|
+
ASSURANCE_LEVELS = ("self_attested", "third_party", "reproduced", "enclave_attested")
|
|
45
|
+
DEFAULT_ASSURANCE = "self_attested"
|
|
40
46
|
# The exact key set of an eval claim; decode/validate reject anything else.
|
|
41
47
|
_REQUIRED = {"schema", "suite", "suite_version", "metric", "comparator", "threshold",
|
|
42
|
-
"passed", "n", "model_id_commit", "dataset_id_commit", "commit_alg", "issuer", "timestamp"
|
|
48
|
+
"passed", "n", "model_id_commit", "dataset_id_commit", "commit_alg", "issuer", "timestamp",
|
|
49
|
+
"assurance_level"}
|
|
43
50
|
_OPTIONAL = {"context_binding", "ci95", "multiple_testing", "prereg_sha256", "provenance"}
|
|
44
51
|
|
|
45
52
|
__all__ = [
|
|
46
|
-
"EVAL_CLAIM_SCHEMA", "COMMIT_ALG", "canonicalize", "build_eval_claim",
|
|
53
|
+
"EVAL_CLAIM_SCHEMA", "COMMIT_ALG", "ASSURANCE_LEVELS", "canonicalize", "build_eval_claim",
|
|
47
54
|
"emit_eval_receipt", "decode_eval_claim", "salted_commit", "issuer_fingerprint",
|
|
55
|
+
"claim_warnings", "verify_commitment", "check_freshness", "sd_jwt_hidden_count",
|
|
48
56
|
]
|
|
49
57
|
|
|
50
58
|
|
|
@@ -135,6 +143,7 @@ def build_eval_claim(*, suite: str, suite_version: str, metric: str, comparator:
|
|
|
135
143
|
issuer: str, timestamp: str, context_binding: Optional[str] = None,
|
|
136
144
|
ci95: Optional[Sequence[str]] = None, multiple_testing: Optional[str] = None,
|
|
137
145
|
prereg_sha256: Optional[str] = None, provenance: Optional[dict] = None,
|
|
146
|
+
assurance_level: str = DEFAULT_ASSURANCE,
|
|
138
147
|
model_salt: Optional[bytes] = None, dataset_salt: Optional[bytes] = None):
|
|
139
148
|
"""Build a valid eval claim from raw values. Computes `passed` ITSELF from the comparator
|
|
140
149
|
(never trusts the caller), creates salted commitments, and returns (claim, salts) with the
|
|
@@ -145,6 +154,8 @@ def build_eval_claim(*, suite: str, suite_version: str, metric: str, comparator:
|
|
|
145
154
|
"""
|
|
146
155
|
if comparator not in _COMPARATORS:
|
|
147
156
|
raise EvalClaimError(f"comparator must be one of {sorted(_COMPARATORS)}")
|
|
157
|
+
if assurance_level not in ASSURANCE_LEVELS:
|
|
158
|
+
raise EvalClaimError(f"assurance_level must be one of {list(ASSURANCE_LEVELS)}")
|
|
148
159
|
# threshold/score must match the PUBLISHED schema's decimal pattern exactly — reject "1e2",
|
|
149
160
|
# "Infinity", "+5", " 5 " etc. that Decimal() would accept but jsonschema rejects (schema-conformance).
|
|
150
161
|
for name, val in (("threshold", threshold), ("score", score)):
|
|
@@ -164,7 +175,7 @@ def build_eval_claim(*, suite: str, suite_version: str, metric: str, comparator:
|
|
|
164
175
|
"metric": metric, "comparator": comparator, "threshold": threshold, "passed": passed,
|
|
165
176
|
"n": n, "model_id_commit": salted_commit(model_id, m_salt),
|
|
166
177
|
"dataset_id_commit": salted_commit(dataset_id, d_salt), "commit_alg": COMMIT_ALG,
|
|
167
|
-
"issuer": issuer, "timestamp": timestamp,
|
|
178
|
+
"issuer": issuer, "timestamp": timestamp, "assurance_level": assurance_level,
|
|
168
179
|
}
|
|
169
180
|
if context_binding is not None:
|
|
170
181
|
claim["context_binding"] = context_binding
|
|
@@ -189,6 +200,11 @@ def emit_eval_receipt(claim: dict, signer: Ed25519PrivateKey, *, prior_leaves: S
|
|
|
189
200
|
"""
|
|
190
201
|
claim = dict(claim)
|
|
191
202
|
claim["issuer"] = issuer_fingerprint(signer)
|
|
203
|
+
# A claim without an explicit assurance_level is self_attested — the weakest, safest default; never
|
|
204
|
+
# silently elevate. (v1.1: keeps pre-1.1 claim JSONs emittable while binding the honest level.)
|
|
205
|
+
claim.setdefault("assurance_level", DEFAULT_ASSURANCE)
|
|
206
|
+
if claim["assurance_level"] not in ASSURANCE_LEVELS:
|
|
207
|
+
raise EvalClaimError(f"assurance_level must be one of {list(ASSURANCE_LEVELS)}")
|
|
192
208
|
missing = _REQUIRED - set(claim)
|
|
193
209
|
if missing:
|
|
194
210
|
raise EvalClaimError(f"claim missing required fields: {sorted(missing)}")
|
|
@@ -223,3 +239,86 @@ def decode_eval_claim(bundle) -> Optional[dict]:
|
|
|
223
239
|
return claim
|
|
224
240
|
except (KeyError, ValueError, EvalClaimError):
|
|
225
241
|
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def claim_warnings(claim: dict) -> list:
|
|
245
|
+
"""Honest trust warnings for an already-verified claim (v1.1). A verified signature proves authorship +
|
|
246
|
+
integrity, NOT that the number is true or the study was pre-registered. The weakest combination —
|
|
247
|
+
self_attested with no pre-registration — is where an issuer could publish the best of many runs; surface
|
|
248
|
+
it so a strong signature never masks a weak assurance. Returns a list of human-readable strings."""
|
|
249
|
+
out = []
|
|
250
|
+
level = claim.get("assurance_level", DEFAULT_ASSURANCE)
|
|
251
|
+
if level == "self_attested" and not claim.get("prereg_sha256"):
|
|
252
|
+
out.append("self_attested with no prereg_sha256 — the weakest assurance: trust rests entirely on the "
|
|
253
|
+
"issuer, who could publish the best of many runs. Pre-register (prereg_sha256) or use a "
|
|
254
|
+
"higher assurance_level (reproduced / enclave_attested) to strengthen it.")
|
|
255
|
+
return out
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def verify_commitment(identifier: str, salt: bytes, commitment: str) -> bool:
|
|
259
|
+
"""Check that a PRESENTED identifier (+ its salt) matches a salted commitment in a claim
|
|
260
|
+
(``model_id_commit`` / ``dataset_id_commit``). Makes a model-swap visible: a claim that silently swapped
|
|
261
|
+
the model cannot produce a matching (identifier, salt). Constant-time compare; the salt stays outside the
|
|
262
|
+
payload (the holder presents it to a verifier out of band)."""
|
|
263
|
+
try:
|
|
264
|
+
expected = salted_commit(identifier, salt)
|
|
265
|
+
except EvalClaimError:
|
|
266
|
+
return False
|
|
267
|
+
import hmac # noqa: PLC0415
|
|
268
|
+
return hmac.compare_digest(expected, str(commitment))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def check_freshness(claim: dict, max_age_seconds: Optional[int] = None, now=None) -> dict:
|
|
272
|
+
"""Replay check (v1.1): parse the claim's timestamp and report its age. A receipt carries a timestamp but
|
|
273
|
+
verify never judged it — an old receipt could be replayed as new. Returns
|
|
274
|
+
{"parsed": bool, "age_seconds": int|None, "fresh": bool|None, "reason": str}. ``fresh`` is None when no
|
|
275
|
+
``max_age_seconds`` bound is given (age reported, not judged). 3.9-safe ISO parsing (normalizes a 'Z')."""
|
|
276
|
+
from datetime import datetime, timezone # noqa: PLC0415
|
|
277
|
+
ts = claim.get("timestamp")
|
|
278
|
+
if not isinstance(ts, str):
|
|
279
|
+
return {"parsed": False, "age_seconds": None, "fresh": None, "reason": "no timestamp"}
|
|
280
|
+
raw = ts[:-1] + "+00:00" if ts.endswith("Z") else ts
|
|
281
|
+
try:
|
|
282
|
+
dt = datetime.fromisoformat(raw)
|
|
283
|
+
except ValueError:
|
|
284
|
+
return {"parsed": False, "age_seconds": None, "fresh": None, "reason": f"unparseable timestamp {ts!r}"}
|
|
285
|
+
if dt.tzinfo is None:
|
|
286
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
287
|
+
ref = now or datetime.now(timezone.utc)
|
|
288
|
+
if ref.tzinfo is None:
|
|
289
|
+
ref = ref.replace(tzinfo=timezone.utc)
|
|
290
|
+
age = int((ref - dt).total_seconds())
|
|
291
|
+
if max_age_seconds is None:
|
|
292
|
+
return {"parsed": True, "age_seconds": age, "fresh": None, "reason": f"age {age}s (no bound given)"}
|
|
293
|
+
fresh = 0 <= age <= max_age_seconds
|
|
294
|
+
return {"parsed": True, "age_seconds": age, "fresh": fresh,
|
|
295
|
+
"reason": (f"age {age}s within {max_age_seconds}s" if fresh
|
|
296
|
+
else f"age {age}s outside [0, {max_age_seconds}]s — possible replay or clock skew")}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def sd_jwt_hidden_count(bundle) -> Optional[int]:
|
|
300
|
+
"""Number of selectively-disclosable (currently withheld) SD-JWT fields in a bundle, so that OMISSION is
|
|
301
|
+
visible: a receipt can hide claims behind the SD-JWT ``_sd`` digests. Returns the count, or None if the
|
|
302
|
+
bundle carries no SD-JWT. Reads the issuer JWT payload's ``_sd`` array without verifying the SD-JWT
|
|
303
|
+
(that is the holder/verifier's job); purely a disclosure-transparency signal."""
|
|
304
|
+
if isinstance(bundle, str):
|
|
305
|
+
bundle = load_bundle(bundle)
|
|
306
|
+
sd = bundle.get("sd_jwt_vc") if isinstance(bundle, dict) else None
|
|
307
|
+
if not sd:
|
|
308
|
+
return None
|
|
309
|
+
# the canonical bundle form (the only one verify_bundle accepts) stores the compact SD-JWT under "compact";
|
|
310
|
+
# sd_jwt/token are accepted as fallbacks for a bare token dict/string.
|
|
311
|
+
token = sd if isinstance(sd, str) else (sd.get("compact") or sd.get("sd_jwt") or sd.get("token") or "")
|
|
312
|
+
if not isinstance(token, str) or "." not in token:
|
|
313
|
+
return None
|
|
314
|
+
try:
|
|
315
|
+
jwt = token.split("~", 1)[0] # issuer JWT, before any disclosures
|
|
316
|
+
payload_b64 = jwt.split(".")[1]
|
|
317
|
+
payload_b64 += "=" * (-len(payload_b64) % 4) # restore base64url padding
|
|
318
|
+
payload = json.loads(base64.urlsafe_b64decode(payload_b64).decode("utf-8"))
|
|
319
|
+
except (ValueError, KeyError, IndexError):
|
|
320
|
+
return None
|
|
321
|
+
if not isinstance(payload, dict): # a valid-JSON non-object payload → nothing to count
|
|
322
|
+
return None
|
|
323
|
+
sd_arr = payload.get("_sd")
|
|
324
|
+
return len(sd_arr) if isinstance(sd_arr, list) else None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proofbundle
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.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
|
|
@@ -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:
|
|
@@ -408,8 +435,10 @@ attestation — see [SECURITY.md](SECURITY.md).
|
|
|
408
435
|
a sharpened honesty guardrail (authenticity/integrity, not computation-correctness), and outreach drafts.
|
|
409
436
|
- **v0.9** — the standards moat: a DSSE-signed in-toto `test-result` export, a C2SP tlog-checkpoint over
|
|
410
437
|
the RFC 6962 root, an Every Eval Ever converter, and standards-native repositioning.
|
|
411
|
-
- **v1.0
|
|
412
|
-
|
|
438
|
+
- **v1.0** — distribution: opt-in framework integrations that auto-emit a signed receipt of an inspect_ai
|
|
439
|
+
eval (end-of-task hook) or a pytest run (pytest11 plugin), plus a composite GitHub Action.
|
|
440
|
+
- **v1.1 (current release)** — trust hardening: a signed `assurance_level`, a THREAT_MODEL, a self_attested-
|
|
441
|
+
without-prereg warning, model-swap + replay + withheld-field checks, and an adversarial No-Fake-PASS suite.
|
|
413
442
|
- **Deferred** (explicitly not yet built) — SD-JWT VC conformance + `vct` metadata,
|
|
414
443
|
Key-Binding JWT, status lists / revocation, an official in-toto PR, DSSE / a full in-toto client.
|
|
415
444
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Adversarial No-Fake-PASS suite (v1.1): actively try to FORGE a passing receipt, and pin down exactly what
|
|
2
|
+
verify catches and what it structurally cannot. Each test documents the honest boundary — a green here means
|
|
3
|
+
the defence held OR the limitation is named, never a hidden false PASS.
|
|
4
|
+
"""
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
from proofbundle import verify_bundle
|
|
10
|
+
from proofbundle.evalclaim import (
|
|
11
|
+
build_eval_claim, check_freshness, claim_warnings, decode_eval_claim, emit_eval_receipt,
|
|
12
|
+
sd_jwt_hidden_count, verify_commitment,
|
|
13
|
+
)
|
|
14
|
+
from proofbundle.emit import generate_signer
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _receipt(score="0.99", threshold="0.80", prereg=None, assurance="self_attested", ts="2020-01-01T00:00:00Z"):
|
|
18
|
+
signer = generate_signer()
|
|
19
|
+
claim, salts = build_eval_claim(
|
|
20
|
+
suite="mmlu", suite_version="1", metric="accuracy", comparator=">=", threshold=threshold,
|
|
21
|
+
score=score, n=1000, model_id="secret-model", dataset_id="secret-data", issuer="",
|
|
22
|
+
timestamp=ts, prereg_sha256=prereg, assurance_level=assurance)
|
|
23
|
+
return emit_eval_receipt(claim, signer), salts
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestAdversarial(unittest.TestCase):
|
|
27
|
+
def test_a_invented_numbers_with_valid_signature_pass_is_expected(self):
|
|
28
|
+
# A receipt binds AUTHORSHIP + INTEGRITY, not TRUTH. A signed but invented score verifies — this is
|
|
29
|
+
# EXPECTED and documented. The honesty gate is the self_attested-without-prereg WARNING.
|
|
30
|
+
bundle, _ = _receipt(score="0.99")
|
|
31
|
+
self.assertTrue(verify_bundle(bundle).ok) # signature/integrity hold
|
|
32
|
+
claim = decode_eval_claim(bundle)
|
|
33
|
+
self.assertIsNotNone(claim)
|
|
34
|
+
self.assertTrue(claim["passed"]) # invented pass, cryptographically fine
|
|
35
|
+
self.assertTrue(claim_warnings(claim), "self_attested+no-prereg MUST warn") # the honest counter
|
|
36
|
+
|
|
37
|
+
def test_a_prereg_or_higher_assurance_removes_the_warning(self):
|
|
38
|
+
self.assertFalse(claim_warnings(decode_eval_claim(_receipt(prereg="a" * 64)[0])))
|
|
39
|
+
self.assertFalse(claim_warnings(decode_eval_claim(_receipt(assurance="reproduced")[0])))
|
|
40
|
+
|
|
41
|
+
def test_b_tampered_payload_fails(self):
|
|
42
|
+
bundle, _ = _receipt()
|
|
43
|
+
tampered = json.loads(json.dumps(bundle))
|
|
44
|
+
payload = json.loads(base64.b64decode(tampered["payload_b64"]))
|
|
45
|
+
payload["passed"] = True
|
|
46
|
+
payload["threshold"] = "0.10" # forge an easier bar
|
|
47
|
+
tampered["payload_b64"] = base64.b64encode(
|
|
48
|
+
json.dumps(payload).encode("utf-8")).decode("ascii")
|
|
49
|
+
self.assertFalse(verify_bundle(tampered).ok) # signature no longer matches
|
|
50
|
+
self.assertIsNone(decode_eval_claim(tampered))
|
|
51
|
+
|
|
52
|
+
def test_c_omitted_sd_jwt_fields_are_counted(self):
|
|
53
|
+
# Selective disclosure hides claims behind _sd digests; the count makes OMISSION visible. This exercises
|
|
54
|
+
# the CANONICAL bundle form (sd_jwt_vc = {"compact": ...}) — the only form verify_bundle accepts — not a
|
|
55
|
+
# bare string, so it would catch the "reads sd_jwt/token but the real key is compact" regression.
|
|
56
|
+
hdr = base64.urlsafe_b64encode(b'{"alg":"ES256"}').decode().rstrip("=")
|
|
57
|
+
pl = base64.urlsafe_b64encode(
|
|
58
|
+
json.dumps({"_sd": ["d1", "d2", "d3"], "iss": "x"}).encode()).decode().rstrip("=")
|
|
59
|
+
canonical = {"sd_jwt_vc": {"compact": f"{hdr}.{pl}.sig~", "issuer_public_key_b64": "AA=="}}
|
|
60
|
+
self.assertEqual(sd_jwt_hidden_count(canonical), 3) # 3 withheld fields surfaced on the REAL form
|
|
61
|
+
self.assertIsNone(sd_jwt_hidden_count({"schema": "x"})) # no sd-jwt → None (nothing hidden)
|
|
62
|
+
# a valid-JSON but non-object payload must return None, never crash (defensive contract)
|
|
63
|
+
bad = base64.urlsafe_b64encode(b'[1,2,3]').decode().rstrip("=")
|
|
64
|
+
self.assertIsNone(sd_jwt_hidden_count({"sd_jwt_vc": {"compact": f"{hdr}.{bad}.sig~"}}))
|
|
65
|
+
# and on the shipped REAL bundle (a verify-passing sd_jwt_vc) it must surface a positive count
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
ex = Path(__file__).resolve().parent.parent / "examples" / "example_bundle.json"
|
|
68
|
+
if ex.is_file():
|
|
69
|
+
n = sd_jwt_hidden_count(json.loads(ex.read_text()))
|
|
70
|
+
self.assertIsNotNone(n)
|
|
71
|
+
self.assertGreater(n, 0)
|
|
72
|
+
|
|
73
|
+
def test_d_model_swap_against_commitment_is_a_mismatch(self):
|
|
74
|
+
bundle, salts = _receipt()
|
|
75
|
+
claim = decode_eval_claim(bundle)
|
|
76
|
+
self.assertTrue(verify_commitment("secret-model", salts["model_salt"], claim["model_id_commit"]))
|
|
77
|
+
self.assertFalse(verify_commitment("swapped-model", salts["model_salt"], claim["model_id_commit"]))
|
|
78
|
+
self.assertFalse(verify_commitment("secret-model", b"\x00" * 16, claim["model_id_commit"]))
|
|
79
|
+
|
|
80
|
+
def test_e_replay_of_old_receipt_is_detectable(self):
|
|
81
|
+
claim = decode_eval_claim(_receipt(ts="2020-01-01T00:00:00Z")[0])
|
|
82
|
+
fresh = check_freshness(claim, max_age_seconds=3600)
|
|
83
|
+
self.assertTrue(fresh["parsed"])
|
|
84
|
+
self.assertFalse(fresh["fresh"]) # years old → not fresh (replay/skew)
|
|
85
|
+
self.assertGreater(fresh["age_seconds"], 3600)
|
|
86
|
+
|
|
87
|
+
def test_f_honest_receipt_still_verifies_end_to_end(self):
|
|
88
|
+
# The hardening must not break a legitimate receipt (guards against over-tightening).
|
|
89
|
+
bundle, _ = _receipt(prereg="b" * 64, assurance="reproduced")
|
|
90
|
+
self.assertTrue(verify_bundle(bundle).ok)
|
|
91
|
+
self.assertIsNotNone(decode_eval_claim(bundle))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|