#!/usr/bin/env python3 # pmagic_verify — offline verifier for Parted Magic signed evidence packs. # # Copyright 2026 Patrick J. Verner, Waupaca, WI, USA # All rights reserved. # # Single file, Python 3.8+, standard library only. # # ./pmagic_verify PACK.zip --pubkey <64-hex-char public key> # ./pmagic_verify PACK.zip --pubkey-file signing.pub # ./pmagic_verify PACK.zip (self-check only — see below) # # (python3 pmagic_verify ... if the execute bit is missing; # py pmagic_verify ... on Windows.) # # Checks, in order: # 1. The Ed25519 signature over MANIFEST.json is valid for the given # public key. # 2. Every file listed in the manifest is present in the pack and its # SHA-256 matches exactly. # 3. No content file exists in the pack that the manifest doesn't # list (nothing smuggled in). # 4. The internal event record (events.jsonl) hash chain is unbroken. # # Exit code 0 = VALID, 1 = TAMPERED or invalid, 2 = usage/read error. # # Get the public key from the organization that ran the erase — their # report server prints it at startup and serves it at /pubkey. Run # without --pubkey and the key inside the pack is used instead: that # proves the pack is internally consistent, but NOT who made it. import argparse import hashlib import json import sys import zipfile # ─── Ed25519 (RFC 8032), pure Python, standard library only ────────────────── # Compact reference implementation: slow but correct, auditable, and free # of dependencies. Used to sign/verify evidence-pack manifests — one # signature per export, so speed is irrelevant. def _sha512(m): return hashlib.sha512(m).digest() _ED_P = 2**255 - 19 _ED_L = 2**252 + 27742317777372353535851937790883648493 def _ed_inv(x): return pow(x, _ED_P - 2, _ED_P) _ED_D = (-121665 * _ed_inv(121666)) % _ED_P _ED_I = pow(2, (_ED_P - 1) // 4, _ED_P) def _ed_xrecover(y): xx = (y * y - 1) * _ed_inv(_ED_D * y * y + 1) x = pow(xx, (_ED_P + 3) // 8, _ED_P) if (x * x - xx) % _ED_P != 0: x = (x * _ED_I) % _ED_P if x % 2 != 0: x = _ED_P - x return x _ED_BY = (4 * _ed_inv(5)) % _ED_P _ED_B = (_ed_xrecover(_ED_BY), _ED_BY) def _ed_add(P, Q): x1, y1 = P x2, y2 = Q x3 = (x1 * y2 + x2 * y1) * _ed_inv(1 + _ED_D * x1 * x2 * y1 * y2) y3 = (y1 * y2 + x1 * x2) * _ed_inv(1 - _ED_D * x1 * x2 * y1 * y2) return (x3 % _ED_P, y3 % _ED_P) def _ed_scalarmult(P, e): Q = (0, 1) while e: if e & 1: Q = _ed_add(Q, P) P = _ed_add(P, P) e >>= 1 return Q def _ed_encodepoint(P): x, y = P return (y | ((x & 1) << 255)).to_bytes(32, "little") def _ed_isoncurve(P): x, y = P return (-x * x + y * y - 1 - _ED_D * x * x * y * y) % _ED_P == 0 def _ed_decodepoint(s): n = int.from_bytes(s, "little") y = n & ((1 << 255) - 1) if y >= _ED_P: raise ValueError("non-canonical point encoding") x = _ed_xrecover(y) if x & 1 != (n >> 255) & 1: x = _ED_P - x P = (x, y) if not _ed_isoncurve(P): raise ValueError("point not on curve") return P def _ed_hint(m): return int.from_bytes(_sha512(m), "little") def _ed_clamp(h32): return 2**254 | (int.from_bytes(h32, "little") & ((1 << 254) - 8)) def ed25519_publickey(sk32): """32-byte secret seed -> 32-byte public key.""" a = _ed_clamp(_sha512(sk32)[:32]) return _ed_encodepoint(_ed_scalarmult(_ED_B, a)) def ed25519_sign(msg, sk32, pk32): """Sign msg with the 32-byte seed; returns the 64-byte signature.""" h = _sha512(sk32) a = _ed_clamp(h[:32]) r = _ed_hint(h[32:64] + msg) R = _ed_encodepoint(_ed_scalarmult(_ED_B, r)) S = (r + _ed_hint(R + pk32 + msg) * a) % _ED_L return R + S.to_bytes(32, "little") def ed25519_verify(sig, msg, pk32): """True iff sig is a valid signature of msg under pk32.""" if len(sig) != 64 or len(pk32) != 32: return False try: R = _ed_decodepoint(sig[:32]) A = _ed_decodepoint(pk32) except Exception: return False S = int.from_bytes(sig[32:], "little") if S >= _ED_L: return False h = _ed_hint(sig[:32] + pk32 + msg) % _ED_L return _ed_scalarmult(_ED_B, S) == _ed_add(R, _ed_scalarmult(A, h)) # ─── end Ed25519 ───────────────────────────────────────────────────────────── def _chain_ok(ev_bytes): """Re-walk the server's per-session hash chain.""" prev, n = "", 0 for i, line in enumerate(ev_bytes.decode("utf-8", "replace").splitlines(), 1): line = line.strip() if not line: continue try: ev = json.loads(line) except json.JSONDecodeError: return False, f"events.jsonl line {i}: not valid JSON" stored = ev.pop("chain", "") core = json.dumps(ev, sort_keys=True, separators=(",", ":")) want = hashlib.sha256((prev + core).encode()).hexdigest() if stored != want: return False, (f"events.jsonl line {i}: chain broken — " "event records were altered") prev, n = stored, n + 1 return True, f"{n} chained events" def main(): ap = argparse.ArgumentParser( description="Verify a Parted Magic signed evidence pack offline.") ap.add_argument("pack", help="evidence pack zip to verify") ap.add_argument("--pubkey", metavar="HEX", help="the signer's Ed25519 public key (64 hex chars)") ap.add_argument("--pubkey-file", metavar="FILE", dest="pubkey_file", help="file containing the public key in hex") args = ap.parse_args() try: z = zipfile.ZipFile(args.pack) except (OSError, zipfile.BadZipFile) as e: print(f"cannot read pack: {e}", file=sys.stderr) sys.exit(2) def read(name): try: return z.read(name) except (KeyError, zipfile.BadZipFile, OSError): # Absent, or present but corrupt (bad CRC) — either way the # bytes the manifest vouches for are not retrievable. return None problems = [] # A zip may carry two entries under one name; verification reads one # while an extractor may surface the other. Refuse the ambiguity. names = z.namelist() if len(names) != len(set(names)): dups = sorted({n for n in names if names.count(n) > 1}) print("TAMPERED: duplicate entries in pack: " + ", ".join(dups)) sys.exit(1) mbytes = read("MANIFEST.json") sighex = read("MANIFEST.sig") if mbytes is None or sighex is None: print("TAMPERED: MANIFEST.json or MANIFEST.sig missing") sys.exit(1) # -- which key ------------------------------------------------------------ self_check = False if args.pubkey: pub_hex = args.pubkey.strip() elif args.pubkey_file: try: pub_hex = open(args.pubkey_file).read().strip() except OSError as e: print(f"cannot read public key file: {e}", file=sys.stderr) sys.exit(2) else: embedded = read("PUBKEY.txt") if embedded is None: print("TAMPERED: no PUBKEY.txt and no --pubkey given") sys.exit(1) pub_hex = embedded.decode().strip() self_check = True try: pub = bytes.fromhex(pub_hex) assert len(pub) == 32 except (ValueError, AssertionError): print("invalid public key (need 64 hex characters)", file=sys.stderr) sys.exit(2) # -- 1. signature --------------------------------------------------------- try: sig = bytes.fromhex(sighex.decode().strip()) except ValueError: sig = b"" if not ed25519_verify(sig, mbytes, pub): problems.append("signature over MANIFEST.json is INVALID for " "this public key") try: manifest = json.loads(mbytes.decode()) listed = dict(manifest.get("files", {})) except (ValueError, AttributeError): print("TAMPERED: MANIFEST.json is not valid JSON") sys.exit(1) fmt = manifest.get("format", "") if fmt != "pmagic-evidence-pack-1": problems.append(f"unknown pack format {fmt!r} — this verifier " "handles pmagic-evidence-pack-1 only") # -- 2. every listed file present and hash-exact -------------------------- for rel, want in sorted(listed.items()): body = read(rel) if body is None: problems.append(f"listed file missing from pack: {rel}") elif hashlib.sha256(body).hexdigest() != want: problems.append(f"hash mismatch (file altered): {rel}") # -- 3. nothing smuggled in ----------------------------------------------- meta = {"MANIFEST.json", "MANIFEST.sig", "PUBKEY.txt", "README.txt"} for name in z.namelist(): if name.endswith("/"): continue if name in meta: continue if name not in listed: problems.append(f"file in pack but not in manifest: {name}") # -- 4. internal event chain ---------------------------------------------- ev = read("events.jsonl") chain_note = "" if ev is None: problems.append("events.jsonl missing") else: okc, chain_note = _chain_ok(ev) if not okc: problems.append(chain_note) # -- report --------------------------------------------------------------- if problems: print("TAMPERED") for pr in problems: print(f" - {pr}") sys.exit(1) print("VALID") print(f" signer public key : {pub_hex}") if self_check: print(" NOTE: verified against the key inside the pack — this " "proves internal\n consistency only, not who made it. " "Re-run with --pubkey using a key\n obtained from the " "organization directly.") print(f" session : {manifest.get('session', '?')}") print(f" exported : {manifest.get('created', '?')}") print(f" files verified : {len(listed)} · {chain_note}") sj = read("session.json") if sj: try: sess = json.loads(sj.decode()) h = sess.get("host", {}) machine = " ".join(x for x in (h.get("manufacturer", ""), h.get("product", "")) if x) if machine or h.get("serial"): print(f" machine : {machine} " f"(serial {h.get('serial', '') or 'N/A'})") ua = sess.get("unattended") # Silent when the key is absent (packs from older servers) # or null (session never reported a mode). if isinstance(ua, bool): print(f" mode : " f"{'unattended' if ua else 'attended'}") results = sess.get("results", {}) or { d: ("Successfully Erased" if okd else "Erase Failed") for d, okd in (sess.get("done", {}) or {}).items()} for dev in sorted(results): print(f" {dev:<16} {results[dev]}") except ValueError: pass sys.exit(0) if __name__ == "__main__": main()