From 3174e7813cc6fab31f996963278037f2ae4b6633 Mon Sep 17 00:00:00 2001 From: Fimeg Date: Sun, 26 Jul 2026 14:58:48 -0400 Subject: [PATCH] ship souveraine-verify-trail in the package that writes the trail --- .gitea/workflows/ci.yml | 1 + packaging/arch/PKGBUILD.prebuilt | 9 +++- packaging/souveraine-verify-trail | 73 +++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100755 packaging/souveraine-verify-trail diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 771fae2..855af06 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -267,6 +267,7 @@ jobs: cp "souveraine-sessiond-$ARCH" "$PKG_WORK/souveraine-sessiond-binary" cp packaging/souveraine-sessiond.service "$PKG_WORK/" fi + cp packaging/souveraine-verify-trail "$PKG_WORK/" cp packaging/arch/PKGBUILD.prebuilt "$PKG_WORK/PKGBUILD" ( cd "$PKG_WORK" diff --git a/packaging/arch/PKGBUILD.prebuilt b/packaging/arch/PKGBUILD.prebuilt index 06ef5e3..c8fa3ad 100644 --- a/packaging/arch/PKGBUILD.prebuilt +++ b/packaging/arch/PKGBUILD.prebuilt @@ -15,8 +15,9 @@ options=('!strip') source=('souveraine-binary' 'souveraine.service' 'souveraine-secrets-binary' 'souveraine-secrets.service' 'souveraine-machined-binary' 'souveraine-machined.service' + 'souveraine-verify-trail' 'LICENSE') -sha256sums=('SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP') +sha256sums=('SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP') # sessiond ships on aarch64 only (the phone is its target; the laptop hit # lock-screen errors with it). CI drops the two files into the build dir for # that arch and omits them otherwise, so package() picks them up conditionally @@ -56,5 +57,11 @@ package() { install -Dm644 "$srcdir/souveraine-machined.service" \ "$pkgdir/usr/lib/systemd/system/souveraine-machined.service" + # Verifies the forensic trail's hash chain. Ships with the daemon that + # writes it, not in the rootfs overlay: the overlay only reaches a device + # at provision time, and the phone is not reflashed. Evidence nobody can + # check is not evidence (DEVICE-STATE-MACHINE.md §11). + install -Dm755 "$srcdir/souveraine-verify-trail" \ + "$pkgdir/usr/bin/souveraine-verify-trail" install -Dm644 "$srcdir/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE" } diff --git a/packaging/souveraine-verify-trail b/packaging/souveraine-verify-trail new file mode 100755 index 0000000..013746a --- /dev/null +++ b/packaging/souveraine-verify-trail @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Verify a Souveraine hash-chained JSONL trail. + +Reads the forensic trail (or SessionAudit's — same contract) and checks that +every entry's hash covers its own body and that each `prev` is the previous +entry's hash. DEVICE-STATE-MACHINE.md §11 called the trail tamper-evident and +listed this as owed: evidence nobody checks is not evidence. + +The contract, in one sentence: strip the trailing `,"hash":""`, close the +object, SHA-256 what is left. + +Rotation is expected, not an error — the live file's first entry chains onto +the last entry of `.jsonl.1`, so a single file verified alone opens with a +`prev` it cannot see. Pass the generations oldest-first to check across them. + + souveraine-verify-trail # the default trail + souveraine-verify-trail f.jsonl.2 f.jsonl.1 f.jsonl +""" +import hashlib +import json +import os +import sys + +DEFAULT = os.path.join( + os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")), + "souveraine", + "forensic.jsonl", +) + + +def verify(paths): + prev = None # None = first file, accept whatever prev it opens with + seq = None + checked = 0 + for path in paths: + try: + lines = open(path).read().splitlines() + except OSError as e: + print(f"cannot read {path}: {e}", file=sys.stderr) + return 2 + for n, line in enumerate(lines, 1): + if not line.strip(): + continue + cut = line.rfind(',"hash":') + if cut < 0: + print(f"{path}:{n}: no hash field") + return 1 + want = hashlib.sha256((line[:cut] + "}").encode()).hexdigest() + try: + entry = json.loads(line) + except json.JSONDecodeError as e: + print(f"{path}:{n}: not JSON ({e})") + return 1 + if entry.get("hash") != want: + print(f"{path}:{n}: seq {entry.get('seq')} hash does not cover its body") + return 1 + if prev is not None and entry.get("prev", "") != prev: + print(f"{path}:{n}: seq {entry.get('seq')} does not chain onto the entry before it") + return 1 + if seq is not None and entry.get("seq") != seq + 1: + # A gap is not tampering by itself — a rotated generation may be + # gone — but it is worth naming, because the chain cannot span + # what is not on disk. + print(f"{path}:{n}: sequence jumps {seq} -> {entry.get('seq')}") + prev = entry["hash"] + seq = entry.get("seq") + checked += 1 + print(f"chain verifies: {checked} entries") + return 0 + + +if __name__ == "__main__": + sys.exit(verify(sys.argv[1:] or [DEFAULT]))