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.
@@ -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.1.0
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 — offline, with nothing installed.
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 — offline, with nothing installed.
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.1.0"
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, "r", encoding="utf-8") as fh:
582
- raw = fh.read()
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
@@ -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