fix: preserve apt signature verification #38
3 changed files with 61 additions and 20 deletions
26
README.md
26
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue