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 -> <tarball>.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 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-25 19:40:30 +01:00
parent d0162ccda1
commit 5700f78c6b
6 changed files with 92 additions and 1 deletions

View File

@ -136,6 +136,36 @@ atomically. (Re-running the installer is idempotent.)
the sudoers.** Forgetting it means those root components silently stay stale until the sudoers.** Forgetting it means those root components silently stay stale until
the next full reinstall. 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-<ver>.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 23 and re-bump `footprint_version` (the
> root-owned public key is part of the footprint).
## Conventions ## Conventions
- **Versioning:** semver in `VERSION`. Bump before building; `latest.json` carries it. - **Versioning:** semver in `VERSION`. Bump before building; `latest.json` carries it.

View File

@ -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 # `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). # 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. # 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" footprint_marker="$lp_lib_dir/.footprint_version"
# Directories — three independently-relocatable roots (see scripts/source/paths.sh # Directories — three independently-relocatable roots (see scripts/source/paths.sh
@ -948,6 +948,12 @@ initRootHelpers()
rm -f "$helper_tmp" rm -f "$helper_tmp"
done 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 # 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 # 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. # needed. Written here because this re-runs on every install/reinstall.

View File

@ -22,6 +22,13 @@ RELEASE_BASE_URL="${LP_RELEASE_BASE_URL:-https://get.libreportal.org}"
PASSWORD="${LP_PASSWORD:-}" PASSWORD="${LP_PASSWORD:-}"
SYSTEM_DIR="" ; CONTAINERS_DIR="" ; BACKUPS_DIR="" ; MANAGER_USER="" ; ALLOW_HOME="" SYSTEM_DIR="" ; CONTAINERS_DIR="" ; BACKUPS_DIR="" ; MANAGER_USER="" ; ALLOW_HOME=""
DRY_RUN=0 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() { usage() {
sed -n '3,12p' "$0" | sed 's/^# \{0,1\}//' sed -n '3,12p' "$0" | sed 's/^# \{0,1\}//'
@ -61,6 +68,7 @@ for arg in "$@"; do
--git-user=*) GIT_USER="${arg#*=}" ;; --git-user=*) GIT_USER="${arg#*=}" ;;
--git-token=*) GIT_TOKEN="${arg#*=}" ;; --git-token=*) GIT_TOKEN="${arg#*=}" ;;
--dry-run) DRY_RUN=1 ;; --dry-run) DRY_RUN=1 ;;
--no-verify-signature) NO_VERIFY_SIG=1 ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) echo "install.sh: unknown option '$arg' (try --help)" >&2; exit 2 ;; *) echo "install.sh: unknown option '$arg' (try --help)" >&2; exit 2 ;;
esac esac
@ -120,6 +128,21 @@ case "$MODE" in
got_sha="$($SHACMD "$STAGE/$tarname" | cut -d' ' -f1)" got_sha="$($SHACMD "$STAGE/$tarname" | cut -d' ' -f1)"
[[ "$got_sha" == "$want_sha" ]] || err "CHECKSUM MISMATCH — refusing to install. expected $want_sha, got $got_sha" [[ "$got_sha" == "$want_sha" ]] || err "CHECKSUM MISMATCH — refusing to install. expected $want_sha, got $got_sha"
echo "✓ checksum verified ($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" mkdir -p "$STAGE/x"
tar xzf "$STAGE/$tarname" -C "$STAGE/x" --strip-components=1 tar xzf "$STAGE/$tarname" -C "$STAGE/x" --strip-components=1
SRC="$STAGE/x" SRC="$STAGE/x"

2
libreportal.pub Normal file
View File

@ -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

View File

@ -53,6 +53,19 @@ cat > "$OUT/latest.json" <<EOF
} }
EOF EOF
# Sign the tarball with minisign if a secret key is configured. Keep that key
# OFFLINE — set LP_MINISIGN_SECKEY to its path on the release machine only. The
# public half lives in libreportal.pub + install.sh; verification activates once
# you replace their REPLACE_ME placeholder. Produces <tarball>.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"
echo "$OUT/$TARBALL.sha256 ($SHA)" echo "$OUT/$TARBALL.sha256 ($SHA)"
echo "$OUT/latest.json" echo "$OUT/latest.json"
echo "$SIGNED"

View File

@ -73,6 +73,23 @@ lpFetchRelease() {
got="$(_lpSha256 "$tar")" got="$(_lpSha256 "$tar")"
if [[ "$got" != "$want_sha" ]]; then isError "lpFetchRelease: CHECKSUM MISMATCH ($tarname) — refusing"; rm -rf "$tmp"; return 1; fi 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). # Replace the install tree (code only; configs/logs are in the system tree).
runInstallOp rm -rf "$script_dir" runInstallOp rm -rf "$script_dir"
runInstallOp mkdir -p "$script_dir" runInstallOp mkdir -p "$script_dir"