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({