#!/usr/bin/env python3 """Forsheur evidence bundle verifier — Python 3 standard library only. Verifies, fully offline, that the media in this bundle is exactly what a genuine device signed and what the Forsheur notary sealed and anchored: 1. sha256(payload.bin) == h_received (the hash the device signed) 2. sha256(envelope.cbor) -> H_env ; sha256(H_env || H_received) == the notarized commitment (H_chunk) 3. device signature over H_chunk, pubkey read from the envelope (Ed25519 for chunk format v2, ECDSA P-256 for v3 — the key's width says which, and both are implemented here in pure Python) 4. leaf -> Merkle path -> merkle_root -> block_hash, Ed25519 notary signature 5. hash-chain segment: every block links prev -> next up to the cover block (and the lower bound: the chain head each chunk says it had seen, signed inside its envelope, must be a block of that segment, hash for hash) 6. the cover block is anchored in Bitcoin (.ots) — three levels: a. `ots verify` + your own Bitcoin node -> fully trustless b. built-in .ots parsing -> reports the Bitcoin block height; cross-check its timestamp on any explorer c. neither -> seal/signatures still proven Usage: python3 verify_bundle.py python3 verify_bundle.py --extract # also write media streams Nothing here contacts any Forsheur server. The notary public keys ship in the bundle; you may cross-check them against https:///.well-known/forsheur-notary-keys.json This script arrives inside the archive it checks, which on its own would be circular: whoever controls the bundle controls the verifier. What breaks the circle is that the same file is published independently, so the copy you were given can be compared against a copy nobody handed you: shasum -a 256 verify_bundle.py # or: certutil -hash-file ... SHA256 https://github.com/Forsheur/verify-bundle releases, with SHA256SUMS https:///custody.sha256 A mismatch does not by itself mean the bundle is bad -- servers are upgraded and older bundles keep the verifier they shipped with, so check the version below against the CHANGELOG first. It does mean you should verify with a copy you obtained yourself before believing anything this one printed. """ # Bumped whenever the bytes of this file change. A reader holding a bundle # needs to know WHICH published copy to compare their hash against, and "the # latest" is the wrong answer: bundles are generated by the server that exists # at the time, and an old bundle legitimately carries an old verifier. VERIFIER_VERSION = "1.2.0" import argparse import base64 import datetime import getpass import hashlib import json import math import os import shutil import subprocess import struct import sys import zipfile # --------------------------------------------------------------------------- # Ed25519 verification (RFC 8032), pure Python. Slow but we check few sigs. # --------------------------------------------------------------------------- _P = 2**255 - 19 _L = 2**252 + 27742317777372353535851937790883648493 _D = (-121665 * pow(121666, _P - 2, _P)) % _P _I = pow(2, (_P - 1) // 4, _P) def _xrecover(y): xx = (y * y - 1) * pow(_D * y * y + 1, _P - 2, _P) x = pow(xx, (_P + 3) // 8, _P) if (x * x - xx) % _P != 0: x = (x * _I) % _P if (x * x - xx) % _P != 0: return None if x % 2 != 0: x = _P - x return x _BY = (4 * pow(5, _P - 2, _P)) % _P _BX = _xrecover(_BY) _BASE = (_BX, _BY, 1, (_BX * _BY) % _P) def _pt_add(p, q): x1, y1, z1, t1 = p x2, y2, z2, t2 = q a = (y1 - x1) * (y2 - x2) % _P b = (y1 + x1) * (y2 + x2) % _P c = t1 * 2 * _D * t2 % _P dd = z1 * 2 * z2 % _P e, f, g, h = b - a, dd - c, dd + c, b + a return (e * f % _P, g * h % _P, f * g % _P, e * h % _P) def _pt_mul(p, e): q = (0, 1, 1, 0) while e > 0: if e & 1: q = _pt_add(q, p) p = _pt_add(p, p) e >>= 1 return q def _pt_decompress(s): if len(s) != 32: return None y = int.from_bytes(s, "little") & ((1 << 255) - 1) if y >= _P: return None sign = s[31] >> 7 x = _xrecover(y) if x is None: return None if x & 1 != sign: x = _P - x if (-x * x + y * y - 1 - _D * x * x * y * y) % _P != 0: return None return (x, y, 1, (x * y) % _P) def _pt_compress(p): x, y, z, _ = p zi = pow(z, _P - 2, _P) x, y = x * zi % _P, y * zi % _P return (y | ((x & 1) << 255)).to_bytes(32, "little") def ed25519_verify(pub: bytes, sig: bytes, msg: bytes) -> bool: if len(pub) != 32 or len(sig) != 64: return False a = _pt_decompress(pub) r = _pt_decompress(sig[:32]) if a is None or r is None: return False s = int.from_bytes(sig[32:], "little") if s >= _L: return False h = int.from_bytes(hashlib.sha512(sig[:32] + pub + msg).digest(), "little") % _L return _pt_compress(_pt_mul(_BASE, s)) == _pt_compress(_pt_add(r, _pt_mul(a, h))) # --------------------------------------------------------------------------- # Canonical CBOR (positional arrays, mirror of notary/src/canonical.rs) # --------------------------------------------------------------------------- def _cbor_head(major, n): if n < 24: return bytes([(major << 5) | n]) if n < 0x100: return bytes([(major << 5) | 24, n]) if n < 0x10000: return bytes([(major << 5) | 25]) + n.to_bytes(2, "big") if n < 0x100000000: return bytes([(major << 5) | 26]) + n.to_bytes(4, "big") return bytes([(major << 5) | 27]) + n.to_bytes(8, "big") def _cbor_uint(n): return _cbor_head(0, n) def _cbor_bytes(b): return _cbor_head(2, len(b)) + b def _cbor_text(s): e = s.encode("utf-8") return _cbor_head(3, len(e)) + e def leaf_hash(entry_type, ref_id, received_at, commitment, plaintext): data = _cbor_head(4, 6) + _cbor_uint(1) + _cbor_text(entry_type) + _cbor_text(ref_id) data += _cbor_uint(received_at) + _cbor_bytes(commitment) data += _cbor_bytes(plaintext) if plaintext is not None else b"\xf6" return hashlib.sha256(b"\x00" + data).digest() def block_hash_of(index, prev, root, seal_time): data = _cbor_head(4, 5) + _cbor_uint(1) + _cbor_uint(index) data += _cbor_bytes(prev) + _cbor_bytes(root) + _cbor_uint(seal_time) return hashlib.sha256(b"\x02" + data).digest() # --------------------------------------------------------------------------- # RFC 6962 Merkle audit path (mirror of notary/src/merkle.rs) # --------------------------------------------------------------------------- def _node(l, r): return hashlib.sha256(b"\x01" + l + r).digest() def _split(n): k = 1 while (k << 1) < n: k <<= 1 return k def root_from_path(leaf, index, size, path): if size == 1: if path: raise ValueError("audit path too long") return leaf k = _split(size) rest, top = path[:-1], path[-1] if index < k: return _node(root_from_path(leaf, index, k, rest), top) return _node(top, root_from_path(leaf, index - k, size - k, rest)) # --------------------------------------------------------------------------- # ECDSA P-256 verification (FIPS 186-4 / SEC 1), pure Python. # --------------------------------------------------------------------------- # # The device signature moved from Ed25519 to P-256 with chunk format v3, when # the signing key moved into the phone's secure element. Both curves live here # permanently: v2 recordings keep verifying under Ed25519 above, the notary's # own block signatures are Ed25519, and so is Roughtime. Nothing was replaced. # # Written out longhand rather than pulled from a library on purpose. The claim # this file exists to support is that a stranger can check a Forsheur recording # with nothing but a Python interpreter — no pip, no network, no trust in us. # That claim is worth more than the ~90 lines below. # # VERIFICATION ONLY. There is no signing here, and that is a deliberate # asymmetry: verification handles no secret, so a slow or non-constant-time # implementation leaks nothing. Signing would need a nonce, and a nonce that # repeats hands over the private key outright — never something to hand-roll. _P256_P = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF _P256_N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 _P256_B = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B _P256_GX = 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296 _P256_GY = 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5 # The curve is y² = x³ - 3x + b: `a` is -3 for every NIST prime curve, which is # what makes the doubling formula below shorter than the general one. def _p256_dbl(pt): """Point doubling in Jacobian coordinates (x = X/Z², y = Y/Z³). Jacobian rather than affine for one reason: affine addition needs a modular inverse per step, and a 256-bit scalar multiplication takes some 380 of them. Deferring to a single inverse at the end (see `_p256_affine_x`) turns a verification that took seconds into one that takes tens of milliseconds — on a file whose whole point is that someone will actually run it. Formula `dbl-2001-b` for a = -3, from the Explicit-Formulas Database. """ x, y, z = pt if z == 0 or y == 0: return (0, 0, 0) # point at infinity p = _P256_P delta = (z * z) % p gamma = (y * y) % p beta = (x * gamma) % p alpha = (3 * (x - delta) * (x + delta)) % p x3 = (alpha * alpha - 8 * beta) % p z3 = ((y + z) * (y + z) - gamma - delta) % p y3 = (alpha * (4 * beta - x3) - 8 * gamma * gamma) % p return (x3, y3, z3) def _p256_add(pt1, pt2): """Point addition in Jacobian coordinates (`add-2007-bl`).""" x1, y1, z1 = pt1 x2, y2, z2 = pt2 if z1 == 0: return pt2 if z2 == 0: return pt1 p = _P256_P z1z1 = (z1 * z1) % p z2z2 = (z2 * z2) % p u1 = (x1 * z2z2) % p u2 = (x2 * z1z1) % p s1 = (y1 * z2 * z2z2) % p s2 = (y2 * z1 * z1z1) % p if u1 == u2: # Same x. Either the same point (double it) or P and -P (infinity). return _p256_dbl(pt1) if s1 == s2 else (0, 0, 0) h = (u2 - u1) % p i = (2 * h) % p i = (i * i) % p j = (h * i) % p r = (2 * (s2 - s1)) % p v = (u1 * i) % p x3 = (r * r - j - 2 * v) % p y3 = (r * (v - x3) - 2 * s1 * j) % p z3 = (((z1 + z2) * (z1 + z2) - z1z1 - z2z2) * h) % p return (x3, y3, z3) def _p256_mul(pt, k): """Scalar multiplication, plain double-and-add. Not constant time, and it does not need to be: `k` here is derived from the signature and the message, both public. No secret passes through this file. """ result = (0, 0, 0) addend = pt while k: if k & 1: result = _p256_add(result, addend) addend = _p256_dbl(addend) k >>= 1 return result def _p256_affine_x(pt): """The affine x of a Jacobian point, or None at infinity. The single modular inversion of the whole verification. """ x, _y, z = pt if z == 0: return None p = _P256_P zinv = pow(z, p - 2, p) # Fermat: z^(p-2) ≡ z⁻¹ for prime p return (x * zinv * zinv) % p def p256_verify(pub: bytes, sig: bytes, msg: bytes) -> bool: """ECDSA P-256 over SHA-256, raw r‖s signature, uncompressed SEC1 key. `msg` is H_chunk and is signed AS A MESSAGE — this function hashes it. See `forsheur-chunk/FORMAT_V3_SIGNING.md` §3.5 for why the format chose that over signing the digest directly: a prehashed variant would have needed DIGEST_NONE keys, which StrongBox is not obliged to allow, so it would have failed first on the best-protected phones. Returns False rather than raising for every malformed input, so one bad chunk in a bundle reports as one bad chunk rather than aborting the run. """ # Uncompressed SEC1 only: 0x04 || X || Y. A compressed key (33 bytes) is # refused rather than decompressed — recovering y needs a modular square # root, and this file does not carry a primitive it does not need. if len(pub) != 65 or pub[0] != 0x04: return False # Raw r‖s only. A DER signature is 70-72 bytes and variable; accepting both # encodings would mean two representations of one signature, therefore two # chunk ids for one chunk. if len(sig) != 64: return False p, n = _P256_P, _P256_N qx = int.from_bytes(pub[1:33], "big") qy = int.from_bytes(pub[33:65], "big") if qx >= p or qy >= p: return False # The key must actually be ON the curve. Skipping this is how invalid-curve # attacks start: a point from another, weaker curve would still run through # the arithmetic below and produce an answer. if (qy * qy - (qx * qx * qx - 3 * qx + _P256_B)) % p != 0: return False r = int.from_bytes(sig[:32], "big") s = int.from_bytes(sig[32:], "big") if not (1 <= r < n and 1 <= s < n): return False # FIPS 186-4 §6.4: e is the leftmost min(N, outlen) bits of the hash. Here # both are 256, so the digest is used whole — no truncation, no shift. e = int.from_bytes(hashlib.sha256(msg).digest(), "big") w = pow(s, n - 2, n) u1 = (e * w) % n u2 = (r * w) % n point = _p256_add( _p256_mul((_P256_GX, _P256_GY, 1), u1), _p256_mul((qx, qy, 1), u2), ) x = _p256_affine_x(point) if x is None: return False return (x % n) == r def device_sig_verify(pub: bytes, sig: bytes, msg: bytes) -> bool: """The device signature, under whichever curve the key's WIDTH names. 32 bytes ⇒ Ed25519 (chunk format v2), 65 ⇒ P-256 (v3). The single place this file decides; a second one could decide differently, and the day they disagreed one of them would stop checking something. The key itself is not taken on trust here — the caller has already required it to appear verbatim inside the envelope that the signature covers. """ if len(pub) == 32: return ed25519_verify(pub, sig, msg) if len(pub) == 65: return p256_verify(pub, sig, msg) return False # --------------------------------------------------------------------------- # CBOR decoder (definite-length subset) — for --extract of the payload wrapper # --------------------------------------------------------------------------- def frozen_frames_from_envelope(envelope, stream_id="video.frozen.v1"): """Freezes the recording device disclosed, re-derived from the SIGNED envelope — not from anything the server said. When the camera sensor skips, the previous image simply stays on screen longer: no frame is duplicated in the file, only its declared duration grows. Without this list a reader cannot tell a held image from a still scene, so the device records each one inside the envelope it signs, and this verifier reads it back from those same bytes. Returns None when the envelope carries NO such stream — a recording that predates this disclosure, about which nothing may be claimed — and a list (possibly empty) when it does. That difference is the whole point: an empty stream says "measured, nothing found", no stream says nobody looked, and collapsing them would report footage no code ever examined as flawless. A malformed stream degrades to an empty list rather than an error: failing to parse a disclosure must never sink the verification of everything else. """ try: env = cbor_decode(envelope) except Exception: return None out = [] present = False for st in (env.get("streams_envelope") or []): if not isinstance(st, dict) or st.get("id") != stream_id: continue present = True for s in (st.get("samples") or []): if isinstance(s, list) and len(s) >= 3 and all(isinstance(v, int) for v in s[:3]): out.append((s[0], s[1], s[2])) return out if present else None def time_refs_from_envelope(envelope): """The clocks the device declared, re-derived from the SIGNED envelope. The phone consults up to four references at capture — its own clock, an internet time server, the satellite fix, and our server's clock as it last saw it — and states all four on every chunk, with a null value where a source was unavailable. NONE OF THIS PROVES A DATE. Every figure is the device's own claim, and a hostile device can claim anything. What the signature buys is that it cannot change its story afterwards, and that disagreement between sources becomes visible instead of staying hidden. The bound on time comes from the notary chain and its outside witnesses, checked further up this report. Returns None when no such stream exists (a recording predating the disclosure), else a list of tuples (t_us, source, value_us, uncertainty_us, age_us) where any of the last three may be None. `age_us` is SIGNED: the envelope is built when the chunk closes, so a reading taken during the chunk is newer than `t_us` and carries a negative age. A null age means the phone could not date the reading at all. """ try: env = cbor_decode(envelope) except Exception: return None out = [] present = False for st in (env.get("streams_envelope") or []): if not isinstance(st, dict) or st.get("id") != "time.v1": continue present = True for sm in (st.get("samples") or []): if not isinstance(sm, list) or len(sm) < 5: continue t_us, source, value, unc, age = sm[0], sm[1], sm[2], sm[3], sm[4] if not isinstance(t_us, int) or not isinstance(source, str): continue num = lambda v: v if isinstance(v, int) else None out.append((t_us, source, num(value), num(unc), num(age))) return out if present else None def observed_notary_from_envelope(envelope): """The notary chain head the device says it had seen, from the SIGNED envelope. The phone learns the head from every server response and embeds the last one it saw in each chunk before signing. A block hash cannot be known before the block is sealed, so if this (index, hash) is really a block of the chain, the chunk was signed AFTER that block: a lower bound on its time that needs nobody's word beyond the chain itself. Returns None when the envelope cannot be read, "none" for the all-zero sentinel (the phone had never seen a head and claims nothing), else (index, hash_bytes). """ try: on = cbor_decode(envelope).get("observed_notary") except Exception: return None if not isinstance(on, dict): return None idx, h = on.get("index"), on.get("hash") if not isinstance(idx, int) or not isinstance(h, bytes) or len(h) != 32: return None if h == bytes(32): return "none" return idx, h def fraud_verdicts_from_envelope(envelope): """On-device screen-refilming verdicts, re-derived from the SIGNED envelope. Each verdict names the `seq` it is ABOUT, which is an earlier chunk than the one carrying it: the network finishes scoring a segment after that segment has already been built and sent. A score is a probability produced by one version of one model. It is evidence, not proof, and it says nothing about whether the scene itself was staged — which is why it is reported in its own section here and never folded into the checks above. Returns None when the stream is absent, meaning no analysis ran at all — never "it looked clean". An empty list means the detector was running but declared nothing in that chunk. """ try: env = cbor_decode(envelope) except Exception: return None out = [] present = False for st in (env.get("streams_envelope") or []): if not isinstance(st, dict) or st.get("id") != "video.fraud.v1": continue present = True for sm in (st.get("samples") or []): if (isinstance(sm, list) and len(sm) >= 5 and isinstance(sm[0], int) and isinstance(sm[1], int) and isinstance(sm[2], (int, float)) and isinstance(sm[3], str) and isinstance(sm[4], int)): out.append((sm[0], sm[1], float(sm[2]), sm[3], sm[4])) return out if present else None MOTION_STREAMS = ("gyro.v1", "accel.v1", "mag.v1", "camera.v1") MOTION_WIDTH = {"gyro.v1": 4, "accel.v1": 4, "mag.v1": 5, "camera.v1": 15} def motion_from_envelope(envelope): """What the phone FELT while it filmed, re-derived from the SIGNED envelope. Four streams, sampled on the video's own clock: gyroscope (rad/s), accelerometer (m/s², gravity included), magnetometer (µT) and one lens reading per frame (focus, exposure, ISO, zoom, white balance, flicker, intrinsics). A picture re-filmed from a screen moves with the hand that holds the phone, not with the camera that shot the scene; these samples let a reader lay the two motions side by side. They describe what the phone measured, never what the scene was. Returns a dict {stream_id: [rows]} holding an entry for EACH stream the envelope carries — an empty list when it is there but empty — and None when it carries none of them. Absent ≠ empty, per stream: a phone without a magnetometer emits no `mag.v1` at all, and nothing may be said about it. A row of the wrong width is skipped; a stream is never dropped for it. """ try: env = cbor_decode(envelope) except Exception: return None out = {} for st in (env.get("streams_envelope") or []): if not isinstance(st, dict) or st.get("id") not in MOTION_STREAMS: continue sid = st["id"] width = MOTION_WIDTH[sid] rows = out.setdefault(sid, []) for sm in (st.get("samples") or []): if not isinstance(sm, list) or len(sm) != width: continue if not isinstance(sm[0], int): continue rows.append(tuple(v if isinstance(v, (int, float)) else None for v in sm)) return out or None def cbor_decode(data): def rd(i): ib = data[i] major, info = ib >> 5, ib & 31 i += 1 if info < 24: n = info elif info == 24: n = data[i] i += 1 elif info == 25: n = int.from_bytes(data[i:i + 2], "big") i += 2 elif info == 26: n = int.from_bytes(data[i:i + 4], "big") i += 4 elif info == 27: n = int.from_bytes(data[i:i + 8], "big") i += 8 else: raise ValueError("indefinite length unsupported") if major == 0: return n, i if major == 1: return -1 - n, i if major == 2: return bytes(data[i:i + n]), i + n if major == 3: return bytes(data[i:i + n]).decode("utf-8"), i + n if major == 4: arr = [] for _ in range(n): v, i = rd(i) arr.append(v) return arr, i if major == 5: m = {} for _ in range(n): k, i = rd(i) v, i = rd(i) m[k] = v return m, i if major == 7: if info == 22: return None, i if info == 20: return False, i if info == 21: return True, i # Floats. `n` already holds the raw bits, read above. Without these # any envelope carrying a GPS fix — every outdoor recording — was # undecodable here, and every disclosure lifted from it silently # read as "not reported". Our encoder only ever emits the 64-bit # form; the two shorter ones are accepted because the format allows # them and refusing them would be a difference from the spec that # nobody could see until it bit. if info == 25: return struct.unpack(">e", n.to_bytes(2, "big"))[0], i if info == 26: return struct.unpack(">f", n.to_bytes(4, "big"))[0], i if info == 27: return struct.unpack(">d", n.to_bytes(8, "big"))[0], i raise ValueError("unsupported CBOR item") v, _ = rd(0) return v # --------------------------------------------------------------------------- # Minimal .ots reader — extracts the committed digest + Bitcoin block heights # --------------------------------------------------------------------------- _OTS_MAGIC = b"\x00OpenTimestamps\x00\x00Proof\x00\xbf\x89\xe2\xe8\x84\xe8\x92\x94\x01" _BITCOIN_TAG = bytes.fromhex("0588960d73d71901") def ots_info(data): """Parse AND EXECUTE the .ots operation tree. Returns (initial_digest, [(bitcoin_height, computed_digest_at_attestation)]) or None on parse failure. The digest reached at a Bitcoin attestation IS, by OTS semantics, the raw merkle-root bytes of the Bitcoin block at that height — so the caller can hand the user one exact 32-byte value to compare against the block header, no full blockchain needed. """ try: if not data.startswith(_OTS_MAGIC): return None pos = [len(_OTS_MAGIC)] def varint(): r, shift = 0, 0 while True: b = data[pos[0]] pos[0] += 1 r |= (b & 0x7F) << shift if not b & 0x80: return r shift += 7 # NB: the trailing \x01 of _OTS_MAGIC is already the version varint — # the next byte is the digest type directly. dt = data[pos[0]] pos[0] += 1 dlen = {0x02: 20, 0x03: 20, 0x08: 32}.get(dt) if dlen is None: return None initial = bytes(data[pos[0]:pos[0] + dlen]) pos[0] += dlen results = [] def parse_ts(digest): while True: tag = data[pos[0]] pos[0] += 1 if tag == 0xFF: # Fork: one branch parsed recursively from the CURRENT # digest, then continue this branch with the same digest. parse_ts(digest) continue if tag == 0x00: atag = bytes(data[pos[0]:pos[0] + 8]) pos[0] += 8 plen = varint() payload = bytes(data[pos[0]:pos[0] + plen]) pos[0] += plen if atag == _BITCOIN_TAG: h, shift = 0, 0 for b in payload: h |= (b & 0x7F) << shift if not b & 0x80: break shift += 7 results.append((h, digest)) return # Execute the op on the running digest. if tag == 0xF0: # append n = varint() digest = digest + bytes(data[pos[0]:pos[0] + n]) pos[0] += n elif tag == 0xF1: # prepend n = varint() digest = bytes(data[pos[0]:pos[0] + n]) + digest pos[0] += n elif tag == 0x08: digest = hashlib.sha256(digest).digest() elif tag == 0x02: digest = hashlib.sha1(digest).digest() elif tag == 0x03: try: digest = hashlib.new("ripemd160", digest).digest() except (ValueError, TypeError): # RIPEMD160 lives in OpenSSL's legacy provider, which is # disabled by default on many modern Linux distros # (OpenSSL 3.0: Ubuntu 22.04+, Debian 12, RHEL 9). Degrade # this branch to unverifiable instead of crashing the run. digest = None elif tag == 0xF2: # reverse digest = digest[::-1] elif tag == 0xF3: # hexlify digest = digest.hex().encode() elif tag == 0x67: # keccak256 — not in stdlib; branch unverifiable digest = None else: raise ValueError("unknown op 0x%02x" % tag) if digest is None: return parse_ts(initial) return initial, results except Exception: return None # --------------------------------------------------------------------------- # Roughtime witnesses: the tight, instant upper bound. # # Where the .ots above proves "this block existed before a Bitcoin block mined # hours later", a Roughtime witness signs a statement that it existed before a # given microsecond, within minutes of the seal. Same direction, far tighter. # # Port of verify_response() in notary/src/roughtime.rs. Pure standard library: # the SHA-512 comes from hashlib and the Ed25519 checks reuse the same # ed25519_verify() the rest of this file uses, so verifying a witness adds no # dependency to the offline path. # # The honest caveat, and the reason these witnesses are reported separately from # the Bitcoin anchor rather than folded into it: Bitcoin needs nobody's word -- # you compare a value against a ledger no one owns. Roughtime rests on the # operators' public keys. The keys below are pinned in this file so the bundle # is self-contained, but a key you obtain from Forsheur proves nothing about # Forsheur. Fetch them from the operators and compare before you rely on this. # # "classic" (Google) Roughtime only: unframed, 64-byte nonce, full 64-byte # SHA-512 in the Merkle tree, microsecond timestamps. RT_PINNED_KEYS = { # source name -> (display label, 32-byte Ed25519 long-term key, base64 form) "int08h": ( "int08h", bytes.fromhex("016e6e0284d24c37c6e4d7d8d5b4e1d3c1949ceaa545bf875616c9dce0c9bec1"), "AW5uAoTSTDfG5NfY1bTh08GUnOqlRb+HVhbJ3ODJvsE=", ), "cloudflare": ( "Cloudflare", bytes.fromhex("d060fb737c8ff3111ce19976cdeb8dd9294bbc3555a1c8ec3d22fcfd197fef38"), "0GD7c3yP8xEc4Zl2zeuN2SlLvDVVocjsPSL8/Rl/7zg=", ), } RT_KEY_SOURCE_URL = "https://github.com/cloudflare/roughtime/blob/master/ecosystem.json" # The terminating NUL is part of what the servers sign. RT_DELE_CONTEXT = b"RoughTime v1 delegation signature--\x00" RT_SREP_CONTEXT = b"RoughTime v1 response signature\x00" def _rt_tag(name): """4-byte tag read as a little-endian uint32, as the wire encodes it.""" return int.from_bytes(name.encode().ljust(4, b"\x00"), "little") def rt_parse(buf): """Parse [N][offsets x (N-1)][tags x N][values] into {tag: bytes}. Validates the header, the 4-aligned monotonic offsets and every bound. Raises on anything malformed rather than returning a partial message a later check might accept. """ if len(buf) < 4: raise ValueError("message shorter than its header") n = int.from_bytes(buf[0:4], "little") if n == 0 or n > 1024: raise ValueError("implausible tag count %d" % n) header_len = 4 + 4 * (n - 1) + 4 * n if len(buf) < header_len: raise ValueError("truncated header") offsets = [int.from_bytes(buf[4 + 4 * i:8 + 4 * i], "little") for i in range(n - 1)] base = 4 + 4 * (n - 1) tags = [int.from_bytes(buf[base + 4 * i:base + 4 * i + 4], "little") for i in range(n)] values = buf[header_len:] out, prev = {}, 0 for k, tag in enumerate(tags): end = len(values) if k == n - 1 else offsets[k] if end % 4 != 0 or end < prev or end > len(values): raise ValueError("bad offset for tag %d" % k) out[tag] = values[prev:end] prev = end return out def _rt_get(msg, name): v = msg.get(_rt_tag(name)) if v is None: raise ValueError("missing " + name) return v def rt_nonce(block_hash: bytes, source: str) -> bytes: """The nonce the notary derived for this (block, witness) pair. Deterministic and recomputable from the PUBLIC block hash, which is what lets anyone confirm the witness signed a tree containing THIS block rather than some value Forsheur chose. Mirrors nonce_for() in roughtime.rs. """ return hashlib.sha512(b"forsheur-roughtime-nonce-v1" + block_hash + source.encode()).digest() def _rt_merkle_root(nonce: bytes, indx: bytes, path: bytes) -> bytes: if len(path) % 64 != 0: raise ValueError("PATH length is not a multiple of 64") index = int.from_bytes(indx[:4], "little") node = hashlib.sha512(b"\x00" + nonce).digest() for o in range(0, len(path), 64): sibling = path[o:o + 64] if index & 1 == 0: node = hashlib.sha512(b"\x01" + node + sibling).digest() else: node = hashlib.sha512(b"\x01" + sibling + node).digest() index >>= 1 return node def rt_verify(pubkey: bytes, block_hash: bytes, source: str, packet: bytes): """Verify one witness packet. Returns (midpoint_us, radius_us); raises on failure. Four checks, in order: 1. the pinned long-term key signs the delegation cert (DELE) 2. the delegated key (DELE.PUBK) signs the response body (SREP) 3. our nonce's leaf hashes up PATH/INDX to the signed SREP.ROOT 4. MIDP falls inside the delegation's validity window [MINT, MAXT] """ top = rt_parse(packet) sig = _rt_get(top, "SIG") srep = _rt_get(top, "SREP") cert = _rt_get(top, "CERT") indx = _rt_get(top, "INDX") path = _rt_get(top, "PATH") cert_msg = rt_parse(cert) dele = _rt_get(cert_msg, "DELE") if not ed25519_verify(pubkey, _rt_get(cert_msg, "SIG"), RT_DELE_CONTEXT + dele): raise ValueError("delegation certificate is not signed by the pinned long-term key") dele_msg = rt_parse(dele) pubk = _rt_get(dele_msg, "PUBK") mint = int.from_bytes(_rt_get(dele_msg, "MINT")[:8], "little") maxt = int.from_bytes(_rt_get(dele_msg, "MAXT")[:8], "little") if not ed25519_verify(pubk, sig, RT_SREP_CONTEXT + srep): raise ValueError("response body is not signed by the delegated key") srep_msg = rt_parse(srep) root = _rt_get(srep_msg, "ROOT") midp = int.from_bytes(_rt_get(srep_msg, "MIDP")[:8], "little") radi = int.from_bytes(_rt_get(srep_msg, "RADI")[:4], "little") if len(root) != 64: raise ValueError("ROOT is %d bytes, expected 64" % len(root)) if _rt_merkle_root(rt_nonce(block_hash, source), indx, path) != root: raise ValueError("this block's nonce is not in the signed tree") if not (mint <= midp <= maxt): raise ValueError("the signed time falls outside the delegation's validity window") return midp, radi def _rt_fmt(us): return datetime.datetime.fromtimestamp(us / 1e6, datetime.timezone.utc).isoformat() # --------------------------------------------------------------------------- # Optional decryption of an encrypted session. # # The provenance verification above is pure standard library. Decryption is an # OPT-IN extra for a recipient who legitimately holds a key, so it may pull in # packages the base verification never needs: # * `cryptography` — AES-256-GCM (not in the stdlib) # * `pynacl` — crypto_box_seal_open, for the --id-priv path # * `mnemonic` — only if --id-priv is given as BIP39 words instead of hex # # Nothing here weakens the promise: the server never holds the DEK, the bundle # ships only ciphertext, and the key comes from the user out-of-band. After # decryption we re-check sha256(plaintext) against the device-signed plaintext # hash, so a wrong key (or a decoy envelope) fails loudly instead of producing # plausible garbage. # --------------------------------------------------------------------------- def _parse_id_priv(text): """A 32-byte X25519 identity private key, as 64 hex chars or BIP39 words.""" raw = text.strip() compact = raw.replace(" ", "") if len(compact) == 64: try: return bytes.fromhex(compact) except ValueError: pass words = raw.split() if len(words) in (12, 15, 18, 21, 24): try: from mnemonic import Mnemonic except ImportError: sys.exit("--id-priv looks like a BIP39 mnemonic; decode it with " "`pip install mnemonic`, or pass the 64-hex-character form") try: return bytes(Mnemonic("english").to_entropy(words)) except Exception as e: # noqa: BLE001 — surface any wordlist/checksum error sys.exit(f"invalid BIP39 mnemonic: {e}") sys.exit("--id-priv must be 64 hex characters or a BIP39 mnemonic (e.g. 24 words)") def _dek_from_id_priv(root, manifest, id_priv): enc = manifest.get("encryption_keys") if not enc: sys.exit("this bundle has no encryption_keys section — nothing is sealed to open") try: from nacl.public import PrivateKey, SealedBox from nacl.exceptions import CryptoError as NaclError except ImportError: sys.exit("--id-priv needs PyNaCl: pip install pynacl") with open(os.path.join(root, enc["envelopes"]), "rb") as f: sealed_list = json.load(f).get("sealed_deks", []) box = SealedBox(PrivateKey(id_priv)) for hexblob in sealed_list: try: dek = bytes(box.decrypt(bytes.fromhex(hexblob))) except (NaclError, ValueError): continue # a real envelope sealed to someone else, or a decoy if len(dek) == 32: return dek sys.exit(f"none of the {len(sealed_list)} sealed envelopes open with this id_priv — " "was this identity granted access when the session was recorded?") def _prompt_secret(label): """Read a secret from the console WITHOUT echoing it, so it never lands in shell history, the process table, or the terminal scrollback. getpass hides input on both Unix (termios) and Windows (msvcrt).""" try: s = getpass.getpass("%s (input hidden — not echoed): " % label).strip() except (EOFError, KeyboardInterrupt): sys.exit("\naborted") if not s: sys.exit("no input provided") return s def resolve_dek(root, manifest, args): """Return a 32-byte DEK from --dek or --id-priv, or None if neither given. Secrets are NEVER accepted on the command line (that would leak them into shell history and `ps`): --dek and --id-priv prompt interactively with hidden input. --id-priv-file is the non-interactive escape hatch (a file is not shell history) for automation.""" if args.dek: hexs = _prompt_secret("Paste the 32-byte session DEK (hex)") try: dek = bytes.fromhex(hexs) except ValueError: sys.exit("DEK must be 64 hex characters (32 bytes)") if len(dek) != 32: sys.exit("DEK must decode to exactly 32 bytes") return dek if args.id_priv_file: with open(args.id_priv_file) as f: return _dek_from_id_priv(root, manifest, _parse_id_priv(f.read())) if args.id_priv: key = _prompt_secret("Paste your identity private key (64 hex, or 24 BIP39 words)") return _dek_from_id_priv(root, manifest, _parse_id_priv(key)) return None def aesgcm_decrypt(dek, iv_hex, ciphertext_and_tag): """AES-256-GCM: payload.bin is ciphertext with the 16-byte tag appended.""" try: from cryptography.hazmat.primitives.ciphers.aead import AESGCM except ImportError: sys.exit("decryption needs the `cryptography` package: pip install cryptography") return AESGCM(dek).decrypt(bytes.fromhex(iv_hex), ciphertext_and_tag, None) def sha256sum_check_cmd(): """The right `... -c SHA256SUMS` invocation for this OS (Linux ships `sha256sum`; macOS ships `shasum`).""" if shutil.which("sha256sum"): return "sha256sum -c SHA256SUMS" if shutil.which("shasum"): return "shasum -a 256 -c SHA256SUMS" return "sha256sum -c SHA256SUMS" # Linux default (Windows: use verify_bundle.ps1) def write_decrypted_pair(root, seq, cid, plaintext, h_received, plaintext_hash, payload_rel): """Write ONLY the decrypted bytes of one chunk. The ciphertext already lives in the bundle at `payload_rel`, so we never copy it — the SHA256SUMS checkfile references it in place (relative path), yet still lets `sha256sum -c` verify BOTH the plaintext and the ciphertext at once, no trust in this script needed.""" d = os.path.join(root, "extracted", "chunks", "%04d-%s" % (seq, cid)) os.makedirs(d, exist_ok=True) with open(os.path.join(d, "plaintext.bin"), "wb") as f: f.write(plaintext) ct_rel = os.path.relpath(os.path.join(root, payload_rel), d) # bundle ciphertext, not copied if h_received and plaintext_hash: with open(os.path.join(d, "SHA256SUMS"), "w") as f: f.write("%s plaintext.bin\n%s %s\n" % (plaintext_hash, h_received, ct_rel)) with open(os.path.join(d, "EXPECTED.txt"), "w") as f: f.write( "Independent hash check (no trust in verify_bundle.py needed):\n" " sha256sum -c SHA256SUMS (macOS: shasum -a 256 -c SHA256SUMS)\n" " Checks plaintext.bin (here) AND the bundle's original ciphertext in place\n" " (%s) — the ciphertext is NOT duplicated.\n\n" "plaintext.bin expected sha256 = %s\n" " the pre-encryption plaintext hash the PHONE signed.\n" "ciphertext (%s) expected sha256 = %s\n" " h_received: the ENCRYPTED bytes the device signed and the notary sealed.\n" % (payload_rel, plaintext_hash or "(unavailable)", payload_rel, h_received or "(unavailable)") ) return d # --------------------------------------------------------------------------- # Verification driver # --------------------------------------------------------------------------- def _self_sha256(): """SHA-256 of this script, so a report quoting it can name which copy ran. Bundles are regenerated on every request and this file is compiled into the server, so two archives of the same session can legitimately carry two different verifiers. A reader comparing two reports is entitled to see which one produced which.""" try: with open(os.path.abspath(__file__), "rb") as f: return hashlib.sha256(f.read()).hexdigest() except OSError: return None class Report: """Counts the checks and, incidentally, records them. `events` exists so `--json` can emit the same checks the human text shows, from the same call sites, in the same order. It is fed here rather than at each caller on purpose: a second list assembled separately would be a second account of what happened, free to disagree with the first.""" def __init__(self): self.fail = 0 self.warn = 0 self.events = [] def ok(self, msg): self.events.append({"status": "ok", "message": msg}) print(" ✓ " + msg) def bad(self, msg): self.fail += 1 self.events.append({"status": "fail", "message": msg}) print(" ✗ FAIL: " + msg) def note(self, msg): self.warn += 1 self.events.append({"status": "note", "message": msg}) print(" ! " + msg) # --------------------------------------------------------------------------- # Manufacturer attestation — the chain that ends at Apple or Google # --------------------------------------------------------------------------- # # Everything above proves that a KEY signed these bytes and that an ACCOUNT # vouched for that key. Neither says the key was ever inside a secure element: # until this section existed the bundle printed "the manufacturer's chain proves # it" on the strength of a string in its own manifest, with no chain anywhere in # the archive. That was the one claim a reader had to take on our word, in the # artefact whose entire purpose is to need nobody's word. # # Two chains, and they prove different things: # # * Android `signing_key_chain` — the leaf's public key IS the key that signed # every chunk. Google's chain therefore states, checkably, that this exact # key was generated inside a TEE or StrongBox and never left it. # * Apple `apple-appattest.cbor` — App Attest attests its OWN key, not an # arbitrary one; Apple exposes no way to attest a Secure Enclave key you # chose. What it proves is that a genuine Apple device running this app # produced THIS certificate body (the clientDataHash binds them). Where the # signing key lives stays the phone's word, and is reported as such. # # The anchors ship in this script, never in the bundle: an anchor a suspect # archive can supply is not an anchor. Both manufacturers' published roots are # below, and --attest-root lets a reader add their own copy on top. # # An earlier draft pinned Apple's root and merely PRINTED Google's fingerprint, # reasoning that Google's root travels inside the chain (it does -- Android's # getCertificateChain returns up to the self-signed root, while Apple omits # its own) and therefore anchors nothing. The first half is true and the # conclusion does not follow: the answer to "the root in this archive proves # nothing" is to pin the root Google PUBLISHES and compare, which is exactly # what the Apple branch does. That left the Android half of the manufacturer # link resting on homework the reader had to do, and most never would. # Apple App Attestation Root CA, fetched from # https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem # Pinned HERE and never read from the bundle: an anchor a suspect archive can # supply is not an anchor. APPLE_APP_ATTEST_ROOT_PEM = """\ -----BEGIN CERTIFICATE----- MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/ MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn 53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV oyFraWVIyd/dganmrduC1bmTBGwD -----END CERTIFICATE----- """ # Google's Android Key Attestation roots, fetched 2026-09-09 from Google's # machine-readable list # https://android.googleapis.com/attestation/root # linked from # https://developer.android.com/privacy-and-security/security-key-attestation # # Two anchors, and both are needed: the RSA-4096 key has signed hardware # attestations for years, and Google's page states the P-384 one "will begin # signing attestation certificate chains on February 1, 2026". # # The fingerprint pinned is of the subjectPublicKey, not of the certificate: # Google re-issues the same RSA key under fresh certificates (2016, 2019, 2021, # 2022 are all published, all with that one key), so a certificate pin would # expire on Google's schedule. GOOGLE_ATTESTATION_ROOTS_PEM = [ """\ -----BEGIN CERTIFICATE----- MIIFHDCCAwSgAwIBAgIJAPHBcqaZ6vUdMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV BAUTEGY5MjAwOWU4NTNiNmIwNDUwHhcNMjIwMzIwMTgwNzQ4WhcNNDIwMzE1MTgw NzQ4WjAbMRkwFwYDVQQFExBmOTIwMDllODUzYjZiMDQ1MIICIjANBgkqhkiG9w0B AQEFAAOCAg8AMIICCgKCAgEAr7bHgiuxpwHsK7Qui8xUFmOr75gvMsd/dTEDDJdS Sxtf6An7xyqpRR90PL2abxM1dEqlXnf2tqw1Ne4Xwl5jlRfdnJLmN0pTy/4lj4/7 tv0Sk3iiKkypnEUtR6WfMgH0QZfKHM1+di+y9TFRtv6y//0rb+T+W8a9nsNL/ggj nar86461qO0rOs2cXjp3kOG1FEJ5MVmFmBGtnrKpa73XpXyTqRxB/M0n1n/W9nGq C4FSYa04T6N5RIZGBN2z2MT5IKGbFlbC8UrW0DxW7AYImQQcHtGl/m00QLVWutHQ oVJYnFPlXTcHYvASLu+RhhsbDmxMgJJ0mcDpvsC4PjvB+TxywElgS70vE0XmLD+O JtvsBslHZvPBKCOdT0MS+tgSOIfga+z1Z1g7+DVagf7quvmag8jfPioyKvxnK/Eg sTUVi2ghzq8wm27ud/mIM7AY2qEORR8Go3TVB4HzWQgpZrt3i5MIlCaY504LzSRi igHCzAPlHws+W0rB5N+er5/2pJKnfBSDiCiFAVtCLOZ7gLiMm0jhO2B6tUXHI/+M RPjy02i59lINMRRev56GKtcd9qO/0kUJWdZTdA2XoS82ixPvZtXQpUpuL12ab+9E aDK8Z4RHJYYfCT3Q5vNAXaiWQ+8PTWm2QgBR/bkwSWc+NpUFgNPN9PvQi8WEg5Um AGMCAwEAAaNjMGEwHQYDVR0OBBYEFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMB8GA1Ud IwQYMBaAFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMA8GA1UdEwEB/wQFMAMBAf8wDgYD VR0PAQH/BAQDAgIEMA0GCSqGSIb3DQEBCwUAA4ICAQB8cMqTllHc8U+qCrOlg3H7 174lmaCsbo/bJ0C17JEgMLb4kvrqsXZs01U3mB/qABg/1t5Pd5AORHARs1hhqGIC W/nKMav574f9rZN4PC2ZlufGXb7sIdJpGiO9ctRhiLuYuly10JccUZGEHpHSYM2G tkgYbZba6lsCPYAAP83cyDV+1aOkTf1RCp/lM0PKvmxYN10RYsK631jrleGdcdkx oSK//mSQbgcWnmAEZrzHoF1/0gso1HZgIn0YLzVhLSA/iXCX4QT2h3J5z3znluKG 1nv8NQdxei2DIIhASWfu804CA96cQKTTlaae2fweqXjdN1/v2nqOhngNyz1361mF mr4XmaKH/ItTwOe72NI9ZcwS1lVaCvsIkTDCEXdm9rCNPAY10iTunIHFXRh+7KPz lHGewCq/8TOohBRn0/NNfh7uRslOSZ/xKbN9tMBtw37Z8d2vvnXq/YWdsm1+JLVw n6yYD/yacNJBlwpddla8eaVMjsF6nBnIgQOf9zKSe06nSTqvgwUHosgOECZJZ1Eu zbH4yswbt02tKtKEFhx+v+OTge/06V+jGsqTWLsfrOCNLuA8H++z+pUENmpqnnHo vaI47gC+TNpkgYGkkBT6B/m/U01BuOBBTzhIlMEZq9qkDWuM2cA5kW5V3FJUcfHn w1IdYIg2Wxg7yHcQZemFQg== -----END CERTIFICATE----- """, """\ -----BEGIN CERTIFICATE----- MIICIjCCAaigAwIBAgIRAISp0Cl7DrWK5/8OgN52BgUwCgYIKoZIzj0EAwMwUjEc MBoGA1UEAwwTS2V5IEF0dGVzdGF0aW9uIENBMTEQMA4GA1UECwwHQW5kcm9pZDET MBEGA1UECgwKR29vZ2xlIExMQzELMAkGA1UEBhMCVVMwHhcNMjUwNzE3MjIzMjE4 WhcNMzUwNzE1MjIzMjE4WjBSMRwwGgYDVQQDDBNLZXkgQXR0ZXN0YXRpb24gQ0Ex MRAwDgYDVQQLDAdBbmRyb2lkMRMwEQYDVQQKDApHb29nbGUgTExDMQswCQYDVQQG EwJVUzB2MBAGByqGSM49AgEGBSuBBAAiA2IABCPaI3FO3z5bBQo8cuiEas4HjqCt G/mLFfRT0MsIssPBEEU5Cfbt6sH5yOAxqEi5QagpU1yX4HwnGb7OtBYpDTB57uH5 Eczm34A5FNijV3s0/f0UPl7zbJcTx6xwqMIRq6NCMEAwDwYDVR0TAQH/BAUwAwEB /zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFFIyuyz7RkOb3NaBqQ5lZuA0QepA MAoGCCqGSM49BAMDA2gAMGUCMETfjPO/HwqReR2CS7p0ZWoD/LHs6hDi422opifH EUaYLxwGlT9SLdjkVpz0UUOR5wIxAIoGyxGKRHVTpqpGRFiJtQEOOTp/+s1GcxeY uR2zh/80lQyu9vAFCj6E4AXc+osmRg== -----END CERTIFICATE----- """, ] KEY_DESCRIPTION_OID = "1.3.6.1.4.1.11129.2.1.17" APP_ATTEST_NONCE_OID = "1.2.840.113635.100.8.2" GOOGLE_ROOTS_URL = "https://developer.android.com/privacy-and-security/security-key-attestation" AAGUID_PROD = b"appattest\x00\x00\x00\x00\x00\x00\x00" AAGUID_DEV = b"appattestdevelop" def _x509_backend(): """The one place this verifier asks for something outside the stdlib. Walking an X.509 chain means parsing certificates and checking ECDSA P-384 and RSA-4096 signatures. That is a lot of cryptography to hand-roll for a check that DEGRADES cleanly: without it the media verdict above is exactly as strong, and only the manufacturer link goes unproven. `cryptography` is already an optional dependency here (decryption uses it), so this asks for nothing new to install. Returns None rather than raising: a missing package is a note, never a FAIL. A bundle does not become suspect because a reader lacks a wheel. """ try: from cryptography import x509 from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat return {"x509": x509, "ec": ec, "rsa": rsa, "padding": padding, "Encoding": Encoding, "PublicFormat": PublicFormat} except ImportError: return None # --- minimal DER reader (stdlib) ------------------------------------------- # # Only used for Android's KeyDescription extension, which no library parses for # us: `cryptography` hands the extension over as opaque bytes, and Google's own # parser is Java. It is a small, strictly-read-only walk — it never builds DER, # so a bug here can refuse a good chain, never accept a bad one. def _der_tlv(buf, off): """Read one TLV. Returns (cls, constructed, tag, content, next_off).""" b0 = buf[off] cls = b0 >> 6 constructed = bool(b0 & 0x20) tag = b0 & 0x1F off += 1 if tag == 0x1F: tag = 0 while True: b = buf[off] off += 1 tag = (tag << 7) | (b & 0x7F) if not b & 0x80: break ln = buf[off] off += 1 if ln & 0x80: n = ln & 0x7F ln = int.from_bytes(buf[off:off + n], "big") off += n if off + ln > len(buf): raise ValueError("DER length runs past the end of the buffer") return cls, constructed, tag, buf[off:off + ln], off + ln def _der_children(content): out, off = [], 0 while off < len(content): cls, cons, tag, val, off = _der_tlv(content, off) out.append((cls, cons, tag, val)) return out def _der_int(raw): return int.from_bytes(raw, "big", signed=True) if raw else 0 def parse_key_description(ext_bytes): """Read Android's KeyDescription. Mirrors `attest/android.rs` field for field. Positional, exactly as the schema declares it — measuring lengths instead of reading positions is how a parser starts agreeing with the wrong layout. """ _, _, _, top, _ = _der_tlv(ext_bytes, 0) seq = _der_children(top) if len(seq) < 8: raise ValueError("KeyDescription has too few elements") out = { "attestation_version": _der_int(seq[0][3]), "attestation_security_level": _der_int(seq[1][3]), "attestation_challenge": seq[4][3], } for enforced, is_hw in ((seq[6][3], False), (seq[7][3], True)): for cls, _cons, tag, val in _der_children(enforced): if cls != 2: # context-specific only continue inner = _der_children(val) if not inner: continue if tag == 705: out["os_version"] = _der_int(inner[0][3]) elif tag == 706: out["os_patch_level"] = _der_int(inner[0][3]) elif tag == 718: out["vendor_patch_level"] = _der_int(inner[0][3]) elif tag == 719: out["boot_patch_level"] = _der_int(inner[0][3]) elif tag == 704 and is_hw: rot = _der_children(inner[0][3]) if len(rot) >= 3: out["device_locked"] = rot[1][3] not in (b"", b"\x00") out["verified_boot_state"] = _der_int(rot[2][3]) elif tag == 709: try: app = _der_children(_der_children(inner[0][3])[0][3]) pkgs = _der_children(app[0][3]) if pkgs: out["package_name"] = _der_children(pkgs[0][3])[0][3].decode( "utf-8", "replace") except (ValueError, IndexError): pass return out def _raw_public_key_bits(pub, be): """The subjectPublicKey BIT STRING contents — what both the Apple credentialId and Forsheur's Android root pin are computed over. NOT the SPKI wrapper; hashing that instead yields a different digest that matches nothing and looks exactly as plausible.""" if isinstance(pub, be["ec"].EllipticCurvePublicKey): return pub.public_bytes(be["Encoding"].X962, be["PublicFormat"].UncompressedPoint) return pub.public_bytes(be["Encoding"].DER, be["PublicFormat"].PKCS1) def _cert_signed_by(child, issuer, be): """True/False for a signature that was checked; raises when it could not be. The distinction is the difference between "this chain is forged" and "this verifier is too old for this chain". Collapsing them into False would let a curve we have not implemented yet be reported as a forgery — an accusation, made by our own gap. """ from cryptography.exceptions import InvalidSignature pub = issuer.public_key() try: if isinstance(pub, be["ec"].EllipticCurvePublicKey): pub.verify(child.signature, child.tbs_certificate_bytes, be["ec"].ECDSA(child.signature_hash_algorithm)) else: pub.verify(child.signature, child.tbs_certificate_bytes, be["padding"].PKCS1v15(), child.signature_hash_algorithm) return True except InvalidSignature: return False def _validity(cert): """(not_before, not_after) as aware UTC, across `cryptography` versions.""" try: return cert.not_valid_before_utc, cert.not_valid_after_utc except AttributeError: # cryptography < 42 tz = datetime.timezone.utc return (cert.not_valid_before.replace(tzinfo=tz), cert.not_valid_after.replace(tzinfo=tz)) def _walk_chain(certs, at, rep, label, be, at_what="the sealing time"): """Verify each link, and check validity AT `at` — never at "now". This distinction is the whole reason the bundle is worth anything in ten years. Certificates expire; recordings do not become false when they do. The question a reader must answer is "was this chain valid when the notary sealed these bytes", and `at` is that instant — carried by a block header that is hash-chained, notary-signed and anchored in Bitcoin, so it is not our clock either. `at_what` names that instant in the report. App Attest is judged at another one (the attestation itself, see the caller), and a line claiming "the sealing time" there would state a check that did not happen. """ ok = True for i in range(len(certs) - 1): try: good = _cert_signed_by(certs[i], certs[i + 1], be) except Exception as e: # Not a failure of the chain — a failure of this verifier to read # it. Reported as such, and never counted as a broken signature. rep.note(f"{label}: link #{i} could not be checked by this verifier " f"({e}) — a signature algorithm it does not implement") ok = False continue if not good: rep.bad(f"{label}: certificate #{i} is not signed by #{i + 1}") ok = False # Does expiry mean anything for THIS chain? # # Not once it roots in a Google anchor pinned above. Google does not use # validity windows as a control in its attestation hierarchy: the leaf it # issues is dated 1970-01-01 to 2106-02-07, and on a 2016-era handset both # intermediates AND the root expired on 2026-05-24. Those certificates are # burned into the device at manufacture and never refreshed, so such a phone # can never present a chain that passes an expiry check. Withdrawal here is # published in the status list, not encoded in notAfter. # # Apple's chain is NOT covered: Apple issues real windows, judged as usual # at whatever instant the caller passes (for App Attest, the attestation). google = _google_root_pins(be) manufacturer_dates_are_not_a_verdict = bool(certs) and ( certs[-1].subject == certs[-1].issuer and hashlib.sha256( _raw_public_key_bits(certs[-1].public_key(), be)).hexdigest() in google) if manufacturer_dates_are_not_a_verdict: expired = [] for i, c in enumerate(certs): nb, na = _validity(c) if at is not None and not (nb <= at <= na): expired.append(f"#{i} (to {na:%Y-%m-%d})") # Disclosed, never silent: "checked and valid" and "not judged, on # purpose" have to stay tellable apart. if expired: rep.note(f"{label}: expiry is not a verdict on this chain — it roots in a " "Google attestation\n anchor pinned in this script, and Google does " "not use validity windows as a\n control here (the leaf it issues " "is dated 1970 to 2106; withdrawal is\n published in the status " "list instead). Outside their window: " + ", ".join(expired) + ".") return ok any_outside = False for i, c in enumerate(certs): nb, na = _validity(c) if at is not None and not (nb <= at <= na): any_outside = True rep.bad(f"{label}: certificate #{i} was not valid at {at_what} " f"({at:%Y-%m-%d %H:%M} UTC; valid {nb:%Y-%m-%d} to {na:%Y-%m-%d})") ok = False # Said out loud when it actually happened, so the caller's summary never has # to claim a check on the chain's behalf. if at is not None and not any_outside: rep.ok(f"{label}: every certificate was inside its validity window at " f"{at_what}") return ok def _report_disclosure(kd, rep): """What the chain says about the handset, said plainly. These fields are published because a signed certificate cannot be redacted field by field — remove one and it stops verifying. The patch levels are the part that costs the filmer something, so a reader should see that they are looking at it rather than have it slip by inside a hex dump. """ boot = {0: "verified (locked bootloader, vendor-signed image)", 1: "self-signed (custom image, signed by the phone's owner)", 2: "unverified", 3: "failed"}.get(kd.get("verified_boot_state")) if boot: rep.ok(f"verified boot state at key generation: {boot}") if kd.get("device_locked") is False: rep.note("the bootloader was UNLOCKED when this key was generated") bits = [f"{k.replace('_', ' ')} {kd[k]}" for k in ("os_version", "os_patch_level", "vendor_patch_level", "boot_patch_level") if kd.get(k) is not None] if bits: print(" The chain discloses the handset's " + ", ".join(bits) + ".") print(" That is the filmer's cost for this proof: patch levels say which\n" " known vulnerabilities the phone had not yet fixed. It cannot be\n" " removed without breaking the manufacturer's signature.") def verify_attestation(root, manifest, device_pubs, seal_at, rep, args): """Level 3: walk the manufacturer's chain, offline, to Apple or Google.""" dev = manifest.get("device") or {} att = dev.get("attestation") if not att: rep.note("this bundle carries no attestation section — it was produced by " "a server from before manufacturer chains travelled with the " "evidence") return state = att.get("state") if state in ("withheld", "withheld_by_filmer"): # Both spellings accepted: `withheld_by_filmer` shipped for a few hours # and credited a decision nobody could make. A bundle written then is # not wrong about the CHAIN, only about who held it back. rep.note("the manufacturer chain exists and was not published. It cannot " "be redacted field by field — its contents sit inside a " "certificate the manufacturer signed — and publishing it " "discloses the handset's security patch levels, so holding it " "back is meant to be the filmer's decision. Forsheur offers no " "way to make that decision yet, so a chain withheld today was " "withheld by Forsheur, not by the person filming. Either way it " "is not the same as no chain having been captured.") return if state == "not_captured": rep.note("no manufacturer chain was captured for this device: its " "certificate predates the day chains were kept, or the platform " "produced none. Not a finding against the device.") return be = _x509_backend() if be is None: rep.note("install the `cryptography` package (pip install cryptography) " "to check the manufacturer chain offline. Everything above is " "unaffected — the media verdict does not depend on it.") return x509m = be["x509"] nonce_hex = att.get("nonce_hex") cert_body_b64 = dev.get("cert_body_b64") cdh = None if nonce_hex and cert_body_b64: cdh = hashlib.sha256(bytes.fromhex(nonce_hex) + base64.b64decode(cert_body_b64)).digest() files = att.get("files") or {} def _load(rel): with open(os.path.join(root, rel), "rb") as f: return f.read() # ── Android: the signing key's own chain ──────────────────────────── sk_files = files.get("signing_key_chain") or [] if sk_files: try: ders = [_load(r) for r in sk_files] certs = [x509m.load_der_x509_certificate(d) for d in ders] except (OSError, ValueError) as e: rep.bad(f"signing-key chain could not be read: {e}") certs = [] if len(certs) < 2: rep.bad("signing-key chain is too short to be an attestation " "(a lone self-signed certificate is what Keystore returns " "when no challenge was given)") elif _walk_chain(certs, seal_at, rep, "signing-key chain", be): rep.ok(f"signing-key chain: {len(certs)} certificates, each signed by the next") leaf_bits = _raw_public_key_bits(certs[0].public_key(), be) # Bound on every path: the anchoring verdict below reads it to tell # a software chain's root apart from an unknown one, and it is only # assigned inside the branch where the leaf matched. kd = None # The leaf's key must BE the key that signed the chunks. A chain # attesting some other key of the same phone verifies perfectly and # says nothing about these bytes. if not device_pubs: rep.note("no chunk carried a device key — nothing to tie the " "chain to") elif leaf_bits != next(iter(device_pubs)): rep.bad("the attestation chain attests a DIFFERENT key than the " "one that signed these chunks") else: rep.ok("the chain's leaf public key IS the key that signed every " "chunk here") try: ext = certs[0].extensions.get_extension_for_oid( x509m.ObjectIdentifier(KEY_DESCRIPTION_OID)) kd = parse_key_description(ext.value.value) except Exception as e: rep.bad(f"Android KeyDescription unreadable: {e}") kd = None if kd is not None: if nonce_hex is None: rep.note("no enrolment nonce in this bundle — the chain " "cannot be shown to answer THIS certificate") elif kd["attestation_challenge"] != bytes.fromhex(nonce_hex): rep.bad("the chain answers a different challenge than this " "certificate's — it may be a genuine attestation " "replayed from elsewhere") else: rep.ok("the chain answers the exact challenge this " "certificate was issued against (not a replay)") level = {0: "software", 1: "a protected area of the phone's " "processor (TEE)", 2: "a StrongBox secure element"}.get( kd["attestation_security_level"], "an unknown level") if kd["attestation_security_level"] == 0: rep.note("the chain states the signing key was generated in " "SOFTWARE, not in a secure element — the signatures " "are just as valid, but someone who took the phone " "could have copied that key") else: rep.ok(f"Google's chain states this key was generated inside " f"{level} and cannot leave it") pkg = att.get("android_package") if pkg and kd.get("package_name") and kd["package_name"] != pkg: rep.bad("the chain was issued to a different app " f"({kd['package_name']}) than this bundle names") elif kd.get("package_name"): rep.ok(f"issued to the app {kd['package_name']}") _report_disclosure(kd, rep) # The root travelling inside the chain anchors nothing by itself. # What anchors it is the root Google publishes, pinned in this # script — and a reader who trusts neither this script nor Google's # page can bring their own copy with --attest-root. root_fp = hashlib.sha256( _raw_public_key_bits(certs[-1].public_key(), be)).hexdigest() reader = _load_reader_roots(args, be) google = _google_root_pins(be) if root_fp in reader: rep.ok("the chain's root is one you supplied with --attest-root") elif root_fp in google: rep.ok("the chain's root is a Google attestation root published at\n" f" {GOOGLE_ROOTS_URL}\n" " and pinned in this script (SHA-256 " f"{root_fp[:16]}…)") elif kd is not None and kd.get("attestation_security_level") == 0: # A software chain roots in Android's SOFTWARE attestation root, # which is by construction not a hardware anchor. Saying so is # not the same as saying the chain failed — the handset is # telling the truth about having no secure element. rep.note("this chain roots outside Google's hardware anchors, which " "is expected\n for a SOFTWARE attestation: fingerprint " f"SHA-256 {root_fp}") else: rep.bad("this chain claims hardware backing but its root is NOT one " "of the roots\n Google publishes, nor one you supplied. " f"Root fingerprint SHA-256\n {root_fp}\n" f" Check it against {GOOGLE_ROOTS_URL} — until it matches, " "the chain is\n internally consistent and vouched for by " "nobody.") print(" Revocation cannot be checked here: Google's status list is " "online-only\n and unsigned. See the dated answer below.") # ── Apple: App Attest ─────────────────────────────────────────────── apple_rel = files.get("apple_appattest") if apple_rel: try: obj = cbor_decode(_load(apple_rel)) except (OSError, ValueError) as e: rep.bad(f"App Attest object could not be read: {e}") obj = None if isinstance(obj, dict): if obj.get("fmt") != "apple-appattest": rep.bad("App Attest object does not announce fmt=apple-appattest") auth_data = obj.get("authData") or b"" x5c = (obj.get("attStmt") or {}).get("x5c") or [] try: certs = [x509m.load_der_x509_certificate(d) for d in x5c] except ValueError as e: rep.bad(f"App Attest x5c is not valid DER: {e}") certs = [] if not certs or len(auth_data) < 55 + 32: rep.bad("App Attest object is malformed (missing chain or " "attested credential data)") else: # Apple does not put its root in x5c, so anchoring is a step of # its own — and the root comes from this script, never from the # archive being examined. Named `apple_root`, not `root`: the # bundle directory is also bound to `root` here, and a closure # that reads files from it would have followed the rebinding. apple_root = x509m.load_pem_x509_certificate( APPLE_APP_ATTEST_ROOT_PEM.encode()) # Judged at the ATTESTATION, not at the seal. Apple issues the # leaf for a few days only (e.g. 2026-09-12 to 09-15) and never # renews it: the object is meant to be checked once, at # enrolment, while the key it vouches for keeps signing for # months. Checked at the seal, every recording made more than # a few days after enrolment would fail, all of them genuine. # The instant is the leaf's notBefore — set and signed by # Apple, so still not our clock — and the question the seal # time answers becomes "was the key attested BEFORE these bytes # were sealed". attested_at, _ = _validity(certs[0]) if _walk_chain(certs + [apple_root], attested_at, rep, "App Attest chain", be, at_what=f"the attestation ({attested_at:%Y-%m-%d %H:%M} UTC)"): rep.ok(f"App Attest chain: {len(certs)} certificates, rooted in the Apple " "App Attestation Root CA pinned inside this script") if seal_at is not None: if attested_at <= seal_at: rep.ok(f"App Attest: the key was attested ({attested_at:%Y-%m-%d}) " f"before these bytes were sealed ({seal_at:%Y-%m-%d}); Apple " "issues short-lived attestation certificates on purpose, so " "their expiry since then is not a verdict") else: rep.bad(f"App Attest: the key was attested " f"({attested_at:%Y-%m-%d %H:%M} UTC) AFTER these bytes were " f"sealed ({seal_at:%Y-%m-%d %H:%M} UTC)") aaguid = auth_data[37:53] env = {AAGUID_PROD: "production", AAGUID_DEV: "development (an Xcode build, not the App Store)"}.get( bytes(aaguid)) if env is None: rep.bad("App Attest object carries an unknown AAGUID") elif env.startswith("development"): rep.note(f"this attestation came from Apple's {env}") cred_id = auth_data[55:55 + 32] leaf_bits = _raw_public_key_bits(certs[0].public_key(), be) if cred_id != hashlib.sha256(leaf_bits).digest(): rep.bad("credentialId does not match the leaf's public key") app_id = att.get("apple_app_id") if app_id: if auth_data[:32] != hashlib.sha256(app_id.encode()).digest(): rep.bad("the object was produced for a different app than " "this bundle names") else: rep.ok(f"produced for the app {app_id} — a name this bundle " "supplies, and which you can check against the app " "you installed") if cdh is None: rep.note("no enrolment nonce in this bundle — the object cannot " "be tied to this certificate") else: want = hashlib.sha256(auth_data + cdh).digest() try: ext = certs[0].extensions.get_extension_for_oid( x509m.ObjectIdentifier(APP_ATTEST_NONCE_OID)) found = want in bytes(ext.value.value) except Exception: found = False if found: rep.ok("Apple's attestation is bound to THIS certificate " "body: a genuine Apple device running this app " "produced the very certificate that names the " "signing key") else: rep.bad("Apple's nonce extension does not match this " "certificate body — the object belongs to another " "registration") # Said next to the success, deliberately. This is the exact # sentence the audit found the product blurring for a year. print(" Apple attests its OWN App Attest key, never one you chose:\n" " this proves the device and the app, and says NOTHING about\n" " where the chunk-signing key lives. On iPhone that placement\n" " is the phone's word, which is why it reads `declared`.") # ── The dated word about revocation ───────────────────────────────── rev = att.get("revocation") if rev: outcome = rev.get("outcome") when = str(rev.get("checked_at", ""))[:19].replace("T", " ") if outcome == "listed": rep.bad(f"Google's status list named this chain as REVOKED when Forsheur " f"asked, on {when}") elif outcome == "clear": rep.note(f"Forsheur asked Google's attestation status list on {when} and " "was told none of these certificates was withdrawn. That is a " "DATED WORD, not a proof: the list is unsigned and online-only, " "so nobody can reproduce it here — including us, now.") else: rep.note(f"Forsheur could not reach Google's status list at enrolment " f"({outcome}); nothing is claimed either way about revocation") def _google_root_pins(be): """Fingerprints of the Google roots pinned in this script. Computed from the certificates rather than hard-coded as hex, so the value checked and the value a reader can inspect are the same bytes. A hex constant sitting next to a PEM invites the two to drift apart, and the one that drifts is always the one nobody re-derives. """ out = set() for pem in GOOGLE_ATTESTATION_ROOTS_PEM: cert = be["x509"].load_pem_x509_certificate(pem.encode()) out.add(hashlib.sha256(_raw_public_key_bits(cert.public_key(), be)).hexdigest()) return out def _load_reader_roots(args, be): """Root fingerprints the READER brought, from --attest-root.""" out = set() for path in getattr(args, "attest_root", None) or []: try: with open(path, "rb") as f: raw = f.read() cert = (be["x509"].load_pem_x509_certificate(raw) if b"-----BEGIN" in raw else be["x509"].load_der_x509_certificate(raw)) out.add(hashlib.sha256( _raw_public_key_bits(cert.public_key(), be)).hexdigest()) except Exception as e: print(f" ! --attest-root {path}: {e}") return out def verify_device_chain(manifest, device_pubs, rep): """Walk the chain from the key that signed the chunks up to the account key. Four links, and this function checks the last two — the chunk signature and the key's presence inside the signed envelope are checked per chunk above: 1. sig_device over H_chunk "these bytes were signed by key K" 2. K appears in the signed envelope "K cannot have been substituted" 3. cert_body contains K, signed by M "the holder of M authorised K" <- here 4. M is the account's master key "and M is that account" <- here Until 2026-09-07 the bundle carried only 1 and 2, so offline it stopped at "an anonymous key signed this" while the public web page proved the whole chain. The artefact meant to outlive the infrastructure proved LESS than the site that depends on it — the largest remaining finding of the audit. WHAT THIS DOES NOT DO, and must never be described as doing: identify anybody. `master_sign_pub` is a number. The link between it and a person lives on a server and is meant to stay there. What closing this chain buys is authenticity and CONSISTENCY — the same signer behind a body of work, demonstrable without us — not identity. """ dev = manifest.get("device") if not dev or not dev.get("cert_body_b64"): rep.note("no device certificate in this bundle — the chain stops at the " "signing key, which nothing here ties to an account") return body = base64.b64decode(dev["cert_body_b64"]) # Two layouts, told apart by the FIRST byte and never by the total length. # Measuring instead of reading is what breaks the day a third layout arrives # with a familiar width — and it already misfired once, in the player, which # read a P-256 body's validity window out of the middle of its public key. key_len = {0x01: 32, 0x02: 65}.get(body[0] if body else None) if key_len is None: rep.note(f"certificate body announces version {body[0] if body else '?'}, " "which this verifier does not know — chain not checked") return key_at = 1 + 4 + 16 issued_at = key_at + key_len if len(body) != issued_at + 16: rep.bad("certificate body length disagrees with the version it announces") return cert_key = body[key_at:issued_at] # The certificate has to name THE key that signed the chunks. A certificate # for some other key of the same account would verify perfectly on its own # and prove nothing about these bytes. if not device_pubs: rep.note("no chunk carried a device key — nothing to tie the certificate to") return if len(device_pubs) > 1: rep.bad("chunks in this session were signed by more than one device key") return if cert_key != next(iter(device_pubs)): rep.bad("the certificate attests a DIFFERENT key than the one that signed " "these chunks") return rep.ok("the certificate names exactly the key that signed these chunks") def _i64(off): v = int.from_bytes(body[off:off + 8], "big") return v - (1 << 64) if v >> 63 else v iss, exp = _i64(issued_at), _i64(issued_at + 8) now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) if now < iss: rep.bad("device certificate is not valid yet") elif now >= exp: # An expired certificate is not a failed one. The chunks were sealed # while it was live, and the notary chain is what dates them — saying # FAIL here would report an aged certificate as a forgery. rep.note("device certificate has expired since capture " f"(valid {datetime.datetime.fromtimestamp(iss, datetime.timezone.utc):%Y-%m-%d} " f"to {datetime.datetime.fromtimestamp(exp, datetime.timezone.utc):%Y-%m-%d})") sig_b64 = dev.get("user_signature_b64") master_b64 = (manifest.get("user") or {}).get("master_sign_pub_b64") if not sig_b64 or not master_b64: # Attest-only certificate: the account never signed for this device. Not # a defect and not something to leave unsaid — it is exactly the # difference the public page calls `attested_only`. rep.note("this certificate carries no account signature (attest-only): the " "device proved its own integrity, but the account never vouched " "for it") return if ed25519_verify(base64.b64decode(master_b64), base64.b64decode(sig_b64), body): rep.ok("the account's master key signed this certificate — chain closed: " "bytes → device key → certificate → account") # Said in the same breath as the success, deliberately. A reader who # stops at the green line must not walk away believing more than this. print(" The account is a public key, not a person: this proves one " "consistent\n signer, not who they are.") else: rep.bad("the account's signature over the device certificate is INVALID") level = dev.get("signing_key_level") if level: proof = dev.get("signing_key_proof") where = {"secure_enclave": "the phone's Secure Enclave", "strongbox": "a StrongBox secure element", "tee": "a protected area of the phone's processor", "software": "the app's ordinary storage"}.get(level, level) if level == "software": rep.note(f"the signing key was held in {where}, not a secure element — " "the signatures are just as valid, but someone who took the " "phone could have copied that key") else: # `attested` and `declared` are different evidence and never share a # sentence: one is a manufacturer's chain, the other the phone's word. # # And `attested` is Forsheur's verdict, reported here — not a check # made here. Until 2026-09-09 this line printed "the manufacturer's # chain proves it" on the strength of that string alone, in bundles # that carried no chain at all: the reader was told a proof existed # and handed nothing to examine. The chain, when it travels, is # checked in the "Manufacturer attestation" section below, and THAT # is where a claim about it belongs. chain_here = ((manifest.get("device") or {}).get("attestation") or {} ).get("state") == "published" how = ("recorded as proven by a manufacturer chain — see below, where " "that chain is actually checked" if proof == "attested" and chain_here else "Forsheur's verdict, recorded at enrolment; the chain that " "produced it is not in this bundle" if proof == "attested" else "as reported by the device — this bundle carries no proof " "of that placement") rep.ok(f"the signing key was held in {where} ({how})") def load_bundle_dir(arg): if os.path.isdir(arg): base = arg elif zipfile.is_zipfile(arg): dest = os.path.splitext(os.path.abspath(arg))[0] + "-extracted" print(f"Extracting {arg} -> {dest}") with zipfile.ZipFile(arg) as z: z.extractall(dest) base = dest else: sys.exit(f"error: {arg} is neither a directory nor a zip file") # The archive has a single forsheur-evidence-* root dir. if os.path.exists(os.path.join(base, "manifest.json")): return base subs = [d for d in os.listdir(base) if os.path.isdir(os.path.join(base, d))] for s in subs: if os.path.exists(os.path.join(base, s, "manifest.json")): return os.path.join(base, s) sys.exit("error: manifest.json not found in bundle") def main(): ap = argparse.ArgumentParser(description="Verify a Forsheur evidence bundle offline.") # Before the positional, so `--version` answers without a bundle: someone # comparing their copy against the published releases has a hash and no # archive to hand, and making them invent one to read a version number # would be an obstacle with nothing behind it. ap.add_argument("--version", action="version", version=f"verify_bundle.py {VERIFIER_VERSION}") ap.add_argument("bundle", help="bundle .zip or extracted directory") ap.add_argument("--extract", action="store_true", help="write verified media streams to extracted/") ap.add_argument("--dek", action="store_true", help="decrypt an encrypted session; prompts (hidden input) for the 32-byte " "session DEK hex — never pass a key on the command line") ap.add_argument("--id-priv", action="store_true", help="decrypt with your X25519 identity private key; prompts (hidden input) for " "it (64 hex chars or a BIP39 mnemonic), opening a sealed DEK from the bundle") ap.add_argument("--id-priv-file", metavar="PATH", help="non-interactive alternative: read the identity key from a file " "(keeps it out of shell history)") ap.add_argument("--attest-root", metavar="PATH", action="append", help="add a root certificate YOU obtained from Google (PEM or DER) " "to the anchors this script already pins. Repeatable, and needed " "only if you would rather not take this script's word for what " "Google publishes, or if Google has added a root newer than this " "copy. Both manufacturers' published roots are pinned inside the " "script; a root arriving inside the archive being checked anchors " "nothing and never counts.") ap.add_argument("--json", metavar="PATH", help="also write a machine-readable summary to PATH. The human " "text on stdout stays exactly as it is and remains the " "authoritative account; this is the same run, restated for " "a program that needs to quote it without parsing prose.") args = ap.parse_args() root = load_bundle_dir(args.bundle) rep = Report() def jload(rel): with open(os.path.join(root, rel), "rb") as f: return json.load(f) manifest = jload("manifest.json") session = manifest["session"] # `== "aes-gcm-256"`, not `!= "none"`. Three transmission modes exist and # only ONE of them puts ciphertext in this archive: # # none clear — never sealed # box-seal-x25519 transport — the phone sealed each payload to the # PLATFORM's key so the recording was never # at rest in cleartext on the device; the # server opened it on arrival, and the # payloads here ARE the media # aes-gcm-256 e2e — the DEK is sealed to USERS, no server # holds a key, and these bytes stay # ciphertext until someone opens them # # Reading transport as encrypted made this script tell the holder of a # perfectly readable bundle that their media was "ciphertext, nothing to # extract without a key", and send them looking for a key that does not # exist for that session — while the README in the same archive correctly # said the session was cleartext. `evidence_bundle.rs` and # `v2_manifest.rs` already make this distinction; this line did not. encrypted = session.get("encryption", "none") == "aes-gcm-256" # Printed first, and printed even when everything below fails: a reader # quoting this transcript is entitled to know which copy of the verifier # produced it without trusting the part of it that could be forged. _self = _self_sha256() print(f"verify_bundle.py {VERIFIER_VERSION}" + (f" sha256={_self}" if _self else " (sha256 unavailable)")) print(f"Session {session['session_id']} chunks={session['chunk_count']} " f"encryption={session.get('encryption')}") # Every device key that actually verified a chunk. A set, so a session # signed by two different keys is visible as such rather than averaged into # whichever one the certificate happens to name. seen_device_pubs = set() keys = {} for k in jload(manifest["notary"]["keys"])["keys"]: keys[k["key_id"]] = bytes.fromhex(k["public_key"]) print(f"Notary keys: {len(keys)} loaded from bundle " "(cross-check /.well-known/forsheur-notary-keys.json if desired)\n") # Optional decryption key for an encrypted session (out-of-band from the user). dek = None if encrypted: dek = resolve_dek(root, manifest, args) if dek is not None: print("Decryption key resolved — encrypted payloads will be decrypted and each " "checked against\nits device-signed plaintext hash.\n") elif args.extract: print("Encrypted session, no key supplied — pass --dek or --id-priv to decrypt.\n") # ── Chain segment (anchor path) ──────────────────────────────────────── headers = {} cover_block = manifest["notary"].get("cover_block") ap_rel = manifest["notary"].get("anchor_path") if ap_rel: print("Chain segment:") apj = jload(ap_rel) blocks = apj.get("blocks", []) prev_hash = None chain_ok = True for b in blocks: idx = b["index"] bh = block_hash_of(idx, bytes.fromhex(b["prev_block_hash"]), bytes.fromhex(b["merkle_root"]), b["seal_time"]) if bh.hex() != b["block_hash"]: rep.bad(f"block #{idx}: recomputed hash mismatch") chain_ok = False key = keys.get(b["signing_key_id"]) if not (key and ed25519_verify(key, bytes.fromhex(b["signature"]), bh)): rep.bad(f"block #{idx}: notary signature invalid") chain_ok = False if prev_hash is not None and b["prev_block_hash"] != prev_hash: rep.bad(f"block #{idx}: chain link broken (prev != previous block hash)") chain_ok = False prev_hash = b["block_hash"] headers[idx] = b if chain_ok and blocks: rep.ok(f"{len(blocks)} block headers recomputed, signed and chained " f"(#{blocks[0]['index']} .. #{blocks[-1]['index']})") else: rep.note("no anchor-path in bundle (chunks not sealed yet?)") # ── Per-chunk verification ───────────────────────────────────────────── verified_payloads = [] # (seq, chunk_id, payload_bytes) for --extract pair_dirs = [] # per-chunk cipher/plain dumps written under --extract frozen_all = [] # (t_us, held_frame, missed) disclosed by the device frozen_reported = False # did ANY chunk carry the stream? absence ≠ clean # Front camera (dual-cam): a second, independent disclosure. The same # absence ≠ clean rule applies per camera — a single-camera recording # simply carries no front stream, and nothing is claimed about it. frozen_front_all = [] frozen_front_reported = False time_all = [] # (t_us, source, value_us, uncertainty_us, age_us) time_reported = False # did ANY chunk declare its clocks? absence ≠ agreement fraud_all = [] # (t_us, seq, score, model_version, frames_scored) fraud_reported = False # did the detector run at all? absence ≠ clean motion_all = {} # stream_id → [(seq, row)], for the streams ANY chunk carries motion_chunks = {} # stream_id → number of chunks carrying it observed_all = {} # seq → "none" | (index, hash), from the signed envelopes for c in manifest["chunks"]: cid = c["chunk_id"] print(f"\nChunk seq={c['seq']} {cid}:") payload = None ppath = os.path.join(root, c["files"]["payload"]) if os.path.exists(ppath): with open(ppath, "rb") as f: payload = f.read() epath = os.path.join(root, c["files"]["envelope"]) envelope = None if os.path.exists(epath): with open(epath, "rb") as f: envelope = f.read() observed = observed_notary_from_envelope(envelope) if observed is not None: observed_all[c["seq"]] = observed prpath = os.path.join(root, c["files"]["proof"]) proofs = [] if os.path.exists(prpath): with open(prpath, "rb") as f: pj = json.load(f) if isinstance(pj, list): proofs = [p for p in pj if p.get("status") == "sealed"] if not proofs and pj: rep.note("notary entry exists but is not sealed yet") else: rep.note(f"no notary proof: {pj.get('error', 'unknown')}") if payload is None: rep.bad("payload.bin missing") continue chunk_all_ok = True for proof in proofs or [None]: if proof is None: # No sealed proof — still bind media to the device signature if # the enrichment material is present in a non-sealed entry. rep.note("verifying without notary seal (media+signature only unavailable)") chunk_all_ok = False break h_received = proof.get("h_received") if h_received is None: rep.note("no recompute material in proof; skipping media binding") chunk_all_ok = False continue # 1. media bytes == the hash the device committed to # # Which hash depends on what these bytes ARE. `h_received` commits # to what left the phone; `plaintext_hash` to the payload before # encryption. Identical for a plain recording. They differ when the # phone encrypted a PUBLIC session for transport — so its video was # never at rest in cleartext on the device — and the server opened # it on arrival and stored the cleartext. Both values sit in the # same device-signed envelope, and step 2 below re-derives H_chunk # from `h_received` regardless, so a wrong claim here still fails # the signature. stored_is_plaintext = bool(proof.get("payload_is_plaintext")) bound_hash = (proof.get("plaintext_hash") if stored_is_plaintext else h_received) if not bound_hash: rep.bad("payload declared as decrypted but no plaintext hash to bind it to") chunk_all_ok = False continue if hashlib.sha256(payload).hexdigest() == bound_hash: rep.ok("sha256(payload) == " + ("plaintext_hash (stored decrypted)" if stored_is_plaintext else "h_received")) else: rep.bad("payload bytes do not match the hash the device signed") chunk_all_ok = False continue # 2. envelope -> H_env -> H_chunk == notarized commitment if envelope is None: rep.bad("envelope.cbor missing") chunk_all_ok = False continue disclosed = frozen_frames_from_envelope(envelope) if disclosed is not None: frozen_reported = True frozen_all.extend(disclosed) disclosed_front = frozen_frames_from_envelope( envelope, "video.front.frozen.v1") if disclosed_front is not None: frozen_front_reported = True frozen_front_all.extend(disclosed_front) clocks = time_refs_from_envelope(envelope) if clocks is not None: time_reported = True time_all.extend(clocks) verdicts = fraud_verdicts_from_envelope(envelope) if verdicts is not None: fraud_reported = True fraud_all.extend(verdicts) motion = motion_from_envelope(envelope) if motion is not None: for sid, rows in motion.items(): motion_chunks[sid] = motion_chunks.get(sid, 0) + 1 motion_all.setdefault(sid, []).extend((c["seq"], r) for r in rows) # The plaintext hash arrived in the proof file, i.e. from the # server. Before it may stand as the binding for these bytes it has # to appear verbatim inside the envelope the device signed — # otherwise a server that swapped the payload could have shipped a # matching hash to go with it. Same guard the decryption path below # applies before trusting a revealed cleartext. if stored_is_plaintext and bytes.fromhex(bound_hash) not in envelope: rep.bad("the plaintext hash these bytes matched is NOT inside the signed envelope") chunk_all_ok = False continue h_env = hashlib.sha256(envelope).digest() h_chunk = hashlib.sha256(h_env + bytes.fromhex(h_received)).digest() if h_chunk.hex() == proof["commitment_hash"]: rep.ok("H_chunk == notarized commitment") else: rep.bad("H_chunk != commitment_hash (envelope or payload tampered)") chunk_all_ok = False continue # 3. device signature over H_chunk, pubkey pinned inside the envelope pub = bytes.fromhex(proof["device_pubkey"]) if pub not in envelope: rep.bad("device pubkey not present in the signed envelope") chunk_all_ok = False elif device_sig_verify(pub, bytes.fromhex(proof["device_signature"]), h_chunk): rep.ok("device %s signature valid" % ("Ed25519" if len(pub) == 32 else "ECDSA P-256")) seen_device_pubs.add(pub) else: rep.bad("device signature INVALID") chunk_all_ok = False continue # 4. leaf -> Merkle root -> block hash -> notary signature plaintext = proof.get("plaintext_hash") lh = leaf_hash(proof["entry_type"], proof["ref_id"], proof["received_at"], bytes.fromhex(proof["commitment_hash"]), bytes.fromhex(plaintext) if plaintext else None) if lh.hex() != proof["leaf_hash"]: rep.bad("recomputed leaf_hash mismatch") chunk_all_ok = False continue blk = proof["block"] path = [bytes.fromhex(x) for x in (proof.get("merkle_path") or [])] try: mroot = root_from_path(lh, proof["leaf_index"], blk["entry_count"], path) except ValueError as e: rep.bad(f"merkle path invalid: {e}") chunk_all_ok = False continue if mroot.hex() != blk["merkle_root"]: rep.bad("merkle root mismatch") chunk_all_ok = False continue bh = block_hash_of(blk["index"], bytes.fromhex(blk["prev_block_hash"]), mroot, blk["seal_time"]) if bh.hex() != blk["block_hash"]: rep.bad("block hash mismatch") chunk_all_ok = False continue key = keys.get(blk["signing_key_id"]) if key and ed25519_verify(key, bytes.fromhex(blk["signature"]), bh): rep.ok(f"sealed in block #{blk['index']} — Merkle + notary signature valid") else: rep.bad("notary signature on block INVALID") chunk_all_ok = False continue # 5. this block is inside the verified chain segment hdr = headers.get(blk["index"]) if hdr is None: rep.note(f"block #{blk['index']} outside bundled chain segment") elif hdr["block_hash"] != blk["block_hash"]: rep.bad(f"block #{blk['index']} differs from chain-segment header") chunk_all_ok = False # 7. Optional decryption, with the FULL two-sided hash guarantee: # (step 1 above) sha256(ciphertext) == h_received — the encrypted # bytes are exactly what the device signed and the notary sealed; # (here) sha256(plaintext) == the hash the PHONE promised # for the pre-encryption bytes. # That promised hash (payload_meta.hash_plaintext) lives INSIDE the # device-signed envelope, so we first bind the notary's plaintext_hash # to the envelope (it must appear verbatim in the signed bytes); then a # match after decryption proves the revealed cleartext is precisely the # original the device attested — a wrong key/decoy/tamper fails loudly. extract_payload = payload if encrypted and dek is not None and chunk_all_ok and proofs: iv_hex = c.get("iv") plaintext_hash = next((p.get("plaintext_hash") for p in proofs if p.get("plaintext_hash")), None) h_received_hex = next((p.get("h_received") for p in proofs if p.get("h_received")), None) if not iv_hex: rep.bad("encrypted chunk has no IV in the manifest — cannot decrypt") chunk_all_ok = False elif not plaintext_hash: rep.note("proof carries no plaintext hash — cannot confirm the decrypted bytes") chunk_all_ok = False elif envelope is not None and bytes.fromhex(plaintext_hash) not in envelope: rep.bad("notarized plaintext hash is NOT the one inside the device-signed envelope") chunk_all_ok = False else: try: clear = aesgcm_decrypt(dek, iv_hex, payload) except Exception as e: # noqa: BLE001 — InvalidTag etc. rep.bad(f"AES-GCM decryption failed ({type(e).__name__}) — wrong DEK or IV") chunk_all_ok = False clear = None if clear is not None: if hashlib.sha256(clear).hexdigest() == plaintext_hash: rep.ok("decrypted; sha256(plaintext) == the plaintext hash the phone signed") extract_payload = clear if args.extract: pair_dirs.append(write_decrypted_pair( root, c["seq"], cid, clear, h_received_hex, plaintext_hash, c["files"]["payload"])) else: rep.bad("decrypted bytes do NOT match the phone's signed plaintext hash") chunk_all_ok = False if chunk_all_ok and proofs: verified_payloads.append((c["seq"], cid, extract_payload)) # ── Device certificate chain ─────────────────────────────────────────── # # After the chunks, because it needs to know WHICH key signed them: a # certificate that verifies against the account but names some other key # would prove nothing about these bytes. print("\nDevice certificate:") verify_device_chain(manifest, seen_device_pubs, rep) # ── Manufacturer attestation ─────────────────────────────────────────── # # Checked AT the sealing time, not now. The earliest block the notary sealed # for this session is the instant these bytes provably existed, and it is # anchored outside us (chain -> Bitcoin, Roughtime). Using the reader's # clock instead would fail a perfectly good recording the day a certificate # expires — which is every recording, eventually. seal_at = None if headers: seal_us = min(h["seal_time"] for h in headers.values()) seal_at = datetime.datetime.fromtimestamp(seal_us / 1_000_000, datetime.timezone.utc) print("\nManufacturer attestation:") if seal_at is None: rep.note("no sealed block in this bundle, so there is no anchored instant " "to check certificate validity against — the chain below is " "checked for structure only") verify_attestation(root, manifest, seen_device_pubs, seal_at, rep, args) # ── Bitcoin anchor on the cover block ────────────────────────────────── print("\nBitcoin anchor:") ots_files = manifest["notary"].get("ots_files", {}) if cover_block is not None and str(cover_block) in ots_files: rel = ots_files[str(cover_block)] opath = os.path.join(root, rel) with open(opath, "rb") as f: ots_bytes = f.read() hdr = headers.get(cover_block) cover_hash = hdr["block_hash"] if hdr else None info = ots_info(ots_bytes) if info and cover_hash: initial, results = info if initial.hex() != cover_hash: rep.bad(".ots digest != cover block hash") elif results: rep.ok(f".ots operations executed: cover block #{cover_block} commits to " f"Bitcoin block(s) {', '.join(str(h) for h, _ in results)}") print(" The proof is complete if the computed merkle root below matches") print(" the 'Merkle Root' field of that Bitcoin block header — one 32-byte") print(" public value, no blockchain download needed.") for h, dg in results: print(f"\n Bitcoin block {h}: expected merkle root =") print(f" {dg[::-1].hex()}") print(" Compare against the 'Merkle root' field on independent explorers:") print(f" https://mempool.space/block/{h}?showDetails=true#details") print(f" https://blockstream.info/block-height/{h} (click 'Details')") print(f" https://www.blockchain.com/explorer/blocks/btc/{h} (abbreviated; click to copy)") print("\n Or, fully trustless, via your own node:") print(" bitcoin-cli getblockheader $(bitcoin-cli getblockhash )") else: rep.note(".ots is a calendar receipt (Bitcoin confirmation pending); re-download later") elif info is None: rep.note("could not parse .ots (try `ots info` from opentimestamps-client)") if cover_hash and shutil.which("ots"): print(" running `ots verify` (official client; needs a Bitcoin node for full trustlessness)...") r = subprocess.run(["ots", "verify", "-d", cover_hash, opath], capture_output=True, text=True) out = (r.stdout + r.stderr).strip() for line in out.splitlines(): print(" " + line) if "Success!" not in out: # Node-less mode: the official client executes the .ots operations # itself and prints the merkle root to check manually — an # independent cross-check of the value computed above. print(" no Bitcoin node — official client in node-less mode (`ots --no-bitcoin verify`):") r2 = subprocess.run(["ots", "--no-bitcoin", "verify", "-d", cover_hash, opath], capture_output=True, text=True) for line in (r2.stdout + r2.stderr).strip().splitlines(): print(" " + line) else: rep.note("`ots` CLI not found — install opentimestamps-client for trustless verification") else: rep.note("no .ots for the cover block in this bundle " f"(cover_status={manifest['notary'].get('cover_status')}) — re-download later") # ── Roughtime time witnesses ─────────────────────────────────────────── # Reported AFTER Bitcoin and under its own heading, never merged into it: # the two answer the same question with different strengths, and collapsing # them would quietly lend the weaker one the other's standing. print("\nTime witnesses (Roughtime):") rt_files = manifest["notary"].get("roughtime_files", {}) if not rt_files: rep.note("no Roughtime witness packets in this bundle — the notary stamps each block " "within a couple of minutes of sealing; re-download later") else: by_block = {} for tag, rel in sorted(rt_files.items()): block_str, _, source = tag.partition("-") try: block = int(block_str) except ValueError: rep.note(f"skipping malformed roughtime entry {tag!r}") continue hdr = headers.get(block) if not hdr: rep.note(f"block #{block} is not in this bundle's chain segment — witness not bound") continue pinned = RT_PINNED_KEYS.get(source) if not pinned: # Refusing is the honest outcome: checking a signature against a # key shipped in this same bundle would prove nothing. rep.note(f'no pinned key for witness "{source}" — its packet was NOT checked ' "(a key taken from this bundle proves nothing about this bundle)") continue label, pubkey, _b64 = pinned try: with open(os.path.join(root, rel), "rb") as f: packet = f.read() except OSError as e: rep.note(f"{rel}: unreadable ({e})") continue try: midp, radi = rt_verify(pubkey, bytes.fromhex(hdr["block_hash"]), source, packet) except Exception as e: rep.bad(f"witness {source} on block #{block}: {e}") continue by_block.setdefault(block, []).append((label, source, midp, radi)) tightest = None labels = set() for block in sorted(by_block): for label, source, midp, radi in by_block[block]: labels.add(label) rep.ok(f"block #{block}: {label} signed this block's hash — existed before " f"{_rt_fmt(midp + radi)} (+/-{radi // 1000} ms)") if tightest is None or midp + radi < tightest: tightest = midp + radi # Witnesses are queried within a few hundred milliseconds of each # other, so their stated intervals MUST overlap. A gap means at least # one is wrong about the time. The protocol cannot say which, and # neither will this verifier — but silence would be the omission # this whole bundle exists to avoid. rows = by_block[block] if len(rows) >= 2: lo = max(m - r for _, _, m, r in rows) hi = min(m + r for _, _, m, r in rows) if lo > hi: rep.bad(f"block #{block}: the witnesses CONTRADICT each other — their stated " f"times cannot all be true (they miss each other by " f"{(lo - hi) // 1000} ms). At least one is wrong about the time; " "nothing here says which, so this block's bound is not reliable.") if tightest is not None: print(f"\n Tightest upper bound across all witnesses: existed before {_rt_fmt(tightest)}") print(" This rests on the operators' signing keys, unlike the Bitcoin anchor above.") print(" Confirm these keys at their source before relying on the bound:") for name in sorted(labels): for src, (label, _pk, b64) in RT_PINNED_KEYS.items(): if label == name: print(f" {label:<12} {b64}") print(f" published at {RT_KEY_SOURCE_URL}") # ── Extraction (optional) ────────────────────────────────────────────── if args.extract: if pair_dirs: print("\nPer-chunk cipher/plaintext written for independent hash checks:") print(f" {len(pair_dirs)} chunk(s) under extracted/chunks/-/ " "(plaintext.bin + SHA256SUMS + EXPECTED.txt; ciphertext not duplicated)") ex = os.path.relpath(pair_dirs[0], root) print(" verify any chunk yourself, e.g.:") print(f" cd {ex} && {sha256sum_check_cmd()}") print("\nExtracting media streams from verified payloads:") if encrypted and dek is None: print(" session is encrypted and no key was supplied — pass --dek or --id-priv") print(" to decrypt; payloads are ciphertext, nothing to extract without a key") else: # For an encrypted session, verified_payloads already hold the # decrypted cleartext CBOR (the per-chunk step above replaced the # ciphertext once the plaintext hash matched), so extraction is # uniform with the cleartext case below. outdir = os.path.join(root, "extracted") os.makedirs(outdir, exist_ok=True) streams = {} for seq, cid, payload in sorted(verified_payloads): try: decoded = cbor_decode(payload) except Exception as e: print(f" ! chunk seq={seq}: payload CBOR decode failed: {e}") continue for s in decoded.get("streams", []): streams.setdefault(s["id"], []).append((seq, s["bytes"])) bins = {} # stream id -> written .bin path for sid, parts in streams.items(): fname = os.path.join(outdir, sid.replace("/", "_") + ".bin") with open(fname, "wb") as f: for _, blob in sorted(parts): f.write(blob) bins[sid] = fname print(f" wrote {fname} ({len(parts)} fragments)") # Remux each camera into a standalone, playable MP4. Dual-camera # sessions carry both video.back.* and video.front.*; each becomes # its own MP4 with the audio duplicated in (the project's dual-cam # export convention: two self-contained files). videos = sorted(k for k in bins if k.startswith("video.")) audios = sorted(k for k in bins if k.startswith("audio.")) audio_bin = bins[audios[0]] if audios else None if videos: have_ffmpeg = shutil.which("ffmpeg") is not None print("\n Assembling playable MP4(s):" if have_ffmpeg else "\n ffmpeg not found — run these to assemble playable MP4(s):") for vid in videos: # video.back.h264_fmp4.v1 -> "back"; fall back to a sanitized id. p = vid.split(".") cam = p[1] if len(p) > 1 else vid.replace(".", "_") out_mp4 = os.path.join(outdir, f"{cam}.mp4") cmd = ["ffmpeg", "-y", "-i", bins[vid]] if audio_bin: cmd += ["-i", audio_bin] cmd += ["-c", "copy", out_mp4] # Copy-pasteable form with paths relative to the bundle root. rel = ["ffmpeg", "-i", os.path.relpath(bins[vid], root)] if audio_bin: rel += ["-i", os.path.relpath(audio_bin, root)] rel += ["-c", "copy", os.path.relpath(out_mp4, root)] print(" " + " ".join(rel)) if shutil.which("ffmpeg"): r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode == 0 and os.path.exists(out_mp4): print(f" -> wrote {out_mp4}") else: tail = (r.stderr or "").strip().splitlines()[-1:] or ["(no stderr)"] print(f" ! ffmpeg failed: {tail[0]}") # ── What the camera did not capture ──────────────────────────────────── # # Printed for every run, including a clean one: "no frames were held" is # itself a finding, and a section that appears only on defects teaches a # reader to assume its absence means nothing was checked. print("\nFrame continuity (from the signed envelopes):") if not frozen_reported: # Deliberately not "no freezes": this recording carries no such # measurement, and saying it was clean would assert something nobody # checked. print(" not reported by this recording — it predates this disclosure,") print(" so nothing can be said either way about held frames") elif not frozen_all: print(" every frame was delivered — no held images disclosed") else: frozen_all.sort(key=lambda f: f[1]) total = sum(f[2] for f in frozen_all) print(f" {total} frame(s) never reached the recorder, " f"over {len(frozen_all)} moment(s):") for t_us, frame, missed in frozen_all[:20]: ts = datetime.datetime.fromtimestamp( t_us / 1e6, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] print(f" {ts}Z frame {frame} held {round(missed * 1000 / 30)} ms " f"({missed} not delivered)") if len(frozen_all) > 20: print(f" …and {len(frozen_all) - 20} more") print(" The image before each gap was held on screen; the frame that") print(" follows it carries an R mark burnt into the overlay, so the") print(" picture and this list can be checked against each other.") # Front camera (dual-cam only). Printed only when at least one chunk # carries the front stream: a single-camera recording has no front footage # to disclose anything about, so silence is the honest output — unlike the # rear block above, whose absence would hide an unmeasured recording. if frozen_front_reported: print("\nFrame continuity — front camera (from the same signed envelopes):") if not frozen_front_all: print(" every front frame was delivered — no held images disclosed") else: frozen_front_all.sort(key=lambda f: f[0]) ftotal = sum(f[2] for f in frozen_front_all) print(f" {ftotal} front frame(s) never reached the recorder, " f"over {len(frozen_front_all)} moment(s):") for t_us, frame, missed in frozen_front_all[:20]: ts = datetime.datetime.fromtimestamp( t_us / 1e6, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] print(f" {ts}Z front frame {frame} held " f"{round(missed * 1000 / 30)} ms ({missed} not delivered)") if len(frozen_front_all) > 20: print(f" …and {len(frozen_front_all) - 20} more") # ── The lower bound: the chain head each chunk says it had seen ──────── # # Checked against the chain segment verified above, so it rests on the # same notary signatures and anchors, and on nothing the server says about # it. Printed on every run: "not claimed" and "not checked" are answers. print("\nLower bound (the notary head the device had seen, from the signed envelopes):") claimed = sorted((seq, o) for seq, o in observed_all.items() if o != "none") if not observed_all: print(" not reported by this recording") elif not claimed: print(" no lower bound claimed — the phone had not yet seen a notary block") else: first_ok = None outside = [] for seq, (idx, h) in claimed: hdr = headers.get(idx) if hdr is None: outside.append(seq) elif hdr["block_hash"] != h.hex(): rep.bad(f"seq {seq}: names notary block #{idx} with a hash that is not that " f"block's — a false lower bound") elif first_ok is None: first_ok = (seq, idx, hdr) if outside: # Bundles made before verifier 1.2.0 start the segment at the # first SEALING block, so the head seen before filming began is # not in them. Not a defect of the recording. rep.note(f"{len(outside)} chunk(s) name a block outside this bundle's chain segment " f"(seq {', '.join(str(x) for x in outside[:5])}{', …' if len(outside) > 5 else ''})" f" — their lower bound cannot be checked from this bundle") backwards = sum(1 for (a, (ia, _)), (b, (ib, _)) in zip(claimed, claimed[1:]) if ib < ia) if backwards: rep.note(f"the head the device reported went backwards {backwards} time(s) " f"between consecutive chunks") if first_ok is not None: seq, idx, hdr = first_ok t = datetime.datetime.fromtimestamp(hdr["seal_time"] / 1e6, datetime.timezone.utc) rep.ok(f"seq {seq} was signed after notary block #{idx}, sealed " f"{t.strftime('%Y-%m-%d %H:%M:%S')}Z — its hash is in the verified chain " f"segment and could not be known before that block existed") print(" That seal time is the notary's own statement: the block's Roughtime and") print(" Bitcoin witnesses bound it from above only.") # ── The clocks the device declared ───────────────────────────────────── # # Printed on every run, like frame continuity, and for the same reason: a # section that only appears on a defect teaches a reader that its absence # means everything was fine, when it may mean nobody looked. print("\nClocks the device declared (from the signed envelopes):") if not time_reported: print(" not reported by this recording — it predates this disclosure,") print(" so nothing can be said either way about its clocks") else: def _span(us): a = abs(us) if a < 1000: return f"{a} us" if a < 1_000_000: return f"{round(a / 1000)} ms" if a < 60_000_000: return f"{a / 1_000_000:.1f} s" if a < 3_600_000_000: return f"{round(a / 60_000_000)} min" if a < 86_400_000_000: return f"{a / 3_600_000_000:.1f} h" return f"{a / 86_400_000_000:.1f} days" # Past this, the word "agreed" would be a lie. A phone and a time # server normally sit within a second or two of each other; a gap of # minutes is a finding, not a rounding error, and it must read as one. AGREEMENT_LIMIT_US = 5_000_000 # "satellite time" is only ever declared where it IS satellite time: # the phone reports no GPS value on iOS, which exposes no GNSS time to # an app — `CLLocation.timestamp` is the device clock stamped at the fix. LABEL = {"device": "the phone's own clock", "ntp": "internet time (NTP)", "gps": "satellite time (GPS)", "server": "the Forsheur server"} # Worst disagreement per source. `value_us + age_us` is what a source # read some time ago is expected to say now, so the remainder is the # real divergence. A negative age is a reading taken after `t_us` and # subtracts. # # A source that gives no age is SHOWN BUT NOT SCORED, which reverses # what this reader first did. Counting a null age as zero looked like # the least favourable, therefore the most honest, reading; it is # neither. An undated reading cannot tell a clock eight seconds fast # from a fix taken eight seconds ago, and the phone declares no age for # GPS on iOS by design — so every stale fix was scored as clock error. # One iOS session was condemned at 8.7 s on GPS fixes that refreshed # every 6-15 s, while NTP, the one source carrying a real age, put the # device clock 23 ms away. # # `server` is kept OUT of the independent set, and that is a matter of # category rather than tuning. The verdict below says "agreed with an # independent reference": NTP and GPS are independent of Forsheur, the # Forsheur server is not — it reaches the phone in an unsigned header. # Counting it cuts both ways: it once condemned a recording whose NTP # and GPS agreed to within a second, and it would just as readily # "confirm" a clock with nothing independent behind it. Still printed, # as a bearing. worst, seen, unavailable, server_worst = {}, set(), set(), None undated = {} for t_us, source, value, unc, age in time_all: if source == "device": continue if value is None: unavailable.add(source) continue d = abs(t_us - (value + (age or 0))) if source == "server": server_worst = d if server_worst is None else max(server_worst, d) continue if age is None: undated[source] = max(d, undated.get(source, -1)) continue seen.add(source) if d > worst.get(source, -1): worst[source] = d for source in seen: undated.pop(source, None) # dated at least once if not seen and not undated: print(" the phone had no independent clock to compare against") elif not seen: print(" an independent clock answered, but the phone never said how old") print(" the reading was — nothing can be concluded from it") else: overall = max(worst.values()) if overall <= AGREEMENT_LIMIT_US: print(f" agreed with {len(seen)} dated independent reference(s) " f"to within {_span(overall)}") else: print(f" DISAGREED with an independent reference by " f"{_span(overall)} — the device clock cannot be taken at " f"face value here") for source in sorted(seen): print(f" {LABEL.get(source, source)} — at worst " f"{_span(worst[source])} apart") for source in sorted(undated): print(f" {LABEL.get(source, source)} — at worst " f"{_span(undated[source])} apart, but the phone did not say how " f"old the reading was, so the gap may be nothing but a stale " f"reading — not counted above") if server_worst is not None: print(f" {LABEL['server']} — at worst {_span(server_worst)} apart " f"(our own clock, sent unsigned — a bearing, not an " f"independent check)") for source in sorted(unavailable - seen - set(undated)): print(f" {LABEL.get(source, source)} — unavailable at capture") print(" Declared by the device and covered by its signature, so it could") print(" not be revised afterwards. This is a cross-check, not a proof of") print(" date: that comes from the notary chain and its witnesses above.") # ── The on-device screen-refilming check ─────────────────────────────── print("\nScreen re-filming check (from the signed envelopes):") if not fraud_reported: # Not "clean": no analysis ran, so there is nothing to report. print(" not measured on this recording") elif not fraud_all: print(" the detector was running, but no segment carries a score") else: flagged = [v for v in fraud_all if v[2] >= 0.5] model = fraud_all[-1][3] worst_v = max(fraud_all, key=lambda v: v[2]) # Only chunks that carry footage can carry a score. The session-open # chunk at seq 0 holds no picture at all — just the GPS fix and the # session marker — so counting it would report a segment that never # existed as one nobody examined. media_seqs = {seq for seq, _cid, _payload in verified_payloads if seq >= 1} scored_seqs = {v[1] for v in fraud_all} unscored = sorted(media_seqs - scored_seqs) scored = len(scored_seqs) if flagged: print(f" a screen-refilming pattern was flagged in {len(flagged)} " f"of {scored} scored segment(s)") for t_us, seq, score, mv, frames in flagged[:20]: ts = datetime.datetime.fromtimestamp( t_us / 1e6, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S") print(f" {ts}Z segment {seq}: score {score:.3f} " f"over {frames} frame(s), model {mv}") if len(flagged) > 20: print(f" …and {len(flagged) - 20} more") else: print(f" no screen-refilming pattern detected in {scored} scored segment(s)") print(f" highest score {worst_v[2]:.3f} on a 0-to-1 scale, model {model}") if unscored: # Naming the expected case is worth the extra branch: "1 segment # carries no score" reads like a defect, when the final segment # having none is how the mechanism necessarily works. if len(unscored) == 1 and unscored[0] == max(media_seqs): print(" the last segment carries no score: a segment is analysed") print(" after it has already been signed and sent, so its verdict") print(" rides in the segment that follows — and the final one has") print(" no follower to carry it") else: listed = ", ".join(str(x) for x in unscored[:10]) more = "" if len(unscored) <= 10 else f" and {len(unscored) - 10} more" print(f" {len(unscored)} segment(s) carry no score (segment " f"{listed}{more}):") print(" a verdict rides in the segment that FOLLOWS the one it") print(" describes, so the final segment never has one — and any") print(" other gap means an upload was interrupted before its") print(" carrier went out") print(" A probability from one version of one model, computed on the phone") print(" and signed with the segment. It is evidence, not proof, and it says") print(" nothing about whether the scene in front of the lens was staged.") # ── What the phone felt while it filmed ───────────────────────────────── print("\nMotion sensors (from the signed envelopes):") motion_json = None if not motion_all: # Not "still": the recording predates the disclosure, or the phone # has none of these sensors. Nothing may be said either way. print(" not reported by this recording") else: motion_json = {} labels = {"gyro.v1": "gyroscope", "accel.v1": "accelerometer", "mag.v1": "magnetometer", "camera.v1": "lens readings"} for sid in MOTION_STREAMS: if sid not in motion_all: continue rows = motion_all[sid] n = len(rows) # Cadence over the span actually covered, per chunk then averaged, # so a gap between chunks does not read as a slow sensor. per_chunk = {} for seq, r in rows: per_chunk.setdefault(seq, []).append(r[0]) rates = [(len(ts) - 1) * 1e6 / (max(ts) - min(ts)) for ts in per_chunk.values() if len(ts) >= 2 and max(ts) > min(ts)] rate = sum(rates) / len(rates) if rates else None line = f" {labels[sid]}: {n} sample(s) over {motion_chunks[sid]} chunk(s)" if rate is not None: line += f", ~{rate:.0f} Hz" entry = {"samples": n, "chunks": motion_chunks[sid], "rate_hz": rate} if sid in ("gyro.v1", "accel.v1") and n: norms = [math.sqrt(r[1] ** 2 + r[2] ** 2 + r[3] ** 2) for _, r in rows if None not in (r[1], r[2], r[3])] if norms: if sid == "gyro.v1": rms = math.sqrt(sum(v * v for v in norms) / len(norms)) line += f"; rotation {rms * 180 / math.pi:.0f}°/s RMS" entry["rotation_rms_rad_s"] = rms else: mean = sum(norms) / len(norms) rms = math.sqrt(sum((v - mean) ** 2 for v in norms) / len(norms)) line += f"; shake {rms:.2f} m/s² RMS (gravity removed)" entry["shake_rms_m_s2"] = rms print(line) motion_json[sid] = entry print(" Sampled on the same clock as the video and signed with each segment,") print(" so the samples cannot be revised afterwards. They describe what the") print(" phone measured — a re-filmed screen moves with the hand holding the") print(" phone, not with the camera that shot the scene — never what the") print(" scene was. The raw values are in each chunk's envelope.cbor.") # ── Verdict ──────────────────────────────────────────────────────────── # Machine-readable restatement, written BEFORE the verdict is printed so a # failure to write it lands above the verdict rather than after it. It # carries no judgement the text does not already carry: same counts, same # checks, same order. if args.json: out = { "schema": "forsheur-verify-bundle/1", "verdict": "pass" if rep.fail == 0 else "fail", "failed_checks": rep.fail, "notes": rep.warn, "chunks_verified": len(verified_payloads), "session": { "session_id": session.get("session_id"), "short_id": session.get("short_id"), "encryption": session.get("encryption", "none"), "chunk_count": session.get("chunk_count"), }, # Null when no chunk carries a motion stream — never an empty dict. "motion": motion_json, "checks": rep.events, "verifier": { "path": os.path.abspath(__file__), "sha256": _self_sha256(), "version": VERIFIER_VERSION, }, } try: with open(args.json, "w", encoding="utf-8") as f: json.dump(out, f, indent=2, sort_keys=True, ensure_ascii=False) f.write("\n") print(f"\nMachine-readable summary written to {args.json}") except OSError as e: # Not a verification failure: every check above still ran. Say so # plainly rather than let a disk error read as a crypto verdict. print(f"\n! could not write {args.json}: {e}") print() if rep.fail == 0: print(f"VERDICT: PASS — {len(verified_payloads)} chunk(s) fully verified" + (f", {rep.warn} note(s)" if rep.warn else "")) sys.exit(0) print(f"VERDICT: FAIL — {rep.fail} check(s) failed, {rep.warn} note(s)") sys.exit(1) if __name__ == "__main__": main()