#!/usr/bin/env python3 """Check that a CAIN-42 evidence bundle was published by the pinned CAIN publisher key. Why this exists (audit F-04, 2026-10-01): each bundle's own verifier (verify_eNN.py) checks the bundle against the signing key shipped INSIDE the bundle. That proves the bundle is self-consistent, not who made it: anyone could edit a bundle, re-hash it, re-sign it with a fresh key and get "INTACT". The publisher attestation adds the missing link. It is a signature, by one long-lived key whose public half is pinned below (and in PINNED_SIGNER_KEYS.json and /.well-known/cain-publisher-key.json on all three sites), over the SHA-256 of every file in the bundle directory. An edited, added or removed file, or a re-signed bundle, fails here. What it does NOT prove: that the bundle's contents are true. It proves only that the CAIN publisher published exactly these bytes. The publisher key is held by the operator, so this is not independent verification. Usage: python3 verify_publisher.py python3 verify_publisher.py --all (expects /_publisher/.json) Needs only the `cryptography` package. Imports nothing from CAIN. """ from __future__ import annotations import base64 import hashlib import json import sys from pathlib import Path from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey PINNED_PUBLISHER_KEY_B64 = "tOh40wrQGGYpF9XrO+Uk0cTj8od4HRyGtGZcEIZwTv8=" DOMAIN = "CAIN42/PUBLISHER-ATTESTATION/v1" SCHEMA = "cain42.publisher-attestation.v1" ATTEST_DIR = "_publisher" def file_hashes(bundle: Path) -> dict: out = {} for p in sorted(bundle.rglob("*")): # Served .html pages get the site chrome injected at serve time, so their bytes differ from disk; they # are navigation, not evidence (bundle SHA256SUMS already leave index.html out for the same reason). if p.is_file() and "__pycache__" not in p.parts and p.suffix.lower() not in (".html", ".htm"): out[p.relative_to(bundle).as_posix()] = hashlib.sha256(p.read_bytes()).hexdigest() return out def body_digest(body: dict) -> str: return hashlib.sha256(json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()).hexdigest() def check(bundle: Path, att: dict) -> list: problems = [] body = {k: att.get(k) for k in ("schema", "bundle", "files", "attested_at", "publisher_public_key_b64")} if att.get("schema") != SCHEMA: problems.append("unknown attestation schema") if att.get("publisher_public_key_b64") != PINNED_PUBLISHER_KEY_B64: problems.append("attestation is not by the pinned CAIN publisher key") if att.get("bundle") != bundle.name: problems.append(f"attestation is for bundle {att.get('bundle')!r}, not {bundle.name!r}") try: Ed25519PublicKey.from_public_bytes(base64.b64decode(PINNED_PUBLISHER_KEY_B64)).verify( base64.b64decode(att.get("signature_b64", "")), f"{DOMAIN}\x1f{body_digest(body)}".encode()) except (InvalidSignature, ValueError, TypeError): problems.append("publisher signature does not verify") live = file_hashes(bundle) want = att.get("files") or {} for name in sorted(set(want) | set(live)): if name not in live: problems.append(f"missing file: {name}") elif name not in want: problems.append(f"file not in attestation: {name}") elif live[name] != want[name]: problems.append(f"changed file: {name}") return problems def main(argv: list) -> int: if len(argv) == 2 and argv[0] == "--all": root = Path(argv[1]) results = {} for a in sorted((root / ATTEST_DIR).glob("*.json")): att = json.loads(a.read_text()) results[att.get("bundle", a.stem)] = check(root / att.get("bundle", a.stem), att) bad = {k: v for k, v in results.items() if v} print(json.dumps({"verifier": "verify_publisher.py", "bundles": len(results), "failed": len(bad), "result": "PUBLISHED_BY_PINNED_KEY" if results and not bad else "BROKEN", "problems": {k: v[:10] for k, v in list(bad.items())[:20]}})) return 0 if results and not bad else 1 if len(argv) != 2: print(__doc__) return 2 problems = check(Path(argv[0]), json.loads(Path(argv[1]).read_text())) print(json.dumps({"verifier": "verify_publisher.py", "bundle": Path(argv[0]).name, "result": "PUBLISHED_BY_PINNED_KEY" if not problems else "BROKEN", "problems": problems[:50]})) return 0 if not problems else 1 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))