coderifts-verifier 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,62 @@
1
+ """CodeRifts offline verifier for Python.
2
+
3
+ Verify a CodeRifts chain receipt, or a DSSE / in-toto envelope carrying one,
4
+ without calling CodeRifts. Offline Ed25519 against a keyring you pin.
5
+
6
+ from coderifts_verifier import unwrap, verify_receipt
7
+
8
+ token = unwrap(whatever_arrived) # compact token, or DSSE -> compact
9
+ result = verify_receipt(token, {"keyring": my_keyring})
10
+ if result["valid"]:
11
+ ...
12
+
13
+ UNWRAPPING IS NOT VERIFICATION. ``unwrap`` / ``from_dsse`` check no signature: a
14
+ DSSE envelope wrapping a bad receipt unwraps cleanly and then fails
15
+ ``verify_receipt``, exactly as that compact token would have. The DSSE proves
16
+ exactly what the compact receipt proves -- a standard container does not
17
+ strengthen a claim.
18
+
19
+ The verification core (``_verify.py``) is VENDORED from the reference JS/Python
20
+ verifier rather than reimplemented, so there is one definition of what a valid
21
+ receipt is. Its signed-bytes reconstruction is byte-identical to ``verify.js``;
22
+ the cross-language tests pin that against shared vectors.
23
+ """
24
+
25
+ from ._verify import ( # noqa: F401
26
+ canonical_json,
27
+ derive_status,
28
+ keyring_from_document,
29
+ reconstruct_signed_input,
30
+ verify_chain,
31
+ verify_receipt,
32
+ )
33
+ from .dsse import ( # noqa: F401
34
+ FORM_ATTESTATION,
35
+ FORM_RECEIPT,
36
+ PAYLOAD_TYPE,
37
+ PREDICATE_TYPE,
38
+ STATEMENT_TYPE,
39
+ DsseError,
40
+ from_dsse,
41
+ looks_like_dsse,
42
+ unwrap,
43
+ )
44
+
45
+ __all__ = [
46
+ "verify_receipt",
47
+ "verify_chain",
48
+ "from_dsse",
49
+ "unwrap",
50
+ "looks_like_dsse",
51
+ "DsseError",
52
+ "keyring_from_document",
53
+ "canonical_json",
54
+ "derive_status",
55
+ "reconstruct_signed_input",
56
+ "PAYLOAD_TYPE",
57
+ "STATEMENT_TYPE",
58
+ "PREDICATE_TYPE",
59
+ "FORM_RECEIPT",
60
+ "FORM_ATTESTATION",
61
+ ]
62
+ __version__ = "0.1.0"
@@ -0,0 +1,96 @@
1
+ """1961 TAG 1 -- finding the receipt that belongs to a commit (Python side).
2
+
3
+ Byte-for-byte the same convention as ``receipt-verifier/receipt-from-commit.js``: the
4
+ ``CodeRifts-Receipt`` git trailer, or ``.coderifts/receipts/<full-sha>.json``. See
5
+ ``receipt-verifier/docs/receipt-commit-binding.md`` for why both exist and why the trailer wins.
6
+
7
+ WARNING -- THE CARRIER PROVES NOTHING. Whichever one delivers the token, the token is then
8
+ verified exactly as if it had been pasted on the command line. A forged sidecar yields
9
+ INVALID_SIGNATURE, not a false pass. The sidecar is a POINTER; the signature is the authority.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import pathlib
15
+ import subprocess
16
+
17
+ TRAILER_KEY = "CodeRifts-Receipt"
18
+ SIDECAR_DIR = os.path.join(".coderifts", "receipts")
19
+
20
+
21
+ def _git(args, cwd):
22
+ return subprocess.run(
23
+ ["git", *args], cwd=cwd, capture_output=True, text=True, check=True,
24
+ ).stdout.strip()
25
+
26
+
27
+ def resolve_sha(ref, cwd):
28
+ """Abbreviations collide, and a verifier resolving one itself would be guessing which commit
29
+ the operator meant. Git knows; ask it."""
30
+ return _git(["rev-parse", ref + "^{commit}"], cwd)
31
+
32
+
33
+ def receipt_from_trailer(sha, cwd):
34
+ raw = _git(["log", "-1", "--format=%%(trailers:key=%s,valueonly)" % TRAILER_KEY, sha], cwd)
35
+ if not raw:
36
+ return None
37
+ # Unfold: git wraps long trailer values, and a receipt token is long. A token has no
38
+ # whitespace in it, so joining the trimmed lines is lossless.
39
+ token = "".join(line.strip() for line in raw.splitlines())
40
+ return token or None
41
+
42
+
43
+ def receipt_from_sidecar(sha, cwd):
44
+ """A MALFORMED SIDECAR IS NOT "NO SIDECAR".
45
+
46
+ Treating it as absent would let a corrupted pointer fall through to the trailer, or to
47
+ "nothing found" -- both of which read as an innocent absence rather than the broken file it is.
48
+ """
49
+ path = pathlib.Path(cwd) / SIDECAR_DIR / (sha + ".json")
50
+ if not path.exists():
51
+ return None
52
+ try:
53
+ doc = json.loads(path.read_text())
54
+ except Exception as e:
55
+ raise ValueError("sidecar %s is not valid JSON: %s" % (path, e))
56
+ token = (doc or {}).get("receipt") or (doc or {}).get("token")
57
+ if not token:
58
+ raise ValueError('sidecar %s has no "receipt" field' % path)
59
+ return {"token": str(token), "envelope": (doc or {}).get("envelope"), "file": str(path)}
60
+
61
+
62
+ def receipt_for_commit(ref, cwd=None):
63
+ """Returns {sha, token, envelope, carrier}. Raises on anything that is not a clean find.
64
+
65
+ WARNING -- TRAILER WINS, BUT A DISAGREEMENT IS REFUSED RATHER THAN RESOLVED. The trailer is
66
+ inside the commit object and the sidecar is a file anyone can edit, so where both exist the
67
+ immutable one is authoritative. When they DISAGREE, silently preferring the trailer would hide
68
+ precisely the tampering the precedence rule exists to make visible.
69
+ """
70
+ cwd = cwd or os.getcwd()
71
+ try:
72
+ sha = resolve_sha(ref, cwd)
73
+ except subprocess.CalledProcessError as e:
74
+ raise ValueError("cannot resolve %s in %s: %s" % (ref, cwd, (e.stderr or "").splitlines()[:1]))
75
+
76
+ trailer = receipt_from_trailer(sha, cwd)
77
+ sidecar = receipt_from_sidecar(sha, cwd)
78
+
79
+ if trailer and sidecar:
80
+ if trailer != sidecar["token"]:
81
+ raise ValueError(
82
+ "receipt conflict for %s: the commit trailer and %s carry DIFFERENT receipts. "
83
+ "The trailer is covered by the commit SHA and the sidecar is not, so they should "
84
+ "never disagree -- resolve it deliberately rather than letting one win silently."
85
+ % (sha, sidecar["file"])
86
+ )
87
+ return {"sha": sha, "token": trailer, "envelope": sidecar["envelope"], "carrier": "both"}
88
+ if trailer:
89
+ return {"sha": sha, "token": trailer, "envelope": None, "carrier": "trailer"}
90
+ if sidecar:
91
+ return {"sha": sha, "token": sidecar["token"], "envelope": sidecar["envelope"], "carrier": "sidecar"}
92
+
93
+ raise ValueError(
94
+ "no receipt attached to %s: no %s trailer and no %s/%s.json. That means no receipt was "
95
+ "ATTACHED -- it does not mean none exists." % (sha, TRAILER_KEY, SIDECAR_DIR, sha)
96
+ )
@@ -0,0 +1,604 @@
1
+ #!/usr/bin/env python3
2
+ """CodeRifts chain-receipt verifier -- Python 3.10+, depends only on `cryptography`.
3
+
4
+ Verify the receipt yourself — offline, no live CodeRifts API call needed.
5
+ The reference format is frozen in ./RECEIPT_FORMAT.md.
6
+
7
+ Usage:
8
+ python3 verify.py <receipt> [--key pub.pem | --keys <url|file>] [--kid <kid>] [--fetch <url>]
9
+ [--envelope <file>] [--audience <a>] [--environment <e>]
10
+ python3 verify.py --chain receipts.txt [--key pub.pem | --keys <url|file>] [--kid <kid>] [--fetch <url>]
11
+
12
+ Key discovery: with no --key/--keys, keys are fetched from
13
+ https://app.coderifts.com/.well-known/coderifts-keys.json (override with --fetch <url>).
14
+ The fetch-and-resolve path accepts BOTH the registry array (active + retired)
15
+ and the legacy single-key body from /api/v1/attestation/public-key.
16
+ --keys resolves each receipt's key by kid from a registry
17
+ ({keys: [{kid, public_key_pem, status, valid_from, retired_at}]}); accepts a URL or file.
18
+
19
+ Output: JSON { valid, status, reason?, payload?, chain? } to stdout — byte-identical to verify.js.
20
+ Exit codes: 0 valid, 1 invalid, 2 usage error.
21
+
22
+ Verification order: structure -> json -> kid -> signature -> delimiter guard -> envelope binding
23
+ -> taxonomy status. Statuses (12): VERIFIED_CURRENT, VERIFIED_EXPIRED, VERIFIED_WRONG_AUDIENCE,
24
+ VERIFIED_WRONG_ENVIRONMENT, VERIFIED_SUPERSEDED, VERIFIED_SCOPE_MISMATCH, UNKNOWN_KEY,
25
+ RETIRED_KEY_VALID_AT_ISSUE, INVALID_SIGNATURE, MALFORMED, UNSUPPORTED_VERSION, REGISTRY_UNREACHABLE.
26
+ """
27
+
28
+ import base64
29
+ import hashlib
30
+ import json
31
+ import math
32
+ import os
33
+ import pathlib
34
+ import sys
35
+ import urllib.request
36
+ from datetime import datetime, timezone
37
+
38
+ from cryptography.exceptions import InvalidSignature
39
+ from cryptography.hazmat.primitives.serialization import load_pem_public_key
40
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
41
+
42
+ DEFAULT_FETCH_URL = "https://app.coderifts.com/.well-known/coderifts-keys.json"
43
+ # P-2 (2026-09-23) -- THE NO-FLAG DEFAULT IS OFFLINE, matching verify.js.
44
+ #
45
+ # WARNING -- THIS CHANGED, AND THE OLD BEHAVIOUR IS WORTH NAMING. Until now, running this verifier
46
+ # with no --key/--keys/--fetch FETCHED the live registry, while verify.js with no flags read a
47
+ # VENDORED SNAPSHOT. So "no flags" meant two different things in two implementations of the same
48
+ # format: one made a network call, the other did not. The previous round measured that divergence
49
+ # and refused to resolve it on its own authority -- which default is correct is a product decision,
50
+ # not something a status helper may decide. Peter chose OFFLINE for both, and this is that change.
51
+ #
52
+ # WHY OFFLINE IS THE RIGHT DEFAULT rather than merely the agreed one: a verifier's first promise
53
+ # is "you can check this yourself, without us". A default that phones home makes the quiet case --
54
+ # someone verifying a receipt on a laptop with no network, or inside a sealed build -- the one that
55
+ # needs a flag. Fetching is still one flag away, and it is the case that deserves to be explicit.
56
+ VENDORED_KEYS_PATH = str(pathlib.Path(__file__).resolve().parent / "keys" / "coderifts-keys.json")
57
+ SIGNING_PREFIX = "crchain.v1"
58
+ MAX_SUPPORTED_V = 4
59
+ SIGNED_FIELDS = ["kid", "fp", "prev", "caller", "ts", "reg", "ir", "expires_at", "bh"]
60
+ # ID104 — verification expiry leeway (ms). exp + leeway < now → VERIFIED_EXPIRED.
61
+ CLOCK_SKEW_LEEWAY_MS = 30_000
62
+
63
+
64
+ def expiry_leeway_ms(context=None):
65
+ """0s grace only when context DECLARES destructive AND production.
66
+
67
+ This surface has envelope.environment / --environment; no destructive /
68
+ operation_class field — never guess from operation labels.
69
+ """
70
+ return CLOCK_SKEW_LEEWAY_MS
71
+
72
+
73
+ def _is_finite_number(value):
74
+ """Match JS Number.isFinite: real int/float only, not bool, not coerced strings."""
75
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
76
+ return False
77
+ return math.isfinite(value)
78
+
79
+
80
+ def is_expired_at(expires_at_ms, now_ms, context=None):
81
+ if not _is_finite_number(expires_at_ms) or not _is_finite_number(now_ms):
82
+ return False
83
+ return (float(expires_at_ms) + expiry_leeway_ms(context)) < float(now_ms)
84
+ USAGE = (
85
+ "usage: python3 verify.py <receipt> [--key pub.pem | --keys <url|file>] [--kid <kid>] [--fetch <url>]\n"
86
+ " python3 verify.py --from-commit <sha> [--repo <path>] [--key pub.pem | --keys <url|file>]\n"
87
+ " reads the CodeRifts-Receipt trailer or .coderifts/receipts/<sha>.json\n"
88
+ " [--envelope <file>] [--audience <a>] [--environment <e>]\n"
89
+ " python3 verify.py --chain receipts.txt [--key pub.pem | --keys <url|file>] [--kid <kid>] [--fetch <url>]\n"
90
+ )
91
+
92
+
93
+ def b64url_decode(seg):
94
+ pad = "=" * (-len(seg) % 4)
95
+ return base64.urlsafe_b64decode(seg + pad)
96
+
97
+
98
+ def sha256hex(text):
99
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
100
+
101
+
102
+ def canonical_json(value):
103
+ """RFC 8785 (JCS) canonical JSON for our data domain — byte-identical to verify.js canonicalJson
104
+ and the issuer's src/canonical-json.js (ASCII keys, JSON scalars, sorted keys, no whitespace)."""
105
+ if value is None:
106
+ return "null"
107
+ if isinstance(value, bool):
108
+ return "true" if value else "false"
109
+ if isinstance(value, str):
110
+ return json.dumps(value, ensure_ascii=False)
111
+ if isinstance(value, (int, float)):
112
+ if isinstance(value, float) and (value != value or value in (float("inf"), float("-inf"))):
113
+ raise ValueError("canonical_json: non-finite number")
114
+ return json.dumps(value)
115
+ if isinstance(value, list):
116
+ return "[" + ",".join(canonical_json(v) for v in value) + "]"
117
+ if isinstance(value, dict):
118
+ keys = sorted(value.keys())
119
+ return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canonical_json(value[k]) for k in keys) + "}"
120
+ raise TypeError("canonical_json: unsupported type")
121
+
122
+
123
+ def reconstruct_signed_input(payload):
124
+ """Byte-identical to verify.js reconstructSignedInput.
125
+
126
+ v1: base ; v2: base|reg ; v3: base|reg|ir ; v4: base|reg|ir|expires_at|bh
127
+ (field order matches the CodeRifts issuer, chain-attestation.js signingInputV4).
128
+ """
129
+ base = "{p}|{kid}|{fp}|{prev}|{caller}|{ts}".format(
130
+ p=SIGNING_PREFIX,
131
+ kid=payload["kid"],
132
+ fp=payload["fp"],
133
+ prev=payload["prev"],
134
+ caller=payload["caller"],
135
+ ts=payload["ts"],
136
+ )
137
+ if payload.get("v") == 4:
138
+ return base + "|" + str(payload["reg"]) + "|" + str(payload["ir"]) + "|" + str(payload["expires_at"]) + "|" + str(payload["bh"])
139
+ if payload.get("v") == 3:
140
+ return base + "|" + str(payload["reg"]) + "|" + str(payload["ir"])
141
+ if payload.get("v") == 2:
142
+ return base + "|" + str(payload["reg"])
143
+ return base
144
+
145
+
146
+ def resolve_entry(ctx, payload):
147
+ """Resolve the key entry for a payload; None => unknown_kid. Returns {public_key, status, retired_at}."""
148
+ kid = payload.get("kid")
149
+ if ctx.get("keyring") is not None:
150
+ entry = ctx["keyring"].get(kid)
151
+ if entry is None:
152
+ return None
153
+ if ctx.get("expected_kid") is not None and kid != ctx["expected_kid"]:
154
+ return None
155
+ return entry
156
+ if ctx.get("expected_kid") is not None and kid != ctx["expected_kid"]:
157
+ return None
158
+ return {"public_key": ctx["public_key"], "status": None, "retired_at": None, "revoked_at": None, "compromised_at": None}
159
+
160
+
161
+ def _parse_iso(ts):
162
+ try:
163
+ return datetime.fromisoformat(str(ts).replace("Z", "+00:00")).timestamp() * 1000
164
+ except Exception:
165
+ return None
166
+
167
+
168
+ def derive_status(payload, entry, opts):
169
+ """The 12-status taxonomy verdict for a signature-valid receipt (mirrors verify.js deriveStatus)."""
170
+ v = payload.get("v")
171
+ if isinstance(v, int) and v > MAX_SUPPORTED_V:
172
+ return "UNSUPPORTED_VERSION"
173
+ # FAIL CLOSED ON A STATUS WE DO NOT UNDERSTAND — mirrors verify.js deriveStatus.
174
+ #
175
+ # MEASURED 2026-08-26: status "revoked" returned valid/VERIFIED_CURRENT here. The status was
176
+ # read for "retired" and otherwise ignored, so an operator marking a stolen key revoked would
177
+ # have believed they had acted while this verifier kept accepting it. The app kernel and the
178
+ # attest/toolset verifiers already reject an unknown status; these two did not.
179
+ #
180
+ # This is only the DIRECTION of the unknown case, not revocation: the rule (compromised_at,
181
+ # REVOKED_KEY / REVOKED_KEY_UNDECIDABLE) is a separate change across eight verifiers.
182
+ if entry.get("status") not in ("active", "retired", "revoked", None):
183
+ return "UNKNOWN_KEY_STATUS"
184
+ # 1079 B — OPTIONAL timestamps, additive. Absent both fields → continue as before.
185
+ # Signing time is payload.ts (receipts have no iat). revoked_at kills history;
186
+ # retired_at only rejects ts >= retired_at.
187
+ revoked_at = entry.get("revoked_at")
188
+ if isinstance(revoked_at, str) and revoked_at:
189
+ return "KEY_REVOKED"
190
+ retired_at = entry.get("retired_at")
191
+ if isinstance(retired_at, str) and retired_at and payload.get("ts"):
192
+ issued = _parse_iso(payload.get("ts"))
193
+ retired = _parse_iso(retired_at)
194
+ if issued is not None and retired is not None and issued >= retired:
195
+ return "KEY_RETIRED_AFTER_SIGNING"
196
+ # REVOKED — RECEIPT_FORMAT.md 7.1 (normative); mirrors verify.js deriveStatus exactly.
197
+ # Both outcomes are valid:false. UNDECIDABLE is not a softer valid.
198
+ if entry.get("status") == "revoked":
199
+ at = entry.get("compromised_at")
200
+ if not isinstance(at, str) or not at:
201
+ return "REVOKED_KEY_UNDECIDABLE"
202
+ boundary = _parse_iso(at)
203
+ issued = _parse_iso(payload.get("ts"))
204
+ if boundary is None or issued is None:
205
+ return "REVOKED_KEY_UNDECIDABLE"
206
+ return "REVOKED_KEY" if issued >= boundary else "REVOKED_KEY_UNDECIDABLE"
207
+ if entry.get("status") == "retired":
208
+ issued = _parse_iso(payload.get("ts"))
209
+ retired = _parse_iso(entry.get("retired_at"))
210
+ if retired is not None and issued is not None and issued < retired:
211
+ return "RETIRED_KEY_VALID_AT_ISSUE"
212
+ return "INVALID_SIGNATURE"
213
+ now = opts["now"] if opts.get("now") is not None else (datetime.now(timezone.utc).timestamp() * 1000)
214
+ if v == 4 and isinstance(payload.get("expires_at"), str):
215
+ exp = _parse_iso(payload["expires_at"])
216
+ context = opts.get("envelope") or {"environment": opts.get("expected_environment")}
217
+ if exp is not None and is_expired_at(exp, now, context):
218
+ return "VERIFIED_EXPIRED"
219
+ env = opts.get("envelope")
220
+ if env:
221
+ if opts.get("expected_audience") is not None and env.get("audience") is not None and env["audience"] != opts["expected_audience"]:
222
+ return "VERIFIED_WRONG_AUDIENCE"
223
+ if opts.get("expected_environment") is not None and env.get("environment") is not None and env["environment"] != opts["expected_environment"]:
224
+ return "VERIFIED_WRONG_ENVIRONMENT"
225
+ return "VERIFIED_CURRENT"
226
+
227
+
228
+ def verify_receipt(token, ctx=None, opts=None):
229
+ from .arity import split3
230
+ c, o = split3("verify_receipt", ctx, opts)
231
+ return _verify_receipt(token, c, o)
232
+
233
+
234
+ def _verify_receipt(token, ctx, opts=None):
235
+ """Return an ordered dict {valid, status, reason?, payload?} matching verify.js key-for-key."""
236
+ opts = opts or {}
237
+ # 1. structure
238
+ if not isinstance(token, str) or len(token) == 0:
239
+ return {"valid": False, "status": "MALFORMED", "reason": "malformed_structure"}
240
+ segments = token.split(".")
241
+ if len(segments) != 2 or any(len(s) == 0 for s in segments):
242
+ return {"valid": False, "status": "MALFORMED", "reason": "malformed_structure"}
243
+
244
+ # 2. json
245
+ try:
246
+ payload = json.loads(b64url_decode(segments[0]).decode("utf-8"))
247
+ except Exception:
248
+ return {"valid": False, "status": "MALFORMED", "reason": "bad_json"}
249
+ if not isinstance(payload, dict):
250
+ return {"valid": False, "status": "MALFORMED", "reason": "bad_json"}
251
+
252
+ # 3. kid
253
+ entry = resolve_entry(ctx, payload)
254
+ if entry is None:
255
+ return {"valid": False, "status": "UNKNOWN_KEY", "reason": "unknown_kid", "payload": payload}
256
+
257
+ # 4. signature (raw Ed25519 over the reconstructed UTF-8 bytes)
258
+ message = reconstruct_signed_input(payload).encode("utf-8")
259
+ try:
260
+ sig = b64url_decode(segments[1])
261
+ except Exception:
262
+ return {"valid": False, "status": "INVALID_SIGNATURE", "reason": "signature_error", "payload": payload}
263
+ try:
264
+ entry["public_key"].verify(sig, message)
265
+ except InvalidSignature:
266
+ return {"valid": False, "status": "INVALID_SIGNATURE", "reason": "signature_mismatch", "payload": payload}
267
+ except Exception:
268
+ return {"valid": False, "status": "INVALID_SIGNATURE", "reason": "signature_error", "payload": payload}
269
+
270
+ # 5. anti-downgrade delimiter guard
271
+ for k in SIGNED_FIELDS:
272
+ if isinstance(payload.get(k), str) and "|" in payload[k]:
273
+ return {"valid": False, "status": "INVALID_SIGNATURE", "reason": "delimiter_in_field", "payload": payload}
274
+
275
+ # 6. envelope binding (v4)
276
+ if opts.get("envelope") and payload.get("v") == 4:
277
+ rest = dict(opts["envelope"])
278
+ rest.pop("receipt", None)
279
+ rest.pop("decision_body_hash", None)
280
+ recomputed = "sha256:" + sha256hex(canonical_json(rest))
281
+ if recomputed != payload.get("bh"):
282
+ return {"valid": False, "status": "INVALID_SIGNATURE", "reason": "body_hash_mismatch", "payload": payload}
283
+
284
+ # 7. taxonomy status
285
+ status = derive_status(payload, entry, opts)
286
+ if status == "KEY_REVOKED":
287
+ return {"valid": False, "status": status, "reason": "KEY_REVOKED", "payload": payload}
288
+ if status == "KEY_RETIRED_AFTER_SIGNING":
289
+ return {"valid": False, "status": status, "reason": "KEY_RETIRED_AFTER_SIGNING", "payload": payload}
290
+ if status == "INVALID_SIGNATURE":
291
+ return {"valid": False, "status": status, "reason": "retired_key_after_issue", "payload": payload}
292
+ valid = status in ("VERIFIED_CURRENT", "RETIRED_KEY_VALID_AT_ISSUE")
293
+ return {"valid": valid, "status": status, "payload": payload}
294
+
295
+
296
+ def verify_chain(tokens, ctx=None, opts=None):
297
+ from .arity import split3
298
+ c, o = split3("verify_chain", ctx, opts)
299
+ return _verify_chain(tokens, c, o)
300
+
301
+
302
+ def _verify_chain(tokens, ctx, opts=None):
303
+ links = []
304
+ all_valid = True
305
+ first = None
306
+
307
+ for i, token in enumerate(tokens):
308
+ res = _verify_receipt(token, ctx, opts)
309
+ link = {"index": i, "signature_valid": res["valid"]}
310
+ if not res["valid"] and "reason" in res:
311
+ link["reason"] = res["reason"]
312
+ prev = res.get("payload", {}).get("prev") if res.get("payload") else None
313
+ if res.get("payload"):
314
+ link["prev"] = prev
315
+
316
+ if i == 0:
317
+ first = "genesis" if prev == "null" else "continuation"
318
+ link["role"] = first
319
+ link["prev_ok"] = True if prev == "null" else None
320
+ else:
321
+ expected = "sha256:" + sha256hex(tokens[i - 1])
322
+ link["expected_prev"] = expected
323
+ link["prev_ok"] = prev == expected
324
+ if not link["prev_ok"]:
325
+ all_valid = False
326
+
327
+ if not res["valid"]:
328
+ all_valid = False
329
+ links.append(link)
330
+
331
+ return {"valid": all_valid, "chain": {"length": len(tokens), "first": first, "links": links}}
332
+
333
+
334
+ def key_from_pem(pem_text):
335
+ key = load_pem_public_key(pem_text.encode("utf-8"))
336
+ if not isinstance(key, Ed25519PublicKey):
337
+ raise ValueError("public key is not Ed25519")
338
+ return key
339
+
340
+
341
+ def keyring_from_document(doc, source):
342
+ """Build a kid -> entry map from a registry document. None when keys[] is missing/empty
343
+ so the caller can try the legacy single-key body."""
344
+ keys = doc.get("keys") if isinstance(doc, dict) else None
345
+ if not keys:
346
+ return None
347
+ keyring = {}
348
+ for k in keys:
349
+ if not k or not k.get("kid") or not k.get("public_key_pem"):
350
+ raise ValueError("registry entry missing kid/public_key_pem in " + source)
351
+ keyring[k["kid"]] = {
352
+ "public_key": key_from_pem(k["public_key_pem"]),
353
+ "status": k.get("status"),
354
+ "retired_at": k.get("retired_at"),
355
+ "revoked_at": k.get("revoked_at"),
356
+ # Carried through for the revoked rule (RECEIPT_FORMAT 7.1). Dropping it makes the
357
+ # rule inert: every revoked key would return UNDECIDABLE regardless of ts.
358
+ "compromised_at": k.get("compromised_at"),
359
+ }
360
+ return keyring
361
+
362
+
363
+ def fetch_key_document(url):
364
+ """Fetch a key document. Accepts BOTH shapes: registry {keys:[...]} and
365
+ legacy {kid, public_key_pem}. Registry includes a keyring so retired kids resolve."""
366
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
367
+ with urllib.request.urlopen(req, timeout=15) as resp: # noqa: S310 (explicit --fetch/default only)
368
+ info = json.loads(resp.read().decode("utf-8"))
369
+ keyring = keyring_from_document(info, url)
370
+ if keyring:
371
+ active_kid = None
372
+ active_entry = None
373
+ for kid, entry in keyring.items():
374
+ if entry.get("status") == "active":
375
+ active_kid, active_entry = kid, entry
376
+ break
377
+ if active_entry is None:
378
+ active_kid = next(iter(keyring))
379
+ active_entry = keyring[active_kid]
380
+ return {"public_key": active_entry["public_key"], "kid": active_kid, "keyring": keyring}
381
+ if not info or not info.get("public_key_pem"):
382
+ raise ValueError("no public_key_pem at " + url)
383
+ return {"public_key": key_from_pem(info["public_key_pem"]), "kid": info.get("kid")}
384
+
385
+
386
+ def fetch_key_info(url):
387
+ """Legacy tuple (public_key, kid) of the active/single key — grant verifier unpacks this."""
388
+ info = fetch_key_document(url)
389
+ return info["public_key"], info.get("kid")
390
+
391
+
392
+ def load_keyring(source):
393
+ """Build a {kid: {public_key, status, retired_at}} map from a CodeRifts key registry
394
+ ({keys: [{kid, public_key_pem, status, valid_from, retired_at}]}). Active + retired both load."""
395
+ if source.lower().startswith(("http://", "https://")):
396
+ req = urllib.request.Request(source, headers={"Accept": "application/json"})
397
+ with urllib.request.urlopen(req, timeout=15) as resp: # noqa: S310 (explicit --keys only)
398
+ text = resp.read().decode("utf-8")
399
+ else:
400
+ with open(source, "r", encoding="utf-8") as fh:
401
+ text = fh.read()
402
+ doc = json.loads(text)
403
+ keyring = keyring_from_document(doc, source)
404
+ if not keyring:
405
+ raise ValueError("no keys[] in registry " + source)
406
+ return keyring
407
+
408
+
409
+ def fail(msg):
410
+ sys.stderr.write(msg + "\n" + USAGE)
411
+ sys.exit(2)
412
+
413
+
414
+ def registry_unreachable_verdict(source, err):
415
+ """1961/7.2 -- discovery failed is NOT "this key is not ours".
416
+
417
+ MEASURED 2026-09-23, before the fix: a discovery that could not complete left this CLI in
418
+ ``fail()`` -- free text on stderr, exit 2, the same shape as a mistyped flag. Nothing on
419
+ stdout, so a caller parsing the verdict document got no status at all. And where an empty or
420
+ stale keyring did reach the verifier, the receipt came back ``UNKNOWN_KEY``, which reads as
421
+ "signed by a key we do not publish" -- forgery-shaped -- when the truth was "we could not ask".
422
+
423
+ NOTHING IS LOOSENED. Both answers are ``valid: False``. What changes is where the operator is
424
+ sent: to their own network, or to the signer. ``REGISTRY_UNREACHABLE`` is already normative in
425
+ RECEIPT_FORMAT.md 7.1, so this is the code catching up with the published format.
426
+
427
+ Byte-identical in shape to cli.js ``registryUnreachableVerdict`` -- the two implementations
428
+ must not describe the same outage differently.
429
+ """
430
+ return {
431
+ "valid": False,
432
+ "status": "REGISTRY_UNREACHABLE",
433
+ "reason": "registry_unreachable",
434
+ "registry_unreachable": {
435
+ "source": source,
436
+ "why": "discovery was requested and could not be completed: " + str(err),
437
+ "remedy": (
438
+ "retry when the registry is reachable, or verify offline against a pinned keyring "
439
+ "(--keys <file>, or the vendored snapshot with no flags). An offline verdict is a "
440
+ "verdict about the keys you pinned, not about the registry as it stands now."
441
+ ),
442
+ },
443
+ }
444
+
445
+
446
+ def discovery_was_mandatory(opts):
447
+ """Did the operator ask for the network? A file path did not.
448
+
449
+ WARNING -- THE DIVERGENCE THIS DOCSTRING USED TO RECORD IS NOW CLOSED (P-2, 2026-09-23). It
450
+ said that with no flags verify.js read a vendored snapshot while this module fetched, so the
451
+ no-flag default was mandatory discovery here and was not there. Peter chose OFFLINE for both;
452
+ the default now loads ``VENDORED_KEYS_PATH`` and makes no network call, so it is NOT mandatory
453
+ discovery in either implementation. Kept as a note rather than deleted: the next reader is
454
+ better served by knowing the two once disagreed than by finding a function that looks as
455
+ though it always agreed.
456
+ """
457
+ if opts.get("fetch_url"):
458
+ return True
459
+ source = opts.get("keys_source")
460
+ if isinstance(source, str) and (source.startswith("http://") or source.startswith("https://")):
461
+ return True
462
+ # No --key, no --keys, no --fetch: the vendored snapshot. Offline is not discovery.
463
+ return False
464
+
465
+
466
+ def parse_args(argv):
467
+ opts = {"receipt": None, "chain_file": None, "key_file": None, "keys_source": None,
468
+ "kid": None, "fetch_url": None, "envelope_file": None, "audience": None,
469
+ "environment": None, "help": False, "from_commit": None, "repo": None}
470
+ i = 0
471
+ while i < len(argv):
472
+ a = argv[i]
473
+ if a == "--chain":
474
+ i += 1
475
+ opts["chain_file"] = argv[i] if i < len(argv) else None
476
+ elif a == "--key":
477
+ i += 1
478
+ opts["key_file"] = argv[i] if i < len(argv) else None
479
+ elif a == "--keys":
480
+ i += 1
481
+ opts["keys_source"] = argv[i] if i < len(argv) else None
482
+ elif a == "--kid":
483
+ i += 1
484
+ opts["kid"] = argv[i] if i < len(argv) else None
485
+ elif a == "--fetch":
486
+ i += 1
487
+ opts["fetch_url"] = argv[i] if i < len(argv) else None
488
+ # 1961 TAG 1 -- read the receipt off a commit instead of the command line.
489
+ elif a == "--from-commit":
490
+ i += 1
491
+ opts["from_commit"] = argv[i] if i < len(argv) else None
492
+ elif a == "--repo":
493
+ i += 1
494
+ opts["repo"] = argv[i] if i < len(argv) else None
495
+ elif a == "--envelope":
496
+ i += 1
497
+ opts["envelope_file"] = argv[i] if i < len(argv) else None
498
+ elif a == "--audience":
499
+ i += 1
500
+ opts["audience"] = argv[i] if i < len(argv) else None
501
+ elif a == "--environment":
502
+ i += 1
503
+ opts["environment"] = argv[i] if i < len(argv) else None
504
+ elif a in ("-h", "--help"):
505
+ opts["help"] = True
506
+ elif a.startswith("--"):
507
+ raise ValueError("unknown flag: " + a)
508
+ elif opts["receipt"] is None:
509
+ opts["receipt"] = a
510
+ else:
511
+ raise ValueError("unexpected argument: " + a)
512
+ i += 1
513
+ if opts["key_file"] and opts["keys_source"]:
514
+ raise ValueError("--key and --keys are mutually exclusive")
515
+ # A receipt from a commit AND one on the command line is an ambiguity, not a convenience:
516
+ # silently preferring one would verify a token the operator did not think they were asking
517
+ # about.
518
+ if opts["from_commit"] and opts["receipt"]:
519
+ raise ValueError("--from-commit and a positional receipt are mutually exclusive")
520
+ if opts["from_commit"] and opts["chain_file"]:
521
+ raise ValueError("--from-commit and --chain are mutually exclusive")
522
+ return opts
523
+
524
+
525
+ def main():
526
+ try:
527
+ opts = parse_args(sys.argv[1:])
528
+ except ValueError as e:
529
+ fail(str(e))
530
+ if opts["help"]:
531
+ sys.stdout.write(USAGE)
532
+ sys.exit(2)
533
+ # 1961 TAG 1 -- resolve the receipt from a commit before anything else needs it.
534
+ #
535
+ # WARNING -- ANY FAILURE HERE IS A USAGE ERROR (exit 2), NOT A VERDICT. "no receipt is
536
+ # attached to this commit" is not a statement about a receipt: there is no receipt to have an
537
+ # opinion about. Emitting valid:false would be a verdict on a token never presented.
538
+ from_commit_envelope = None
539
+ if opts["from_commit"]:
540
+ try:
541
+ from ._from_commit import receipt_for_commit
542
+ found = receipt_for_commit(opts["from_commit"], cwd=opts["repo"] or os.getcwd())
543
+ opts["receipt"] = found["token"]
544
+ from_commit_envelope = found["envelope"]
545
+ sys.stderr.write("receipt for %s via %s\n" % (found["sha"], found["carrier"]))
546
+ except Exception as e:
547
+ fail(str(e))
548
+
549
+ if not opts["chain_file"] and not opts["receipt"]:
550
+ fail("no receipt provided")
551
+
552
+ # Resolve the verification key(s) + expected kid.
553
+ try:
554
+ if opts["keys_source"]:
555
+ ctx = {"keyring": load_keyring(opts["keys_source"]), "expected_kid": opts["kid"]}
556
+ elif opts["key_file"]:
557
+ with open(opts["key_file"], "r", encoding="utf-8") as fh:
558
+ public_key = key_from_pem(fh.read())
559
+ ctx = {"public_key": public_key, "expected_kid": opts["kid"]}
560
+ elif opts["fetch_url"]:
561
+ # Opt-in live discovery. Explicit, because it is the case that makes a network call.
562
+ info = fetch_key_document(opts["fetch_url"])
563
+ if info.get("keyring"):
564
+ ctx = {"keyring": info["keyring"], "expected_kid": opts["kid"]}
565
+ else:
566
+ ctx = {"public_key": info["public_key"], "expected_kid": opts["kid"] or info.get("kid")}
567
+ else:
568
+ # P-2: default = vendored snapshot, no network. Same as verify.js with no flags.
569
+ ctx = {"keyring": load_keyring(VENDORED_KEYS_PATH), "expected_kid": opts["kid"]}
570
+ except Exception as e:
571
+ # A FAILED MANDATORY DISCOVERY IS A VERDICT, NOT A USAGE ERROR: structured status on
572
+ # stdout and the verdict exit code, so a machine can tell an outage from a mistyped flag.
573
+ # An unreadable LOCAL file stays a usage error -- dressing an operator's own bad path as a
574
+ # registry outage would send them to the network to debug a filename.
575
+ if discovery_was_mandatory(opts):
576
+ source = opts["fetch_url"] or opts["keys_source"] or DEFAULT_FETCH_URL
577
+ sys.stdout.write(json.dumps(registry_unreachable_verdict(source, e)) + "\n")
578
+ sys.exit(1)
579
+ fail("could not load public key: " + str(e))
580
+
581
+ envelope = from_commit_envelope
582
+ if opts["envelope_file"]:
583
+ try:
584
+ with open(opts["envelope_file"], "r", encoding="utf-8") as fh:
585
+ envelope = json.loads(fh.read())
586
+ except Exception as e:
587
+ fail("could not read --envelope: " + str(e))
588
+ verify_opts = {"envelope": envelope, "expected_audience": opts["audience"], "expected_environment": opts["environment"]}
589
+
590
+ if opts["chain_file"]:
591
+ with open(opts["chain_file"], "r", encoding="utf-8") as fh:
592
+ tokens = [ln.strip() for ln in fh.read().split("\n") if ln.strip()]
593
+ if not tokens:
594
+ fail("chain file is empty")
595
+ result = verify_chain(tokens, ctx, verify_opts)
596
+ else:
597
+ result = _verify_receipt(opts["receipt"], ctx, verify_opts)
598
+
599
+ sys.stdout.write(json.dumps(result, separators=(",", ":")) + "\n")
600
+ sys.exit(0 if result["valid"] else 1)
601
+
602
+
603
+ if __name__ == "__main__":
604
+ main()
@@ -0,0 +1,29 @@
1
+ """1129 — unified (token, opts) with opts['ctx'] / opts['intended']."""
2
+ import warnings
3
+
4
+ _warned = set()
5
+
6
+
7
+ def warn_once(name):
8
+ if name in _warned:
9
+ return
10
+ _warned.add(name)
11
+ warnings.warn(
12
+ f"DEPRECATION: {name}(token, ctx, opts) is deprecated; use {name}(token, {{'ctx': ..., 'intended': ...}}). "
13
+ "This warning is emitted once.",
14
+ DeprecationWarning,
15
+ stacklevel=3,
16
+ )
17
+
18
+
19
+ def split3(name, ctx, opts):
20
+ if opts is not None:
21
+ warn_once(name)
22
+ return ctx or {}, opts or {}
23
+ if isinstance(ctx, dict) and ("ctx" in ctx or "intended" in ctx or "envelope" in ctx):
24
+ return ctx.get("ctx") or {}, ctx
25
+ return ctx or {}, {}
26
+
27
+
28
+ def reset_warned_for_test():
29
+ _warned.clear()
@@ -0,0 +1,181 @@
1
+ """DSSE / in-toto envelope unwrapping for CodeRifts artifacts.
2
+
3
+ Mirrors ``receipt-verifier/to-dsse.js`` ``fromDSSE``. The format is specified in
4
+ ``RECEIPT_FORMAT.md`` section 9, which is the source of truth for both
5
+ implementations; the constants below are byte-exact to it.
6
+
7
+ WHAT THIS DOES
8
+ It unwraps. ``from_dsse`` returns the compact token that was wrapped, byte
9
+ for byte, and ``verify_receipt`` then decides.
10
+
11
+ WHAT IT DOES NOT DO
12
+ It does not verify. No signature is checked while reading an envelope. An
13
+ envelope wrapping a receipt with a bad signature unwraps cleanly and then
14
+ FAILS verification, exactly as that compact token would have -- the
15
+ signature is over the compact bytes. A DSSE envelope in hand is a container,
16
+ not a verdict.
17
+
18
+ WHY THE PAYLOAD SEGMENT IS PRESERVED
19
+ ``predicate.compact.encoded_payload`` carries the ORIGINAL base64url
20
+ segment, not a re-serialisation of ``predicate.fields``. A signature is over
21
+ bytes, and re-encoding JSON is not byte-stable -- key order and whitespace
22
+ are free -- so a rebuilt payload would fail to verify for a reason that has
23
+ nothing to do with authenticity. Section 9.4.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import base64
29
+ import binascii
30
+ import json
31
+
32
+ #: Section 9.1. Byte-exact with to-dsse.js; a divergence here breaks
33
+ #: interoperability silently, so tests pin both against the spec.
34
+ PAYLOAD_TYPE = "application/vnd.in-toto+json"
35
+ STATEMENT_TYPE = "https://in-toto.io/Statement/v1"
36
+ PREDICATE_TYPE = "https://coderifts.com/attestations/agent-action-authorization/v1"
37
+
38
+ #: The compact forms an envelope may carry (section 9.3).
39
+ FORM_RECEIPT = "crchain.v1"
40
+ FORM_ATTESTATION = "cr.exec.attest.v1"
41
+
42
+
43
+ class DsseError(Exception):
44
+ """Named refusal. ``code`` is one of MALFORMED / UNSUPPORTED / PREDICATE_MISMATCH."""
45
+
46
+ def __init__(self, message: str, code: str):
47
+ super().__init__(message)
48
+ self.code = code
49
+
50
+
51
+ def _b64url_decode(segment: str) -> bytes:
52
+ pad = "=" * (-len(segment) % 4)
53
+ try:
54
+ return base64.urlsafe_b64decode(segment + pad)
55
+ except (binascii.Error, ValueError) as exc:
56
+ raise DsseError(f"payload segment is not base64url: {exc}", "MALFORMED") from exc
57
+
58
+
59
+ def _decode_payload(encoded: str) -> dict:
60
+ try:
61
+ obj = json.loads(_b64url_decode(encoded).decode("utf-8"))
62
+ except DsseError:
63
+ raise
64
+ except (ValueError, UnicodeDecodeError) as exc:
65
+ raise DsseError(f"payload segment is not base64url JSON: {exc}", "MALFORMED") from exc
66
+ if not isinstance(obj, dict):
67
+ raise DsseError("payload is not a JSON object", "MALFORMED")
68
+ return obj
69
+
70
+
71
+ def _sort_deep(value):
72
+ """Stable key order for the consistency comparison. Lists keep their order."""
73
+ if isinstance(value, list):
74
+ return [_sort_deep(v) for v in value]
75
+ if isinstance(value, dict):
76
+ return {k: _sort_deep(value[k]) for k in sorted(value)}
77
+ return value
78
+
79
+
80
+ def looks_like_dsse(candidate) -> bool:
81
+ """Is this a DSSE envelope rather than a compact token?
82
+
83
+ Keyed on ``payloadType``, which is the envelope's own discriminator -- not on
84
+ "does it look like JSON". A compact receipt is ``<b64url>.<b64url>`` and a
85
+ compact attestation is pipe-delimited; neither can be mistaken for a mapping
86
+ carrying an in-toto payloadType.
87
+ """
88
+ if isinstance(candidate, dict):
89
+ return candidate.get("payloadType") == PAYLOAD_TYPE
90
+ if isinstance(candidate, (str, bytes)):
91
+ text = candidate.decode("utf-8", "replace") if isinstance(candidate, bytes) else candidate
92
+ if not text.lstrip().startswith("{"):
93
+ return False
94
+ try:
95
+ parsed = json.loads(text)
96
+ except ValueError:
97
+ return False
98
+ return isinstance(parsed, dict) and parsed.get("payloadType") == PAYLOAD_TYPE
99
+ return False
100
+
101
+
102
+ def from_dsse(envelope) -> str:
103
+ """Reassemble the compact token from a DSSE envelope.
104
+
105
+ Refuses rather than guesses. Every refusal below is a case where returning a
106
+ token anyway would hand the caller bytes that do not correspond to the
107
+ envelope they read.
108
+
109
+ :raises DsseError: MALFORMED | UNSUPPORTED | PREDICATE_MISMATCH
110
+ """
111
+ if isinstance(envelope, (str, bytes)):
112
+ text = envelope.decode("utf-8", "replace") if isinstance(envelope, bytes) else envelope
113
+ try:
114
+ envelope = json.loads(text)
115
+ except ValueError as exc:
116
+ raise DsseError(f"from_dsse: envelope is not JSON: {exc}", "MALFORMED") from exc
117
+ if not isinstance(envelope, dict):
118
+ raise DsseError("from_dsse: envelope must be an object", "MALFORMED")
119
+
120
+ if envelope.get("payloadType") != PAYLOAD_TYPE:
121
+ raise DsseError(
122
+ f"from_dsse: unsupported payloadType {json.dumps(envelope.get('payloadType'))}",
123
+ "UNSUPPORTED",
124
+ )
125
+
126
+ raw = envelope.get("payload")
127
+ try:
128
+ statement = json.loads(base64.b64decode(str(raw), validate=True).decode("utf-8"))
129
+ except (binascii.Error, ValueError, UnicodeDecodeError) as exc:
130
+ raise DsseError(f"from_dsse: payload is not base64 JSON: {exc}", "MALFORMED") from exc
131
+ if not isinstance(statement, dict) or statement.get("predicateType") != PREDICATE_TYPE:
132
+ got = statement.get("predicateType") if isinstance(statement, dict) else None
133
+ raise DsseError(f"from_dsse: unsupported predicateType {json.dumps(got)}", "UNSUPPORTED")
134
+
135
+ predicate = statement.get("predicate") or {}
136
+ compact = predicate.get("compact") or {}
137
+ signatures = envelope.get("signatures")
138
+ signatures = signatures if isinstance(signatures, list) else []
139
+ if len(signatures) != 1 or not isinstance(signatures[0], dict) or not signatures[0].get("sig"):
140
+ raise DsseError("from_dsse: exactly one signature with a sig is required", "MALFORMED")
141
+
142
+ encoded = compact.get("encoded_payload")
143
+ if not isinstance(encoded, str) or not encoded:
144
+ raise DsseError("from_dsse: predicate.compact.encoded_payload missing", "MALFORMED")
145
+
146
+ # The predicate carries the payload twice -- preserved bytes and decoded
147
+ # fields. If they disagree, the readable half describes something the signed
148
+ # half does not contain, and a reader without a CodeRifts verifier would
149
+ # believe the readable half. Refuse instead (section 9.7).
150
+ decoded = _decode_payload(encoded)
151
+ if _sort_deep(decoded) != _sort_deep(predicate.get("fields") or {}):
152
+ raise DsseError(
153
+ "from_dsse: predicate.fields does not match predicate.compact.encoded_payload -- "
154
+ "the readable half of this envelope disagrees with the signed half",
155
+ "PREDICATE_MISMATCH",
156
+ )
157
+
158
+ sig = signatures[0]["sig"]
159
+ form = compact.get("form")
160
+ if form == FORM_ATTESTATION:
161
+ if not compact.get("tag") or not compact.get("kid"):
162
+ raise DsseError(
163
+ "from_dsse: attestation form needs compact.tag and compact.kid", "MALFORMED"
164
+ )
165
+ return "|".join([compact["tag"], compact["kid"], encoded, sig])
166
+ if form == FORM_RECEIPT:
167
+ return f"{encoded}.{sig}"
168
+ raise DsseError(f"from_dsse: unknown compact form {json.dumps(form)}", "UNSUPPORTED")
169
+
170
+
171
+ def unwrap(candidate) -> str:
172
+ """Accept either form and return the compact token.
173
+
174
+ A caller that does not know which form it received can hand it here. A
175
+ compact token passes through untouched; an envelope is unwrapped.
176
+ """
177
+ if looks_like_dsse(candidate):
178
+ return from_dsse(candidate)
179
+ if isinstance(candidate, str) and candidate:
180
+ return candidate
181
+ raise DsseError("unwrap: neither a compact token nor a DSSE envelope", "MALFORMED")
@@ -0,0 +1 @@
1
+ {"keys":[{"kid":"2026-07-k1","public_key_pem":"-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAJh8xDXLaOCpQ+bBC9d2I+zG1qVsCpeCuWbtH+aGjC7A=\n-----END PUBLIC KEY-----\n","status":"active","valid_from":null,"retired_at":null}]}
@@ -0,0 +1 @@
1
+ bfe0c898cda3c3a4c8141726d0adb4cdb7eb9762de6264e430eff6469b90422e coderifts-keys.json
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: coderifts-verifier
3
+ Version: 0.1.0
4
+ Summary: Offline verifier for CodeRifts chain receipts and DSSE/in-toto envelopes. Ed25519, no network.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/coderifts/receipt-verifier
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: cryptography>=41
11
+ Dynamic: license-file
12
+
13
+ # coderifts-verifier (Python)
14
+
15
+ Verify a CodeRifts chain receipt — or a DSSE / in-toto envelope carrying one —
16
+ offline, in Python, without calling CodeRifts.
17
+
18
+ ```python
19
+ from coderifts_verifier import unwrap, verify_receipt, keyring_from_document
20
+
21
+ keyring = keyring_from_document(registry_json, "my-registry.json")
22
+ token = unwrap(whatever_arrived) # compact token, or DSSE -> compact
23
+ result = verify_receipt(token, {"keyring": keyring})
24
+
25
+ if result["valid"]:
26
+ ...
27
+ else:
28
+ handle(result["status"]) # INVALID_SIGNATURE, UNKNOWN_KEY, ...
29
+ ```
30
+
31
+ Ed25519 via `cryptography`. No network on the verify path.
32
+
33
+ ## This is not the CodeRifts SDK
34
+
35
+ Two different packages, on purpose:
36
+
37
+ | | `coderifts` (SDK) | `coderifts-verifier` (this) |
38
+ | --- | --- | --- |
39
+ | does | calls the API to get a decision | verifies evidence you already hold |
40
+ | crypto | none — deliberately | Ed25519 |
41
+ | network | yes, that is the point | none on the verify path |
42
+
43
+ A dependency on `cryptography` is correct here: this package checks signatures.
44
+ The SDK stays crypto-free.
45
+
46
+ ## Unwrapping is not verification
47
+
48
+ `unwrap` and `from_dsse` check **no signature**. An envelope wrapping a receipt
49
+ with a bad signature unwraps cleanly and then fails `verify_receipt`, exactly as
50
+ that compact token would have — the signature is over the compact bytes.
51
+
52
+ The DSSE envelope proves **exactly what the compact receipt proves**. A standard
53
+ container does not strengthen a claim.
54
+
55
+ ```python
56
+ token = from_dsse(envelope) # succeeds — nothing checked yet
57
+ verify_receipt(token, {"keyring": keyring}) # this is the verdict
58
+ ```
59
+
60
+ ## Cross-language agreement
61
+
62
+ The signed-bytes reconstruction is byte-identical to the JavaScript verifier —
63
+ `verify.js` says so in its own source, and `tests/xlang-vectors.json` holds
64
+ receipts produced and verified by that implementation. The test suite asserts
65
+ Python reaches the **same verdict on the same bytes**, for a passing receipt and
66
+ for two distinct failure modes.
67
+
68
+ Two implementations disagreeing about whether a receipt is valid is the failure
69
+ a multi-language verifier exists to avoid, so the agreement is pinned rather than
70
+ assumed.
71
+
72
+ The verification core (`coderifts_verifier/_verify.py`) is **vendored** from the
73
+ reference verifier, not reimplemented. The only edit is the import path for the
74
+ vendored `arity` helper; the file is otherwise byte-identical, so there is one
75
+ definition of what a valid receipt is rather than two that can drift.
76
+
77
+ ## Refusals are named
78
+
79
+ `from_dsse` raises `DsseError` with a `code`:
80
+
81
+ | code | when |
82
+ | --- | --- |
83
+ | `MALFORMED` | not an envelope; unreadable payload; zero, two, or signature-less entries; missing `encoded_payload` |
84
+ | `UNSUPPORTED` | a `payloadType`, `predicateType`, or compact `form` this version does not implement |
85
+ | `PREDICATE_MISMATCH` | the decoded `predicate.fields` disagree with the preserved payload segment |
86
+
87
+ That last one matters: the predicate carries the payload twice, once as bytes and
88
+ once decoded for a reader. If they disagree, the readable half describes
89
+ something the signed half does not contain — refuse rather than hand back bytes
90
+ that do not match the envelope someone read.
91
+
92
+ ## What a valid verdict proves
93
+
94
+ * a holder of a key in **the keyring you supplied** signed this receipt;
95
+ * the receipt's signed fields are unmodified;
96
+ * per `status`: whether it is current, or was issued while a now-retired key was
97
+ still valid.
98
+
99
+ ## What it does NOT prove
100
+
101
+ * **That anything happened.** A receipt is a signed statement about a decision,
102
+ not an observation of an effect.
103
+ * **That the key is uncompromised.** Everything reduces to "a holder of this key
104
+ signed this". Custody is yours.
105
+ * **That the receipt is the right one for what you are about to do.** Scope
106
+ matching — does this receipt cover THIS operation on THIS target — is the
107
+ caller's check, against the decision envelope. This verifies the receipt.
108
+ * **That a human saw anything.**
109
+
110
+ ## Format
111
+
112
+ `RECEIPT_FORMAT.md` in the reference verifier is the specification; section 9
113
+ covers the DSSE / in-toto export. The constants here are byte-exact to it and
114
+ pinned by a test.
115
+
116
+ ## Tests
117
+
118
+ ```bash
119
+ python3 -m pytest tests/ -q
120
+ ```
121
+
122
+ ## License
123
+
124
+ MIT
@@ -0,0 +1,12 @@
1
+ coderifts_verifier/__init__.py,sha256=Cwmzp_zULHwao6ynRVBpABfUL9SVW5lKTHDaFUV8ypI,1782
2
+ coderifts_verifier/_from_commit.py,sha256=dI_4Wdda1n_1AKaV4jG3Mt7ZpAGGHs4lu1j-2BTA6B0,4158
3
+ coderifts_verifier/_verify.py,sha256=YmHIa8M24wB7cW3LPSbzUXpDk7bMfV_DIVxfgnSJ3bM,27747
4
+ coderifts_verifier/arity.py,sha256=s9KgFqSFNNgoGe1vE4dTtwxtMNDtcVnTZur7gczjfSQ,749
5
+ coderifts_verifier/dsse.py,sha256=3VHp7Ch8eBPIOMndqcwu4mmqCQQo6perW37ESVHZhB4,7645
6
+ coderifts_verifier/keys/coderifts-keys.json,sha256=v-DImM2jw6TIFBcm0K20zbfrl2LeYmTkMO_2RpuQQi4,221
7
+ coderifts_verifier/keys/coderifts-keys.json.sha256,sha256=CkyftXHWs9wITq6votBQziPyjRVQEJubfnX9-zujJzg,86
8
+ coderifts_verifier-0.1.0.dist-info/licenses/LICENSE,sha256=LCpDJxtKbo95q7AySzxWz7EosZLSBfgE5uDxePqRq0g,1066
9
+ coderifts_verifier-0.1.0.dist-info/METADATA,sha256=nbnnhym9fw8-oydAW3StM-z_1t_E4W_oeBHFOD5u5So,4629
10
+ coderifts_verifier-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ coderifts_verifier-0.1.0.dist-info/top_level.txt,sha256=hauGkn-B2KvjzMT45hmX0QuX5sYb64hjM_AZAnebqW4,19
12
+ coderifts_verifier-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CodeRifts
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ coderifts_verifier