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.
- coderifts_verifier/__init__.py +62 -0
- coderifts_verifier/_from_commit.py +96 -0
- coderifts_verifier/_verify.py +604 -0
- coderifts_verifier/arity.py +29 -0
- coderifts_verifier/dsse.py +181 -0
- coderifts_verifier/keys/coderifts-keys.json +1 -0
- coderifts_verifier/keys/coderifts-keys.json.sha256 +1 -0
- coderifts_verifier-0.1.0.dist-info/METADATA +124 -0
- coderifts_verifier-0.1.0.dist-info/RECORD +12 -0
- coderifts_verifier-0.1.0.dist-info/WHEEL +5 -0
- coderifts_verifier-0.1.0.dist-info/licenses/LICENSE +21 -0
- coderifts_verifier-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|