#!/usr/bin/env python3 """Independent monitor for the clawx.click transparency logs. Imports no CAIN code; run it on YOUR machine or in YOUR CI, on a schedule, and keep its state directory. It is how an outsider watches the log instead of trusting it. Each run, for every log (clawx.click/tlog/cain-mr-01, clawx.click/tlog/cain-mr-02): 1. fetches the checkpoint and verifies its C2SP signed-note signature against a verifier key YOU pinned (first run: pass --vkey, or it pins the key it sees and says so); 2. fetches the proof index and recomputes the RFC 6962 Merkle root of the first proof hashes; 3. checks append-only: every checkpoint this monitor ever saw is a prefix of the new tree (same root at that size); 4. checks that the index did not shrink and that no earlier proof hash changed since the last run; 5. records any independent cosignature lines on the checkpoint; 6. optionally (--openssl) verifies each new RFC 3161 timestamp token with `openssl ts -verify`. Any failure prints ALARM and exits 2. Keep the state directory: it is your own copy of the log's history, and two monitors that compare their saved checkpoints can detect a log that shows different visitors different histories. pip install cryptography python3 verify_tlog_monitor.py --state ~/.clawx-monitor [--vkey "clawx.click/tlog++"] [--openssl] Exit codes: 0 consistent, 2 ALARM (fork, rewrite, bad signature, root mismatch), 3 could not check (network). """ from __future__ import annotations import argparse import base64 import hashlib import json import os import subprocess import sys import tempfile import urllib.request from pathlib import Path BASE = "https://clawx.click" LOGS = {"cain-mr-01": "/evidence/hourly-proof/index.json", "cain-mr-02": "/evidence/hourly-proof-mr02/index.json"} def get(path: str) -> bytes: req = urllib.request.Request(BASE + path, headers={"User-Agent": "clawx-tlog-monitor/1"}) with urllib.request.urlopen(req, timeout=60) as r: return r.read() def mth(leaves): n = len(leaves) if n == 0: return hashlib.sha256(b"").digest() if n == 1: return leaves[0] k = 1 while k * 2 < n: k *= 2 return hashlib.sha256(b"\x01" + mth(leaves[:k]) + mth(leaves[k:])).digest() def verify_note(note: str, vkey: str): from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey name, kh, rest = vkey.strip().split("+", 2) raw = base64.b64decode(rest) if raw[0] != 1: raise ValueError("not an Ed25519 note key") if hashlib.sha256(name.encode() + b"\n\x01" + raw[1:]).digest()[:4].hex() != kh: raise ValueError("verifier key hash does not match its key") body, _, sigs = note.partition("\n\n") body += "\n" ok, cosigners = False, [] for line in sigs.strip().splitlines(): if not line.startswith("— "): raise ValueError("malformed signature line") signer, b64sig = line[2:].rsplit(" ", 1) blob = base64.b64decode(b64sig) if signer == name and blob[:4].hex() == kh: Ed25519PublicKey.from_public_bytes(raw[1:]).verify(blob[4:], body.encode()) # raises if invalid ok = True else: cosigners.append(signer) if not ok: raise ValueError("no valid signature by the pinned log key") origin, size, root = body.splitlines() return origin, int(size), root, cosigners, body def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--state", default=os.path.expanduser("~/.clawx-monitor")) ap.add_argument("--vkey", help="pin this C2SP verifier key (recommended: obtain it out of band)") ap.add_argument("--openssl", action="store_true", help="also verify new RFC 3161 tokens with openssl") a = ap.parse_args() st = Path(a.state) st.mkdir(parents=True, exist_ok=True) pin_p = st / "vkey.txt" alarms, notes = [], [] try: live_vkey = get("/evidence/tlog/vkey.txt").decode().strip() except Exception as e: # noqa: BLE001 print(f"could not reach {BASE}: {e}"); return 3 if a.vkey: pin_p.write_text(a.vkey.strip() + "\n") if not pin_p.exists(): pin_p.write_text(live_vkey + "\n") notes.append("first run: pinned the verifier key the site serves now (compare it out of band)") vkey = pin_p.read_text().strip() if live_vkey != vkey: alarms.append(f"the site now serves a DIFFERENT log key: {live_vkey!r} (pinned {vkey!r})") for cluster, idx_path in LOGS.items(): d = st / cluster d.mkdir(exist_ok=True) try: note = get(f"/evidence/tlog/{cluster}/checkpoint.txt").decode() idx = json.loads(get(idx_path)) hist = [json.loads(l) for l in get(f"/evidence/tlog/{cluster}/checkpoints.jsonl").decode().splitlines() if l.strip()] except Exception as e: # noqa: BLE001 print(f"{cluster}: could not fetch: {e}"); return 3 try: origin, size, root, cos, body = verify_note(note, vkey) except Exception as e: # noqa: BLE001 alarms.append(f"{cluster}: checkpoint signature: {e}"); continue hashes = [p["proof_hash"] for p in idx["proofs"]] leaves = [hashlib.sha256(b"\x00" + bytes.fromhex(h)).digest() for h in hashes] if size > len(leaves): alarms.append(f"{cluster}: checkpoint size {size} exceeds the {len(leaves)} published proofs") continue if base64.b64encode(mth(leaves[:size])).decode() != root: alarms.append(f"{cluster}: Merkle root of the first {size} proofs does not match the signed checkpoint") seen_p = d / "checkpoints.jsonl" seen = [json.loads(l) for l in seen_p.read_text().splitlines()] if seen_p.exists() else [] for s in seen + [{"size": h["size"], "root_b64": h["root_b64"]} for h in hist]: if s["size"] <= len(leaves) and base64.b64encode(mth(leaves[: s["size"]])).decode() != s["root_b64"]: alarms.append(f"{cluster}: FORK/REWRITE: the tree at size {s['size']} no longer has root {s['root_b64']}") prev_p = d / "proof_hashes.json" if prev_p.exists(): prev = json.loads(prev_p.read_text()) if len(hashes) < len(prev): alarms.append(f"{cluster}: the proof index SHRANK from {len(prev)} to {len(hashes)}") elif hashes[: len(prev)] != prev: i = next(i for i, (x, y) in enumerate(zip(hashes, prev)) if x != y) alarms.append(f"{cluster}: proof #{i} CHANGED since the last run") new_cp = not any(s["size"] == size and s["root_b64"] == root for s in seen) if new_cp: with seen_p.open("a") as f: f.write(json.dumps({"size": size, "root_b64": root, "checkpoint": note, "cosigners": cos}) + "\n") (d / f"checkpoint-{size:06d}.txt").write_text(note) if a.openssl: last = next((h for h in reversed(hist) if h["size"] == size), None) for t in (last or {}).get("timestamps", []): if not t.get("token_b64"): continue with tempfile.TemporaryDirectory() as td: tok = Path(td) / "t.tsr" tok.write_bytes(base64.b64decode(t["token_b64"])) dig = hashlib.sha256(body.encode()).hexdigest() r = subprocess.run(["openssl", "ts", "-verify", "-digest", dig, "-in", str(tok), "-CApath", "/etc/ssl/certs"], capture_output=True, text=True) notes.append(f"{cluster}: RFC 3161 {t['tsa']}: " + ("Verification: OK" if "Verification: OK" in r.stdout + r.stderr else "not verified with the system CA store (FreeTSA needs its own CA: -CAfile)")) prev_p.write_text(json.dumps(hashes)) notes.append(f"{cluster}: size {size}, root {root[:16]}..., {len(seen) + (1 if new_cp else 0)} checkpoint(s) seen, cosigners: {', '.join(cos) or 'none'}") for n in notes: print(" " + n) if alarms: for x in alarms: print("ALARM: " + x) return 2 print("CONSISTENT: every checkpoint signature, Merkle root and earlier tree checked; state kept in", st) return 0 if __name__ == "__main__": sys.exit(main())