ship souveraine-verify-trail in the package that writes the trail
This commit is contained in:
parent
f2a4b93ee3
commit
3174e7813c
3 changed files with 82 additions and 1 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
73
packaging/souveraine-verify-trail
Executable file
73
packaging/souveraine-verify-trail
Executable file
|
|
@ -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":"<hex>"`, 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]))
|
||||
Loading…
Reference in a new issue