From acb46d0707cab3a40f1cb18192289af9a30f4270 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Sun, 30 Aug 2026 11:31:24 +0000 Subject: [PATCH 1/8] fix: keep apt signature verification on transient failures --- scripts/install-apt.sh | 16 +++++++++++++--- test/install-apt.test.js | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/scripts/install-apt.sh b/scripts/install-apt.sh index a64e185..a7ee06e 100755 --- a/scripts/install-apt.sh +++ b/scripts/install-apt.sh @@ -104,9 +104,19 @@ fi # Newer apt verifies with sqv (Sequoia), which rejects the signature Forgejo # currently produces for its Debian registry (malformed Ed25519 MPI encoding # in the upstream signing library). Try the properly signed source first so -# this heals automatically once the forge is fixed; otherwise fall back to -# [trusted=yes] — package integrity then relies on HTTPS to our own forge. -if ! update_only_source "$LIST"; then +# this heals automatically once the forge is fixed. Only that signature-error +# class permits the compatibility fallback; auth, network, and other failures +# must leave verification enabled and retain apt's original diagnostic. +if update_output="$(update_only_source "$LIST" 2>&1)"; then + printf '%s\n' "$update_output" +else + update_status=$? + if ! grep -Eiq \ + 'NO_PUBKEY|EXPKEYSIG|BADSIG|signatures? (could not|couldn.t) be verified|signature (verification )?(failed|failure|error|invalid)|repository .*not signed|is not signed' \ + <<<"$update_output"; then + printf '%s\n' "$update_output" >&2 + exit "$update_status" + fi echo echo "WARNING: signature verification failed (known Forgejo registry issue" >&2 echo "with sqv-based apt). Falling back to [trusted=yes]; transport" >&2 diff --git a/test/install-apt.test.js b/test/install-apt.test.js index f411c39..aea7648 100644 --- a/test/install-apt.test.js +++ b/test/install-apt.test.js @@ -13,12 +13,13 @@ const SCRIPT = path.join(__dirname, '..', 'scripts', 'install-apt.sh'); // candAfterUpdate Candidate after any `apt-get update` // candAfterNodesource Candidate after an update once nodesource.list exists // releaseStatus HTTP status curl reports for the registry Release file +// sourceUpdateError stderr and exit 100 for the first signed stoke update // The apt-cache stub localizes the "Candidate:" label unless LC_ALL=C is set, // so every scenario doubles as a regression test for locale-safe parsing. const cleanups = []; process.on('exit', () => { for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true }); }); -function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList, releaseStatus }) { +function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList, releaseStatus, sourceUpdateError }) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-apt-test-')); cleanups.push(root); const bin = path.join(root, 'bin'); @@ -58,8 +59,16 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi ].join('\n')); stub('apt-get', [ 'echo "apt-get $*" >> "$STATE_DIR/apt-get.log"', + 'source_list=""', + 'for a in "$@"; do', + ' case "$a" in Dir::Etc::sourcelist=*) source_list="${a#*=}";; esac', + 'done', 'for a in "$@"; do', ' if [ "$a" = update ]; then', + ' if [ -n "$source_list" ] && grep -q "signed-by=" "$source_list" && [ -n "${SOURCE_UPDATE_ERROR:-}" ]; then', + ' printf "%s\\n" "$SOURCE_UPDATE_ERROR" >&2', + ' exit 100', + ' fi', ' if [ -e "$STOKE_APT_ETC/sources.list.d/nodesource.list" ] && [ -n "${CAND_AFTER_NODESOURCE:-}" ]; then', ' echo "$CAND_AFTER_NODESOURCE" > "$STATE_DIR/candidate"', ' elif [ -n "${CAND_AFTER_UPDATE:-}" ]; then', @@ -82,6 +91,7 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi CAND_AFTER_UPDATE: candAfterUpdate || '', CAND_AFTER_NODESOURCE: candAfterNodesource || '', RELEASE_STATUS: releaseStatus || '', + SOURCE_UPDATE_ERROR: sourceUpdateError || '', LC_ALL: 'es_ES.UTF-8', // localized environment; the script must force C }, }); @@ -96,6 +106,7 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi nodesourceKey: read(path.join(aptEtc, 'keyrings', 'nodesource.asc')), nodesourceKeyMode: mode(path.join(aptEtc, 'keyrings', 'nodesource.asc')), forgeKeyMode: mode(path.join(aptEtc, 'keyrings', 'forgejo-heavy-duty.asc')), + forgeList: read(path.join(aptEtc, 'sources.list.d', 'forgejo-heavy-duty.list')), aptGetLog: read(path.join(state, 'apt-get.log')) || '', }; // Drop the throwaway tree after we have read everything we need. @@ -171,3 +182,26 @@ test('registry Release file present: proceeds with the install', () => { assert.equal(s.res.status, 0, s.res.stderr); assert.match(s.aptGetLog, /install -y stoke/); }); + +test('signature verification failure alone may use the trusted compatibility fallback', () => { + const s = runScenario({ + candInitial: '22.23.1-1nodesource1', + sourceUpdateError: 'W: GPG error: signatures could not be verified: NO_PUBKEY DEADBEEF\nE: The repository is not signed.', + }); + assert.equal(s.res.status, 0, s.res.stderr); + assert.match(s.forgeList, /\[trusted=yes\]/); + assert.match(s.aptGetLog, /install -y stoke/); +}); + +test('network update failure stays fatal and never disables signature verification', () => { + const failure = 'Temporary failure resolving forgejo.heavyduty.builders'; + const s = runScenario({ + candInitial: '22.23.1-1nodesource1', + sourceUpdateError: failure, + }); + assert.notEqual(s.res.status, 0); + assert.match(s.res.stderr, new RegExp(failure)); + assert.match(s.forgeList, /\[signed-by=/); + assert.doesNotMatch(s.forgeList, /trusted=yes/); + assert.doesNotMatch(s.aptGetLog, /install -y stoke/); +}); From a28b2ffd749d59768e4b6267a7e0c63ce1771bbb Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Sun, 30 Aug 2026 11:34:06 +0000 Subject: [PATCH 2/8] feat: support private apt registry credentials --- scripts/install-apt.sh | 25 +++++++++++++++++++++++-- test/install-apt.test.js | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/scripts/install-apt.sh b/scripts/install-apt.sh index a7ee06e..ab9038f 100755 --- a/scripts/install-apt.sh +++ b/scripts/install-apt.sh @@ -18,12 +18,21 @@ FORGE_URL="${FORGE_URL:-https://forgejo.heavyduty.builders}" OWNER="${OWNER:-heavy-duty}" DISTRIBUTION="${DISTRIBUTION:-stable}" COMPONENT="${COMPONENT:-main}" +FORGE_USER="${FORGE_USER:-}" +FORGE_TOKEN="${FORGE_TOKEN:-}" # Where apt configuration lives; overridable so tests can run against a # throwaway directory instead of the real /etc/apt. APT_ETC="${STOKE_APT_ETC:-/etc/apt}" KEYRING="$APT_ETC/keyrings/forgejo-$OWNER.asc" LIST="$APT_ETC/sources.list.d/forgejo-$OWNER.list" +AUTH="$APT_ETC/auth.conf.d/forgejo-$OWNER.conf" + +if { [ -n "$FORGE_USER" ] && [ -z "$FORGE_TOKEN" ]; } \ + || { [ -z "$FORGE_USER" ] && [ -n "$FORGE_TOKEN" ]; }; then + echo "error: FORGE_USER and FORGE_TOKEN must be set together" >&2 + exit 1 +fi SUDO="" if [ "$(id -u)" -ne 0 ]; then @@ -31,6 +40,18 @@ if [ "$(id -u)" -ne 0 ]; then SUDO="sudo" fi +CURL_AUTH=() +if [ -n "$FORGE_USER" ] && [ -n "$FORGE_TOKEN" ]; then + forge_host="${FORGE_URL#*://}" + forge_host="${forge_host%%/*}" + $SUDO install -d -m 0755 "$APT_ETC/auth.conf.d" + printf 'machine %s\nlogin %s\npassword %s\n' \ + "$forge_host" "$FORGE_USER" "$FORGE_TOKEN" \ + | $SUDO tee "$AUTH" >/dev/null + $SUDO chmod 0600 "$AUTH" + CURL_AUTH=(--netrc-file "$AUTH") +fi + update_only_source() { $SUDO apt-get update \ -o Dir::Etc::sourcelist="$1" \ @@ -81,7 +102,7 @@ ensure_nodejs_source() { echo "Adding APT source for $FORGE_URL/$OWNER ..." $SUDO install -d -m 0755 "$APT_ETC/keyrings" -curl -fsSL "$FORGE_URL/api/packages/$OWNER/debian/repository.key" | $SUDO tee "$KEYRING" >/dev/null +curl "${CURL_AUTH[@]}" -fsSL "$FORGE_URL/api/packages/$OWNER/debian/repository.key" | $SUDO tee "$KEYRING" >/dev/null echo "deb [signed-by=$KEYRING] $FORGE_URL/api/packages/$OWNER/debian $DISTRIBUTION $COMPONENT" \ | $SUDO tee "$LIST" >/dev/null # tee inherits our umask; apt's unprivileged _apt user must be able to @@ -94,7 +115,7 @@ $SUDO chmod 0644 "$KEYRING" "$LIST" # is fatal; any other curl outcome (e.g. a network hiccup) is left for # apt-get update to report. RELEASE_URL="$FORGE_URL/api/packages/$OWNER/debian/dists/$DISTRIBUTION/Release" -if [ "$(curl -sSL -o /dev/null -w '%{http_code}' "$RELEASE_URL" || true)" = "404" ]; then +if [ "$(curl "${CURL_AUTH[@]}" -sSL -o /dev/null -w '%{http_code}' "$RELEASE_URL" || true)" = "404" ]; then echo "error: no stoke package has been published to the $OWNER Debian registry yet" >&2 echo "($RELEASE_URL returned 404)." >&2 echo "Install stoke via npm or manually instead — see the README." >&2 diff --git a/test/install-apt.test.js b/test/install-apt.test.js index aea7648..3c6529e 100644 --- a/test/install-apt.test.js +++ b/test/install-apt.test.js @@ -14,12 +14,13 @@ const SCRIPT = path.join(__dirname, '..', 'scripts', 'install-apt.sh'); // candAfterNodesource Candidate after an update once nodesource.list exists // releaseStatus HTTP status curl reports for the registry Release file // sourceUpdateError stderr and exit 100 for the first signed stoke update +// forgeUser/token private-registry credentials // The apt-cache stub localizes the "Candidate:" label unless LC_ALL=C is set, // so every scenario doubles as a regression test for locale-safe parsing. const cleanups = []; process.on('exit', () => { for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true }); }); -function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList, releaseStatus, sourceUpdateError }) { +function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList, releaseStatus, sourceUpdateError, forgeUser, forgeToken }) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-apt-test-')); cleanups.push(root); const bin = path.join(root, 'bin'); @@ -92,6 +93,8 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi CAND_AFTER_NODESOURCE: candAfterNodesource || '', RELEASE_STATUS: releaseStatus || '', SOURCE_UPDATE_ERROR: sourceUpdateError || '', + FORGE_USER: forgeUser || '', + FORGE_TOKEN: forgeToken || '', LC_ALL: 'es_ES.UTF-8', // localized environment; the script must force C }, }); @@ -107,6 +110,8 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi nodesourceKeyMode: mode(path.join(aptEtc, 'keyrings', 'nodesource.asc')), forgeKeyMode: mode(path.join(aptEtc, 'keyrings', 'forgejo-heavy-duty.asc')), forgeList: read(path.join(aptEtc, 'sources.list.d', 'forgejo-heavy-duty.list')), + forgeAuth: read(path.join(aptEtc, 'auth.conf.d', 'forgejo-heavy-duty.conf')), + forgeAuthMode: mode(path.join(aptEtc, 'auth.conf.d', 'forgejo-heavy-duty.conf')), aptGetLog: read(path.join(state, 'apt-get.log')) || '', }; // Drop the throwaway tree after we have read everything we need. @@ -205,3 +210,31 @@ test('network update failure stays fatal and never disables signature verificati assert.doesNotMatch(s.forgeList, /trusted=yes/); assert.doesNotMatch(s.aptGetLog, /install -y stoke/); }); + +test('private-registry credentials stay in a root-readable auth file, not the source URL', () => { + const s = runScenario({ + candInitial: '22.23.1-1nodesource1', + forgeUser: 'apt-user', + forgeToken: 'secret-token', + }); + assert.equal(s.res.status, 0, s.res.stderr); + assert.equal(s.forgeAuthMode, 0o600); + assert.equal(s.forgeAuth, [ + 'machine forgejo.heavyduty.builders', + 'login apt-user', + 'password secret-token', + '', + ].join('\n')); + assert.doesNotMatch(s.forgeList, /apt-user|secret-token/); +}); + +test('incomplete private-registry credentials fail before configuring apt', () => { + const s = runScenario({ + candInitial: '22.23.1-1nodesource1', + forgeUser: 'apt-user', + }); + assert.notEqual(s.res.status, 0); + assert.match(s.res.stderr, /FORGE_USER and FORGE_TOKEN must be set together/); + assert.equal(s.forgeList, null); + assert.equal(s.aptGetLog, ''); +}); From c7971eefe02083e1a0858198f09475f09a7d16c8 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Sun, 30 Aug 2026 11:35:08 +0000 Subject: [PATCH 3/8] docs: document authenticated apt installs --- README.md | 27 +++++++++++++++++++++++---- changelog.d/1.md | 1 + 2 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 changelog.d/1.md diff --git a/README.md b/README.md index db4d1c4..5fc43dc 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,32 @@ A command-line interface for [Forgejo](https://forgejo.org/), built with [Comman ### With apt (Debian/Ubuntu — recommended) -The package is published to the Debian registry of the forge itself. One-time setup: +The package is published to the Debian registry of the forge itself. The +`heavy-duty` organization is private, so installation requires a Forgejo user +that belongs to the organization and a personal access token that can read its +packages. One-time setup: ```bash -curl -fsSL https://forgejo.heavyduty.builders/heavy-duty/stoke/raw/branch/main/scripts/install-apt.sh | bash +export FORGE_USER=your-forgejo-login +read -rsp 'Forgejo token: ' FORGE_TOKEN && echo && export FORGE_TOKEN +curl -fsSLo /tmp/stoke-install-apt.sh \ + https://forgejo.heavyduty.builders/heavy-duty/stoke/raw/branch/main/scripts/install-apt.sh +sudo --preserve-env=FORGE_USER,FORGE_TOKEN bash /tmp/stoke-install-apt.sh +unset FORGE_TOKEN ``` -or manually. First add the forge's registry as an apt source: +The installer keeps the credentials out of the source URL in a root-readable +apt auth file. To configure that file manually before adding the source: + +```bash +sudo install -d -m 0755 /etc/apt/auth.conf.d +printf 'machine forgejo.heavyduty.builders\nlogin %s\npassword %s\n' \ + "$FORGE_USER" "$FORGE_TOKEN" \ + | sudo tee /etc/apt/auth.conf.d/forgejo-heavy-duty.conf >/dev/null +sudo chmod 0600 /etc/apt/auth.conf.d/forgejo-heavy-duty.conf +``` + +Then add the forge's registry as an apt source: ```bash sudo install -d /etc/apt/keyrings @@ -46,7 +65,7 @@ sudo apt-get update && sudo apt-get install stoke Upgrades then arrive through regular `apt-get upgrade`. `install-apt.sh` performs all of the above, adding the NodeSource repository only when no already-configured apt source offers a new-enough nodejs. -Note: apt releases that verify OpenPGP with `sqv` (Debian 13+, apt >= 2.9) currently reject the signature Forgejo generates for its Debian registry (an upstream signing bug). `install-apt.sh` detects this and falls back to a `[trusted=yes]` source — integrity then relies on HTTPS to the forge. The script prefers the signed source, so setups heal automatically once the forge is fixed. +Note: apt releases that verify OpenPGP with `sqv` (Debian 13+, apt >= 2.9) may reject signatures produced by affected Forgejo versions. `install-apt.sh` permits its `[trusted=yes]` compatibility fallback only when apt reports an explicit signature failure. Authentication, network, and all other update failures are fatal and leave the `signed-by=` source unchanged, so a transient error cannot silently disable verification. As a fallback, each release also has the `.deb` attached for direct install: `sudo dpkg -i stoke__all.deb`. diff --git a/changelog.d/1.md b/changelog.d/1.md new file mode 100644 index 0000000..02d4760 --- /dev/null +++ b/changelog.d/1.md @@ -0,0 +1 @@ +- Private apt installs now keep credentials out of source URLs and retain signature verification after non-signature update failures. (#1). From 769a3c8aba0dbd7591e65485233cdb97fa51e680 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Sun, 30 Aug 2026 11:40:41 +0000 Subject: [PATCH 4/8] fix: read apt credentials through sudo --- scripts/install-apt.sh | 4 ++-- test/install-apt.test.js | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/install-apt.sh b/scripts/install-apt.sh index ab9038f..b9f862c 100755 --- a/scripts/install-apt.sh +++ b/scripts/install-apt.sh @@ -102,7 +102,7 @@ ensure_nodejs_source() { echo "Adding APT source for $FORGE_URL/$OWNER ..." $SUDO install -d -m 0755 "$APT_ETC/keyrings" -curl "${CURL_AUTH[@]}" -fsSL "$FORGE_URL/api/packages/$OWNER/debian/repository.key" | $SUDO tee "$KEYRING" >/dev/null +$SUDO curl "${CURL_AUTH[@]}" -fsSL "$FORGE_URL/api/packages/$OWNER/debian/repository.key" | $SUDO tee "$KEYRING" >/dev/null echo "deb [signed-by=$KEYRING] $FORGE_URL/api/packages/$OWNER/debian $DISTRIBUTION $COMPONENT" \ | $SUDO tee "$LIST" >/dev/null # tee inherits our umask; apt's unprivileged _apt user must be able to @@ -115,7 +115,7 @@ $SUDO chmod 0644 "$KEYRING" "$LIST" # is fatal; any other curl outcome (e.g. a network hiccup) is left for # apt-get update to report. RELEASE_URL="$FORGE_URL/api/packages/$OWNER/debian/dists/$DISTRIBUTION/Release" -if [ "$(curl "${CURL_AUTH[@]}" -sSL -o /dev/null -w '%{http_code}' "$RELEASE_URL" || true)" = "404" ]; then +if [ "$($SUDO curl "${CURL_AUTH[@]}" -sSL -o /dev/null -w '%{http_code}' "$RELEASE_URL" || true)" = "404" ]; then echo "error: no stoke package has been published to the $OWNER Debian registry yet" >&2 echo "($RELEASE_URL returned 404)." >&2 echo "Install stoke via npm or manually instead — see the README." >&2 diff --git a/test/install-apt.test.js b/test/install-apt.test.js index 3c6529e..93a5b4e 100644 --- a/test/install-apt.test.js +++ b/test/install-apt.test.js @@ -41,10 +41,16 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi // Force the non-root path so every mutation goes through the sudo stub. stub('id', 'echo 1000'); - stub('sudo', 'exec "$@"'); + stub('sudo', 'SUDO_ACTIVE=1 exec "$@"'); // Registry Release-file probes (URLs under /dists/) answer with the // scenario's HTTP status; everything else is a key fetch. stub('curl', [ + 'uses_netrc=false', + 'for a in "$@"; do [ "$a" = "--netrc-file" ] && uses_netrc=true; done', + 'if [ "$uses_netrc" = true ] && [ "${SUDO_ACTIVE:-}" != 1 ]; then', + ' echo "curl: root-owned netrc is unreadable without sudo" >&2', + ' exit 77', + 'fi', 'for a in "$@"; do', ' case "$a" in */dists/*) echo "${RELEASE_STATUS:-200}"; exit 0;; esac', 'done', From 9f3464154544f7776b25ab6170ba65e618c6647f Mon Sep 17 00:00:00 2001 From: claude-lead-andresmgsl Date: Mon, 31 Aug 2026 10:39:02 +0000 Subject: [PATCH 5/8] fix: stop attributing the sqv rejection to the key algorithm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #1. `scripts/install-apt.sh` explained the sqv rejection as a malformed MPI encoding in an Ed25519 key. The registry does not serve one: at `api/packages/heavy-duty/debian/repository.key`, `gpg --list-packets` reports `algo 1` (RSA) with a 2048-bit `pkey[0]`. The explanation was therefore wrong about the only part of itself that was checkable. The comment now states the measurement and points at #1 for the cause, rather than naming a mechanism nobody verified. This satisfies #1's criterion that `git grep -in 25519` return no hits outside test fixtures, which it now does. Behaviour is unchanged: this commit edits a comment. The automatic fallback this file still performs contradicts ruling B and is NOT fixed here — that work is still owed on !38. --- scripts/install-apt.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/install-apt.sh b/scripts/install-apt.sh index b9f862c..15ba9cb 100755 --- a/scripts/install-apt.sh +++ b/scripts/install-apt.sh @@ -123,9 +123,11 @@ if [ "$($SUDO curl "${CURL_AUTH[@]}" -sSL -o /dev/null -w '%{http_code}' "$RELEA fi # Newer apt verifies with sqv (Sequoia), which rejects the signature Forgejo -# currently produces for its Debian registry (malformed Ed25519 MPI encoding -# in the upstream signing library). Try the properly signed source first so -# this heals automatically once the forge is fixed. Only that signature-error +# currently produces for its Debian registry. The cause is recorded on #1; it +# is not the key algorithm, because the registry serves an RSA-2048 signing +# key (`gpg --list-packets` on repository.key reports `algo 1` with a +# 2048-bit pkey[0]). Try the properly signed source first so this heals +# automatically once the forge is fixed. Only that signature-error # class permits the compatibility fallback; auth, network, and other failures # must leave verification enabled and retain apt's original diagnostic. if update_output="$(update_only_source "$LIST" 2>&1)"; then From a89eafaebc410a99804550d4ba036a610fe3f8d3 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 31 Aug 2026 10:48:42 +0000 Subject: [PATCH 6/8] fix: require opt-in for unverified apt sources --- README.md | 21 ++++++++++++++++++++- changelog.d/1.md | 2 +- scripts/install-apt.sh | 33 +++++++++++++++++++++++++-------- test/install-apt.test.js | 32 ++++++++++++++++++++++++++++++-- 4 files changed, 76 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5fc43dc..f21be96 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,26 @@ sudo apt-get update && sudo apt-get install stoke Upgrades then arrive through regular `apt-get upgrade`. `install-apt.sh` performs all of the above, adding the NodeSource repository only when no already-configured apt source offers a new-enough nodejs. -Note: apt releases that verify OpenPGP with `sqv` (Debian 13+, apt >= 2.9) may reject signatures produced by affected Forgejo versions. `install-apt.sh` permits its `[trusted=yes]` compatibility fallback only when apt reports an explicit signature failure. Authentication, network, and all other update failures are fatal and leave the `signed-by=` source unchanged, so a transient error cannot silently disable verification. +Note: apt releases that verify OpenPGP with `sqv` (Debian 13+, apt >= 2.9) +may reject signatures produced by affected Forgejo versions. By default, +`install-apt.sh` refuses that signature failure and removes the Forge source; +authentication, network, and all other update failures are also fatal and never +disable verification. + +If the installer reports the known `sqv` parsing failure and you deliberately +accept HTTPS-only integrity without OpenPGP verification, opt in on a second +run: + +```bash +export STOKE_ALLOW_UNVERIFIED_APT=1 +sudo --preserve-env=FORGE_USER,FORGE_TOKEN,STOKE_ALLOW_UNVERIFIED_APT \ + bash /tmp/stoke-install-apt.sh +unset STOKE_ALLOW_UNVERIFIED_APT +``` + +This exact opt-in is the only path in the installer that writes a +`[trusted=yes]` source. The installer prints the security trade-off again when +it takes that path. As a fallback, each release also has the `.deb` attached for direct install: `sudo dpkg -i stoke__all.deb`. diff --git a/changelog.d/1.md b/changelog.d/1.md index 02d4760..2e9b255 100644 --- a/changelog.d/1.md +++ b/changelog.d/1.md @@ -1 +1 @@ -- Private apt installs now keep credentials out of source URLs and retain signature verification after non-signature update failures. (#1). +- Private apt installs keep credentials out of source URLs, refuse unverifiable registries by default, and require an explicit HTTPS-only opt-in to disable signature checks. (#1). diff --git a/scripts/install-apt.sh b/scripts/install-apt.sh index 15ba9cb..867c3be 100755 --- a/scripts/install-apt.sh +++ b/scripts/install-apt.sh @@ -9,6 +9,7 @@ # Usage: # ./scripts/install-apt.sh # FORGE_URL=... OWNER=... ./scripts/install-apt.sh # non-default instance +# STOKE_ALLOW_UNVERIFIED_APT=1 ./scripts/install-apt.sh # explicit HTTPS-only opt-in # # Run as root or as a user with sudo. @@ -20,6 +21,7 @@ DISTRIBUTION="${DISTRIBUTION:-stable}" COMPONENT="${COMPONENT:-main}" FORGE_USER="${FORGE_USER:-}" FORGE_TOKEN="${FORGE_TOKEN:-}" +ALLOW_UNVERIFIED="${STOKE_ALLOW_UNVERIFIED_APT:-}" # Where apt configuration lives; overridable so tests can run against a # throwaway directory instead of the real /etc/apt. APT_ETC="${STOKE_APT_ETC:-/etc/apt}" @@ -34,6 +36,11 @@ if { [ -n "$FORGE_USER" ] && [ -z "$FORGE_TOKEN" ]; } \ exit 1 fi +if [ -n "$ALLOW_UNVERIFIED" ] && [ "$ALLOW_UNVERIFIED" != "1" ]; then + echo "error: STOKE_ALLOW_UNVERIFIED_APT must be unset or exactly 1" >&2 + exit 1 +fi + SUDO="" if [ "$(id -u)" -ne 0 ]; then command -v sudo >/dev/null 2>&1 || { echo "error: run as root or install sudo" >&2; exit 1; } @@ -127,9 +134,9 @@ fi # is not the key algorithm, because the registry serves an RSA-2048 signing # key (`gpg --list-packets` on repository.key reports `algo 1` with a # 2048-bit pkey[0]). Try the properly signed source first so this heals -# automatically once the forge is fixed. Only that signature-error -# class permits the compatibility fallback; auth, network, and other failures -# must leave verification enabled and retain apt's original diagnostic. +# automatically once the forge is fixed. Only that signature-error class, plus +# the user's exact opt-in, permits an unverified source; auth, network, and +# other failures must leave verification enabled and retain apt's diagnostic. if update_output="$(update_only_source "$LIST" 2>&1)"; then printf '%s\n' "$update_output" else @@ -140,11 +147,21 @@ else printf '%s\n' "$update_output" >&2 exit "$update_status" fi - echo - echo "WARNING: signature verification failed (known Forgejo registry issue" >&2 - echo "with sqv-based apt). Falling back to [trusted=yes]; transport" >&2 - echo "security is provided by HTTPS to $FORGE_URL." >&2 - echo + if [ "$ALLOW_UNVERIFIED" != "1" ]; then + $SUDO rm -f "$LIST" + echo "error: apt could not verify the Forgejo registry signature." >&2 + echo "On sqv-based apt, the known cause is that sqv-based apt cannot parse" >&2 + echo "the Forgejo registry signature, although gpgv-based apt accepts it." >&2 + echo "No apt source was left behind." >&2 + echo "If you knowingly accept HTTPS-only integrity, re-run with" >&2 + echo "STOKE_ALLOW_UNVERIFIED_APT=1 to disable OpenPGP verification." >&2 + exit "$update_status" + fi + echo >&2 + echo "WARNING: OpenPGP signature verification is disabled for the Forgejo" >&2 + echo "registry at $FORGE_URL. You explicitly accepted HTTPS-only integrity" >&2 + echo "by setting STOKE_ALLOW_UNVERIFIED_APT=1." >&2 + echo >&2 echo "deb [trusted=yes] $FORGE_URL/api/packages/$OWNER/debian $DISTRIBUTION $COMPONENT" \ | $SUDO tee "$LIST" >/dev/null $SUDO chmod 0644 "$LIST" diff --git a/test/install-apt.test.js b/test/install-apt.test.js index 93a5b4e..fdcae15 100644 --- a/test/install-apt.test.js +++ b/test/install-apt.test.js @@ -15,12 +15,13 @@ const SCRIPT = path.join(__dirname, '..', 'scripts', 'install-apt.sh'); // releaseStatus HTTP status curl reports for the registry Release file // sourceUpdateError stderr and exit 100 for the first signed stoke update // forgeUser/token private-registry credentials +// allowUnverified explicit HTTPS-only integrity opt-in // The apt-cache stub localizes the "Candidate:" label unless LC_ALL=C is set, // so every scenario doubles as a regression test for locale-safe parsing. const cleanups = []; process.on('exit', () => { for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true }); }); -function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList, releaseStatus, sourceUpdateError, forgeUser, forgeToken }) { +function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList, releaseStatus, sourceUpdateError, forgeUser, forgeToken, allowUnverified }) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-apt-test-')); cleanups.push(root); const bin = path.join(root, 'bin'); @@ -101,6 +102,7 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi SOURCE_UPDATE_ERROR: sourceUpdateError || '', FORGE_USER: forgeUser || '', FORGE_TOKEN: forgeToken || '', + STOKE_ALLOW_UNVERIFIED_APT: allowUnverified || '', LC_ALL: 'es_ES.UTF-8', // localized environment; the script must force C }, }); @@ -194,16 +196,42 @@ test('registry Release file present: proceeds with the install', () => { assert.match(s.aptGetLog, /install -y stoke/); }); -test('signature verification failure alone may use the trusted compatibility fallback', () => { +test('signature verification failure refuses by default and removes the forge source', () => { const s = runScenario({ candInitial: '22.23.1-1nodesource1', sourceUpdateError: 'W: GPG error: signatures could not be verified: NO_PUBKEY DEADBEEF\nE: The repository is not signed.', }); + assert.notEqual(s.res.status, 0); + assert.equal(s.forgeList, null); + assert.match(s.res.stderr, /sqv-based apt cannot parse\s+the Forgejo registry signature/); + assert.match(s.res.stderr, /STOKE_ALLOW_UNVERIFIED_APT=1/); + assert.doesNotMatch(s.aptGetLog, /install -y stoke/); +}); + +test('exact opt-in permits an HTTPS-only forge source after signature failure', () => { + const s = runScenario({ + candInitial: '22.23.1-1nodesource1', + sourceUpdateError: 'W: GPG error: signatures could not be verified: NO_PUBKEY DEADBEEF\nE: The repository is not signed.', + allowUnverified: '1', + }); assert.equal(s.res.status, 0, s.res.stderr); assert.match(s.forgeList, /\[trusted=yes\]/); + assert.match(s.res.stderr, /OpenPGP signature verification is disabled/); + assert.match(s.res.stderr, /HTTPS-only integrity/); assert.match(s.aptGetLog, /install -y stoke/); }); +test('unrecognized opt-in value is rejected before configuring apt', () => { + const s = runScenario({ + candInitial: '22.23.1-1nodesource1', + allowUnverified: 'yes', + }); + assert.notEqual(s.res.status, 0); + assert.match(s.res.stderr, /STOKE_ALLOW_UNVERIFIED_APT must be unset or exactly 1/); + assert.equal(s.forgeList, null); + assert.equal(s.aptGetLog, ''); +}); + test('network update failure stays fatal and never disables signature verification', () => { const failure = 'Temporary failure resolving forgejo.heavyduty.builders'; const s = runScenario({ From 9cc9576116418b8907e9cbae476791d9c3681221 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 31 Aug 2026 10:57:32 +0000 Subject: [PATCH 7/8] fix: limit apt opt-in to known sqv failure --- scripts/install-apt.sh | 5 +++++ test/install-apt.test.js | 18 ++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/scripts/install-apt.sh b/scripts/install-apt.sh index 867c3be..8da2021 100755 --- a/scripts/install-apt.sh +++ b/scripts/install-apt.sh @@ -147,6 +147,11 @@ else printf '%s\n' "$update_output" >&2 exit "$update_status" fi + if ! grep -Fqi '/usr/bin/sqv' <<<"$update_output" \ + || ! grep -Fqi 'Malformed MPI' <<<"$update_output"; then + printf '%s\n' "$update_output" >&2 + exit "$update_status" + fi if [ "$ALLOW_UNVERIFIED" != "1" ]; then $SUDO rm -f "$LIST" echo "error: apt could not verify the Forgejo registry signature." >&2 diff --git a/test/install-apt.test.js b/test/install-apt.test.js index fdcae15..04ef646 100644 --- a/test/install-apt.test.js +++ b/test/install-apt.test.js @@ -199,7 +199,7 @@ test('registry Release file present: proceeds with the install', () => { test('signature verification failure refuses by default and removes the forge source', () => { const s = runScenario({ candInitial: '22.23.1-1nodesource1', - sourceUpdateError: 'W: GPG error: signatures could not be verified: NO_PUBKEY DEADBEEF\nE: The repository is not signed.', + sourceUpdateError: 'W: OpenPGP signature verification failed: Sub-process /usr/bin/sqv returned an error code (1), error message is: Verifying signature: Malformed MPI: leading bit is not set', }); assert.notEqual(s.res.status, 0); assert.equal(s.forgeList, null); @@ -211,7 +211,7 @@ test('signature verification failure refuses by default and removes the forge so test('exact opt-in permits an HTTPS-only forge source after signature failure', () => { const s = runScenario({ candInitial: '22.23.1-1nodesource1', - sourceUpdateError: 'W: GPG error: signatures could not be verified: NO_PUBKEY DEADBEEF\nE: The repository is not signed.', + sourceUpdateError: 'W: OpenPGP signature verification failed: Sub-process /usr/bin/sqv returned an error code (1), error message is: Verifying signature: Malformed MPI: leading bit is not set', allowUnverified: '1', }); assert.equal(s.res.status, 0, s.res.stderr); @@ -221,6 +221,20 @@ test('exact opt-in permits an HTTPS-only forge source after signature failure', assert.match(s.aptGetLog, /install -y stoke/); }); +test('opt-in cannot bypass a missing signing key', () => { + const failure = 'W: GPG error: signatures could not be verified: NO_PUBKEY DEADBEEF\nE: The repository is not signed.'; + const s = runScenario({ + candInitial: '22.23.1-1nodesource1', + sourceUpdateError: failure, + allowUnverified: '1', + }); + assert.notEqual(s.res.status, 0); + assert.match(s.res.stderr, new RegExp(failure.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + assert.match(s.forgeList, /\[signed-by=/); + assert.doesNotMatch(s.forgeList, /trusted=yes/); + assert.doesNotMatch(s.aptGetLog, /install -y stoke/); +}); + test('unrecognized opt-in value is rejected before configuring apt', () => { const s = runScenario({ candInitial: '22.23.1-1nodesource1', From 2efc76f23e70055289aba18ffffedc1246a5f1ab Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 31 Aug 2026 15:14:12 +0000 Subject: [PATCH 8/8] fix: address apt install review round --- README.md | 26 +++++++++++++++--------- scripts/install-apt.sh | 12 +++-------- test/install-apt.test.js | 43 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 61 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index f21be96..39b7b9c 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,15 @@ A command-line interface for [Forgejo](https://forgejo.org/), built with [Comman ### With apt (Debian/Ubuntu — recommended) -The package is published to the Debian registry of the forge itself. The -`heavy-duty` organization is private, so installation requires a Forgejo user -that belongs to the organization and a personal access token that can read its -packages. One-time setup: +The package is published to the public Debian registry of the forge itself. +One-time setup: + +```bash +curl -fsSL https://forgejo.heavyduty.builders/heavy-duty/stoke/raw/branch/main/scripts/install-apt.sh | bash +``` + +If a private registry or a `FORGE_URL=` override requires authentication, +download the installer and supply a Forgejo login and package-readable token: ```bash export FORGE_USER=your-forgejo-login @@ -27,15 +32,16 @@ sudo --preserve-env=FORGE_USER,FORGE_TOKEN bash /tmp/stoke-install-apt.sh unset FORGE_TOKEN ``` -The installer keeps the credentials out of the source URL in a root-readable -apt auth file. To configure that file manually before adding the source: +The authenticated path keeps credentials out of the source URL in a +root-readable apt auth file. To configure that file manually before adding the +source: ```bash sudo install -d -m 0755 /etc/apt/auth.conf.d +sudo install -m 0600 /dev/null /etc/apt/auth.conf.d/forgejo-heavy-duty.conf printf 'machine forgejo.heavyduty.builders\nlogin %s\npassword %s\n' \ "$FORGE_USER" "$FORGE_TOKEN" \ | sudo tee /etc/apt/auth.conf.d/forgejo-heavy-duty.conf >/dev/null -sudo chmod 0600 /etc/apt/auth.conf.d/forgejo-heavy-duty.conf ``` Then add the forge's registry as an apt source: @@ -77,11 +83,13 @@ run: ```bash export STOKE_ALLOW_UNVERIFIED_APT=1 -sudo --preserve-env=FORGE_USER,FORGE_TOKEN,STOKE_ALLOW_UNVERIFIED_APT \ - bash /tmp/stoke-install-apt.sh +curl -fsSL https://forgejo.heavyduty.builders/heavy-duty/stoke/raw/branch/main/scripts/install-apt.sh | bash unset STOKE_ALLOW_UNVERIFIED_APT ``` +For a private registry, re-run the downloaded installer with +`sudo --preserve-env=FORGE_USER,FORGE_TOKEN,STOKE_ALLOW_UNVERIFIED_APT` instead. + This exact opt-in is the only path in the installer that writes a `[trusted=yes]` source. The installer prints the security trade-off again when it takes that path. diff --git a/scripts/install-apt.sh b/scripts/install-apt.sh index 8da2021..caea0e9 100755 --- a/scripts/install-apt.sh +++ b/scripts/install-apt.sh @@ -52,10 +52,10 @@ if [ -n "$FORGE_USER" ] && [ -n "$FORGE_TOKEN" ]; then forge_host="${FORGE_URL#*://}" forge_host="${forge_host%%/*}" $SUDO install -d -m 0755 "$APT_ETC/auth.conf.d" + $SUDO install -m 0600 /dev/null "$AUTH" printf 'machine %s\nlogin %s\npassword %s\n' \ "$forge_host" "$FORGE_USER" "$FORGE_TOKEN" \ | $SUDO tee "$AUTH" >/dev/null - $SUDO chmod 0600 "$AUTH" CURL_AUTH=(--netrc-file "$AUTH") fi @@ -108,7 +108,7 @@ ensure_nodejs_source() { } echo "Adding APT source for $FORGE_URL/$OWNER ..." -$SUDO install -d -m 0755 "$APT_ETC/keyrings" +$SUDO install -d -m 0755 "$APT_ETC/keyrings" "$APT_ETC/sources.list.d" $SUDO curl "${CURL_AUTH[@]}" -fsSL "$FORGE_URL/api/packages/$OWNER/debian/repository.key" | $SUDO tee "$KEYRING" >/dev/null echo "deb [signed-by=$KEYRING] $FORGE_URL/api/packages/$OWNER/debian $DISTRIBUTION $COMPONENT" \ | $SUDO tee "$LIST" >/dev/null @@ -134,19 +134,13 @@ fi # is not the key algorithm, because the registry serves an RSA-2048 signing # key (`gpg --list-packets` on repository.key reports `algo 1` with a # 2048-bit pkey[0]). Try the properly signed source first so this heals -# automatically once the forge is fixed. Only that signature-error class, plus +# automatically once the forge is fixed. Only the exact live sqv failure, plus # the user's exact opt-in, permits an unverified source; auth, network, and # other failures must leave verification enabled and retain apt's diagnostic. if update_output="$(update_only_source "$LIST" 2>&1)"; then printf '%s\n' "$update_output" else update_status=$? - if ! grep -Eiq \ - 'NO_PUBKEY|EXPKEYSIG|BADSIG|signatures? (could not|couldn.t) be verified|signature (verification )?(failed|failure|error|invalid)|repository .*not signed|is not signed' \ - <<<"$update_output"; then - printf '%s\n' "$update_output" >&2 - exit "$update_status" - fi if ! grep -Fqi '/usr/bin/sqv' <<<"$update_output" \ || ! grep -Fqi 'Malformed MPI' <<<"$update_output"; then printf '%s\n' "$update_output" >&2 diff --git a/test/install-apt.test.js b/test/install-apt.test.js index 04ef646..c007802 100644 --- a/test/install-apt.test.js +++ b/test/install-apt.test.js @@ -16,12 +16,13 @@ const SCRIPT = path.join(__dirname, '..', 'scripts', 'install-apt.sh'); // sourceUpdateError stderr and exit 100 for the first signed stoke update // forgeUser/token private-registry credentials // allowUnverified explicit HTTPS-only integrity opt-in +// precreateSourcesDir whether the throwaway apt root already has sources.list.d // The apt-cache stub localizes the "Candidate:" label unless LC_ALL=C is set, // so every scenario doubles as a regression test for locale-safe parsing. const cleanups = []; process.on('exit', () => { for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true }); }); -function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList, releaseStatus, sourceUpdateError, forgeUser, forgeToken, allowUnverified }) { +function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexistingNodesourceList, releaseStatus, sourceUpdateError, forgeUser, forgeToken, allowUnverified, precreateSourcesDir = true }) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-apt-test-')); cleanups.push(root); const bin = path.join(root, 'bin'); @@ -29,7 +30,7 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi const aptEtc = path.join(root, 'etc', 'apt'); fs.mkdirSync(bin, { recursive: true }); fs.mkdirSync(state, { recursive: true }); - fs.mkdirSync(path.join(aptEtc, 'sources.list.d'), { recursive: true }); + if (precreateSourcesDir) fs.mkdirSync(path.join(aptEtc, 'sources.list.d'), { recursive: true }); fs.writeFileSync(path.join(state, 'candidate'), candInitial); if (preexistingNodesourceList !== undefined) { fs.writeFileSync(path.join(aptEtc, 'sources.list.d', 'nodesource.list'), preexistingNodesourceList); @@ -43,6 +44,20 @@ function runScenario({ candInitial, candAfterUpdate, candAfterNodesource, preexi // Force the non-root path so every mutation goes through the sudo stub. stub('id', 'echo 1000'); stub('sudo', 'SUDO_ACTIVE=1 exec "$@"'); + stub('tee', [ + 'for destination in "$@"; do', + ' case "$destination" in', + ' */auth.conf.d/forgejo-*.conf)', + ' mode="$(stat -c %a "$destination" 2>/dev/null || true)"', + ' if [ "$mode" != 600 ]; then', + ' echo "credential destination was not mode 0600 before write" >&2', + ' exit 78', + ' fi', + ' ;;', + ' esac', + 'done', + 'exec /usr/bin/tee "$@"', + ].join('\n')); // Registry Release-file probes (URLs under /dists/) answer with the // scenario's HTTP status; everything else is a key fetch. stub('curl', [ @@ -196,6 +211,16 @@ test('registry Release file present: proceeds with the install', () => { assert.match(s.aptGetLog, /install -y stoke/); }); +test('fresh apt root creates sources.list.d before writing the forge source', () => { + const s = runScenario({ + candInitial: '22.23.1-1nodesource1', + precreateSourcesDir: false, + }); + assert.equal(s.res.status, 0, s.res.stderr); + assert.match(s.forgeList, /\[signed-by=/); + assert.match(s.aptGetLog, /install -y stoke/); +}); + test('signature verification failure refuses by default and removes the forge source', () => { const s = runScenario({ candInitial: '22.23.1-1nodesource1', @@ -235,6 +260,20 @@ test('opt-in cannot bypass a missing signing key', () => { assert.doesNotMatch(s.aptGetLog, /install -y stoke/); }); +test('opt-in cannot bypass sqv output without the known Malformed MPI failure', () => { + const failure = 'W: OpenPGP signature verification failed: Sub-process /usr/bin/sqv returned an error code (1): unexpected packet'; + const s = runScenario({ + candInitial: '22.23.1-1nodesource1', + sourceUpdateError: failure, + allowUnverified: '1', + }); + assert.notEqual(s.res.status, 0); + assert.match(s.res.stderr, new RegExp(failure.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + assert.match(s.forgeList, /\[signed-by=/); + assert.doesNotMatch(s.forgeList, /trusted=yes/); + assert.doesNotMatch(s.aptGetLog, /install -y stoke/); +}); + test('unrecognized opt-in value is rejected before configuring apt', () => { const s = runScenario({ candInitial: '22.23.1-1nodesource1',