From 5700f78c6b341414cb753cefd820e92e26e0a65f Mon Sep 17 00:00:00 2001 From: librelad Date: Mon, 25 May 2026 19:40:30 +0100 Subject: [PATCH] feat(release): minisign signature signing + verification The sha256 only proves a download is intact; a compromised host could swap the tarball + its checksum. Add minisign signatures, which prove authenticity (the host can't forge them without the offline secret key). Ships INACTIVE behind a REPLACE_ME placeholder, so installs work until a real key is generated; then it's REQUIRED. - make_release.sh: signs the tarball when LP_MINISIGN_SECKEY is set -> .minisig. - libreportal.pub: the public key (placeholder), ships in the tarball and is installed to the ROOT-OWNED footprint (/usr/local/lib/libreportal/libreportal.pub) by init.sh -> the manager can't swap it to accept forged updates. footprint_version -> 2. - install.sh: LP_MINISIGN_PUBKEY constant; once non-placeholder, downloads + verifies the .minisig (minisign -P) and REFUSES on invalid/missing (auto-installs minisign if needed). --no-verify-signature is a dev-only escape hatch. - fetch.sh (update path): verifies against the footprint .pub (minisign -p), refuses on invalid/missing. - docs/DEVELOPMENT.md: keygen (minisign -G), paste pubkey into libreportal.pub + install.sh, keep the secret key offline, sign builds via LP_MINISIGN_SECKEY, bump footprint_version on key rotation. Verified end-to-end with a real throwaway key: good signature accepted; tampered, wrong-key, and missing-signature all refused; placeholder skips (sha256 still enforced). Co-Authored-By: Claude Opus 4.7 Signed-off-by: librelad --- docs/DEVELOPMENT.md | 30 ++++++++++++++++++++++++++++++ init.sh | 8 +++++++- install.sh | 23 +++++++++++++++++++++++ libreportal.pub | 2 ++ scripts/release/make_release.sh | 13 +++++++++++++ scripts/source/fetch.sh | 17 +++++++++++++++++ 6 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 libreportal.pub diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 4372cb2..12d7b88 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -136,6 +136,36 @@ atomically. (Re-running the installer is idempotent.) the sudoers.** Forgetting it means those root components silently stay stale until the next full reinstall. +## Signing releases (minisign) + +The sha256 only proves a download is intact — a *compromised host* could swap the +tarball **and** its checksum. A minisign signature proves the release is genuinely +ours: the host can't forge it without the offline secret key. It ships **inactive** +(a `REPLACE_ME` placeholder), so installs work today; once you set a real key, +verification becomes **required** for release installs + updates. + +**One-time setup:** +```bash +minisign -G -p libreportal.pub -s ~/.minisign/libreportal.key # generate the keypair +# 1. keep ~/.minisign/libreportal.key OFFLINE (this is the thing to protect) +# 2. paste the PUBLIC key (the RW… line) into BOTH: +# - libreportal.pub (ships + installed to the root footprint, used by updates) +# - install.sh LP_MINISIGN_PUBKEY=… (the standalone bootstrap) +# 3. bump footprint_version in init.sh (the footprint's public key changed) +``` + +**Signing a build:** point `make_release` at the secret key on the release machine: +```bash +LP_MINISIGN_SECKEY=~/.minisign/libreportal.key scripts/release/make_release.sh stable +``` +It emits `libreportal-.tar.gz.minisig` alongside the tarball. `install.sh` and +the updater (`lpFetchRelease`) download `.minisig`, verify it against the public key, +and **refuse** on a bad/missing signature. `--no-verify-signature` on `install.sh` +is a dev-only escape hatch. + +> Rotating the key later = repeat steps 2–3 and re-bump `footprint_version` (the +> root-owned public key is part of the footprint). + ## Conventions - **Versioning:** semver in `VERSION`. Bump before building; `latest.json` carries it. diff --git a/init.sh b/init.sh index 25268b8..73b46c8 100755 --- a/init.sh +++ b/init.sh @@ -130,7 +130,7 @@ command_symlink="/usr/local/bin/libreportal" # `update apply` runs as the manager and CANNOT rewrite root-owned files, so a bump # tells the updater the new release needs a root re-install (which re-bakes them). # Recorded at install in $lp_lib_dir/.footprint_version. See docs/DEVELOPMENT.md. -footprint_version=1 +footprint_version=2 footprint_marker="$lp_lib_dir/.footprint_version" # Directories — three independently-relocatable roots (see scripts/source/paths.sh @@ -948,6 +948,12 @@ initRootHelpers() rm -f "$helper_tmp" done + # Install the release-signing PUBLIC key into the root-owned footprint, so the + # runtime updater verifies signatures against a key the manager can't swap. + if [[ -f "$script_dir/libreportal.pub" ]]; then + sudo install -m 0644 -o root -g root "$script_dir/libreportal.pub" "$lp_lib_dir/libreportal.pub" + fi + # Record the footprint version now installed (root-owned, world-readable) so the # manager-run updater can tell when a release bumps it and a root re-install is # needed. Written here because this re-runs on every install/reinstall. diff --git a/install.sh b/install.sh index 774a7ae..3314ec0 100644 --- a/install.sh +++ b/install.sh @@ -22,6 +22,13 @@ RELEASE_BASE_URL="${LP_RELEASE_BASE_URL:-https://get.libreportal.org}" PASSWORD="${LP_PASSWORD:-}" SYSTEM_DIR="" ; CONTAINERS_DIR="" ; BACKUPS_DIR="" ; MANAGER_USER="" ; ALLOW_HOME="" DRY_RUN=0 +NO_VERIFY_SIG=0 + +# minisign public key. Keep the SECRET key offline. Once you run `minisign -G`, +# paste the public key here AND into libreportal.pub. While it contains REPLACE_ME, +# signature verification is skipped (the sha256 still runs); once replaced, a valid +# signature becomes REQUIRED for release installs. +LP_MINISIGN_PUBKEY="RWREPLACE_ME_run_minisign_-G_then_paste_the_public_key_here_and_in_install.sh" usage() { sed -n '3,12p' "$0" | sed 's/^# \{0,1\}//' @@ -61,6 +68,7 @@ for arg in "$@"; do --git-user=*) GIT_USER="${arg#*=}" ;; --git-token=*) GIT_TOKEN="${arg#*=}" ;; --dry-run) DRY_RUN=1 ;; + --no-verify-signature) NO_VERIFY_SIG=1 ;; -h|--help) usage; exit 0 ;; *) echo "install.sh: unknown option '$arg' (try --help)" >&2; exit 2 ;; esac @@ -120,6 +128,21 @@ case "$MODE" in got_sha="$($SHACMD "$STAGE/$tarname" | cut -d' ' -f1)" [[ "$got_sha" == "$want_sha" ]] || err "CHECKSUM MISMATCH — refusing to install. expected $want_sha, got $got_sha" echo "✓ checksum verified ($got_sha)" + + # Signature: once the public key is real (not the REPLACE_ME placeholder), a + # valid minisign signature is REQUIRED — it survives a compromised host (the + # checksum alone doesn't, since the host serves it too). --no-verify-signature + # is a dev escape hatch only. + if [[ "$LP_MINISIGN_PUBKEY" != *REPLACE_ME* && "$NO_VERIFY_SIG" -ne 1 ]]; then + command -v minisign >/dev/null 2>&1 || { echo "Installing minisign to verify the release ..."; apt-get install -y minisign >/dev/null 2>&1 || true; } + command -v minisign >/dev/null 2>&1 || err "minisign required to verify the release but couldn't be installed" + fetch "$tarurl.minisig" "$STAGE/$tarname.minisig" || err "release signature (.minisig) missing — refusing" + # -P takes the public key as a string (our inline constant is just the + # key line, not a full 2-line pubkey file). + minisign -Vm "$STAGE/$tarname" -P "$LP_MINISIGN_PUBKEY" -x "$STAGE/$tarname.minisig" >/dev/null 2>&1 \ + || err "SIGNATURE INVALID — refusing to install (the release isn't authentic)" + echo "✓ signature verified" + fi mkdir -p "$STAGE/x" tar xzf "$STAGE/$tarname" -C "$STAGE/x" --strip-components=1 SRC="$STAGE/x" diff --git a/libreportal.pub b/libreportal.pub new file mode 100644 index 0000000..896cf4c --- /dev/null +++ b/libreportal.pub @@ -0,0 +1,2 @@ +untrusted comment: LibrePortal release signing key — REPLACE_ME (run `minisign -G`) +RWREPLACE_ME_run_minisign_-G_then_paste_the_public_key_here_and_in_install.sh diff --git a/scripts/release/make_release.sh b/scripts/release/make_release.sh index 6703b11..0ca5d84 100644 --- a/scripts/release/make_release.sh +++ b/scripts/release/make_release.sh @@ -53,6 +53,19 @@ cat > "$OUT/latest.json" <.minisig. +if [[ -n "${LP_MINISIGN_SECKEY:-}" ]]; then + command -v minisign >/dev/null 2>&1 || { echo "make_release: LP_MINISIGN_SECKEY set but 'minisign' isn't installed" >&2; exit 1; } + minisign -Sm "$OUT/$TARBALL" -s "$LP_MINISIGN_SECKEY" -t "libreportal $VERSION ($CHANNEL)" >/dev/null + SIGNED=" ✓ $OUT/$TARBALL.minisig (signed)" +else + SIGNED=" ℹ︎ unsigned (set LP_MINISIGN_SECKEY to sign)" +fi + echo "✓ $OUT/$TARBALL" echo "✓ $OUT/$TARBALL.sha256 ($SHA)" echo "✓ $OUT/latest.json" +echo "$SIGNED" diff --git a/scripts/source/fetch.sh b/scripts/source/fetch.sh index 84bc4fb..3a64c56 100644 --- a/scripts/source/fetch.sh +++ b/scripts/source/fetch.sh @@ -73,6 +73,23 @@ lpFetchRelease() { got="$(_lpSha256 "$tar")" if [[ "$got" != "$want_sha" ]]; then isError "lpFetchRelease: CHECKSUM MISMATCH ($tarname) — refusing"; rm -rf "$tmp"; return 1; fi + # Signature: once the root-owned public key is real (not the REPLACE_ME + # placeholder), a valid minisign signature is REQUIRED. The key lives in the + # footprint so the manager can't swap it to accept a forged update. + local pub="/usr/local/lib/libreportal/libreportal.pub" + if [[ -f "$pub" ]] && ! grep -q REPLACE_ME "$pub" 2>/dev/null; then + if ! command -v minisign >/dev/null 2>&1; then + isError "lpFetchRelease: minisign required to verify the release but not installed"; rm -rf "$tmp"; return 1 + fi + if ! _lpDownload "$base/$channel/$tarname.minisig" "$tar.minisig"; then + isError "lpFetchRelease: release signature (.minisig) missing — refusing"; rm -rf "$tmp"; return 1 + fi + if ! minisign -Vm "$tar" -p "$pub" -x "$tar.minisig" >/dev/null 2>&1; then + isError "lpFetchRelease: SIGNATURE INVALID ($tarname) — refusing"; rm -rf "$tmp"; return 1 + fi + isNotice "Release signature verified." + fi + # Replace the install tree (code only; configs/logs are in the system tree). runInstallOp rm -rf "$script_dir" runInstallOp mkdir -p "$script_dir"