#!/usr/bin/env python3 # SPDX-License-Identifier: Apache-2.0 # Copyright 2026 Aaron Cohen (ReserveBeat / Reserve Ledger). # Licensed under the Apache License, Version 2.0 (verifier/LICENSE). Everything # under verifier/ is; the rest of the repository is not (see /LICENSE). """rb_verify — the independent verifier for ReserveBeat evidence bundles. It checks a bundle against `verifier/FORMAT.md` v1 using only this file, the Python standard library and one pinned package, `rfc8785` (RFC 8785 JSON canonicalisation; `verifier/requirements.txt`). It never talks to ReserveBeat. Commands: rb_verify.py bundle [--ots-dir DIR] [--require-anchored] M1/M2 the manifest, C1 the hash chain, A1 every anchor's MANIFEST and Merkle root, A2 coverage of the chain head, T1 every RFC 3161 leg, T2 the time each verified leg's TSA signed it (its genTime, printed and checked against the anchor's records and `anchored_at`, FORMAT.md §6.4), P1 every carried payload, S1-S3 every carried STORAGE_PROOF. A2: records past the last anchor are PENDING while the oldest is within the 30 h anchoring deadline of the bundle's as_of (FORMAT.md §5.4), and OVERDUE, a failure, past it; --require-anchored fails PENDING ones too. --tsa-ca PEM verifies a timestamp leg the bundle stores no CA chain for against trust roots YOU choose (a stored chain is always used as is). --ots-dir writes each anchor's payload and its OpenTimestamps proof as `.payload` + `.payload.ots`, for `ots verify`. rb_verify.py storage-proof S1-S3 over one STORAGE_PROOF payload (FORMAT.md §7.3). rb_verify.py pdf --record --bundle Is the document chain record commits to? Compares SHA256(file) with the record's content_sha256, and verifies the bundle that carries the record. What needs what: * `openssl` on PATH for T1 (`openssl ts -verify`). Without it every leg that holds a token is NOT VERIFIED and the run fails; nothing is assumed. T2 reads genTime itself (a DER walk below), from verified legs only. * OpenTimestamps is not checked here: that needs the `ots` client and a Bitcoin node. The tally counts the proofs; --ots-dir hands them over. * Keccak-256 for S1-S3 is implemented below in pure Python (no dependency). Exit status: 0 = every check that applies verified and the chain head is covered, or the records past the last anchor are PENDING (the banner says how many and that they are not yet timestamped); 1 = at least one failure (all are listed, checking continues past the first); 2 = the input could not be read or is not a format this file reads. The closing banner prints only on exit 0, and carries the leg tally it claims. A bundle PREPARES evidence for examination. Verifying one says the records, anchors and self-made payloads are the ones committed, in the order and at the times claimed. It says nothing about whether the figures in the underlying documents are true. The primitives below are vendored from the producer (canonicalisation, the RFC 6962 Merkle root, the record and anchor preimages, the TSA check and the Ethereum proof walk) so that both sides compute the same thing; the producer's test suite runs this file against the producer over the same bundles. """ from __future__ import annotations import argparse import base64 import hashlib import json import re import shutil import subprocess import sys import tempfile import zipfile from datetime import datetime, timedelta from pathlib import Path try: import rfc8785 except ImportError: # the storage-proof command needs no canonicalisation rfc8785 = None FORMAT_NAME = "reservebeat-evidence-bundle" FORMAT_VERSIONS = (1,) EXIT_OK, EXIT_FAIL, EXIT_REFUSED = 0, 1, 2 #: FORMAT.md §5.4: the producer anchors every chain once a day, each anchor #: covering the chain up to its head at the time of the run, so a record waits #: at most one cadence for its anchor; the grace is for the nightly run's own #: duration and a late start. Published constants of the document, never read #: from a bundle: a producer does not set the deadline it is judged against. ANCHOR_CADENCE_H = 24 ANCHOR_GRACE_H = 6 ANCHOR_DEADLINE_H = ANCHOR_CADENCE_H + ANCHOR_GRACE_H #: §4.2's one timestamp form, parsed only to measure an age (A2) or compare an #: instant with a TSA's (T2). Hashing always uses the string verbatim. TS_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" #: FORMAT.md §6.4: how far a verified leg's genTime (the TSA's own signing #: time) may sit from the anchor's `anchored_at`. The producer takes #: `anchored_at` once per anchoring run, after it has requested both legs (each #: at most 3 tries of 30 s plus backoff) and the OpenTimestamps stamp (at most #: 120 s): about 270 s of designed worst case, against 0.0-6.9 s measured over #: every prod and dev anchor on 2026-10-03. A published constant of the #: document, never read from a bundle, like the anchoring deadline above. TSA_TIME_BOUND_S = 600 class Refused(Exception): """The input cannot be verified at all (unreadable, or not format v1).""" # =========================================================================== # Hashing and canonicalisation (FORMAT.md §2) # =========================================================================== def sha256_hex(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def canonical_bytes(value) -> bytes: if rfc8785 is None: raise Refused("the `rfc8785` package is not installed: " "pip install -r verifier/requirements.txt") return rfc8785.dumps(value) # =========================================================================== # The hash chain (FORMAT.md §4) # =========================================================================== def genesis_prev_hash(issuer_id: str) -> str: return sha256_hex((issuer_id + "GENESIS").encode()) def record_hash(issuer_id: str, rec: dict) -> str: """§4.3. `uploaded_at` is hashed as the bundle's string, verbatim.""" return sha256_hex(canonical_bytes({ "issuer_id": issuer_id, "seq": rec["seq"], "type": rec["type"], "period": rec["period"], "content_sha256": rec["content_sha256"], "prev_hash": rec["prev_hash"], "uploaded_at": rec["uploaded_at"], })) def walk_chain(issuer_id: str, records: list[dict], head: dict | None) -> list[str]: """Check C1. Returns the problems, each naming its seq.""" problems: list[str] = [] if not records: return ["chain_head is set but the chain has no records"] if head else [] expected_prev = genesis_prev_hash(issuer_id) for i, rec in enumerate(records, start=1): if rec["seq"] != i: problems.append(f"seq gap: expected {i}, found {rec['seq']}") break if rec["prev_hash"] != expected_prev: problems.append(f"seq {rec['seq']}: prev_hash mismatch") if record_hash(issuer_id, rec) != rec["record_hash"]: problems.append(f"seq {rec['seq']}: record_hash mismatch") expected_prev = rec["record_hash"] if head is None: problems.append("chain_head is null but the chain has records") elif head.get("seq") != records[-1]["seq"] or \ head.get("record_hash") != records[-1]["record_hash"]: problems.append("chain_head does not match the last record") return problems # =========================================================================== # Merkle root, RFC 6962 §2.1 (FORMAT.md §5.2) # =========================================================================== def merkle_root(leaves_hex: list[str]) -> str: """Bottom-up with odd-node promotion, which equals RFC 6962's MTH.""" if not leaves_hex: raise ValueError("an anchor over zero leaves is malformed") level = [hashlib.sha256(b"\x00" + bytes.fromhex(h)).digest() for h in leaves_hex] while len(level) > 1: nxt = [hashlib.sha256(b"\x01" + level[i] + level[i + 1]).digest() for i in range(0, len(level) - 1, 2)] if len(level) % 2 == 1: nxt.append(level[-1]) level = nxt return level[0].hex() # =========================================================================== # The anchor payload and the RFC 3161 legs (FORMAT.md §5.1, §6) # =========================================================================== ANCHOR_PAYLOAD_KEYS = ("issuer_id", "kind", "covers_from", "covers_to", "covers_seq_from", "covers_seq_to", "merkle_root", "manifest_sha256") PROV_EXTERNAL_RFC3161 = "EXTERNAL_RFC3161" PROV_SELF_SIGNED_LOCAL = "SELF_SIGNED_LOCAL" PROV_CONTRACTED_STUB = "CONTRACTED_STUB" #: A verified leg's tally bucket, by RECORDED provenance. A word this file has #: never heard of lands in `verified_unattributed`, never in `external`. VERIFIED_BUCKET = { PROV_EXTERNAL_RFC3161: "verified_external", PROV_SELF_SIGNED_LOCAL: "verified_self_signed", } def anchor_payload_bytes(anchor: dict) -> bytes: return canonical_bytes({k: anchor[k] for k in ANCHOR_PAYLOAD_KEYS}) def openssl_available() -> bool: return shutil.which("openssl") is not None def verify_tsr(digest_hex: str, tsr: bytes, ca_pem: bytes) -> bool: """`openssl ts -verify -digest -in -CAfile `.""" with tempfile.TemporaryDirectory() as td: tsr_p, ca_p = Path(td) / "resp.tsr", Path(td) / "ca.pem" tsr_p.write_bytes(tsr) ca_p.write_bytes(ca_pem) proc = subprocess.run( ["openssl", "ts", "-verify", "-digest", digest_hex, "-in", str(tsr_p), "-CAfile", str(ca_p)], capture_output=True, check=False, ) return proc.returncode == 0 # The TSA's own time (RFC 3161 §2.4.2 TSTInfo, FORMAT.md §6.4). A minimal DER # reader for exactly the path TimeStampResp -> ContentInfo -> SignedData -> # encapContentInfo -> TSTInfo: the bytes `openssl ts -verify` checked the # signature over. Anything else in the layout is refused, never guessed. _OID_SIGNED_DATA = bytes.fromhex("2a864886f70d010702") # 1.2.840.113549.1.7.2 _OID_TST_INFO = bytes.fromhex("2a864886f70d0109100104") # 1.2.840.113549.1.9.16.1.4 def _der_items(buf: bytes, start: int, end: int) -> list[tuple[int, int, int]]: """The DER elements in buf[start:end] as (tag, content start, content end). Definite lengths and one-byte tags only, as DER has them.""" out, i = [], start while i < end: if i + 2 > end: raise ValueError("truncated DER") tag, ln = buf[i], buf[i + 1] i += 2 if tag & 0x1F == 0x1F: raise ValueError("multi-byte DER tag") if ln == 0x80: raise ValueError("indefinite length (BER, not DER)") if ln & 0x80: n = ln & 0x7F if n > 4 or i + n > end: raise ValueError("bad DER length") ln = int.from_bytes(buf[i:i + n], "big") i += n if i + ln > end: raise ValueError("truncated DER") out.append((tag, i, i + ln)) i += ln return out def _der_kids(buf: bytes, item: tuple[int, int, int], tag: int, what: str): if item[0] != tag: raise ValueError(f"{what}: tag 0x{item[0]:02x}, expected 0x{tag:02x}") return _der_items(buf, item[1], item[2]) def _der_bytes(buf: bytes, item: tuple[int, int, int], tag: int, what: str) -> bytes: if item[0] != tag: raise ValueError(f"{what}: tag 0x{item[0]:02x}, expected 0x{tag:02x}") return buf[item[1]:item[2]] def token_time(tsr: bytes, digest_hex: str) -> dict: """The TSTInfo genTime of an RFC 3161 token, and its accuracy when the token states one. `tsr` is a TimeStampResp (§6.1's `token_b64`) or a bare token. Returns {text, at, resolution_s, accuracy_s}: `at` a naive UTC datetime, `resolution_s` one unit of genTime's last digit (1 s when it has no fraction), `accuracy_s` None when not stated. Raises ValueError on any other layout, and when the TSTInfo's messageImprint is not `digest_hex` (so the time read is the time of the token T1 verified).""" top = _der_items(tsr, 0, len(tsr)) if len(top) != 1: raise ValueError("not one DER object") kids = _der_kids(tsr, top[0], 0x30, "TimeStampResp") if kids and kids[0][0] == 0x30: # PKIStatusInfo first: a TimeStampResp if len(kids) < 2: raise ValueError("the response carries no token") token = kids[1] else: token = top[0] ci = _der_kids(tsr, token, 0x30, "ContentInfo") if len(ci) != 2 or _der_bytes(tsr, ci[0], 0x06, "contentType") != _OID_SIGNED_DATA: raise ValueError("the token is not CMS SignedData") wrapped = _der_kids(tsr, ci[1], 0xA0, "content") if len(wrapped) != 1: raise ValueError("content is not one SignedData") sd = _der_kids(tsr, wrapped[0], 0x30, "SignedData") if len(sd) < 3: raise ValueError("SignedData is truncated") eci = _der_kids(tsr, sd[2], 0x30, "encapContentInfo") if len(eci) != 2 or _der_bytes(tsr, eci[0], 0x06, "eContentType") != _OID_TST_INFO: raise ValueError("the signed content is not a TSTInfo") inner = _der_kids(tsr, eci[1], 0xA0, "eContent") if len(inner) != 1: raise ValueError("eContent is not one OCTET STRING") tst = _der_bytes(tsr, inner[0], 0x04, "eContent") top = _der_items(tst, 0, len(tst)) if len(top) != 1: raise ValueError("TSTInfo is not one DER object") f = _der_kids(tst, top[0], 0x30, "TSTInfo") if len(f) < 5: raise ValueError("TSTInfo is truncated") imprint = _der_kids(tst, f[2], 0x30, "messageImprint") if len(imprint) != 2 or _der_bytes(tst, imprint[1], 0x04, "hashedMessage") != \ bytes.fromhex(digest_hex): raise ValueError("the TSTInfo's messageImprint is not this anchor's digest") gen = _der_bytes(tst, f[4], 0x18, "genTime").decode("ascii", "replace") m = re.fullmatch(r"(\d{14})(?:\.(\d*[1-9]))?Z", gen) if m is None: raise ValueError(f"genTime {gen!r} is not DER GeneralizedTime") frac = m.group(2) or "" at = datetime.strptime(m.group(1), "%Y%m%d%H%M%S") + \ timedelta(microseconds=int((frac + "000000")[:6])) accuracy = None if len(f) > 5 and f[5][0] == 0x30: accuracy = 0.0 for tag, s, e in _der_kids(tst, f[5], 0x30, "accuracy"): unit = {0x02: 1.0, 0x80: 1e-3, 0x81: 1e-6}.get(tag) if unit is None: raise ValueError(f"accuracy: unexpected tag 0x{tag:02x}") accuracy += int.from_bytes(tst[s:e], "big") * unit return {"text": at.strftime("%Y-%m-%dT%H:%M:%S") + (f".{frac}" if frac else "") + "Z", "at": at, "resolution_s": max(10.0 ** -len(frac), 1e-6), "accuracy_s": accuracy} # =========================================================================== # Keccak-256 (Ethereum's: original Keccak padding, NOT NIST SHA3-256) # =========================================================================== # # Keccak-f[1600] written out from the Keccak reference; the round constants # and rotation offsets are generated by their defining recurrences rather # than typed, so there is no table to mistype. Slow (pure Python) and fast # enough: a storage proof hashes a few dozen trie nodes. _M64 = (1 << 64) - 1 def _round_constants() -> list[int]: out, r = [], 1 for _ in range(24): rc = 0 for j in range(7): if r & 1: rc |= 1 << ((1 << j) - 1) r = ((r << 1) ^ 0x171) if r & 0x80 else (r << 1) out.append(rc) return out def _rotation_offsets() -> list[int]: rot = [0] * 25 x, y = 1, 0 for t in range(24): rot[x + 5 * y] = ((t + 1) * (t + 2) // 2) % 64 x, y = y, (2 * x + 3 * y) % 5 return rot _RC = _round_constants() _ROT = _rotation_offsets() def _rol(v: int, n: int) -> int: return ((v << n) | (v >> (64 - n))) & _M64 if n else v def _keccak_f(a: list[int]) -> list[int]: for rc in _RC: c = [a[x] ^ a[x + 5] ^ a[x + 10] ^ a[x + 15] ^ a[x + 20] for x in range(5)] d = [c[(x - 1) % 5] ^ _rol(c[(x + 1) % 5], 1) for x in range(5)] a = [a[i] ^ d[i % 5] for i in range(25)] b = [0] * 25 for x in range(5): for y in range(5): b[y + 5 * ((2 * x + 3 * y) % 5)] = _rol(a[x + 5 * y], _ROT[x + 5 * y]) a = [b[i] ^ (~b[(i % 5 + 1) % 5 + 5 * (i // 5)] & b[(i % 5 + 2) % 5 + 5 * (i // 5)]) for i in range(25)] a[0] ^= rc return a def keccak256(data: bytes) -> bytes: rate = 136 padded = bytearray(data) + b"\x01" + bytes((-len(data) - 1) % rate) padded[-1] |= 0x80 state = [0] * 25 for off in range(0, len(padded), rate): block = padded[off:off + rate] for i in range(rate // 8): state[i] ^= int.from_bytes(block[8 * i:8 * i + 8], "little") state = _keccak_f(state) return b"".join(state[i].to_bytes(8, "little") for i in range(4)) # =========================================================================== # Ethereum storage proofs (FORMAT.md §7.3): RLP, the header hash, the MPT walk # =========================================================================== def hex_to_bytes(h: str) -> bytes: h = h.removeprefix("0x") return bytes.fromhex("0" + h if len(h) % 2 else h) def quantity_bytes(h: str) -> bytes: """JSON-RPC quantity -> minimal big-endian bytes ('0x0' -> b'').""" n = int(h, 16) return n.to_bytes((n.bit_length() + 7) // 8, "big") if n else b"" def rlp_encode(item) -> bytes: if isinstance(item, bytes): if len(item) == 1 and item[0] < 0x80: return item return _rlp_length(len(item), 0x80) + item if isinstance(item, list): payload = b"".join(rlp_encode(x) for x in item) return _rlp_length(len(payload), 0xC0) + payload raise TypeError(f"cannot RLP-encode {type(item)}") def _rlp_length(n: int, offset: int) -> bytes: if n < 56: return bytes([offset + n]) n_bytes = n.to_bytes((n.bit_length() + 7) // 8, "big") return bytes([offset + 55 + len(n_bytes)]) + n_bytes def rlp_decode(buf: bytes): item, rest = _rlp_decode_one(buf) if rest: raise ValueError("trailing bytes after RLP item") return item def _rlp_decode_one(buf: bytes): if not buf: raise ValueError("empty RLP input") b0 = buf[0] if b0 < 0x80: return buf[:1], buf[1:] if b0 < 0xB8: # short string n = b0 - 0x80 return buf[1:1 + n], buf[1 + n:] if b0 < 0xC0: # long string ln = b0 - 0xB7 n = int.from_bytes(buf[1:1 + ln], "big") return buf[1 + ln:1 + ln + n], buf[1 + ln + n:] if b0 < 0xF8: # short list n = b0 - 0xC0 payload, rest = buf[1:1 + n], buf[1 + n:] else: # long list ln = b0 - 0xF7 n = int.from_bytes(buf[1:1 + ln], "big") payload, rest = buf[1 + ln:1 + ln + n], buf[1 + ln + n:] items = [] while payload: item, payload = _rlp_decode_one(payload) items.append(item) return items, rest # (json_key, kind) in consensus RLP order. The fields after `nonce` are # fork-gated (London/Shanghai/Cancun/Prague) and included when present. _HEADER_FIELDS = [ ("parentHash", "b"), ("sha3Uncles", "b"), ("miner", "b"), ("stateRoot", "b"), ("transactionsRoot", "b"), ("receiptsRoot", "b"), ("logsBloom", "b"), ("difficulty", "q"), ("number", "q"), ("gasLimit", "q"), ("gasUsed", "q"), ("timestamp", "q"), ("extraData", "b"), ("mixHash", "b"), ("nonce", "b"), ("baseFeePerGas", "q"), ("withdrawalsRoot", "b"), ("blobGasUsed", "q"), ("excessBlobGas", "q"), ("parentBeaconBlockRoot", "b"), ("requestsHash", "b"), ] _REQUIRED_THROUGH = 15 # everything up to and including nonce is mandatory def compute_header_hash(header: dict) -> str: fields = [] for i, (key, kind) in enumerate(_HEADER_FIELDS): if key not in header or header[key] is None: if i < _REQUIRED_THROUGH: raise ValueError(f"header missing required field {key}") continue raw = header[key] fields.append(quantity_bytes(raw) if kind == "q" else hex_to_bytes(raw)) return "0x" + keccak256(rlp_encode(fields)).hex() def _nibbles(key: bytes) -> list[int]: out = [] for b in key: out.append(b >> 4) out.append(b & 0x0F) return out def _decode_hp(path: bytes) -> tuple[list[int], bool]: """Hex-prefix decode -> (nibbles, is_leaf).""" nibs = _nibbles(path) flag = nibs[0] return (nibs[1:] if flag % 2 else nibs[2:]), flag >= 2 def verify_mpt_proof(root: bytes, key: bytes, proof_nodes: list[bytes]) -> bytes | None: """Walk a Merkle-Patricia proof from `root` along `key`. Returns the leaf value, or None for a proven exclusion; raises ValueError when the proof does not verify.""" by_hash = {keccak256(raw): raw for raw in proof_nodes} nibbles = _nibbles(key) def resolve(ref): if isinstance(ref, bytes): if len(ref) == 0: return None if len(ref) == 32: raw = by_hash.get(ref) if raw is None: raise ValueError("proof is missing a referenced node") return rlp_decode(raw) raise ValueError("malformed child reference") return ref # an embedded node, already decoded if root not in by_hash: raise ValueError("no proof node hashes to the expected root") node = rlp_decode(by_hash[root]) while True: if isinstance(node, list) and len(node) == 17: # branch if not nibbles: return node[16] if node[16] else None nxt = resolve(node[nibbles[0]]) if nxt is None: return None nibbles = nibbles[1:] node = nxt elif isinstance(node, list) and len(node) == 2: # extension or leaf path, is_leaf = _decode_hp(node[0]) if is_leaf: return node[1] if nibbles == path else None if nibbles[:len(path)] != path: return None nibbles = nibbles[len(path):] nxt = resolve(node[1]) if nxt is None: raise ValueError("extension points at nothing") node = nxt else: raise ValueError("malformed trie node") def verify_storage_proof(payload: dict) -> list[dict]: """S1-S3 over a STORAGE_PROOF payload. Returns [{check, ok, detail}]; every check runs even after a failure.""" checks: list[dict] = [] def check(name: str, fn): try: checks.append({"check": name, "ok": True, "detail": fn()}) except Exception as exc: # noqa: BLE001 — a raise means does-not-verify, reported checks.append({"check": name, "ok": False, "detail": str(exc)}) header = payload.get("header") or {} proof = payload.get("proof") or {} def s1(): computed = compute_header_hash(header) if computed != header.get("hash"): raise ValueError(f"computed {computed} != stated {header.get('hash')}") return computed def s2(): value = verify_mpt_proof( hex_to_bytes(header["stateRoot"]), keccak256(hex_to_bytes(payload["contract_address"])), [hex_to_bytes(n) for n in proof["accountProof"]]) if value is None: raise ValueError("the account is absent from the state trie") _nonce, _balance, storage_hash, code_hash = rlp_decode(value) for name, got, want in [("storageHash", storage_hash, proof["storageHash"]), ("codeHash", code_hash, proof["codeHash"])]: if "0x" + got.hex() != want: raise ValueError(f"account {name}: proven 0x{got.hex()} != stated {want}") return {"storageHash": "0x" + storage_hash.hex()} def s3(): slot = int(payload["slot"], 16) entry = next((s for s in proof["storageProof"] if int(s["key"], 16) == slot), None) if entry is None: raise ValueError(f"no storageProof entry for slot {payload['slot']}") value = verify_mpt_proof(hex_to_bytes(proof["storageHash"]), keccak256(slot.to_bytes(32, "big")), [hex_to_bytes(n) for n in entry["proof"]]) proven = int.from_bytes(rlp_decode(value), "big") if value is not None else 0 if proven != int(entry["value"], 16): raise ValueError(f"proven slot value {proven} != stated {entry['value']}") if proven != int(payload["total_supply"]): raise ValueError(f"proven slot value {proven} != total_supply " f"{payload['total_supply']}") return {"total_supply": proven} check("S1 header hash recomputes", s1) check("S2 account proof to stateRoot", s2) check("S3 storage proof to storageHash", s3) return checks # =========================================================================== # Reading a bundle (FORMAT.md §2) # =========================================================================== def read_bundle(path: Path) -> dict[str, bytes]: """Every entry of a bundle, from the ZIP or from a directory it was extracted into. Nothing is extracted to disk.""" if path.is_dir(): return {p.relative_to(path).as_posix(): p.read_bytes() for p in sorted(path.rglob("*")) if p.is_file()} if not path.is_file(): raise Refused(f"{path}: no such file or directory") try: with zipfile.ZipFile(path) as zf: return {info.filename: zf.read(info) for info in zf.infolist() if not info.is_dir()} except zipfile.BadZipFile as exc: raise Refused(f"{path}: not a ZIP archive or a directory ({exc})") from exc def _json(entries: dict[str, bytes], name: str, problems: list[str]): if name not in entries: problems.append(f"M1: `{name}` is not in the bundle") return None try: return json.loads(entries[name]) except ValueError as exc: problems.append(f"M1: `{name}` is not JSON ({exc})") return None # =========================================================================== # The bundle check # =========================================================================== class Report: def __init__(self, out): self.out = out self.problems: list[str] = [] self.tally: dict[str, int] = {} #: A2's PENDING tail (§5.4), when there is one and it is not a failure: #: {count, first, last, oldest_seq, oldest_at, age_h, as_of}. self.pending: dict | None = None def say(self, line: str = "") -> None: if self.out is not None: print(line, file=self.out) def fail(self, problem: str) -> None: self.problems.append(problem) def bump(self, key: str) -> None: self.tally[key] = self.tally.get(key, 0) + 1 @property def ok(self) -> bool: return not self.problems def legs_line(self) -> str: t = self.tally.get return (f"timestamp legs: {t('verified', 0)} verified by `openssl ts -verify` " f"({t('verified_external', 0)} external, " f"{t('verified_self_signed', 0)} self-signed, " f"{t('verified_unattributed', 0)} provenance not recorded), " f"{t('unverified', 0)} present but NOT verified, " f"{t('absent', 0)} recorded absent") def verify_bundle(path: Path, out=sys.stdout, ots_dir: Path | None = None, require_anchored: bool = False, tsa_ca: bytes | None = None) -> Report: """Every check FORMAT.md v1 names, over one bundle. Raises Refused when the input is not a v1 bundle at all. `require_anchored` fails a PENDING tail too (A2 as revision 1.0 of FORMAT.md read it); `tsa_ca` is a PEM trust root for timestamp legs the bundle stores no CA chain for (§6.1).""" rep = Report(out) entries = read_bundle(path) if "manifest.json" not in entries: raise Refused(f"{path}: no manifest.json — not an evidence bundle") try: manifest = json.loads(entries["manifest.json"]) except ValueError as exc: raise Refused(f"{path}: manifest.json is not JSON ({exc})") from exc if not isinstance(manifest, dict) or manifest.get("format") != FORMAT_NAME: raise Refused(f"{path}: manifest.format is not {FORMAT_NAME!r}") if manifest.get("format_version") not in FORMAT_VERSIONS: raise Refused(f"{path}: format_version {manifest.get('format_version')!r}; " f"this verifier reads {list(FORMAT_VERSIONS)}") issuer = manifest.get("issuer") or {} rep.say(f"bundle {path.name}: format v{manifest['format_version']}, issuer " f"{issuer.get('id')} ({issuer.get('kind')}" + (f", {issuer['name']}" if issuer.get("name") else "") + ")" + (f", scope {manifest['scope']['symbol']}" if (manifest.get("scope") or {}).get("symbol") else "")) # --- M1: every named file is present and hashes to its manifest value files = manifest.get("files") or {} m1 = len(rep.problems) for name, digest in sorted(files.items()): if name not in entries: rep.fail(f"M1: `{name}` is named in manifest.files but not in the bundle") elif sha256_hex(entries[name]) != digest: rep.fail(f"M1: `{name}` does not hash to its manifest.files value") for name in sorted(set(entries) - set(files) - {"manifest.json"}): rep.fail(f"M1: `{name}` is in the bundle but not in manifest.files") rep.say(f" M1 manifest file hashes: " f"{'OK' if len(rep.problems) == m1 else 'FAIL'} ({len(files)} files)") rec_doc = _json(entries, "records.json", rep.problems) or {} anc_doc = _json(entries, "anchors.json", rep.problems) or {} issuer_id = rec_doc.get("issuer_id") records: list[dict] = rec_doc.get("records") or [] anchors: list[dict] = anc_doc.get("anchors") or [] head = rec_doc.get("chain_head") payloads = {n.removeprefix("payloads/"): b for n, b in entries.items() if n.startswith("payloads/")} # --- M2: the entries describe one chain m2 = len(rep.problems) if not (issuer.get("id") == issuer_id == anc_doc.get("issuer_id")): rep.fail("M2: manifest.issuer.id, records.json and anchors.json name " "different issuers") if manifest.get("chain_head") != head: rep.fail("M2: manifest.chain_head differs from records.json's chain_head") withheld = manifest.get("payloads_withheld") or [] counts = manifest.get("counts") or {} if counts != {"records": len(records), "anchors": len(anchors), "payloads_carried": len(payloads), "payloads_withheld": len(withheld)}: rep.fail("M2: manifest.counts differs from what the bundle holds") for a in anchors: if a.get("issuer_id") != issuer_id: rep.fail(f"M2: anchor {a.get('id')} names another issuer") rep.say(f" M2 one chain: {'OK' if len(rep.problems) == m2 else 'FAIL'}") if not isinstance(issuer_id, str): rep.fail("C1: records.json names no issuer_id; the chain cannot be walked") issuer_id = "" # The walk below indexes fields, so a malformed entry is named here and # the chain and anchor checks are skipped: nothing is verified over it. shape = shape_problems(records, anchors, head) for p in shape: rep.fail(p) if shape: rep.say(f" shape: FAIL — {len(shape)} malformed entr(ies); C1/A1/A2/T1 not run") records, anchors, issuer_id = [], [], "" # --- C1: the chain walk chain_problems = walk_chain(issuer_id, records, head) if issuer_id else [] for p in chain_problems: rep.fail(f"C1: {p}") rep.say(f" C1 chain walk: {'OK' if not chain_problems else 'FAIL'} " f"({len(records)} records" + (f", head seq {head['seq']}" if head else ", no head") + ")") by_seq = {r["seq"]: r for r in records} content_hashes = {r["content_sha256"] for r in records} type_of = {} for r in records: type_of.setdefault(r["content_sha256"], r["type"]) # --- A2: coverage of the chain head manifest_digests = {a["manifest_sha256"] for a in anchors} if records and not anchors: rep.fail(f"A2: NO anchors at all — {len(records)} record(s), head seq " f"{records[-1]['seq']}, and none has been timestamped") rep.say(" A2 coverage: FAIL — no anchors") elif records: anchored_to = max(a["covers_seq_to"] for a in anchors) past = [r for r in records if r["seq"] > anchored_to and not (r["type"] == "MANIFEST" and r["content_sha256"] in manifest_digests)] if past: _judge_tail(rep, past, records, anchors, anchored_to, require_anchored) else: tail = records[-1]["seq"] - anchored_to rep.say(f" A2 coverage: OK — head seq {records[-1]['seq']}, anchored through " f"seq {anchored_to}" + (f", + {tail} anchor MANIFEST(s) attested by " "their own anchor payload" if tail else "")) # --- A1 + T1: per anchor withheld_by_digest = {w.get("content_sha256"): w for w in withheld} have_openssl = openssl_available() ots = {"present": 0, "absent": 0} for a in anchors: label = f"anchor {a.get('id')} [{a.get('kind')} {a.get('status')}]" _check_anchor(rep, label, a, anchors, by_seq, payloads, withheld_by_digest) _check_legs(rep, label, a, have_openssl, tsa_ca, by_seq) if a.get("ots_proof_b64"): ots["present"] += 1 if ots_dir is not None: ots_dir.mkdir(parents=True, exist_ok=True) (ots_dir / f"{a['id']}.payload").write_bytes(anchor_payload_bytes(a)) (ots_dir / f"{a['id']}.payload.ots").write_bytes( base64.b64decode(a["ots_proof_b64"])) else: ots["absent"] += 1 # --- P1 and S1-S3: the carried payloads for digest in sorted(payloads): data = payloads[digest] if sha256_hex(data) != digest: rep.fail(f"P1: payloads/{digest} does not hash to its name") continue if digest not in content_hashes: rep.fail(f"P1: payloads/{digest} is no record's content_sha256") continue if type_of[digest] == "STORAGE_PROOF": try: proof = json.loads(data) except ValueError: rep.fail(f"S1: payloads/{digest} (STORAGE_PROOF) is not JSON") continue checks = verify_storage_proof(proof) what = f"{proof.get('token_symbol')}@{proof.get('block_number')}" for c in checks: if not c["ok"]: rep.fail(f"{c['check'].split()[0]}: payloads/{digest} ({what}): " f"{c['detail']}") rep.say(f" STORAGE_PROOF {what} total_supply {proof.get('total_supply')}: " + ("S1 S2 S3 OK" if all(c["ok"] for c in checks) else "FAIL " + ", ".join(c["check"].split()[0] for c in checks if not c["ok"]))) for w in withheld: if w.get("content_sha256") in payloads: rep.fail(f"P1: payload {w.get('content_sha256')} is both carried and withheld") elif w.get("content_sha256") not in content_hashes: rep.fail(f"P1: withheld payload {w.get('content_sha256')} is no record's") else: rep.say(f" payload withheld: seq {w.get('seq')} {w.get('type')} — " f"{w.get('reason')} (a disclosed absence, not checked)") rep.say(f" P1 payloads: {len(payloads)} carried, {len(withheld)} withheld") rep.say(f" ots: {ots['present']} proof(s) present, {ots['absent']} absent — not checked " "here (needs the `ots` client and a Bitcoin node" + (f"; written to {ots_dir})" if ots_dir is not None and ots["present"] else ")")) return rep _RECORD_FIELDS = {"seq": int, "type": str, "period": (str, type(None)), "content_sha256": str, "prev_hash": str, "uploaded_at": str, "record_hash": str} _ANCHOR_FIELDS = {"id": str, "issuer_id": str, "kind": str, "covers_from": str, "covers_to": str, "covers_seq_from": int, "covers_seq_to": int, "merkle_root": str, "manifest_sha256": str, "tsa": dict} def _bad_fields(entry, fields: dict) -> list[str]: if not isinstance(entry, dict): return ["not an object"] return [f"`{k}`" for k, t in fields.items() if k not in entry or not isinstance(entry[k], t) or isinstance(entry[k], bool)] def shape_problems(records, anchors, head) -> list[str]: """The fields the checks read, present and of the §4/§5 types.""" out = [] if not isinstance(records, list) or not isinstance(anchors, list): return ["shape: `records` and `anchors` must be arrays"] for i, r in enumerate(records, start=1): bad = _bad_fields(r, _RECORD_FIELDS) if bad: out.append(f"shape: record #{i} is malformed ({', '.join(bad)})") for i, a in enumerate(anchors, start=1): bad = _bad_fields(a, _ANCHOR_FIELDS) if bad: out.append(f"shape: anchor #{i} is malformed ({', '.join(bad)})") if head is not None and _bad_fields(head, {"seq": int, "record_hash": str}): out.append("shape: chain_head is malformed") return out def _parse_ts(value) -> datetime: if not isinstance(value, str): raise ValueError(f"{value!r} is not a §4.2 timestamp") return datetime.strptime(value, TS_FORMAT) def bundle_as_of(records: list[dict], anchors: list[dict]) -> str | None: """§3.1's `as_of`, recomputed from the entries (the manifest's copy is not read): the latest of the head record's `uploaded_at` and every anchor's `created_at`, `anchored_at` and `ots_final_at`. Raises ValueError on an instant that is not in §4.2 form.""" instants = [records[-1]["uploaded_at"]] if records else [] instants += [a.get(k) for a in anchors for k in ("created_at", "anchored_at", "ots_final_at") if a.get(k) is not None] return max(instants, key=_parse_ts) if instants else None def _judge_tail(rep: Report, past: list[dict], records: list[dict], anchors: list[dict], anchored_to: int, require_anchored: bool) -> None: """A2 over the records past every anchor (FORMAT.md §5.3, §5.4): PENDING while the oldest of them is within the anchoring deadline of the bundle's own `as_of`; OVERDUE, a failure, past it. Judged at `as_of`, never at the time this runs, so the same bytes always give the same verdict.""" where = (f"seqs {past[0]['seq']}-{past[-1]['seq']}; head seq {records[-1]['seq']}, " f"anchored through {anchored_to}") base = f"A2: {len(past)} record(s) past every anchor ({where}) — NOT timestamped by any anchor" head = f"anchored through seq {anchored_to}, {len(past)} record(s) past it" try: as_of = bundle_as_of(records, anchors) oldest = min(past, key=lambda r: _parse_ts(r["uploaded_at"])) age_h = (_parse_ts(as_of) - _parse_ts(oldest["uploaded_at"])).total_seconds() / 3600 except ValueError as exc: rep.fail(f"{base}; their age cannot be judged ({exc})") rep.say(f" A2 coverage: FAIL — {head}; their age cannot be judged") return deadline = (f"the {ANCHOR_DEADLINE_H} h anchoring deadline ({ANCHOR_CADENCE_H} h daily " f"cadence + {ANCHOR_GRACE_H} h grace, FORMAT.md §5.4)") if age_h > ANCHOR_DEADLINE_H: rep.fail(f"{base}, and OVERDUE: the oldest, seq {oldest['seq']} uploaded " f"{oldest['uploaded_at']}, is {age_h:.1f} h older than the bundle's as_of " f"{as_of}, past {deadline}") rep.say(f" A2 coverage: FAIL — {head}, OVERDUE: the oldest is {age_h:.1f} h older " f"than as_of, past the {ANCHOR_DEADLINE_H} h anchoring deadline") return if require_anchored: rep.fail(f"{base}; PENDING ({age_h:.1f} h old at as_of, inside {deadline}), " "failed because --require-anchored was given") rep.say(f" A2 coverage: FAIL — {head}, PENDING, and --require-anchored was given") return rep.pending = {"count": len(past), "first": past[0]["seq"], "last": past[-1]["seq"], "oldest_seq": oldest["seq"], "oldest_at": oldest["uploaded_at"], "age_h": age_h, "as_of": as_of} rep.say(f" A2 coverage: PENDING — head seq {records[-1]['seq']}, {head} (seqs " f"{past[0]['seq']}-{past[-1]['seq']}), the oldest {age_h:.1f} h older than as_of " f"{as_of}: inside the {ANCHOR_DEADLINE_H} h anchoring deadline, NOT yet " "timestamped by any anchor") def _check_anchor(rep: Report, label: str, a: dict, anchors: list[dict], by_seq: dict, payloads: dict, withheld_by_digest: dict) -> None: """Check A1 for one anchor.""" digest = a.get("manifest_sha256") if digest not in payloads: w = withheld_by_digest.get(digest) why = f"withheld ({w.get('reason')})" if w else "not in the bundle" rep.fail(f"A1: {label}: MANIFEST payload {why} — anchor UNVERIFIABLE") rep.say(f" {label}: MANIFEST {why} — UNVERIFIABLE") return data = payloads[digest] if sha256_hex(data) != digest: rep.fail(f"A1: {label}: MANIFEST payload hash mismatch") return try: man = json.loads(data) except ValueError: rep.fail(f"A1: {label}: MANIFEST payload is not JSON") return lo, hi = a["covers_seq_from"], a["covers_seq_to"] before = len(rep.problems) if a["kind"] == "MONTHLY": dailies = sorted((d for d in anchors if d.get("kind") == "DAILY" and d["covers_seq_to"] >= lo and d["covers_seq_from"] <= hi), key=lambda d: d["covers_seq_from"]) expected = [{"anchor_commit_id": d["id"], "covers_from": d["covers_from"], "covers_to": d["covers_to"], "covers_seq_from": d["covers_seq_from"], "covers_seq_to": d["covers_seq_to"], "merkle_root": d["merkle_root"], "manifest_sha256": d["manifest_sha256"]} for d in dailies] listed = man.get("daily_anchor_manifests") or [] if listed != expected: rep.fail(f"A1: {label}: the roll-up list differs from the DAILY anchors " "in anchors.json over its range") leaves = [x.get("manifest_sha256") for x in listed] detail = f"rolls up {len(listed)} dailies, seqs {lo}-{hi}" else: covered = [by_seq[s]["record_hash"] if s in by_seq else None for s in range(lo, hi + 1)] leaves = [x.get("record_hash") for x in man.get("artifacts") or []] if None in covered: rep.fail(f"A1: {label}: covers seqs {lo}-{hi}, which the chain does not hold") elif leaves != covered: rep.fail(f"A1: {label}: the MANIFEST's record list differs from the chain " f"over seqs {lo}-{hi}") detail = f"seqs {lo}-{hi}" try: root = merkle_root(leaves) except (ValueError, TypeError) as exc: rep.fail(f"A1: {label}: {exc}") return if root != a["merkle_root"] or man.get("merkle_root") != a["merkle_root"]: rep.fail(f"A1: {label}: merkle root mismatch") if len(rep.problems) == before: rep.say(f" {label}: manifest + merkle root OK ({detail})") def _check_legs(rep: Report, label: str, a: dict, have_openssl: bool, tsa_ca: bytes | None = None, by_seq: dict | None = None) -> None: """Checks T1 and T2 for one anchor's two RFC 3161 legs. `tsa_ca` is a trust root the verifier's user supplied (--tsa-ca, FORMAT.md §6.1): it is used only for a leg whose bundle stores no CA chain, never in place of one. T2 reads the TSA's time from a leg T1 verified, and only from one.""" digest = sha256_hex(anchor_payload_bytes(a)) verified = 0 for leg in ("primary", "secondary"): v = (a.get("tsa") or {}).get(leg) or {} prov = v.get("provenance") or "provenance not recorded" note = v.get("note") or "no reason recorded" via = "" if v.get("ca_chain_b64") else " against the --tsa-ca trust root" if not v.get("token_b64"): rep.bump("absent") rep.say(f" tsa {leg}: absent, no timestamp evidence — [{prov}] {note}") elif not v.get("ca_chain_b64") and tsa_ca is None: rep.bump("unverified") rep.fail(f"T1: {label}: tsa {leg} token present but NOT VERIFIED — no CA " f"chain in the bundle for this leg [{prov}]") rep.say(f" tsa {leg}: token present but NOT VERIFIED — no CA chain [{prov}] " "(--tsa-ca FILE verifies it against a trust root you choose)") elif not have_openssl: rep.bump("unverified") rep.fail(f"T1: {label}: tsa {leg} NOT VERIFIED — `openssl` is not on PATH") rep.say(f" tsa {leg}: NOT VERIFIED — `openssl` is not on PATH [{prov}]") elif verify_tsr(digest, base64.b64decode(v["token_b64"]), base64.b64decode(v["ca_chain_b64"]) if v.get("ca_chain_b64") else tsa_ca): verified += 1 rep.bump("verified") rep.bump(VERIFIED_BUCKET.get(v.get("provenance"), "verified_unattributed")) rep.say(f" tsa {leg}: openssl ts -verify OK{via} [{prov}]") _check_leg_time(rep, label, leg, a, base64.b64decode(v["token_b64"]), digest, by_seq or {}) else: rep.bump("unverified") rep.fail(f"T1: {label}: tsa {leg} token FAILED openssl ts -verify{via}") rep.say(f" tsa {leg}: FAILED openssl ts -verify{via} [{prov}]") if verified == 0: rep.fail(f"T1: {label}: NO timestamp leg was cryptographically verified") rep.say(" tsa: NONE of this anchor's legs was cryptographically verified") def _check_leg_time(rep: Report, label: str, leg: str, a: dict, tsr: bytes, digest: str, by_seq: dict) -> None: """Check T2 for one verified leg (FORMAT.md §6.4): the TSA's genTime is not before the newest record the anchor covers, and is within TSA_TIME_BOUND_S of the producer's `anchored_at`. The TSA's time is the interval genTime -accuracy .. genTime +resolution +accuracy, so a genTime truncated to the second is never failed for its own truncation. A time that cannot be read or compared is a failure, never a pass.""" where = f"T2: {label}: tsa {leg}" try: tok = token_time(tsr, digest) except ValueError as exc: rep.fail(f"{where}: the TSA's genTime cannot be read ({exc}); its time is not checked") rep.say(f" tsa {leg}: T2 genTime: FAIL — cannot be read ({exc})") return acc = tok["accuracy_s"] or 0.0 early = tok["at"] - timedelta(seconds=acc) late = tok["at"] + timedelta(seconds=tok["resolution_s"] + acc) shown = tok["text"] + (f" ±{acc:g} s" if tok["accuracy_s"] is not None else "") covered = [by_seq[s] for s in range(a["covers_seq_from"], a["covers_seq_to"] + 1) if s in by_seq] try: newest = max(covered, key=lambda r: _parse_ts(r["uploaded_at"]), default=None) anchored = _parse_ts(a.get("anchored_at")) except ValueError as exc: rep.fail(f"{where}: genTime {tok['text']} cannot be compared ({exc})") rep.say(f" tsa {leg}: T2 genTime {shown}: FAIL — cannot be compared ({exc})") return if newest is None: rep.fail(f"{where}: genTime {tok['text']} cannot be compared: the bundle holds no " f"record of seqs {a['covers_seq_from']}-{a['covers_seq_to']}") rep.say(f" tsa {leg}: T2 genTime {shown}: FAIL — no covered record to compare") return uploaded = _parse_ts(newest["uploaded_at"]) to_rec = (tok["at"] - uploaded).total_seconds() to_anchored = (tok["at"] - anchored).total_seconds() gap = max((early - anchored).total_seconds(), (anchored - late).total_seconds(), 0.0) problems = [] if late < uploaded: problems.append(f"genTime {tok['text']} is {-to_rec:.1f} s before seq {newest['seq']}'s " f"uploaded_at {newest['uploaded_at']}: the TSA signed before the " "newest record the anchor covers was committed") if gap > TSA_TIME_BOUND_S: problems.append(f"genTime {tok['text']} is {abs(to_anchored):.1f} s " f"{'before' if to_anchored < 0 else 'after'} anchored_at " f"{a['anchored_at']}, past the {TSA_TIME_BOUND_S} s bound " "(FORMAT.md §6.4)") for p in problems: rep.fail(f"{where}: {p}") rel = "before" if to_anchored < 0 else "after" rec = (f"{to_rec:.1f} s after" if to_rec >= 0 else f"{-to_rec:.1f} s before") + f" seq {newest['seq']}, the newest covered record" if to_rec < 0 and late >= uploaded: rec += f" (within the token's {tok['resolution_s'] + acc:g} s precision)" rep.say(f" tsa {leg}: T2 genTime {shown}: {'FAIL' if problems else 'OK'} — " f"{abs(to_anchored):.1f} s {rel} anchored_at (bound ±{TSA_TIME_BOUND_S} s); {rec}") # =========================================================================== # Commands # =========================================================================== def _finish(rep: Report, what: str) -> int: legs = rep.legs_line() if rep.problems: rep.say("\nFAILURES:") for p in rep.problems: rep.say(f" - {p}") rep.say(f"\n{legs}") rep.say(f"NOT all evidence in {what} was verified — see FAILURES above.") return EXIT_FAIL pending = "" if rep.pending: p = rep.pending pending = (f" {p['count']} record(s) past the last anchor (seqs {p['first']}-{p['last']})" f" are PENDING: hash-chained, {p['age_h']:.1f} h old at the bundle's as_of," f" inside the {ANCHOR_DEADLINE_H} h anchoring deadline, and NOT yet" " timestamped by any anchor; a later bundle shows them anchored.") rep.say(f"\nAll verifiable evidence in {what} verified.{pending} ({legs}; an absent leg is a " "disclosed absence, never assurance, and a self-signed leg is not " "third-party evidence.)") return EXIT_OK def read_tsa_ca(name: str | None) -> bytes | None: """--tsa-ca: a PEM file of trust roots the user chooses (FORMAT.md §6.1).""" if name is None: return None try: pem = Path(name).read_bytes() except OSError as exc: raise Refused(f"--tsa-ca {name}: {exc.strerror}") from exc if b"-----BEGIN CERTIFICATE-----" not in pem: raise Refused(f"--tsa-ca {name}: no PEM certificate in the file") return pem def cmd_bundle(args, out) -> int: path = Path(args.bundle) rep = verify_bundle(path, out, Path(args.ots_dir) if args.ots_dir else None, require_anchored=args.require_anchored, tsa_ca=read_tsa_ca(args.tsa_ca)) return _finish(rep, path.name) def cmd_storage_proof(args, out) -> int: path = Path(args.payload) try: payload = json.loads(path.read_bytes()) except OSError as exc: raise Refused(f"{path}: {exc.strerror}") from exc except ValueError as exc: raise Refused(f"{path}: not JSON ({exc})") from exc checks = verify_storage_proof(payload) print(f"storage proof {path.name}: {payload.get('token_symbol')} " f"{payload.get('contract_address')} at block {payload.get('block_number')}, " f"total_supply {payload.get('total_supply')}", file=out) for c in checks: print(f" {c['check']}: {'OK' if c['ok'] else 'FAIL — ' + str(c['detail'])}", file=out) if all(c["ok"] for c in checks): print("\nStorage proof verified: the stated total_supply is the value of the " "stated slot in the stated contract's storage at the stated block header.", file=out) return EXIT_OK print("\nStorage proof NOT verified — see FAIL above.", file=out) return EXIT_FAIL def cmd_pdf(args, out) -> int: doc = Path(args.file) try: digest = sha256_hex(doc.read_bytes()) except OSError as exc: raise Refused(f"{doc}: {exc.strerror}") from exc bundle = Path(args.bundle) rep = verify_bundle(bundle, None, # quiet: only the verdict is printed tsa_ca=read_tsa_ca(args.tsa_ca)) records = json.loads(read_bundle(bundle)["records.json"]).get("records") or [] rec = next((r for r in records if r.get("seq") == args.record), None) print(f"document {doc.name}: sha256 {digest}", file=out) if rec is None: print(f" record seq {args.record}: not in {bundle.name}", file=out) matched = False else: matched = rec["content_sha256"] == digest print(f" record seq {rec['seq']} ({rec['type']}, period {rec['period']}): " f"content_sha256 {rec['content_sha256']} — " + ("MATCHES" if matched else "DOES NOT MATCH"), file=out) p = rep.pending if p and p["first"] <= rec["seq"] <= p["last"]: print(f" record seq {rec['seq']} is PENDING (A2): hash-chained, NOT yet " "timestamped by any anchor", file=out) print(f" bundle {bundle.name}: " + ("verified" if rep.ok else f"NOT verified ({len(rep.problems)} failure(s); run `rb_verify.py bundle` " "for the list)"), file=out) if matched and rep.ok: print(f"\nThe document is the one record seq {args.record} commits to, and the " "chain carrying it verified.", file=out) return EXIT_OK print("\nNOT verified.", file=out) return EXIT_FAIL def main(argv: list[str] | None = None, out=None) -> int: out = sys.stdout if out is None else out parser = argparse.ArgumentParser( prog="rb_verify.py", description=__doc__.split("\n\n")[0]) sub = parser.add_subparsers(dest="command", required=True) p = sub.add_parser("bundle", help="verify an evidence bundle (zip or directory)") p.add_argument("bundle") p.add_argument("--ots-dir", default=None, help="write each anchor's payload + .ots proof here for `ots verify`") p.add_argument("--require-anchored", action="store_true", help="fail on any record past every anchor, a PENDING one too " "(A2 as FORMAT.md revision 1.0 read it)") p.add_argument("--tsa-ca", default=None, metavar="PEM", help="trust roots you choose (e.g. your system's CA bundle, or the " "TSA's published root) for timestamp legs the bundle stores no " "CA chain for") p.set_defaults(fn=cmd_bundle) p = sub.add_parser("storage-proof", help="verify one STORAGE_PROOF payload (JSON)") p.add_argument("payload") p.set_defaults(fn=cmd_storage_proof) p = sub.add_parser("pdf", help="check a document against a chain record") p.add_argument("file") p.add_argument("--record", type=int, required=True, help="the record's seq") p.add_argument("--bundle", required=True, help="the bundle holding the record") p.add_argument("--tsa-ca", default=None, metavar="PEM", help="as for `bundle`: trust roots for legs with no stored CA chain") p.set_defaults(fn=cmd_pdf) args = parser.parse_args(argv) try: return args.fn(args, out) except Refused as exc: print(f"rb_verify: {exc}", file=sys.stderr) return EXIT_REFUSED if __name__ == "__main__": sys.exit(main())