librelad b28268a61f feat(system): "Verified" integrity check against the signed release manifest
Adds per-file integrity attestation on top of the existing signed-tarball
release flow. make_release now generates a SHA256SUMS manifest over the shipped
tree and (when a key is configured) signs it, riding both inside the release
tarball so they land in the install tree with no extra download.

lpVerifyInstall (scripts/source/verify.sh) re-hashes the install tree against
that manifest and verifies the manifest's minisign signature against the
root-owned footprint pubkey, yielding states: verified / modified / tampered /
unsigned / unverifiable / development. webuiSystemVerify writes verify_status.json
(throttled daily, force on demand, also after each update apply), surfaced as an
Integrity line + "Verify now" button on the Admin → Overview Updates card and a
row in the update details panel. `libreportal verify` exposes the same check on
the CLI.

Honest framing: this is a self-check (run by the software it verifies), so red
fires only for genuine modified/tampered states; the badge tooltip points to
out-of-band `minisign -Vm` for an independent guarantee.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-28 19:41:22 +01:00

113 lines
4.9 KiB
Bash

#!/bin/bash
#
# Integrity verification of the installed LibrePortal code tree.
#
# Re-hashes $script_dir against the signed SHA256SUMS manifest that ships INSIDE
# every release tarball (built by scripts/release/make_release.sh) and checks
# that manifest's minisign signature against the ROOT-OWNED public key in the
# footprint (/usr/local/lib/libreportal/libreportal.pub). A pass proves the
# installed files still match the release we published and signed.
#
# Honest scope: this runs BY the software it verifies, so it catches accidental
# drift, partial tampering, and "is this a real signed release" — but it is NOT
# tamper-proof on its own (a wholesale code swap could also fake the result).
# The hard guarantee is out-of-band: the user running `minisign -Vm <tarball>`.
#
# Results land in LP_VERIFY_* globals so the generator + CLI can format them:
# LP_VERIFY_STATE verified|modified|tampered|unsigned|unverifiable|development
# LP_VERIFY_SIGNED true|false (a real, non-placeholder key signed it)
# LP_VERIFY_SIG_VALID true|false|null (null = not applicable / couldn't check)
# LP_VERIFY_TOTAL files listed in the manifest
# LP_VERIFY_OK / _MODIFIED / _MISSING
# LP_VERIFY_SAMPLE up to 5 offending relative paths, newline-separated
# LP_VERIFY_ERROR human message, or empty
lpVerifyPubKeyPath() { echo "/usr/local/lib/libreportal/libreportal.pub"; }
lpVerifyInstall() {
LP_VERIFY_STATE="development"
LP_VERIFY_SIGNED="false"
LP_VERIFY_SIG_VALID="null"
LP_VERIFY_TOTAL=0; LP_VERIFY_OK=0; LP_VERIFY_MODIFIED=0; LP_VERIFY_MISSING=0
LP_VERIFY_SAMPLE=""
LP_VERIFY_ERROR=""
local mode="${CFG_INSTALL_MODE:-release}"
local root="${script_dir%/}"
local manifest="$root/SHA256SUMS"
local sig="$root/SHA256SUMS.minisig"
local pub; pub="$(lpVerifyPubKeyPath)"
# No manifest — a git/local dev install, or a build without one. There's
# nothing to verify against, so report a neutral "development build" rather
# than implying something is wrong.
if [[ "$mode" != "release" || ! -f "$manifest" ]]; then
LP_VERIFY_STATE="development"
return 0
fi
LP_VERIFY_TOTAL=$(wc -l < "$manifest" 2>/dev/null | tr -d ' ')
[[ -n "$LP_VERIFY_TOTAL" ]] || LP_VERIFY_TOTAL=0
# Manifest signature. The pubkey is in the root-owned footprint so the
# manager can't swap it to bless a forged manifest. A REPLACE_ME placeholder
# means signing isn't activated for this build → treat the manifest as
# unsigned (drift detection still works, we just can't vouch for it).
if [[ -f "$pub" ]] && ! grep -q REPLACE_ME "$pub" 2>/dev/null; then
LP_VERIFY_SIGNED="true"
if ! command -v minisign >/dev/null 2>&1; then
LP_VERIFY_STATE="unverifiable"
LP_VERIFY_ERROR="minisign is not installed, so the signed manifest can't be checked."
return 0
fi
if [[ ! -f "$sig" ]]; then
LP_VERIFY_STATE="tampered"
LP_VERIFY_SIG_VALID="false"
LP_VERIFY_ERROR="The release manifest signature (SHA256SUMS.minisig) is missing."
return 0
fi
if ! minisign -Vm "$manifest" -p "$pub" -x "$sig" >/dev/null 2>&1; then
LP_VERIFY_STATE="tampered"
LP_VERIFY_SIG_VALID="false"
LP_VERIFY_ERROR="The release manifest signature is invalid."
return 0
fi
LP_VERIFY_SIG_VALID="true"
fi
# Re-hash every listed file. `--check --quiet` prints only failures; each is
# either "<path>: FAILED" (content differs) or "<path>: FAILED open or read"
# (missing/unreadable). Lines prefixed "sha256sum:" are diagnostics/summary
# — skip them so a missing file isn't double-counted. Run from $root so the
# manifest's ./relative paths resolve.
local check_out line path sample_count=0
check_out="$(cd "$root" && sha256sum --check --quiet "$manifest" 2>&1)"
while IFS= read -r line; do
[[ -z "$line" || "$line" == sha256sum:* ]] && continue
if [[ "$line" == *": FAILED open or read" ]]; then
LP_VERIFY_MISSING=$((LP_VERIFY_MISSING + 1)); path="${line%: FAILED open or read}"
elif [[ "$line" == *": FAILED" ]]; then
LP_VERIFY_MODIFIED=$((LP_VERIFY_MODIFIED + 1)); path="${line%: FAILED}"
else
continue
fi
if (( sample_count < 5 )); then
LP_VERIFY_SAMPLE+="${LP_VERIFY_SAMPLE:+$'\n'}${path#./}"
sample_count=$((sample_count + 1))
fi
done <<< "$check_out"
local bad=$((LP_VERIFY_MODIFIED + LP_VERIFY_MISSING))
LP_VERIFY_OK=$((LP_VERIFY_TOTAL - bad))
(( LP_VERIFY_OK < 0 )) && LP_VERIFY_OK=0
if (( bad > 0 )); then
LP_VERIFY_STATE="modified"
elif [[ "$LP_VERIFY_SIGNED" == "true" ]]; then
LP_VERIFY_STATE="verified"
else
LP_VERIFY_STATE="unsigned"
fi
return 0
}