#!/usr/bin/env python3 """Verify the hash chain of a published Institute committee record. Standalone. python3 verify-minutes.py pmcps-2026-10-02.md Run it against the raw .md record published beside the record page, NOT against the page. The chain hashes the record's own section text, so a rendered HTML page cannot be verified and this will say so rather than reporting a broken chain. Exit 0 means the chain verified: every section hashes to the value recorded in the record's own Hash Chain table, and the final running head equals the published head. Editing any section changes that section's hash and every head below it. Python 3.8 or later. No dependencies, no network. This file is the whole algorithm, so nothing has to be reimplemented from a description: a mismatch caused by a translation error would be indistinguishable from a mismatch caused by tampering, which is why this is published as code rather than as prose. """ import hashlib import re import sys from pathlib import Path HEADING = re.compile(r"^## .+$", re.MULTILINE) HEAD_HASH = re.compile(r"\*\*Head hash: `([0-9a-f]{64})`\*\*") CHAIN_SECTION = "## Hash Chain" GENESIS = "0" * 64 def _sha(text): return hashlib.sha256(text.encode("utf-8")).hexdigest() def sections(minutes): """(heading, body) for every section, excluding the chain section itself. The text before the first heading is a section too, named (header). Each body runs from its heading to the next, right-stripped, with a trailing horizontal rule removed. The Hash Chain section is excluded because it cannot contain its own hash. """ matches = list(HEADING.finditer(minutes)) out = [] if not matches: return [("(header)", minutes.rstrip())] if minutes.strip() else [] header = minutes[: matches[0].start()].rstrip() if header: out.append(("(header)", header)) for index, match in enumerate(matches): end = matches[index + 1].start() if index + 1 < len(matches) else len(minutes) heading = match.group(0).strip() if heading == CHAIN_SECTION: continue body = re.sub(r"\n+-{3,}\s*$", "", minutes[match.start():end].rstrip()) out.append((heading, body)) return out def compute(minutes): """(entries, head). Each section's hash is chained onto the running head. Note the concatenation: the previous head and the section hash are joined as hex STRINGS and then encoded. Joining raw digests instead gives a different head. """ entries, head = [], GENESIS for heading, body in sections(minutes): section_hash = _sha(body) head = _sha(head + section_hash) entries.append((heading, section_hash, head)) return entries, head def main(argv): if len(argv) != 1: print(__doc__.strip()) return 2 path = Path(argv[0]) if not path.is_file(): print(f"no such file: {path}") return 2 text = path.read_text(encoding="utf-8") recorded = HEAD_HASH.search(text) if not recorded: # Distinguish a record that was never chained from a chained record whose head # is missing. The first is ordinary: records published before hash chaining was # added carry no chain and nothing is wrong with them. The second is damage. # A reader trying this on the earliest published record meets the first case, # and "no head hash found" alone reads as though something were absent. if CHAIN_SECTION not in text: print("NOT CHAINED: this record contains no hash chain, so there is nothing") print("to verify. Records produced before the committee software chained its") print("minutes carry no chain. That is not a defect and not a tampering") print("result: this tool has checked nothing either way.") print("What you CAN do, if a digest for this file is published beside it:") print("compare it against the sha256 of your download. That confirms you") print("received the file that was served. It is NOT evidence the record is") print("unaltered, because the publisher computed that digest too. Only a hash") print("chain gives you that, and this record has none, so for this record no") print("self-contained check exists. If no digest is published, there is") print("nothing further to compare and that is the honest position.") return 2 print("DAMAGED RECORD: this record has a Hash Chain section but no head hash line,") print("so the chain cannot be checked against anything. The head may have been") print("removed. This is not a verification result; nothing has been checked.") return 2 if not HEADING.search(text): print("NOT A CHAINED RECORD: this file carries a head hash but no '## ' section") print("headings, so it is not the markdown record the chain was computed over.") print("A rendered HTML page cannot be verified. Download the raw .md record") print("published beside the page and run this against that file.") print("This is NOT a tampering result. Nothing has been checked.") return 2 entries, head = compute(text) if head == recorded.group(1): print(f"VERIFIED: chain intact over {len(entries)} sections.") print(f"head {head}") return 0 print(f"CHAIN BROKEN: this record publishes head {recorded.group(1)}") print(f" but its sections hash to {head}") print() # Name the section, rather than reporting two numbers and leaving the reader to # locate it. A check that reports only a digest proves something changed and # conceals what; the record's own table carries enough structure to say where. published = dict(re.findall(r"^\| *\d+ *\| *(.+?) *\| *`([0-9a-f]{16})` *\|", text, re.MULTILINE)) if not published: print("The record's Hash Chain table could not be read, so the changed section") print("cannot be named. Compare each section's sha256 against that table by hand.") return 1 named = False for heading, section_hash, _ in entries: want = published.get(heading) if want and not section_hash.startswith(want): print(f"FIRST CHANGED SECTION: {heading}") print(f" the record's table publishes {want} (first 16 characters)") print(f" this file's text hashes to {section_hash[:16]}") named = True break if not named: print("Every section matches the record's own table, so the sections are intact") print("and the published head does not follow from them. The Hash Chain table or") print("the head line has been altered, rather than the record's content.") print() print("This verifier does NOT say which of the two moved, and for most records it") print("cannot. The table's running-head column is abbreviated to sixteen characters,") print("so an alteration to the head line beyond its sixteenth character leaves the") print("table in agreement with it and the two indistinguishable from the record") print("alone. Where a record publishes its final running head in full, compare that") print("row against the head line to tell them apart.") else: print() print("Sections above it are intact. Every head below it changes as a consequence,") print("so this is the one place to look.") return 1 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))