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.
- runback_verify-2.1.0/.gitignore +35 -0
- runback_verify-2.1.0/PKG-INFO +64 -0
- runback_verify-2.1.0/README.md +44 -0
- runback_verify-2.1.0/pyproject.toml +54 -0
- runback_verify-2.1.0/runback_verify/__init__.py +625 -0
- runback_verify-2.1.0/runback_verify/__main__.py +9 -0
- runback_verify-2.1.0/tests/test_conformance.py +164 -0
|
@@ -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,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
|
+
)
|