runback-verify 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ node_modules/
2
+ .next/
3
+ out/
4
+ dist/
5
+ .env
6
+ .community-check-scratch/
7
+ .env.local
8
+ *.tsbuildinfo
9
+ next-env.d.ts
10
+ .DS_Store
11
+ .vercel
12
+
13
+ # secrets — belt & suspenders (keep .env.example committable)
14
+ .env.*
15
+ !.env.example
16
+ # native/syscall build artifacts
17
+ *.dylib
18
+ *.so
19
+ .secrets/
20
+
21
+ # Python (packages/sdk-python)
22
+ __pycache__/
23
+ *.pyc
24
+ *.egg-info/
25
+ .venv/
26
+ .pytest_cache/
27
+ build/
28
+ *.egg
29
+
30
+ # Saved auth for local QA scripts — holds a live Supabase access_token, so a
31
+ # stray `git add -A` would publish working credentials.
32
+ session.json
33
+ # Throwaway browser-driving scripts used to click through the app by hand.
34
+ check_*.mjs
35
+ packages/sdk/LICENSE
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.5
2
+ Name: runback-verify
3
+ Version: 2.1.0
4
+ Summary: Verify tamper-evidence and integrity of Runback audit cassettes (runback.cassette/v1). No account required, no dependencies.
5
+ Project-URL: Homepage, https://runback.dev
6
+ Project-URL: Specification, https://runback.dev/spec
7
+ Project-URL: Source, https://github.com/letsRunback/runback
8
+ Author-email: Runback <contact@runback.dev>
9
+ License-Expression: MIT
10
+ Keywords: ai-agents,audit,cassette,ed25519,eu-ai-act,governance,runback,tamper-evident,verification
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Legal Industry
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Security :: Cryptography
17
+ Classifier: Topic :: Software Development :: Quality Assurance
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+
21
+ # runback-verify
22
+
23
+ Verify the integrity of a Runback audit record — offline, with nothing installed.
24
+
25
+ ```bash
26
+ pip install runback-verify
27
+ runback-verify record.json
28
+ ```
29
+
30
+ ```python
31
+ from runback_verify import verify_json
32
+
33
+ result = verify_json(open("record.json").read())
34
+ print(result["verdict"]) # "valid" | "unverified" | "invalid"
35
+ ```
36
+
37
+ ## Zero dependencies, on purpose
38
+
39
+ This package is separate from `runback-sdk` because the SDK needs `requests` and
40
+ `python-ulid` to do its job, and an auditor needs neither. Ed25519 verification
41
+ is implemented here from RFC 8032 in plain Python, so the package installs and
42
+ runs on a stock interpreter in a locked-down environment.
43
+
44
+ ## What the verdicts mean
45
+
46
+ | verdict | meaning |
47
+ |---|---|
48
+ | `valid` | integrity holds **and** the signer is a key you pinned |
49
+ | `unverified` | self-consistent, but the origin is unproven |
50
+ | `invalid` | something failed — it is not the record it claims to be |
51
+
52
+ The chain algorithm is public, so anyone can author a self-consistent record
53
+ from nothing, or edit a real one and re-chain it. Integrity alone proves only
54
+ internal coherence; provenance is a separate question, answered only by a
55
+ signature that verifies against a key you pinned **out of band**.
56
+
57
+ Exit codes: `0` valid, `1` invalid, `2` unverified.
58
+
59
+ ## The format
60
+
61
+ `runback.cassette/v1` is specified at <https://runback.dev/spec>. The reference
62
+ implementation is [`@runback/verify`](https://www.npmjs.com/package/@runback/verify)
63
+ on npm; this package is a port held to the same generated conformance fixture,
64
+ and where the two disagree the reference is authoritative.
@@ -0,0 +1,44 @@
1
+ # runback-verify
2
+
3
+ Verify the integrity of a Runback audit record — offline, with nothing installed.
4
+
5
+ ```bash
6
+ pip install runback-verify
7
+ runback-verify record.json
8
+ ```
9
+
10
+ ```python
11
+ from runback_verify import verify_json
12
+
13
+ result = verify_json(open("record.json").read())
14
+ print(result["verdict"]) # "valid" | "unverified" | "invalid"
15
+ ```
16
+
17
+ ## Zero dependencies, on purpose
18
+
19
+ This package is separate from `runback-sdk` because the SDK needs `requests` and
20
+ `python-ulid` to do its job, and an auditor needs neither. Ed25519 verification
21
+ is implemented here from RFC 8032 in plain Python, so the package installs and
22
+ runs on a stock interpreter in a locked-down environment.
23
+
24
+ ## What the verdicts mean
25
+
26
+ | verdict | meaning |
27
+ |---|---|
28
+ | `valid` | integrity holds **and** the signer is a key you pinned |
29
+ | `unverified` | self-consistent, but the origin is unproven |
30
+ | `invalid` | something failed — it is not the record it claims to be |
31
+
32
+ The chain algorithm is public, so anyone can author a self-consistent record
33
+ from nothing, or edit a real one and re-chain it. Integrity alone proves only
34
+ internal coherence; provenance is a separate question, answered only by a
35
+ signature that verifies against a key you pinned **out of band**.
36
+
37
+ Exit codes: `0` valid, `1` invalid, `2` unverified.
38
+
39
+ ## The format
40
+
41
+ `runback.cassette/v1` is specified at <https://runback.dev/spec>. The reference
42
+ implementation is [`@runback/verify`](https://www.npmjs.com/package/@runback/verify)
43
+ on npm; this package is a port held to the same generated conformance fixture,
44
+ and where the two disagree the reference is authoritative.
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "runback-verify"
7
+ version = "2.1.0"
8
+ description = "Verify tamper-evidence and integrity of Runback audit cassettes (runback.cassette/v1). No account required, no dependencies."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [{ name = "Runback", email = "contact@runback.dev" }]
12
+ requires-python = ">=3.9"
13
+ keywords = [
14
+ "ai-agents",
15
+ "audit",
16
+ "governance",
17
+ "tamper-evident",
18
+ "verification",
19
+ "runback",
20
+ "cassette",
21
+ "eu-ai-act",
22
+ "ed25519",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 5 - Production/Stable",
26
+ "Intended Audience :: Developers",
27
+ "Intended Audience :: Legal Industry",
28
+ "License :: OSI Approved :: MIT License",
29
+ "Programming Language :: Python :: 3",
30
+ "Topic :: Security :: Cryptography",
31
+ "Topic :: Software Development :: Quality Assurance",
32
+ ]
33
+
34
+ # Deliberately empty, and load-bearing.
35
+ #
36
+ # The version is pinned to @runback/verify's so the two ports of one format
37
+ # carry one number — a record that verifies under runback-verify 2.1.0 verifies
38
+ # identically under @runback/verify 2.1.0, and "which version checked this?" has
39
+ # a single answer across languages.
40
+ dependencies = []
41
+
42
+ [project.scripts]
43
+ runback-verify = "runback_verify:_console"
44
+
45
+ [project.urls]
46
+ Homepage = "https://runback.dev"
47
+ Specification = "https://runback.dev/spec"
48
+ Source = "https://github.com/letsRunback/runback"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["runback_verify"]
52
+
53
+ [tool.hatch.build.targets.sdist]
54
+ include = ["runback_verify", "README.md", "tests"]
@@ -0,0 +1,625 @@
1
+ """
2
+ runback-verify — check a Runback audit record offline, with nothing installed.
3
+
4
+ pip install runback-verify
5
+ python3 -m runback_verify record.json
6
+
7
+ from runback_verify import verify_json
8
+ verify_json(open("record.json").read())["verdict"] # valid|unverified|invalid
9
+
10
+ ZERO DEPENDENCIES, ON PURPOSE
11
+
12
+ This package exists separately from runback-sdk for one reason: the SDK needs
13
+ requests and python-ulid to do its job, and an auditor does not need either. The
14
+ claim the format makes is "you can check our evidence without us", and an
15
+ auditor who must first get a Rust-building wheel past a locked-down build is an
16
+ auditor who cannot check the record where the question was asked.
17
+
18
+ So Ed25519 verification is implemented here from RFC 8032 in plain Python —
19
+ about eighty lines of integer arithmetic, nothing imported beyond hashlib. It
20
+ verifies in a few milliseconds, which is irrelevant when the alternative is
21
+ being unable to verify at all. The same reasoning produced a zero-dependency
22
+ @runback/verify on npm; this is that promise kept in a second language.
23
+
24
+ WHAT "VALID" MEANS, AND WHAT IT DOES NOT
25
+
26
+ The chain algorithm is public, so anyone can author a self-consistent record
27
+ from nothing, or edit a real one and re-chain it. Integrity proves only internal
28
+ coherence. Provenance is a separate question, answered only by a signature
29
+ verifying against a key you pinned OUT OF BAND — hence three outcomes:
30
+
31
+ valid integrity holds AND the signer is a key you pinned
32
+ unverified self-consistent, but the origin is unproven
33
+ invalid something failed — not the record it claims to be
34
+
35
+ THE REFERENCE, AND WHY A FOURTH COPY OF canonical() IS SAFE
36
+
37
+ packages/verify/index.js defines the format; this is a port, and where the two
38
+ disagree the reference is right. The canonicalisation below is duplicated from
39
+ runback-sdk rather than imported, because importing it would reintroduce the
40
+ dependency this package exists to avoid.
41
+
42
+ Duplication is how three implementations silently drifted apart once already
43
+ (exponent padding, HTML escaping, non-BMP key order — each changing the digest).
44
+ What makes a fourth copy safe is that every copy is now held to one generated
45
+ fixture, packages/verify/testdata/jcs-vectors.json, by a conformance suite in
46
+ its own language. A copy that drifts fails its tests.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import base64
52
+ import hashlib
53
+ import json
54
+ import re
55
+ from typing import Any, Optional, Sequence, Union
56
+
57
+ def sha256(s: str) -> str:
58
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
59
+
60
+
61
+ def _js_number(x: float) -> str:
62
+ """ECMAScript Number::toString — what JSON.stringify emits, and what RFC 8785
63
+ is defined against.
64
+
65
+ Python's json module is NOT a drop-in here. Two differences, both of which
66
+ changed the digest:
67
+
68
+ json.dumps(1e-7) -> "1e-07" JSON.stringify(1e-7) -> "1e-7"
69
+ json.dumps(1e16) -> "1e+16" JSON.stringify(1e16) -> "10000000000000000"
70
+
71
+ Python pads the exponent to two digits and switches to scientific notation
72
+ at 1e16; ECMAScript strips the pad and switches at 1e21. A record carrying
73
+ either value hashed differently in Python than in JS, so the Python side
74
+ would report TAMPERED on a record the reference verifier calls VALID.
75
+ """
76
+ if x != x or x in (float("inf"), float("-inf")):
77
+ return "null" # JSON.stringify(NaN) === JSON.stringify(Infinity) === "null"
78
+ if x == 0:
79
+ return "0" # also covers -0.0; ECMAScript prints negative zero as "0"
80
+ if x < 0:
81
+ return "-" + _js_number(-x)
82
+ if x == int(x) and x < 1e21:
83
+ return str(int(x))
84
+
85
+ # Shortest round-trip digits, renormalised to value == 0.<digits> x 10**n,
86
+ # which is the form the ECMAScript spec's cases are written against.
87
+ r = repr(float(x))
88
+ mant, _, e = r.partition("e")
89
+ exp = int(e) if e else 0
90
+ ip, _, fp = mant.partition(".")
91
+ ip_sig = ip.lstrip("0")
92
+ digits = (ip + fp).lstrip("0")
93
+ n = (len(ip_sig) + exp) if ip_sig else (exp - (len(fp) - len(fp.lstrip("0"))))
94
+ digits = digits.rstrip("0") or "0"
95
+ k = len(digits)
96
+
97
+ if k <= n <= 21:
98
+ return digits + "0" * (n - k)
99
+ if 0 < n <= 21:
100
+ return digits[:n] + "." + digits[n:]
101
+ if -6 < n <= 0:
102
+ return "0." + "0" * (-n) + digits
103
+ sign = "+" if n - 1 >= 0 else "-"
104
+ head = digits if k == 1 else digits[0] + "." + digits[1:]
105
+ return head + "e" + sign + str(abs(n - 1))
106
+
107
+
108
+ def _utf16_key(k: str) -> bytes:
109
+ """Sort key giving UTF-16 code-unit order, which is what RFC 8785 requires.
110
+
111
+ Python's `sorted()` orders strings by Unicode CODE POINT; RFC 8785 (and JS's
112
+ own Object.keys().sort()) order by UTF-16 CODE UNIT. They agree across the
113
+ BMP and disagree above it, because a non-BMP character encodes as a
114
+ surrogate pair in 0xD800-0xDFFF, which sorts BELOW ordinary characters in
115
+ 0xE000-0xFFFF while its code point sorts above them.
116
+
117
+ Measured: {"\U0001F600": 1, "\uFFFD": 2} canonicalises with the emoji key
118
+ FIRST in JS and SECOND in Python. Same object, two digests, and an auditor
119
+ running the Python verifier would call a genuine record tampered.
120
+ """
121
+ return k.encode("utf-16-be", "surrogatepass")
122
+
123
+
124
+ def canonical(v: Any) -> str:
125
+ if isinstance(v, bool):
126
+ # Before the int branch: bool is a subclass of int in Python, so
127
+ # `isinstance(True, int)` is True and True would serialise as "1".
128
+ return "true" if v else "false"
129
+ if isinstance(v, float):
130
+ return _js_number(v)
131
+ if v is None or not isinstance(v, (dict, list)):
132
+ return json.dumps(v, separators=(",", ":"), ensure_ascii=False)
133
+ if isinstance(v, list):
134
+ return "[" + ",".join(canonical(x) for x in v) + "]"
135
+ o: dict = v
136
+ parts = []
137
+ # Python has no distinct "undefined" — a key simply absent from the dict
138
+ # never appears in `o.keys()` to begin with, which already matches
139
+ # JSON.stringify's "omit undefined-valued keys" behavior with nothing
140
+ # extra to check for here.
141
+ for k in sorted(o.keys(), key=_utf16_key):
142
+ parts.append(json.dumps(k, ensure_ascii=False) + ":" + canonical(o[k]))
143
+ return "{" + ",".join(parts) + "}"
144
+
145
+
146
+ def chain_step(prev: str, entry: dict) -> str:
147
+ """One step of the hash chain over an entry's defining content (not its own hash)."""
148
+ return sha256(prev + canonical({"kind": entry["kind"], "key": entry["key"], "output": entry["output"]}))
149
+
150
+
151
+ def _project_input(input_value: Any, projection: Optional[dict]) -> Any:
152
+ if not projection:
153
+ return input_value
154
+ keep = projection.get("keep")
155
+ drop = projection.get("drop")
156
+ if not keep and not drop:
157
+ return input_value
158
+ if not isinstance(input_value, (dict, list)):
159
+ return input_value
160
+ work = json.loads(json.dumps(input_value)) # deep clone, matching the TS jsonClone
161
+ if keep and isinstance(work, dict):
162
+ keep_top = {k.split(".")[0] for k in keep}
163
+ for k in list(work.keys()):
164
+ if k not in keep_top:
165
+ del work[k]
166
+ if drop:
167
+ for p in drop:
168
+ _drop_path(work, p.split("."))
169
+ return work
170
+
171
+
172
+ def _drop_path(node: Any, segs: list[str]) -> None:
173
+ if node is None or not segs or not isinstance(node, (dict, list)):
174
+ return
175
+ if isinstance(node, list):
176
+ for el in node:
177
+ _drop_path(el, segs)
178
+ return
179
+ head, *rest = segs
180
+ if not rest:
181
+ node.pop(head, None)
182
+ elif head in node:
183
+ _drop_path(node[head], rest)
184
+
185
+
186
+ def tool_key_p(name: str, input_value: Any, projection: Optional[dict] = None) -> str:
187
+ return sha256(f"tool:{name}:{canonical(_project_input(input_value, projection))}")
188
+
189
+
190
+ def llm_key_p(model_id: str, request: Any, projection: Optional[dict] = None) -> str:
191
+ return sha256(f"llm:{model_id}:{canonical(_project_input(request, projection))}")
192
+
193
+
194
+ def oracle_entry_of(event: dict) -> Optional[dict]:
195
+ """
196
+ Map a trace event (dict, matching the wire schema) to the entry it
197
+ contributes to the deterministic chain, or None if it isn't a
198
+ nondeterminism boundary (run/reasoning envelopes) — the exact same rule
199
+ packages/replay/src/cassette.ts's oracleEntryOf enforces, so a digest
200
+ computed here and one computed server-side from the same stored events
201
+ are identical by construction.
202
+ """
203
+ t = event.get("type")
204
+ if t == "llm":
205
+ error = event.get("error")
206
+ return {
207
+ "kind": "llm",
208
+ "key": llm_key_p(event["model"]["model_id"], event["request"], event.get("key_projection")),
209
+ "output": {"error": error} if error else event.get("response"),
210
+ }
211
+ if t == "tool":
212
+ error = event.get("error")
213
+ return {
214
+ "kind": "tool",
215
+ "key": tool_key_p(event["tool_name"], event.get("input"), event.get("key_projection")),
216
+ "output": {"error": error} if error else event.get("output"),
217
+ }
218
+ if t == "env":
219
+ return {"kind": event["kind"], "key": event["key"], "output": event.get("output")}
220
+ return None
221
+
222
+
223
+ __all__ = [
224
+ "verify",
225
+ "verify_json",
226
+ "compute_event_chain",
227
+ "compute_cassette_digest",
228
+ "RUNBACK_AUDIT_PUBKEYS_PEM",
229
+ "KNOWN_SCHEMAS",
230
+ ]
231
+
232
+ KNOWN_SCHEMAS = {"runback.audit/v2", "runback.cassette/v1"}
233
+
234
+ # Runback's published production signing keys, mirroring
235
+ # packages/verify/index.js. Kept as a list so a rotated-out key can stay
236
+ # accepted for the lifetime of records it signed.
237
+ RUNBACK_AUDIT_PUBKEYS_PEM: list[str] = [
238
+ "-----BEGIN PUBLIC KEY-----\n"
239
+ "MCowBQYDK2VwAyEAGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE=\n"
240
+ "-----END PUBLIC KEY-----\n",
241
+ ]
242
+ RUNBACK_AUDIT_REVOKED_PUBKEYS_PEM: list[str] = []
243
+
244
+
245
+ # ── Ed25519 verification, RFC 8032, pure Python ──────────────────────────────
246
+ # Reference implementation arithmetic. Not constant-time — irrelevant here,
247
+ # because verification uses only public values: a signature, a public key and a
248
+ # message an attacker already holds. There is no secret to leak by timing.
249
+
250
+ _P = 2**255 - 19
251
+ _L = 2**252 + 27742317777372353535851937790883648493
252
+ _D = -121665 * pow(121666, _P - 2, _P) % _P
253
+ _I = pow(2, (_P - 1) // 4, _P)
254
+
255
+
256
+ def _x_recover(y: int) -> int:
257
+ xx = (y * y - 1) * pow(_D * y * y + 1, _P - 2, _P)
258
+ x = pow(xx, (_P + 3) // 8, _P)
259
+ if (x * x - xx) % _P != 0:
260
+ x = (x * _I) % _P
261
+ if x % 2 != 0:
262
+ x = _P - x
263
+ return x
264
+
265
+
266
+ _BY = 4 * pow(5, _P - 2, _P) % _P
267
+ _BX = _x_recover(_BY)
268
+ _B = (_BX % _P, _BY % _P, 1, _BX * _BY % _P)
269
+
270
+
271
+ def _point_add(p: tuple, q: tuple) -> tuple:
272
+ a = (p[1] - p[0]) * (q[1] - q[0]) % _P
273
+ b = (p[1] + p[0]) * (q[1] + q[0]) % _P
274
+ c = 2 * p[3] * q[3] * _D % _P
275
+ d = 2 * p[2] * q[2] % _P
276
+ e, f, g, h = b - a, d - c, d + c, b + a
277
+ return (e * f % _P, g * h % _P, f * g % _P, e * h % _P)
278
+
279
+
280
+ def _scalar_mult(p: tuple, e: int) -> tuple:
281
+ q = (0, 1, 1, 0) # neutral element
282
+ while e > 0:
283
+ if e & 1:
284
+ q = _point_add(q, p)
285
+ p = _point_add(p, p)
286
+ e >>= 1
287
+ return q
288
+
289
+
290
+ def _point_equal(p: tuple, q: tuple) -> bool:
291
+ if (p[0] * q[2] - q[0] * p[2]) % _P != 0:
292
+ return False
293
+ return (p[1] * q[2] - q[1] * p[2]) % _P == 0
294
+
295
+
296
+ def _point_decompress(s: bytes) -> Optional[tuple]:
297
+ if len(s) != 32:
298
+ return None
299
+ y = int.from_bytes(s, "little")
300
+ sign = y >> 255
301
+ y &= (1 << 255) - 1
302
+ if y >= _P:
303
+ return None
304
+ x = _x_recover(y)
305
+ if x == 0 and sign:
306
+ return None
307
+ if x & 1 != sign:
308
+ x = _P - x
309
+ return (x, y, 1, x * y % _P)
310
+
311
+
312
+ def ed25519_verify(public_key: bytes, message: bytes, signature: bytes) -> bool:
313
+ """RFC 8032 Ed25519 verification. Returns False rather than raising."""
314
+ if len(signature) != 64 or len(public_key) != 32:
315
+ return False
316
+ a = _point_decompress(public_key)
317
+ if a is None:
318
+ return False
319
+ r = _point_decompress(signature[:32])
320
+ if r is None:
321
+ return False
322
+ s = int.from_bytes(signature[32:], "little")
323
+ if s >= _L:
324
+ return False
325
+ h = int.from_bytes(
326
+ hashlib.sha512(signature[:32] + public_key + message).digest(), "little"
327
+ ) % _L
328
+ sb = _scalar_mult(_B, s)
329
+ check = _point_add(r, _scalar_mult(a, h))
330
+ return _point_equal(sb, check)
331
+
332
+
333
+ # ── SPKI PEM parsing ─────────────────────────────────────────────────────────
334
+
335
+
336
+ def _spki_der(pem: str) -> Optional[bytes]:
337
+ """Raw 32-byte Ed25519 public key from an SPKI PEM.
338
+
339
+ An Ed25519 SPKI DER is a fixed 44 bytes whose final 32 are the key, so the
340
+ prefix is checked rather than parsed: a real ASN.1 parser here would be more
341
+ code and more attack surface for one fixed shape.
342
+ """
343
+ body = re.sub(r"-----(BEGIN|END)[^-]*-----|\s", "", pem or "")
344
+ try:
345
+ der = base64.b64decode(body, validate=True)
346
+ except Exception:
347
+ return None
348
+ if len(der) != 44:
349
+ return None
350
+ # 302a300506032b6570032100 — SEQUENCE, AlgorithmIdentifier id-Ed25519, BIT STRING
351
+ if der[:12] != bytes.fromhex("302a300506032b6570032100"):
352
+ return None
353
+ return der[12:]
354
+
355
+
356
+ # ── The chain and the cassette digest ────────────────────────────────────────
357
+
358
+
359
+ def compute_event_chain(events: Sequence[dict]) -> dict:
360
+ """h_0 = ""; h_i = SHA-256(h_{i-1} + canonical(event_i without _hash))."""
361
+ prev = ""
362
+ hashes = []
363
+ for e in events:
364
+ # Skip _hash without copying the event. Copying is not cosmetic here:
365
+ # dict(e) is fine in Python, but the JS reference had to serialise in
366
+ # place because Object.assign drops an own "__proto__" key, and the two
367
+ # implementations must hash the same bytes for the same input.
368
+ body = {k: v for k, v in e.items() if k != "_hash"}
369
+ h = sha256(prev + canonical(body))
370
+ hashes.append(h)
371
+ prev = h
372
+ return {"hashes": hashes, "digest": prev or sha256("")}
373
+
374
+
375
+ def compute_cassette_digest(events: Sequence[dict]) -> dict:
376
+ ordered = sorted(events, key=lambda e: e.get("seq") or 0)
377
+ prev = ""
378
+ count = 0
379
+ for e in ordered:
380
+ entry = oracle_entry_of(e)
381
+ if not entry:
382
+ continue
383
+ prev = chain_step(prev, entry)
384
+ count += 1
385
+ return {"digest": prev or sha256(""), "entry_count": count}
386
+
387
+
388
+ # ── Consistency: the unsigned convenience fields must match the signed events ─
389
+
390
+
391
+ def _check_consistency(record: dict) -> dict:
392
+ """Bind the record's summary to the events the signature actually covers.
393
+
394
+ The signed material is the event chain plus the cassette digest. It does NOT
395
+ cover manifest.run_id or the top-level `run` summary — so on a genuinely
396
+ signed record those can be rewritten and every hash still verifies. The
397
+ reference implementation demonstrated it: a real signed record with
398
+ run.status flipped error -> completed and a fabricated approval passed.
399
+
400
+ An auditor reads the summary, not the raw event stream, so a record that
401
+ misrepresents its own outcome is not valid however intact its chain.
402
+ """
403
+ events = record.get("events") or []
404
+ reasons: list[str] = []
405
+
406
+ run_ids = {e.get("run_id") for e in events if isinstance(e, dict) and e.get("run_id")}
407
+ manifest_run_id = (record.get("manifest") or {}).get("run_id")
408
+ if manifest_run_id and run_ids and manifest_run_id not in run_ids:
409
+ reasons.append(
410
+ f"manifest.run_id ({manifest_run_id}) is not the run_id in the signed events"
411
+ )
412
+
413
+ end = next(
414
+ (e for e in events if isinstance(e, dict) and e.get("type") == "run" and e.get("phase") == "end"),
415
+ None,
416
+ )
417
+ run = record.get("run")
418
+ if isinstance(run, dict) and end:
419
+ if "status" in run and run["status"] != end.get("status"):
420
+ reasons.append(
421
+ f"run.status ({run['status']}) contradicts the sealed run-end event ({end.get('status')})"
422
+ )
423
+ if "output" in run and run["output"] != end.get("output"):
424
+ reasons.append("run.output does not match the sealed run-end event")
425
+ if "error" in run and run["error"] != end.get("error"):
426
+ reasons.append("run.error does not match the sealed run-end event")
427
+
428
+ return {"ok": not reasons, "reasons": reasons}
429
+
430
+
431
+ # ── Public API ───────────────────────────────────────────────────────────────
432
+
433
+ PemList = Union[str, Sequence[str], None]
434
+
435
+
436
+ def verify(
437
+ record: dict,
438
+ signing_key: Optional[str] = None,
439
+ *,
440
+ expected_public_key: PemList = "__default__",
441
+ revoked_public_keys: Optional[Sequence[str]] = None,
442
+ ) -> dict:
443
+ """Verify a parsed cassette record. Mirrors packages/verify/index.js verify()."""
444
+ if not isinstance(record, dict) or not isinstance(record.get("manifest"), dict) \
445
+ or not isinstance(record.get("events"), list):
446
+ raise TypeError("Not a runback.cassette record — missing manifest or events array.")
447
+
448
+ manifest = record["manifest"]
449
+ events = record["events"]
450
+
451
+ chain = compute_event_chain(events)
452
+ cassette = compute_cassette_digest(events)
453
+
454
+ chain_ok = all(
455
+ h == (events[i].get("_hash") if isinstance(events[i], dict) else None)
456
+ for i, h in enumerate(chain["hashes"])
457
+ )
458
+ digest_ok = chain["digest"] == manifest.get("content_digest")
459
+ cassette_ok = cassette["digest"] == (manifest.get("replay") or {}).get("cassette_digest")
460
+ schema_ok = record.get("$schema") in KNOWN_SCHEMAS
461
+
462
+ signature = "unsigned"
463
+ signature_alg = None
464
+ sig = manifest.get("signature") or {}
465
+ if sig.get("value"):
466
+ # Must match web/lib/audit.ts exactly: the signature covers both the
467
+ # event chain and the replay digest, so a record cannot be re-pointed at
468
+ # a different cassette without breaking it.
469
+ payload = f"{manifest.get('content_digest')}:{(manifest.get('replay') or {}).get('cassette_digest')}"
470
+
471
+ if sig.get("alg") == "Ed25519":
472
+ signature_alg = "Ed25519"
473
+ raw_pinned = (
474
+ RUNBACK_AUDIT_PUBKEYS_PEM if expected_public_key == "__default__" else expected_public_key
475
+ )
476
+ if raw_pinned is None:
477
+ pinned: list[str] = []
478
+ elif isinstance(raw_pinned, str):
479
+ pinned = [raw_pinned]
480
+ else:
481
+ pinned = list(raw_pinned)
482
+ revoked = list(
483
+ RUNBACK_AUDIT_REVOKED_PUBKEYS_PEM if revoked_public_keys is None else revoked_public_keys
484
+ )
485
+
486
+ embedded = sig.get("pubkey")
487
+ if not embedded:
488
+ signature = "no-key"
489
+ else:
490
+ key = _spki_der(embedded)
491
+ try:
492
+ raw_sig = bytes.fromhex(str(sig["value"]))
493
+ except ValueError:
494
+ raw_sig = b""
495
+ if key is None or not ed25519_verify(key, payload.encode("utf-8"), raw_sig):
496
+ signature = "invalid"
497
+ else:
498
+ # Compare normalised DER, not PEM text: whitespace and line
499
+ # wrapping differ between exporters and must not change the
500
+ # verdict.
501
+ def matches(lst: Sequence[str]) -> bool:
502
+ return any(_spki_der(p) == key for p in lst)
503
+
504
+ if matches(revoked):
505
+ # A sound signature from a known-compromised key proves
506
+ # nothing about who produced the record — checked before
507
+ # the unpinned case so passing expected_public_key=None
508
+ # cannot launder a revoked signer into "valid-unpinned".
509
+ signature = "revoked"
510
+ elif not pinned:
511
+ signature = "valid-unpinned"
512
+ elif matches(pinned):
513
+ signature = "valid"
514
+ else:
515
+ signature = "valid-unpinned"
516
+ elif sig.get("alg") in ("HMAC-SHA256", "hmac-sha256"):
517
+ signature_alg = "HMAC-SHA256"
518
+ if not signing_key:
519
+ # Symmetric: only someone already holding the secret can check
520
+ # it, so a third party gets "no-key" by construction.
521
+ signature = "no-key"
522
+ else:
523
+ import hmac
524
+
525
+ expect = hmac.new(
526
+ signing_key.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256
527
+ ).hexdigest()
528
+ signature = "valid" if hmac.compare_digest(expect, str(sig["value"])) else "invalid"
529
+
530
+ consistency = _check_consistency(record)
531
+ integrity = chain_ok and digest_ok and cassette_ok and schema_ok and consistency["ok"]
532
+ valid = integrity and signature == "valid"
533
+ verdict = "invalid" if (not integrity or signature == "invalid") else ("valid" if valid else "unverified")
534
+
535
+ signed_run_ids = [e.get("run_id") for e in events if isinstance(e, dict) and e.get("run_id")]
536
+
537
+ return {
538
+ "valid": valid,
539
+ "verdict": verdict,
540
+ "integrity": integrity,
541
+ "checks": {
542
+ "schema": schema_ok,
543
+ "chain": chain_ok,
544
+ "digest": digest_ok,
545
+ "cassette": cassette_ok,
546
+ "consistent": consistency["ok"],
547
+ "signature": signature,
548
+ "signature_alg": signature_alg,
549
+ },
550
+ "consistency_failures": consistency["reasons"],
551
+ "meta": {
552
+ "schema": record.get("$schema"),
553
+ # From the SIGNED events, not the unsigned manifest, so the
554
+ # displayed identity is one the signature actually covers.
555
+ "run_id": (signed_run_ids[0] if signed_run_ids else manifest.get("run_id")),
556
+ "event_count": len(events),
557
+ "generated_at": manifest.get("generated_at"),
558
+ "spec_url": manifest.get("spec_url") or "https://runback.dev/spec",
559
+ },
560
+ }
561
+
562
+
563
+ def verify_json(raw: str, signing_key: Optional[str] = None, **kwargs) -> dict:
564
+ """Verify a cassette from its raw JSON text."""
565
+ return verify(json.loads(raw), signing_key, **kwargs)
566
+
567
+
568
+ def _main(argv: Sequence[str]) -> int:
569
+ import sys
570
+
571
+ if not argv:
572
+ print("usage: python3 -m runback.verify <record.json> [--json]", file=sys.stderr)
573
+ return 2
574
+ path = argv[0]
575
+ as_json = "--json" in argv
576
+ # An auditor running this on a file that is missing, unreadable or not JSON
577
+ # should be told so in one line. A Python traceback reads as "the tool is
578
+ # broken" rather than "check the path", and this is a tool whose whole job
579
+ # is to be usable by someone who does not write Python.
580
+ try:
581
+ with open(path, "r", encoding="utf-8") as fh:
582
+ raw = fh.read()
583
+ except OSError as exc:
584
+ print(f"cannot read {path}: {exc.strerror}", file=sys.stderr)
585
+ return 2
586
+ try:
587
+ result = verify_json(raw)
588
+ except json.JSONDecodeError as exc:
589
+ print(f"{path} is not valid JSON (line {exc.lineno}, column {exc.colno}).", file=sys.stderr)
590
+ return 2
591
+ except TypeError as exc:
592
+ print(f"{path}: {exc}", file=sys.stderr)
593
+ return 2
594
+ if as_json:
595
+ print(json.dumps(result, indent=2))
596
+ else:
597
+ c = result["checks"]
598
+ print(f"{path}: {result['verdict'].upper()}")
599
+ print(f" schema {'ok' if c['schema'] else 'FAILED'}")
600
+ print(f" chain {'ok' if c['chain'] else 'FAILED'}")
601
+ print(f" digest {'ok' if c['digest'] else 'FAILED'}")
602
+ print(f" cassette {'ok' if c['cassette'] else 'FAILED'}")
603
+ print(f" consistent {'ok' if c['consistent'] else 'FAILED'}")
604
+ print(f" signature {c['signature']}" + (f" ({c['signature_alg']})" if c["signature_alg"] else ""))
605
+ for r in result["consistency_failures"]:
606
+ print(f" ! {r}")
607
+ if result["verdict"] == "unverified":
608
+ print("\n Self-consistent, but its origin is unproven. Anyone can author a")
609
+ print(" record that satisfies the public chain algorithm; only a signature")
610
+ print(" from a key you pinned out of band shows who produced this one.")
611
+ # 0 valid, 1 invalid, 2 unverified — so CI can tell the three apart.
612
+ return {"valid": 0, "invalid": 1, "unverified": 2}[result["verdict"]]
613
+
614
+
615
+ def _console() -> int:
616
+ """Console-script entry point declared in pyproject.toml."""
617
+ import sys
618
+
619
+ return _main(sys.argv[1:])
620
+
621
+
622
+ if __name__ == "__main__":
623
+ import sys
624
+
625
+ raise SystemExit(_main(sys.argv[1:]))
@@ -0,0 +1,9 @@
1
+ """`python3 -m runback_verify record.json` — the same entry point as the
2
+ console script, for an auditor who would rather not put a wheel's bin/ on PATH.
3
+ """
4
+
5
+ import sys
6
+
7
+ from . import _main
8
+
9
+ raise SystemExit(_main(sys.argv[1:]))
@@ -0,0 +1,164 @@
1
+ """
2
+ The standalone package must agree with the reference, and with the SDK's copy.
3
+
4
+ runback_verify duplicates canonicalisation from runback-sdk rather than
5
+ importing it, because importing it would reintroduce the requests/python-ulid
6
+ dependency this package exists to avoid.
7
+
8
+ Duplication is exactly how three implementations silently drifted apart once
9
+ already — exponent padding, HTML escaping, non-BMP key ordering, each changing
10
+ the digest and each invisible because every implementation was tested only
11
+ against itself. So this suite pins the standalone copy to the SAME generated
12
+ fixture the JS, SDK-Python and Go suites use, and additionally asserts it agrees
13
+ with the SDK copy character for character. A copy that drifts fails here.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ import pytest
24
+
25
+ _HERE = Path(__file__).resolve().parent
26
+ _REPO = _HERE.parents[2]
27
+ _FIXTURE = _REPO / "packages" / "verify" / "testdata" / "jcs-vectors.json"
28
+ _RECORDS = _REPO / "packages" / "verify" / "testdata" / "records"
29
+
30
+ sys.path.insert(0, str(_HERE.parent))
31
+
32
+ from runback_verify import ( # noqa: E402
33
+ canonical,
34
+ compute_event_chain,
35
+ ed25519_verify,
36
+ verify,
37
+ verify_json,
38
+ )
39
+
40
+
41
+ def _vectors():
42
+ return json.loads(_FIXTURE.read_text(encoding="utf-8"))["vectors"]
43
+
44
+
45
+ def _cases():
46
+ return json.loads((_RECORDS / "expected.json").read_text(encoding="utf-8"))["cases"]
47
+
48
+
49
+ @pytest.mark.parametrize("vec", _vectors(), ids=lambda v: v["name"])
50
+ def test_canonical_matches_reference(vec):
51
+ got = canonical(vec["value"])
52
+ assert got == vec["canonical"], (
53
+ f"\n vector: {vec['name']}\n expected: {vec['canonical']}\n got: {got}\n\n"
54
+ "This package and packages/verify/index.js now disagree about the bytes that\n"
55
+ "get hashed, so one will call a genuine record TAMPERED. The reference is\n"
56
+ "authoritative: fix this port, not the fixture."
57
+ )
58
+
59
+
60
+ def test_canonical_agrees_with_the_sdk_copy():
61
+ """The two Python copies must be identical in behaviour, not just both 'correct'.
62
+
63
+ They are separate files by necessity. This is the check that keeps them one
64
+ implementation in practice.
65
+ """
66
+ sdk = _REPO / "packages" / "sdk-python"
67
+ probe = (
68
+ "import json,sys;"
69
+ f"sys.path.insert(0, {str(sdk)!r});"
70
+ "from runback.cassette import canonical;"
71
+ f"vs = json.load(open({str(_FIXTURE)!r}))['vectors'];"
72
+ "print(json.dumps([canonical(v['value']) for v in vs]))"
73
+ )
74
+ out = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True)
75
+ assert out.returncode == 0, f"could not run the SDK copy: {out.stderr}"
76
+ sdk_out = json.loads(out.stdout)
77
+ mine = [canonical(v["value"]) for v in _vectors()]
78
+ assert mine == sdk_out, "the standalone copy and the SDK copy have drifted apart"
79
+
80
+
81
+ @pytest.mark.parametrize("name", sorted(_cases().keys()))
82
+ def test_verdict_matches_reference(name):
83
+ exp = _cases()[name]
84
+ record = json.loads((_RECORDS / f"{name}.json").read_text(encoding="utf-8"))
85
+ got = verify(record, expected_public_key=exp["opts"]["expectedPublicKey"])
86
+ assert got["verdict"] == exp["verdict"], (
87
+ f"{name}: expected {exp['verdict']}, got {got['verdict']} — checks {got['checks']}"
88
+ )
89
+ assert got["valid"] == exp["valid"]
90
+ for key, want in exp["checks"].items():
91
+ assert got["checks"][key] == want, f"{name}: checks.{key}"
92
+
93
+
94
+ def test_forged_summary_is_rejected_despite_a_sound_signature():
95
+ record = json.loads((_RECORDS / "forged-summary.json").read_text(encoding="utf-8"))
96
+ got = verify(record, expected_public_key=None)
97
+ assert got["checks"]["signature"] in ("valid", "valid-unpinned"), (
98
+ "the fixture must carry a SOUND signature or this passes for the wrong reason"
99
+ )
100
+ assert got["checks"]["chain"] is True
101
+ assert got["verdict"] == "invalid"
102
+ assert got["consistency_failures"]
103
+
104
+
105
+ def test_ed25519_is_not_vacuous():
106
+ """A verifier that returns True unconditionally would pass most of the suite."""
107
+ record = json.loads((_RECORDS / "signed-genuine.json").read_text(encoding="utf-8"))
108
+ from runback_verify import _spki_der
109
+
110
+ m = record["manifest"]
111
+ key = _spki_der(m["signature"]["pubkey"])
112
+ payload = f"{m['content_digest']}:{m['replay']['cassette_digest']}".encode("utf-8")
113
+ sig = bytes.fromhex(m["signature"]["value"])
114
+
115
+ assert ed25519_verify(key, payload, sig) is True
116
+ assert ed25519_verify(key, payload + b"x", sig) is False
117
+ assert ed25519_verify(key, payload, bytes(64)) is False
118
+
119
+
120
+ def test_chain_recomputes_published_hashes():
121
+ record = json.loads((_RECORDS / "signed-genuine.json").read_text(encoding="utf-8"))
122
+ chain = compute_event_chain(record["events"])
123
+ assert chain["digest"] == record["manifest"]["content_digest"]
124
+
125
+
126
+ def test_verify_json_accepts_raw_text():
127
+ raw = (_RECORDS / "signed-genuine.json").read_text(encoding="utf-8")
128
+ assert verify_json(raw, expected_public_key=None)["integrity"] is True
129
+
130
+
131
+ def test_the_package_imports_nothing_third_party():
132
+ """The whole reason this package is separate from runback-sdk.
133
+
134
+ Measured as a DIFFERENCE against a baseline interpreter, not as an absolute
135
+ module list. The first version compared sys.modules against
136
+ sys.stdlib_module_names and failed on `sitecustomize` — a startup hook the
137
+ interpreter injects before any user code runs, which this package neither
138
+ imports nor can prevent. That check was measuring the environment rather
139
+ than the import, which is the same mistake as a guard that passes because
140
+ it looked at the wrong thing.
141
+ """
142
+
143
+ def modules(import_line: str) -> set:
144
+ probe = (
145
+ "import sys;"
146
+ f"sys.path.insert(0, {str(_HERE.parent)!r});"
147
+ f"{import_line}"
148
+ "print(','.join(sorted(sys.modules)))"
149
+ )
150
+ out = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True)
151
+ assert out.returncode == 0, out.stderr
152
+ return set(out.stdout.strip().split(","))
153
+
154
+ baseline = modules("")
155
+ with_pkg = modules("import runback_verify;")
156
+ added = {m.split(".")[0] for m in (with_pkg - baseline)}
157
+ added.discard("runback_verify")
158
+
159
+ stdlib = set(sys.stdlib_module_names)
160
+ third_party = sorted(m for m in added if m not in stdlib and not m.startswith("_"))
161
+ assert third_party == [], (
162
+ f"importing runback_verify pulled in non-stdlib modules: {third_party}. "
163
+ "This package is published on the promise that it needs nothing installed."
164
+ )