runback-verify 2.1.0__tar.gz → 2.2.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.2.0/.gitignore +55 -0
- {runback_verify-2.1.0 → runback_verify-2.2.0}/PKG-INFO +32 -3
- {runback_verify-2.1.0 → runback_verify-2.2.0}/README.md +31 -2
- {runback_verify-2.1.0 → runback_verify-2.2.0}/pyproject.toml +1 -1
- {runback_verify-2.1.0 → runback_verify-2.2.0}/runback_verify/__init__.py +168 -2
- runback_verify-2.2.0/tests/test_pack.py +81 -0
- runback_verify-2.1.0/.gitignore +0 -35
- {runback_verify-2.1.0 → runback_verify-2.2.0}/runback_verify/__main__.py +0 -0
- {runback_verify-2.1.0 → runback_verify-2.2.0}/tests/test_conformance.py +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
# Worktrees symlink node_modules from the main checkout; a symlink is a file,
|
|
3
|
+
# which "node_modules/" (directories only) does not match.
|
|
4
|
+
node_modules
|
|
5
|
+
/web/node_modules
|
|
6
|
+
.next/
|
|
7
|
+
.next-*/
|
|
8
|
+
out/
|
|
9
|
+
dist/
|
|
10
|
+
.env
|
|
11
|
+
.community-check-scratch/
|
|
12
|
+
.env.local
|
|
13
|
+
*.tsbuildinfo
|
|
14
|
+
next-env.d.ts
|
|
15
|
+
.DS_Store
|
|
16
|
+
.vercel
|
|
17
|
+
|
|
18
|
+
# secrets — belt & suspenders (keep .env.example committable)
|
|
19
|
+
.env.*
|
|
20
|
+
!.env.example
|
|
21
|
+
# native/syscall build artifacts
|
|
22
|
+
*.dylib
|
|
23
|
+
*.so
|
|
24
|
+
.secrets/
|
|
25
|
+
|
|
26
|
+
# Python (packages/sdk-python)
|
|
27
|
+
__pycache__/
|
|
28
|
+
*.pyc
|
|
29
|
+
*.egg-info/
|
|
30
|
+
.venv/
|
|
31
|
+
.pytest_cache/
|
|
32
|
+
build/
|
|
33
|
+
*.egg
|
|
34
|
+
|
|
35
|
+
# Saved auth for local QA scripts — holds a live Supabase access_token, so a
|
|
36
|
+
# stray `git add -A` would publish working credentials.
|
|
37
|
+
session.json
|
|
38
|
+
# Throwaway browser-driving scripts used to click through the app by hand.
|
|
39
|
+
check_*.mjs
|
|
40
|
+
# Generated by packages/sdk-replay/scripts/copy-licence.mjs from LICENSE-COMMUNITY.
|
|
41
|
+
packages/sdk-replay/LICENSE
|
|
42
|
+
|
|
43
|
+
# Agent worktrees. These are full checkouts created by the Agent tool's
|
|
44
|
+
# isolation mode, and they live inside the repo — 1.8GB of them at the time of
|
|
45
|
+
# writing.
|
|
46
|
+
#
|
|
47
|
+
# They were already excluded locally via .git/info/exclude, so git never saw
|
|
48
|
+
# them. The Vercel CLI does not read that file: it falls back to .gitignore,
|
|
49
|
+
# which is tracked. So a deploy uploaded 3,516 files / 3.6MB instead of the
|
|
50
|
+
# usual 1,261 / ~800KB — stale duplicate copies of the whole application,
|
|
51
|
+
# shipped to the build machine for no reason.
|
|
52
|
+
#
|
|
53
|
+
# Listed here rather than in .vercelignore so one rule covers both, and so the
|
|
54
|
+
# next person reading .gitignore learns these exist.
|
|
55
|
+
.claude/worktrees/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: runback-verify
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Verify tamper-evidence and integrity of Runback audit cassettes (runback.cassette/v1). No account required, no dependencies.
|
|
5
5
|
Project-URL: Homepage, https://runback.dev
|
|
6
6
|
Project-URL: Specification, https://runback.dev/spec
|
|
@@ -20,7 +20,7 @@ Description-Content-Type: text/markdown
|
|
|
20
20
|
|
|
21
21
|
# runback-verify
|
|
22
22
|
|
|
23
|
-
Verify the integrity of a Runback audit record
|
|
23
|
+
Verify the integrity of a Runback audit record offline, with nothing but Python's standard library.
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
26
|
pip install runback-verify
|
|
@@ -34,6 +34,22 @@ result = verify_json(open("record.json").read())
|
|
|
34
34
|
print(result["verdict"]) # "valid" | "unverified" | "invalid"
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
Evidence packs (the signed PDF from `GET /api/compliance/pack`, 2.2.0) are
|
|
38
|
+
detected by their `%PDF-` header:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python -m runback_verify runback-evidence-pack-2026-10-01-2026-10-31.pdf
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from runback_verify import verify_pack
|
|
46
|
+
verify_pack(open("pack.pdf", "rb").read())["verdict"]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Same checks and exit codes as `@runback/verify` 3.1: every byte outside the
|
|
50
|
+
embedded manifest must match its SHA-256, the manifest must carry Runback's
|
|
51
|
+
Ed25519 signature, and a SAMPLE pack is never `valid`.
|
|
52
|
+
|
|
37
53
|
## Zero dependencies, on purpose
|
|
38
54
|
|
|
39
55
|
This package is separate from `runback-sdk` because the SDK needs `requests` and
|
|
@@ -54,7 +70,20 @@ from nothing, or edit a real one and re-chain it. Integrity alone proves only
|
|
|
54
70
|
internal coherence; provenance is a separate question, answered only by a
|
|
55
71
|
signature that verifies against a key you pinned **out of band**.
|
|
56
72
|
|
|
57
|
-
Exit codes: `0` valid, `1` invalid, `2` unverified.
|
|
73
|
+
Exit codes: `0` valid, `1` invalid, `2` unverified. The CLI also exits `2` when
|
|
74
|
+
the file cannot be read or is not a Runback record, so a CI step should read
|
|
75
|
+
the printed verdict (or `--json`) before treating `2` as "unverified".
|
|
76
|
+
|
|
77
|
+
The CLI takes `--json` and nothing else. By default it pins Runback's published
|
|
78
|
+
Ed25519 keys, so a record signed by a self-hosted deployment's own key reports
|
|
79
|
+
`unverified`. Pin your key from Python instead:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
result = verify_json(raw, expected_public_key=open("audit_public.pem").read())
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Legacy HMAC-signed records verify only with the shared secret:
|
|
86
|
+
`verify_json(raw, signing_key=...)`.
|
|
58
87
|
|
|
59
88
|
## The format
|
|
60
89
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# runback-verify
|
|
2
2
|
|
|
3
|
-
Verify the integrity of a Runback audit record
|
|
3
|
+
Verify the integrity of a Runback audit record offline, with nothing but Python's standard library.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
pip install runback-verify
|
|
@@ -14,6 +14,22 @@ result = verify_json(open("record.json").read())
|
|
|
14
14
|
print(result["verdict"]) # "valid" | "unverified" | "invalid"
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
Evidence packs (the signed PDF from `GET /api/compliance/pack`, 2.2.0) are
|
|
18
|
+
detected by their `%PDF-` header:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
python -m runback_verify runback-evidence-pack-2026-10-01-2026-10-31.pdf
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from runback_verify import verify_pack
|
|
26
|
+
verify_pack(open("pack.pdf", "rb").read())["verdict"]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Same checks and exit codes as `@runback/verify` 3.1: every byte outside the
|
|
30
|
+
embedded manifest must match its SHA-256, the manifest must carry Runback's
|
|
31
|
+
Ed25519 signature, and a SAMPLE pack is never `valid`.
|
|
32
|
+
|
|
17
33
|
## Zero dependencies, on purpose
|
|
18
34
|
|
|
19
35
|
This package is separate from `runback-sdk` because the SDK needs `requests` and
|
|
@@ -34,7 +50,20 @@ from nothing, or edit a real one and re-chain it. Integrity alone proves only
|
|
|
34
50
|
internal coherence; provenance is a separate question, answered only by a
|
|
35
51
|
signature that verifies against a key you pinned **out of band**.
|
|
36
52
|
|
|
37
|
-
Exit codes: `0` valid, `1` invalid, `2` unverified.
|
|
53
|
+
Exit codes: `0` valid, `1` invalid, `2` unverified. The CLI also exits `2` when
|
|
54
|
+
the file cannot be read or is not a Runback record, so a CI step should read
|
|
55
|
+
the printed verdict (or `--json`) before treating `2` as "unverified".
|
|
56
|
+
|
|
57
|
+
The CLI takes `--json` and nothing else. By default it pins Runback's published
|
|
58
|
+
Ed25519 keys, so a record signed by a self-hosted deployment's own key reports
|
|
59
|
+
`unverified`. Pin your key from Python instead:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
result = verify_json(raw, expected_public_key=open("audit_public.pem").read())
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Legacy HMAC-signed records verify only with the shared secret:
|
|
66
|
+
`verify_json(raw, signing_key=...)`.
|
|
38
67
|
|
|
39
68
|
## The format
|
|
40
69
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "runback-verify"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.2.0"
|
|
8
8
|
description = "Verify tamper-evidence and integrity of Runback audit cassettes (runback.cassette/v1). No account required, no dependencies."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -565,6 +565,142 @@ def verify_json(raw: str, signing_key: Optional[str] = None, **kwargs) -> dict:
|
|
|
565
565
|
return verify(json.loads(raw), signing_key, **kwargs)
|
|
566
566
|
|
|
567
567
|
|
|
568
|
+
# ── Evidence packs (runback.pack/v1) ─────────────────────────────────────────
|
|
569
|
+
#
|
|
570
|
+
# A port of packages/verify/index.js verifyPack(); where they disagree the JS
|
|
571
|
+
# reference is right. The pack is a PDF with one reserved region between two
|
|
572
|
+
# marker lines; every byte outside it is hashed into a signed manifest held in
|
|
573
|
+
# the region. No PDF parsing: the markers are found by byte search.
|
|
574
|
+
|
|
575
|
+
PACK_SCHEMA = "runback.pack/v1"
|
|
576
|
+
PACK_MANIFEST_BEGIN = b"%RUNBACK-PACK-MANIFEST-BEGIN\n"
|
|
577
|
+
PACK_MANIFEST_END = b"\n%RUNBACK-PACK-MANIFEST-END"
|
|
578
|
+
PACK_SIGNATURE_PREFIX = "runback-pack:"
|
|
579
|
+
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _ed25519_verdict(payload: bytes, sig: Any, expected_public_key: PemList, revoked_public_keys: Optional[Sequence[str]]) -> tuple:
|
|
583
|
+
"""(verdict, alg) for an Ed25519 {alg, value, pubkey?} signature: the JS signatureVerdict()."""
|
|
584
|
+
if not isinstance(sig, dict) or not sig.get("value"):
|
|
585
|
+
return "unsigned", None
|
|
586
|
+
if sig.get("alg") == "HMAC-SHA256":
|
|
587
|
+
return "no-key", "HMAC-SHA256"
|
|
588
|
+
if sig.get("alg") != "Ed25519":
|
|
589
|
+
return "invalid", None
|
|
590
|
+
raw_pinned = RUNBACK_AUDIT_PUBKEYS_PEM if expected_public_key == "__default__" else expected_public_key
|
|
591
|
+
pinned = [] if raw_pinned is None else [raw_pinned] if isinstance(raw_pinned, str) else list(raw_pinned)
|
|
592
|
+
revoked = list(RUNBACK_AUDIT_REVOKED_PUBKEYS_PEM if revoked_public_keys is None else revoked_public_keys)
|
|
593
|
+
candidates = [sig["pubkey"]] if sig.get("pubkey") else pinned
|
|
594
|
+
if not candidates:
|
|
595
|
+
return "no-key", "Ed25519"
|
|
596
|
+
try:
|
|
597
|
+
raw_sig = bytes.fromhex(str(sig["value"]))
|
|
598
|
+
except ValueError:
|
|
599
|
+
return "invalid", "Ed25519"
|
|
600
|
+
signer = None
|
|
601
|
+
for pem in candidates:
|
|
602
|
+
key = _spki_der(pem)
|
|
603
|
+
if key is not None and ed25519_verify(key, payload, raw_sig):
|
|
604
|
+
signer = key
|
|
605
|
+
break
|
|
606
|
+
if signer is None:
|
|
607
|
+
return "invalid", "Ed25519"
|
|
608
|
+
if any(_spki_der(p) == signer for p in revoked):
|
|
609
|
+
return "revoked", "Ed25519"
|
|
610
|
+
if not pinned:
|
|
611
|
+
return "valid-unpinned", "Ed25519"
|
|
612
|
+
return ("valid" if any(_spki_der(p) == signer for p in pinned) else "valid-unpinned"), "Ed25519"
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _checkpoint_verdict(cp: Any, expected_public_key: PemList, revoked_public_keys: Optional[Sequence[str]]) -> str:
|
|
616
|
+
"""valid | unverified | invalid for a runback.checkpoint/v3 document (verifyCheckpoint in JS)."""
|
|
617
|
+
if not isinstance(cp, dict):
|
|
618
|
+
return "invalid"
|
|
619
|
+
seq = cp.get("seq")
|
|
620
|
+
shape = (
|
|
621
|
+
cp.get("$schema") == "runback.checkpoint/v3"
|
|
622
|
+
and isinstance(cp.get("org_id"), str)
|
|
623
|
+
and isinstance(seq, int) and not isinstance(seq, bool) and seq >= 0
|
|
624
|
+
and isinstance(cp.get("head_hash"), str) and bool(_HEX64.match(cp["head_hash"]))
|
|
625
|
+
and isinstance(cp.get("merkle_root"), str) and bool(_HEX64.match(cp["merkle_root"]))
|
|
626
|
+
and (cp.get("tombstone_digest") is None or (isinstance(cp["tombstone_digest"], str) and bool(_HEX64.match(cp["tombstone_digest"]))))
|
|
627
|
+
)
|
|
628
|
+
payload = f"ledger:{cp.get('org_id')}:{seq}:{cp.get('head_hash')}:{cp.get('merkle_root')}"
|
|
629
|
+
if cp.get("tombstone_digest"):
|
|
630
|
+
payload += f":ts:{cp['tombstone_digest']}"
|
|
631
|
+
sig, _ = _ed25519_verdict(payload.encode("utf-8"), cp.get("signature"), expected_public_key, revoked_public_keys)
|
|
632
|
+
if not shape or sig in ("invalid", "revoked"):
|
|
633
|
+
return "invalid"
|
|
634
|
+
return "valid" if sig == "valid" else "unverified"
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def verify_pack(
|
|
638
|
+
data: bytes,
|
|
639
|
+
*,
|
|
640
|
+
expected_public_key: PemList = "__default__",
|
|
641
|
+
revoked_public_keys: Optional[Sequence[str]] = None,
|
|
642
|
+
) -> dict:
|
|
643
|
+
"""Verify a Runback evidence pack (the PDF from GET /api/compliance/pack).
|
|
644
|
+
|
|
645
|
+
"valid" only when every byte outside the manifest matches its signed
|
|
646
|
+
SHA-256 and the signer is a pinned key. A SAMPLE pack is never valid.
|
|
647
|
+
"""
|
|
648
|
+
checks: dict = {"pdf": data[:5] == b"%PDF-", "manifest": False, "schema": False, "body_hash": False,
|
|
649
|
+
"signature": "unsigned", "signature_alg": None, "checkpoint": None}
|
|
650
|
+
|
|
651
|
+
def fail(error: str) -> dict:
|
|
652
|
+
return {"valid": False, "verdict": "invalid", "sample": False, "checks": checks, "manifest": None, "error": error}
|
|
653
|
+
|
|
654
|
+
bi = data.find(PACK_MANIFEST_BEGIN)
|
|
655
|
+
if bi < 0:
|
|
656
|
+
return fail("no Runback manifest in this file")
|
|
657
|
+
if data.find(PACK_MANIFEST_BEGIN, bi + 1) >= 0:
|
|
658
|
+
return fail("the manifest begin marker appears more than once")
|
|
659
|
+
start = bi + len(PACK_MANIFEST_BEGIN)
|
|
660
|
+
ei = data.find(PACK_MANIFEST_END, start)
|
|
661
|
+
if ei < 0:
|
|
662
|
+
return fail("the manifest end marker is missing")
|
|
663
|
+
if data.find(PACK_MANIFEST_END, ei + 1) >= 0 or data[:bi].find(PACK_MANIFEST_END) >= 0:
|
|
664
|
+
return fail("the manifest end marker appears more than once")
|
|
665
|
+
try:
|
|
666
|
+
region = json.loads(data[start:ei].decode("utf-8").strip())
|
|
667
|
+
manifest_text = region["manifest"]
|
|
668
|
+
if not isinstance(manifest_text, str):
|
|
669
|
+
raise TypeError
|
|
670
|
+
manifest = json.loads(manifest_text)
|
|
671
|
+
if not isinstance(manifest, dict):
|
|
672
|
+
raise TypeError
|
|
673
|
+
except Exception:
|
|
674
|
+
return fail("the manifest region does not hold a readable manifest")
|
|
675
|
+
checks["manifest"] = True
|
|
676
|
+
checks["schema"] = manifest.get("$schema") == PACK_SCHEMA
|
|
677
|
+
computed = hashlib.sha256(data[:start] + data[ei:]).hexdigest()
|
|
678
|
+
claimed = (manifest.get("pdf") or {}).get("sha256")
|
|
679
|
+
checks["body_hash"] = isinstance(claimed, str) and bool(_HEX64.match(claimed)) and claimed == computed
|
|
680
|
+
|
|
681
|
+
sig, alg = _ed25519_verdict((PACK_SIGNATURE_PREFIX + manifest_text).encode("utf-8"), region.get("signature"),
|
|
682
|
+
expected_public_key, revoked_public_keys)
|
|
683
|
+
checks["signature"], checks["signature_alg"] = sig, alg
|
|
684
|
+
|
|
685
|
+
cp = (manifest.get("ledger") or {}).get("checkpoint")
|
|
686
|
+
if cp:
|
|
687
|
+
verdict = _checkpoint_verdict(cp, expected_public_key, revoked_public_keys)
|
|
688
|
+
if not isinstance(cp, dict) or cp.get("org_id") != manifest.get("org_id"):
|
|
689
|
+
verdict = "invalid"
|
|
690
|
+
checks["checkpoint"] = verdict
|
|
691
|
+
|
|
692
|
+
sample = manifest.get("sample") is True
|
|
693
|
+
integrity = checks["pdf"] and checks["schema"] and checks["body_hash"] and checks["checkpoint"] != "invalid"
|
|
694
|
+
if not integrity or sig in ("invalid", "revoked"):
|
|
695
|
+
verdict = "invalid"
|
|
696
|
+
elif sig == "valid" and not sample:
|
|
697
|
+
verdict = "valid"
|
|
698
|
+
else:
|
|
699
|
+
verdict = "unverified"
|
|
700
|
+
return {"valid": verdict == "valid", "verdict": verdict, "sample": sample, "checks": checks,
|
|
701
|
+
"manifest": manifest, "computed_sha256": computed}
|
|
702
|
+
|
|
703
|
+
|
|
568
704
|
def _main(argv: Sequence[str]) -> int:
|
|
569
705
|
import sys
|
|
570
706
|
|
|
@@ -578,11 +714,18 @@ def _main(argv: Sequence[str]) -> int:
|
|
|
578
714
|
# broken" rather than "check the path", and this is a tool whose whole job
|
|
579
715
|
# is to be usable by someone who does not write Python.
|
|
580
716
|
try:
|
|
581
|
-
with open(path, "
|
|
582
|
-
|
|
717
|
+
with open(path, "rb") as fh:
|
|
718
|
+
data = fh.read()
|
|
583
719
|
except OSError as exc:
|
|
584
720
|
print(f"cannot read {path}: {exc.strerror}", file=sys.stderr)
|
|
585
721
|
return 2
|
|
722
|
+
if data[:5] == b"%PDF-":
|
|
723
|
+
return _main_pack(path, data, as_json)
|
|
724
|
+
try:
|
|
725
|
+
raw = data.decode("utf-8")
|
|
726
|
+
except UnicodeDecodeError:
|
|
727
|
+
print(f"{path} is not UTF-8 JSON or a Runback evidence pack.", file=sys.stderr)
|
|
728
|
+
return 2
|
|
586
729
|
try:
|
|
587
730
|
result = verify_json(raw)
|
|
588
731
|
except json.JSONDecodeError as exc:
|
|
@@ -612,6 +755,29 @@ def _main(argv: Sequence[str]) -> int:
|
|
|
612
755
|
return {"valid": 0, "invalid": 1, "unverified": 2}[result["verdict"]]
|
|
613
756
|
|
|
614
757
|
|
|
758
|
+
def _main_pack(path: str, data: bytes, as_json: bool) -> int:
|
|
759
|
+
"""An evidence pack (a PDF): the same report and exit codes as runback-verify on npm."""
|
|
760
|
+
result = verify_pack(data)
|
|
761
|
+
code = {"valid": 0, "invalid": 1, "unverified": 2}[result["verdict"]]
|
|
762
|
+
if as_json:
|
|
763
|
+
print(json.dumps(result, indent=2))
|
|
764
|
+
return code
|
|
765
|
+
c = result["checks"]
|
|
766
|
+
print(f"{path}: evidence pack {result['verdict'].upper()}")
|
|
767
|
+
if result.get("error"):
|
|
768
|
+
print(f" {result['error']}")
|
|
769
|
+
return code
|
|
770
|
+
m = result["manifest"]
|
|
771
|
+
if result["sample"]:
|
|
772
|
+
print(" SAMPLE PACK: illustrative figures, not a record of any organisation. Not evidence.")
|
|
773
|
+
print(f" org {m.get('org_name', '')} ({m.get('org_id')})")
|
|
774
|
+
print(f" period {(m.get('period') or {}).get('start')} to {(m.get('period') or {}).get('end')} (UTC)")
|
|
775
|
+
print(f" file {'ok' if c['body_hash'] else 'CHANGED'} (sha256 {result['computed_sha256']})")
|
|
776
|
+
print(f" signature {c['signature']}" + (f" ({c['signature_alg']})" if c["signature_alg"] else ""))
|
|
777
|
+
print(f" checkpoint {c['checkpoint'] or 'none cited'}")
|
|
778
|
+
return code
|
|
779
|
+
|
|
780
|
+
|
|
615
781
|
def _console() -> int:
|
|
616
782
|
"""Console-script entry point declared in pyproject.toml."""
|
|
617
783
|
import sys
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Evidence packs: the Python port must reach the JS reference's verdicts on the
|
|
3
|
+
same files. packages/verify/testdata/packs/ is written by gen-packs.mjs with a
|
|
4
|
+
throwaway key (only its public half is kept), and test/pack.test.js checks the
|
|
5
|
+
same files in JS.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
_HERE = Path(__file__).resolve().parent
|
|
15
|
+
_PACKS = _HERE.parents[1] / "verify" / "testdata" / "packs"
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, str(_HERE.parent))
|
|
18
|
+
|
|
19
|
+
from runback_verify import verify_pack # noqa: E402
|
|
20
|
+
|
|
21
|
+
KEY = (_PACKS / "key.pem").read_text()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _read(name: str) -> bytes:
|
|
25
|
+
return (_PACKS / name).read_bytes()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_valid_pack_verifies_against_the_pinned_key():
|
|
29
|
+
r = verify_pack(_read("valid.pdf"), expected_public_key=KEY)
|
|
30
|
+
assert r["verdict"] == "valid"
|
|
31
|
+
assert r["checks"]["body_hash"] is True
|
|
32
|
+
assert r["checks"]["signature"] == "valid"
|
|
33
|
+
assert r["checks"]["checkpoint"] == "valid"
|
|
34
|
+
assert r["manifest"]["org_name"] == "Acme Insurance — 東京 \U0001F642"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_changed_byte_is_invalid():
|
|
38
|
+
r = verify_pack(_read("tampered.pdf"), expected_public_key=KEY)
|
|
39
|
+
assert r["verdict"] == "invalid"
|
|
40
|
+
assert r["checks"]["body_hash"] is False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_sample_is_never_valid():
|
|
44
|
+
r = verify_pack(_read("sample.pdf"), expected_public_key=KEY)
|
|
45
|
+
assert r["sample"] is True
|
|
46
|
+
assert r["verdict"] == "unverified"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_unpinned_signer_is_unverified():
|
|
50
|
+
# Runback's published key is not the fixture key.
|
|
51
|
+
r = verify_pack(_read("valid.pdf"))
|
|
52
|
+
assert r["checks"]["signature"] == "valid-unpinned"
|
|
53
|
+
assert r["verdict"] == "unverified"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_revoked_signer_is_invalid():
|
|
57
|
+
r = verify_pack(_read("valid.pdf"), expected_public_key=KEY, revoked_public_keys=[KEY])
|
|
58
|
+
assert r["verdict"] == "invalid"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_edited_manifest_fails_the_signature():
|
|
62
|
+
data = bytearray(_read("valid.pdf"))
|
|
63
|
+
i = data.find(b'\\"total\\":120')
|
|
64
|
+
assert i > 0
|
|
65
|
+
data[i:i + 13] = b'\\"total\\":999'
|
|
66
|
+
r = verify_pack(bytes(data), expected_public_key=KEY)
|
|
67
|
+
assert r["checks"]["signature"] == "invalid"
|
|
68
|
+
assert r["verdict"] == "invalid"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_no_manifest_is_invalid():
|
|
72
|
+
assert verify_pack(b"%PDF-1.7\n%%EOF\n")["error"] == "no Runback manifest in this file"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_cli_exit_codes():
|
|
76
|
+
def run(name: str) -> int:
|
|
77
|
+
return subprocess.run([sys.executable, "-m", "runback_verify", str(_PACKS / name)],
|
|
78
|
+
cwd=str(_HERE.parent), capture_output=True).returncode
|
|
79
|
+
assert run("tampered.pdf") == 1
|
|
80
|
+
assert run("sample.pdf") == 2
|
|
81
|
+
assert run("valid.pdf") == 2 # fixture key, not Runback's published one
|
runback_verify-2.1.0/.gitignore
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|