From aedc760a2eddd31207065e5f6599126a6da957bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Fri, 24 Jul 2026 16:34:15 -0300 Subject: [PATCH 1/2] feat(install): verify release asset minisign signatures Download the .sig companion published for each Tauri-updater asset and verify it with minisign (or rsign from rsign2) when available on PATH, using the public key embedded from src-tauri/tauri.conf.json. A missing verifier only warns and proceeds; a present verifier makes verification mandatory and fails closed on a missing, undecodable, or bad signature. --- scripts/install.sh | 68 ++++++++++++++++++ tests/install-script-smoke.mjs | 124 ++++++++++++++++++++++++++++----- 2 files changed, 174 insertions(+), 18 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 9f8d1e6..5df45a9 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -11,6 +11,11 @@ BIN_NAME="pickscribe-app" APP_ID="pickscribe-app" WM_CLASS="Pickscribe-app" +# Tauri updater signing key (src-tauri/tauri.conf.json -> plugins.updater.pubkey, +# base64-decoded to its minisign public-key line). Embedded so the installer +# never fetches the trust root over the network. Key id: C9FDD29AA6D3DEA1. +readonly MINISIGN_PUBKEY="RWSh3tOmmtL9yYOe9M6YhBqmVJx3TibwJaHXYq4YbYKONVfjlBdKqRk6" + # Environment overrides: # PICKSCRIBE_INSTALL_DIR Wrapper/AppImage directory. Default: $HOME/.local/bin. # PICKSCRIBE_VERSION Install a specific release tag, such as v0.1.0. @@ -248,6 +253,68 @@ download_asset() { [ -s "$asset_path" ] || die "downloaded asset is empty: $asset_name" } +select_signature_verifier() { + if command -v minisign >/dev/null 2>&1; then + verifier_cmd="minisign" + elif command -v rsign >/dev/null 2>&1; then + verifier_cmd="rsign" + else + verifier_cmd="" + fi +} + +warn_signature_verification_skipped() { + printf '\n' >&2 + printf '!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n' >&2 + printf '!! WARNING: signature verification skipped for %s\n' "$asset_name" >&2 + printf '!!\n' >&2 + printf '!! No minisign-compatible verifier (minisign or rsign) was found on\n' >&2 + printf '!! PATH, so the downloaded release asset could not be authenticated\n' >&2 + printf '!! before install. Install a verifier and re-run to enable this check:\n' >&2 + printf '!! macOS: brew install minisign\n' >&2 + printf '!! Linux: apt install minisign (or your distro equivalent)\n' >&2 + printf '!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n' >&2 + printf '\n' >&2 +} + +# Verifies the downloaded asset against its `.sig` companion (a +# base64-wrapped minisign signature, as published by the Tauri updater +# bundler) before the archive is parsed or installed. If a compatible +# verifier is on PATH, verification is mandatory and fails closed: a missing, +# undecodable, or invalid signature aborts the install. If no verifier is +# available, this prints a loud warning and lets the install proceed. +verify_asset_signature() { + select_signature_verifier + if [ -z "$verifier_cmd" ]; then + warn_signature_verification_skipped + return 0 + fi + + sig_encoded_path="$asset_path.sig.b64" + sig_path="$asset_path.minisig" + + download_to "$asset_url.sig" "$sig_encoded_path" || + die "failed to download the signature for $asset_name; refusing to install an unverified asset (found $verifier_cmd on PATH, so verification is mandatory)" + [ -s "$sig_encoded_path" ] || die "downloaded signature for $asset_name is empty" + + base64 -d < "$sig_encoded_path" > "$sig_path" 2>/dev/null || + die "failed to decode the signature for $asset_name" + [ -s "$sig_path" ] || die "decoded signature for $asset_name is empty" + + case "$verifier_cmd" in + minisign) + minisign -V -q -m "$asset_path" -x "$sig_path" -P "$MINISIGN_PUBKEY" || + die "signature verification failed for $asset_name; aborting install" + ;; + rsign) + rsign verify "$asset_path" -P "$MINISIGN_PUBKEY" -x "$sig_path" -q || + die "signature verification failed for $asset_name; aborting install" + ;; + esac + + printf '%s signature verified (%s).\n' "$asset_name" "$verifier_cmd" +} + desktop_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/`/\\`/g; s/\$/\\$/g; s/%/%%/g' } @@ -559,6 +626,7 @@ main() { trap 'exit 130' INT trap 'exit 143' TERM download_asset + verify_asset_signature if [ "$platform" = "macos" ]; then install_macos_app else diff --git a/tests/install-script-smoke.mjs b/tests/install-script-smoke.mjs index 587ba18..40551b8 100644 --- a/tests/install-script-smoke.mjs +++ b/tests/install-script-smoke.mjs @@ -7,6 +7,12 @@ import { join } from "node:path"; const repoRoot = new URL("..", import.meta.url).pathname; const installer = join(repoRoot, "scripts", "install.sh"); +// Deliberately excludes the host's own PATH (eg. Homebrew's /opt/homebrew/bin): +// tests must not pass or fail depending on whether the developer's machine +// happens to have a real minisign/rsign installed. Verifier presence is +// controlled per test via fakebin. +const STANDARD_PATH = "/usr/bin:/bin:/usr/sbin:/sbin"; + function makeTempRoot(name) { const root = execFileSync("mktemp", ["-d", join(tmpdir(), `${name}.XXXXXX`)], { encoding: "utf8", @@ -39,9 +45,18 @@ exit 0 `, ); writeMacBundleFixture(fixture, "first bundle"); + writeSignatureFixtures(fixture); return fixture; } +// The real installer decodes `.sig` as base64 before handing it to +// minisign/rsign. Content doesn't matter here since the fake verifier below +// never actually parses it -- it only needs to decode to something non-empty. +function writeSignatureFixtures(fixture) { + writeFileSync(join(fixture, "PickScribe_9.9.9_amd64.AppImage.sig"), Buffer.from("fake-sig-amd64").toString("base64")); + writeFileSync(join(fixture, "PickScribe_9.9.9_aarch64.app.tar.gz.sig"), Buffer.from("fake-sig-aarch64").toString("base64")); +} + function writeMacBundleFixture(fixture, marker) { const bundleRoot = join(fixture, "bundle"); const bundleBinary = join(bundleRoot, "PickScribe.app", "Contents", "MacOS", "pickscribe-app"); @@ -124,6 +139,12 @@ case "$url" in *.AppImage|*.app.tar.gz) cp "$PICKSCRIBE_TEST_FIXTURE/\${url##*/}" "$out" ;; + *.sig) + if [ ! -f "$PICKSCRIBE_TEST_FIXTURE/\${url##*/}" ]; then + exit 22 + fi + cp "$PICKSCRIBE_TEST_FIXTURE/\${url##*/}" "$out" + ;; *) echo "unexpected url: $url" >&2 exit 64 @@ -133,24 +154,46 @@ esac ); } -function runInstaller(root, fixture, extraEnv = {}) { - const fakebin = join(root, "fakebin"); - mkdirSync(fakebin, { recursive: true }); - writeFakeCurl(fakebin); - writeFakeUname(fakebin); - writeFakeSysctl(fakebin); +// Writes a fake `minisign` (or `rsign`) that records the arguments it was +// called with and exits with `exitCode`, standing in for real signature +// verification so tests can force the pass/fail outcome deterministically. +function writeFakeVerifier(fakebin, { name, exitCode, callLog }) { + writeExecutable( + join(fakebin, name), + `#!/bin/sh +printf '%s\\n' "$*" >> "${callLog}" +exit ${exitCode} +`, + ); +} - const env = { +function baseEnv(root, fixture, extraEnv) { + return { ...process.env, HOME: join(root, "home"), XDG_DATA_HOME: join(root, "home", ".local", "share"), - PATH: `${fakebin}:${process.env.PATH}`, PICKSCRIBE_TEST_FIXTURE: fixture, PICKSCRIBE_RELEASE_API_URL: "https://release.test/latest", PICKSCRIBE_TEST_OS: "Linux", PICKSCRIBE_TEST_ARCH: "x86_64", ...extraEnv, }; +} + +function runInstaller(root, fixture, extraEnv = {}, { verifier } = {}) { + const fakebin = join(root, "fakebin"); + mkdirSync(fakebin, { recursive: true }); + writeFakeCurl(fakebin); + writeFakeUname(fakebin); + writeFakeSysctl(fakebin); + if (verifier) { + writeFakeVerifier(fakebin, verifier); + } + + const env = { + ...baseEnv(root, fixture, extraEnv), + PATH: `${fakebin}:${STANDARD_PATH}`, + }; return execFileSync("sh", [installer], { cwd: repoRoot, @@ -160,23 +203,19 @@ function runInstaller(root, fixture, extraEnv = {}) { }); } -function runInstallerFailure(root, fixture, extraEnv = {}) { +function runInstallerFailure(root, fixture, extraEnv = {}, { verifier } = {}) { const fakebin = join(root, "fakebin"); mkdirSync(fakebin, { recursive: true }); writeFakeCurl(fakebin); writeFakeUname(fakebin); writeFakeSysctl(fakebin); + if (verifier) { + writeFakeVerifier(fakebin, verifier); + } const env = { - ...process.env, - HOME: join(root, "home"), - XDG_DATA_HOME: join(root, "home", ".local", "share"), - PATH: `${fakebin}:${process.env.PATH}`, - PICKSCRIBE_TEST_FIXTURE: fixture, - PICKSCRIBE_RELEASE_API_URL: "https://release.test/latest", - PICKSCRIBE_TEST_OS: "Linux", - PICKSCRIBE_TEST_ARCH: "x86_64", - ...extraEnv, + ...baseEnv(root, fixture, extraEnv), + PATH: `${fakebin}:${STANDARD_PATH}`, }; return spawnSync("sh", [installer], { @@ -325,3 +364,52 @@ test("AppImage install refuses to overwrite an unrelated command", (root) => { assert.notEqual(result.status, 0); assert.match(result.stderr, /command path already exists and was not created by PickScribe/); }); + +test("verifier present + good signature installs and reports verification", (root) => { + const fixture = writeFixture(root); + const callLog = join(root, "minisign-calls.log"); + + const output = runInstaller(root, fixture, {}, { verifier: { name: "minisign", exitCode: 0, callLog } }); + + assert.match(output, /PickScribe_9\.9\.9_amd64\.AppImage signature verified \(minisign\)\./); + assert.equal(existsSync(join(root, "home", ".local", "bin", "PickScribe.AppImage")), true); + const calls = readFileSync(callLog, "utf8"); + assert.match(calls, /-P RWSh3tOmmtL9yYOe9M6YhBqmVJx3TibwJaHXYq4YbYKONVfjlBdKqRk6/); +}); + +test("verifier present + bad signature aborts before install", (root) => { + const fixture = writeFixture(root); + const callLog = join(root, "minisign-calls.log"); + + const result = runInstallerFailure(root, fixture, {}, { verifier: { name: "minisign", exitCode: 1, callLog } }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /signature verification failed for PickScribe_9\.9\.9_amd64\.AppImage; aborting install/); + assert.equal(existsSync(join(root, "home", ".local", "bin", "PickScribe.AppImage")), false); +}); + +test("verifier present + missing .sig aborts before install", (root) => { + const fixture = writeFixture(root); + rmSync(join(fixture, "PickScribe_9.9.9_amd64.AppImage.sig")); + const callLog = join(root, "minisign-calls.log"); + + const result = runInstallerFailure(root, fixture, {}, { verifier: { name: "minisign", exitCode: 0, callLog } }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /failed to download the signature for PickScribe_9\.9\.9_amd64\.AppImage/); + assert.match(result.stderr, /verification is mandatory/); + assert.equal(existsSync(join(root, "home", ".local", "bin", "PickScribe.AppImage")), false); + assert.equal(existsSync(callLog), false); +}); + +test("no verifier on PATH warns loudly and proceeds with install", (root) => { + const fixture = writeFixture(root); + + const result = runInstallerFailure(root, fixture); + + assert.equal(result.status, 0); + assert.match(result.stderr, /WARNING: signature verification skipped for PickScribe_9\.9\.9_amd64\.AppImage/); + assert.match(result.stderr, /brew install minisign/); + assert.match(result.stderr, /apt install minisign/); + assert.equal(existsSync(join(root, "home", ".local", "bin", "PickScribe.AppImage")), true); +}); From 9f8fd82557c7d92b3ff493d453e7bef1196e3120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Fri, 24 Jul 2026 16:45:46 -0300 Subject: [PATCH 2/2] fix(install): fall back to base64 -D for signature decode Older macOS base64 only accepts -D (not -d) for decoding, so a Mac with minisign installed but an old base64 would fail-closed and abort a good install. Try -d first, then -D, before handing the decoded signature to the verifier. --- scripts/install.sh | 13 +++++++++- tests/install-script-smoke.mjs | 46 ++++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 5df45a9..35b4e23 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -277,6 +277,17 @@ warn_signature_verification_skipped() { printf '\n' >&2 } +# BSD base64 (older macOS) only accepts `-D` for decode; GNU base64 (Linux) +# and modern macOS accept `-d`. Try `-d` first, then fall back to `-D`, so +# decoding works across both without misdetecting the platform. +decode_base64_to() { + decode_src=$1 + decode_dest=$2 + + base64 -d < "$decode_src" > "$decode_dest" 2>/dev/null || + base64 -D < "$decode_src" > "$decode_dest" 2>/dev/null +} + # Verifies the downloaded asset against its `.sig` companion (a # base64-wrapped minisign signature, as published by the Tauri updater # bundler) before the archive is parsed or installed. If a compatible @@ -297,7 +308,7 @@ verify_asset_signature() { die "failed to download the signature for $asset_name; refusing to install an unverified asset (found $verifier_cmd on PATH, so verification is mandatory)" [ -s "$sig_encoded_path" ] || die "downloaded signature for $asset_name is empty" - base64 -d < "$sig_encoded_path" > "$sig_path" 2>/dev/null || + decode_base64_to "$sig_encoded_path" "$sig_path" || die "failed to decode the signature for $asset_name" [ -s "$sig_path" ] || die "decoded signature for $asset_name is empty" diff --git a/tests/install-script-smoke.mjs b/tests/install-script-smoke.mjs index 40551b8..b060fee 100644 --- a/tests/install-script-smoke.mjs +++ b/tests/install-script-smoke.mjs @@ -154,6 +154,29 @@ esac ); } +// Writes a `base64` that rejects `-d` (like older BSD/macOS base64, which +// only supports `-D`) so tests can prove install.sh's decode_base64_to() +// actually falls back to `-D` instead of assuming GNU-style `-d` support. +function writeFakeBase64DOnly(fakebin) { + writeExecutable( + join(fakebin, "base64"), + `#!/bin/sh +case "\${1:-}" in + -d) + exit 1 + ;; + -D) + cat + exit 0 + ;; + *) + exit 64 + ;; +esac +`, + ); +} + // Writes a fake `minisign` (or `rsign`) that records the arguments it was // called with and exits with `exitCode`, standing in for real signature // verification so tests can force the pass/fail outcome deterministically. @@ -180,7 +203,7 @@ function baseEnv(root, fixture, extraEnv) { }; } -function runInstaller(root, fixture, extraEnv = {}, { verifier } = {}) { +function runInstaller(root, fixture, extraEnv = {}, { verifier, fakeBase64DOnly } = {}) { const fakebin = join(root, "fakebin"); mkdirSync(fakebin, { recursive: true }); writeFakeCurl(fakebin); @@ -189,6 +212,9 @@ function runInstaller(root, fixture, extraEnv = {}, { verifier } = {}) { if (verifier) { writeFakeVerifier(fakebin, verifier); } + if (fakeBase64DOnly) { + writeFakeBase64DOnly(fakebin); + } const env = { ...baseEnv(root, fixture, extraEnv), @@ -203,7 +229,7 @@ function runInstaller(root, fixture, extraEnv = {}, { verifier } = {}) { }); } -function runInstallerFailure(root, fixture, extraEnv = {}, { verifier } = {}) { +function runInstallerFailure(root, fixture, extraEnv = {}, { verifier, fakeBase64DOnly } = {}) { const fakebin = join(root, "fakebin"); mkdirSync(fakebin, { recursive: true }); writeFakeCurl(fakebin); @@ -212,6 +238,9 @@ function runInstallerFailure(root, fixture, extraEnv = {}, { verifier } = {}) { if (verifier) { writeFakeVerifier(fakebin, verifier); } + if (fakeBase64DOnly) { + writeFakeBase64DOnly(fakebin); + } const env = { ...baseEnv(root, fixture, extraEnv), @@ -377,6 +406,19 @@ test("verifier present + good signature installs and reports verification", (roo assert.match(calls, /-P RWSh3tOmmtL9yYOe9M6YhBqmVJx3TibwJaHXYq4YbYKONVfjlBdKqRk6/); }); +test("verifier present + -d-unsupported base64 falls back to -D and still verifies", (root) => { + const fixture = writeFixture(root); + const callLog = join(root, "minisign-calls.log"); + + const output = runInstaller(root, fixture, {}, { + verifier: { name: "minisign", exitCode: 0, callLog }, + fakeBase64DOnly: true, + }); + + assert.match(output, /PickScribe_9\.9\.9_amd64\.AppImage signature verified \(minisign\)\./); + assert.equal(existsSync(join(root, "home", ".local", "bin", "PickScribe.AppImage")), true); +}); + test("verifier present + bad signature aborts before install", (root) => { const fixture = writeFixture(root); const callLog = join(root, "minisign-calls.log");