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.
Files changed (58) hide show
  1. {proofbundle-1.0.0/src/proofbundle.egg-info → proofbundle-1.1.0}/PKG-INFO +32 -3
  2. {proofbundle-1.0.0 → proofbundle-1.1.0}/README.md +31 -2
  3. {proofbundle-1.0.0 → proofbundle-1.1.0}/pyproject.toml +1 -1
  4. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/__init__.py +1 -1
  5. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/cli.py +12 -1
  6. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/evalclaim.py +104 -5
  7. {proofbundle-1.0.0 → proofbundle-1.1.0/src/proofbundle.egg-info}/PKG-INFO +32 -3
  8. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle.egg-info/SOURCES.txt +1 -0
  9. proofbundle-1.1.0/tests/test_adversarial.py +95 -0
  10. {proofbundle-1.0.0 → proofbundle-1.1.0}/LICENSE +0 -0
  11. {proofbundle-1.0.0 → proofbundle-1.1.0}/setup.cfg +0 -0
  12. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/_inspect_registry.py +0 -0
  13. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/_integration.py +0 -0
  14. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/adapters/__init__.py +0 -0
  15. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/adapters/eee.py +0 -0
  16. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/adapters/inspect_ai.py +0 -0
  17. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/adapters/lm_eval.py +0 -0
  18. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/bundle.py +0 -0
  19. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/checkpoint.py +0 -0
  20. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/dsse.py +0 -0
  21. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/eee_eval_schema.json +0 -0
  22. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/emit.py +0 -0
  23. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/errors.py +0 -0
  24. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/inspect_hook.py +0 -0
  25. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/intoto.py +0 -0
  26. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/merkle.py +0 -0
  27. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/py.typed +0 -0
  28. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/pytest_plugin.py +0 -0
  29. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/sdjwt.py +0 -0
  30. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/sdjwt_issue.py +0 -0
  31. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle/signature.py +0 -0
  32. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle.egg-info/dependency_links.txt +0 -0
  33. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle.egg-info/entry_points.txt +0 -0
  34. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle.egg-info/requires.txt +0 -0
  35. {proofbundle-1.0.0 → proofbundle-1.1.0}/src/proofbundle.egg-info/top_level.txt +0 -0
  36. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_adapters.py +0 -0
  37. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_bundle.py +0 -0
  38. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_bundle_robustness.py +0 -0
  39. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_checkpoint.py +0 -0
  40. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_cli.py +0 -0
  41. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_cli_eval.py +0 -0
  42. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_eee.py +0 -0
  43. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_emit.py +0 -0
  44. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_eval_claim_schema.py +0 -0
  45. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_evalclaim.py +0 -0
  46. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_examples.py +0 -0
  47. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_inspect_hook.py +0 -0
  48. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_intoto.py +0 -0
  49. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_intoto_dsse.py +0 -0
  50. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_merkle.py +0 -0
  51. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_merkle_property.py +0 -0
  52. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_pytest_plugin.py +0 -0
  53. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_rekor_interop.py +0 -0
  54. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_rfc6962_external_vectors.py +0 -0
  55. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_schema.py +0 -0
  56. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_sdjwt_issue.py +0 -0
  57. {proofbundle-1.0.0 → proofbundle-1.1.0}/tests/test_sdjwt_reference.py +0 -0
  58. {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.0.0
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 (current release)** — distribution: opt-in framework integrations that auto-emit a signed receipt
412
- of an inspect_ai eval (end-of-task hook) or a pytest run (pytest11 plugin), plus a composite GitHub Action.
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 (current release)** — distribution: opt-in framework integrations that auto-emit a signed receipt
367
- of an inspect_ai eval (end-of-task hook) or a pytest run (pytest11 plugin), plus a composite GitHub Action.
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.0.0"
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"
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  from typing import TYPE_CHECKING
15
15
 
16
- __version__ = "1.0.0"
16
+ __version__ = "1.1.0"
17
17
 
18
18
  __all__ = [
19
19
  "__version__",
@@ -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 decode_eval_claim # noqa: PLC0415
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 proves exactly one thing — *suite S scored `comparator` threshold T,
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.0.0
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 (current release)** — distribution: opt-in framework integrations that auto-emit a signed receipt
412
- of an inspect_ai eval (end-of-task hook) or a pytest run (pytest11 plugin), plus a composite GitHub Action.
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
 
@@ -31,6 +31,7 @@ src/proofbundle/adapters/eee.py
31
31
  src/proofbundle/adapters/inspect_ai.py
32
32
  src/proofbundle/adapters/lm_eval.py
33
33
  tests/test_adapters.py
34
+ tests/test_adversarial.py
34
35
  tests/test_bundle.py
35
36
  tests/test_bundle_robustness.py
36
37
  tests/test_checkpoint.py
@@ -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