diff --git a/Dockerfile b/Dockerfile index 262d838..1eb4b32 100644 --- a/Dockerfile +++ b/Dockerfile @@ -98,17 +98,51 @@ RUN chmod +x /usr/local/bin/verify-checksum SHELL ["/bin/bash", "-c"] # 2a. External APT repos (GitHub CLI, Eza) — needs gnupg, stays combined -RUN mkdir -p -m 755 /etc/apt/keyrings \ +# +# Signing keys are pinned by full primary-key fingerprint. Each downloaded +# keyring must contain exactly the expected set of primary keys (subkeys are +# ignored) before it is trusted as an APT signer; any difference fails the +# build. To rotate, verify the new fingerprint out-of-band (see SECURITY.md) +# and update the ARG. Values are space-separated, upper-case, 40-hex. +# GitHub CLI: https://github.com/cli/cli/blob/trunk/docs/install_linux.md +# Eza (deb.gierens.de): key file pinned to an immutable eza commit +ARG GH_CLI_KEY_FINGERPRINTS="2C6106201985B60E6C7AC87323F3D4EA75716059 7F38BBB59D064DBCB3D84D725612B36462313325" +ARG EZA_KEY_FINGERPRINTS="1548BC8A4B4D2688F9B0DAF7EC29E2090CE3FD43" +ARG EZA_KEY_URL=https://raw.githubusercontent.com/eza-community/eza/1cff499fb218f2a133aafa01824ddab090f4389e/deb.asc +RUN set -euo pipefail \ + && mkdir -p -m 755 /etc/apt/keyrings \ && ARCH=$(dpkg --print-architecture) \ && apt-get update \ && apt-get install -y --no-install-recommends gnupg \ + && KEYDIR=$(mktemp -d) \ + && verify_apt_key() { \ + local name=$1 file=$2 expected=$3 actual want; \ + want=$(printf '%s\n' $expected | tr '[:lower:]' '[:upper:]' | sort -u | paste -sd' ' -); \ + [ -n "$want" ] || { echo "Error: no expected fingerprint configured for ${name} APT key" >&2; return 1; }; \ + actual=$(GNUPGHOME="$KEYDIR/gnupg" gpg --batch --quiet --show-keys --with-colons "$file" 2>/dev/null \ + | awk -F: '$1 == "pub" { want_fpr = 1; next } want_fpr && $1 == "fpr" { print $10; want_fpr = 0 }' \ + | sort -u | paste -sd' ' -); \ + if [ "$actual" != "$want" ]; then \ + echo "Error: ${name} APT signing key fingerprint mismatch" >&2; \ + echo " expected: ${want}" >&2; \ + echo " actual: ${actual:-}" >&2; \ + return 1; \ + fi; \ + echo "Verified ${name} APT signing key: ${actual}"; \ + } \ + && mkdir -m 700 "$KEYDIR/gnupg" \ # GitHub CLI - && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ - && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && curl -fsSL -o "$KEYDIR/githubcli.gpg" https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + && verify_apt_key "GitHub CLI" "$KEYDIR/githubcli.gpg" "$GH_CLI_KEY_FINGERPRINTS" \ + && install -m 644 "$KEYDIR/githubcli.gpg" /etc/apt/keyrings/githubcli-archive-keyring.gpg \ && echo "deb [arch=${ARCH} signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ # Eza - && curl -fsSL https://raw.githubusercontent.com/eza-community/eza/main/deb.asc | gpg --dearmor -o /etc/apt/keyrings/gierens.gpg \ + && curl -fsSL -o "$KEYDIR/eza.asc" "$EZA_KEY_URL" \ + && verify_apt_key "Eza" "$KEYDIR/eza.asc" "$EZA_KEY_FINGERPRINTS" \ + && GNUPGHOME="$KEYDIR/gnupg" gpg --batch --dearmor -o /etc/apt/keyrings/gierens.gpg < "$KEYDIR/eza.asc" \ + && chmod 644 /etc/apt/keyrings/gierens.gpg \ && echo "deb [signed-by=/etc/apt/keyrings/gierens.gpg] https://deb.gierens.de stable main" | tee /etc/apt/sources.list.d/gierens.list \ + && rm -rf "$KEYDIR" \ # Install from repos && apt-get update \ && apt-get install -y --no-install-recommends gh eza \ diff --git a/SECURITY.md b/SECURITY.md index 910b23e..3701a7e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -150,7 +150,7 @@ aliases. | Tool tier | Source | Integrity policy | Version policy | | --- | --- | --- | --- | | Image tier binary artifacts | Official GitHub Releases | Squarebox-pinned SHA-256, checked against the exact GitHub release-asset digest during pin refresh; installation fails closed if missing/mismatched | Pinned in the Candidate | -| Image tier APT packages | Ubuntu and configured signed repositories | APT repository signatures | Distribution/repository version | +| Image tier APT packages | Ubuntu archive; build-only GitHub CLI and Eza repositories | APT repository signatures; external signing keys pinned by full primary-key fingerprint and verified before they are trusted | Distribution/repository version | | Box tier packages | APT inside the Box | APT repository signatures | Reconciled when selected | | Managed-home GitHub tools | Official GitHub Releases | Exact release tag and asset name; GitHub release-asset SHA-256 digest; fail closed if missing, duplicate, malformed, or mismatched | Selected latest or explicit release | | Managed-home Git sources | Official GitHub repositories (LazyVim, Oh My Zsh, and Zsh plugins) | Default branch resolved through GitHub metadata to a full commit SHA; exact fetch/checkout and HEAD verification before activation | Resolved only during an explicit setup/reconcile action; local changes are preserved by refusal | @@ -169,6 +169,24 @@ Unsupported architectures and invalid Tool-tier/destination combinations fail before network or destination mutation. Extracted archives reject escaping links, special files, and ambiguous executable matches before promotion. +The GitHub CLI and Eza APT repositories are configured only while the image +builds (then removed). Their signing keys are not trusted on download alone: +the Dockerfile pins each key's expected full 40-hex primary-key fingerprint set +in `GH_CLI_KEY_FINGERPRINTS` and `EZA_KEY_FINGERPRINTS`, and the build fails +unless the downloaded keyring contains exactly that set of primary keys +(subkeys are not compared). The check runs before any keyring is installed +under `/etc/apt/keyrings` or any source list refers to it. The Eza key is +fetched from an immutable eza commit (`EZA_KEY_URL`), not a branch. +`tests/test-apt-key-policy.sh` asserts this policy statically. + +To rotate a key, obtain the new key and confirm its fingerprint out-of-band +before editing the ARG: compare `gpg --show-keys --with-colons` output with the +publisher's documentation (GitHub CLI lists its fingerprints in +`docs/install_linux.md`) and with the issuer fingerprint on the live +repository's `Release.gpg`/`InRelease` signature. Never copy the value from a +failed build's "actual" output alone; a mismatch is exactly the signal this +control exists to raise. + Image-tier runtime updates receive one additional gate: the exact current-arch artifact in the Candidate checksum manifest must equal GitHub's digest for the resolved upstream release asset. Otherwise `sqrbx-update` reports that a newer diff --git a/tests/test-apt-key-policy.sh b/tests/test-apt-key-policy.sh new file mode 100755 index 0000000..bc81f21 --- /dev/null +++ b/tests/test-apt-key-policy.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Static policy: every external APT signing key the Dockerfile downloads must be +# verified against a pinned full primary-key fingerprint set before it is +# installed as an APT signer or any source list referencing it is written. +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +DOCKERFILE="$ROOT/Dockerfile" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +line_of() { + # First line number containing the fixed string $1 (0 if absent). + grep -nF -- "$1" "$DOCKERFILE" | head -n1 | cut -d: -f1 || true +} + +# Fingerprint ARGs: non-empty, each token a 40-hex upper-case fingerprint, no duplicates. +mapfile -t fpr_args < <(sed -n 's/^ARG \([A-Z0-9_]*_KEY_FINGERPRINTS\)=.*/\1/p' "$DOCKERFILE") +[ "${#fpr_args[@]}" -ge 2 ] || fail "expected GitHub CLI and Eza fingerprint ARGs" +for required in GH_CLI_KEY_FINGERPRINTS EZA_KEY_FINGERPRINTS; do + printf '%s\n' "${fpr_args[@]}" | grep -qx "$required" || fail "missing ARG $required" +done +for arg in "${fpr_args[@]}"; do + [ "$(grep -c "^ARG ${arg}=" "$DOCKERFILE")" -eq 1 ] || fail "$arg must be declared exactly once" + value=$(sed -n "s/^ARG ${arg}=\"\{0,1\}\([^\"]*\)\"\{0,1\}\$/\1/p" "$DOCKERFILE") + [ -n "$value" ] || fail "$arg is empty" + read -r -a tokens <<<"$value" + [ "${#tokens[@]}" -ge 1 ] || fail "$arg has no fingerprints" + for token in "${tokens[@]}"; do + [[ "$token" =~ ^[0-9A-F]{40}$ ]] || fail "$arg value '$token' is not a 40-hex upper-case fingerprint" + done + [ "$(printf '%s\n' "${tokens[@]}" | sort -u | wc -l)" -eq "${#tokens[@]}" ] \ + || fail "$arg contains duplicate fingerprints" + grep -Fq "\"\$${arg}\"" "$DOCKERFILE" || fail "$arg is declared but never used for verification" +done + +# The verifier compares primary-key fingerprints (pub -> following fpr) as an exact set. +grep -Fq 'verify_apt_key() {' "$DOCKERFILE" || fail "Dockerfile does not define verify_apt_key" +grep -Fq "\$1 == \"pub\" { want_fpr = 1; next } want_fpr && \$1 == \"fpr\" { print \$10; want_fpr = 0 }" "$DOCKERFILE" \ + || fail "verify_apt_key must extract only primary-key fingerprints" +grep -Fq 'if [ "$actual" != "$want" ]; then' "$DOCKERFILE" \ + || fail "verify_apt_key must require the exact expected fingerprint set" + +# No key may be piped straight from the network into a keyring. +if grep -Eq 'curl[^|]*(\.gpg|\.asc|keyring)[^|]*\|' "$DOCKERFILE"; then + fail "an APT key is piped from curl without fingerprint verification" +fi +if grep -Eq 'raw\.githubusercontent\.com/eza-community/eza/(main|master)/' "$DOCKERFILE"; then + fail "Eza key URL must be pinned to an immutable commit, not a branch" +fi +grep -Eq '^ARG EZA_KEY_URL=https://raw\.githubusercontent\.com/eza-community/eza/[0-9a-f]{40}/deb\.asc$' "$DOCKERFILE" \ + || fail "Eza key URL must be pinned to a full commit SHA" + +# Every downloaded key file is verified, and verification precedes keyring +# installation and source-list creation for that repository. +mapfile -t key_files < <(grep -oE 'curl -fsSL -o "\$KEYDIR/[^"]+"' "$DOCKERFILE" | sed 's/.*"\$KEYDIR\/\([^"]*\)"/\1/') +[ "${#key_files[@]}" -ge 2 ] || fail "expected at least two downloaded APT keys" +for key in "${key_files[@]}"; do + download=$(line_of "curl -fsSL -o \"\$KEYDIR/$key\"") + verify=$(grep -nE "verify_apt_key \"[^\"]+\" \"\\\$KEYDIR/${key//./\\.}\" \"\\\$[A-Z0-9_]+_KEY_FINGERPRINTS\"" "$DOCKERFILE" | head -n1 | cut -d: -f1 || true) + [ -n "$verify" ] || fail "downloaded key $key is never fingerprint-verified" + [ "$verify" -gt "$download" ] || fail "key $key is verified before it is downloaded" + use=$(grep -nF "\"\$KEYDIR/$key\"" "$DOCKERFILE" | cut -d: -f1 | awk -v v="$verify" '$1 > v' | head -n1) + [ -n "$use" ] || fail "verified key $key is never installed" +done + +# Every signed-by keyring is installed only after a verification step, and the +# number of signer keyrings matches the number of verified downloads. +mapfile -t signers < <(grep -oE 'signed-by=/etc/apt/keyrings/[^] ]+' "$DOCKERFILE" | sort -u) +[ "${#signers[@]}" -eq "${#key_files[@]}" ] \ + || fail "signed-by keyrings (${#signers[@]}) do not match verified downloads (${#key_files[@]})" +first_verify=$(grep -nF 'verify_apt_key "' "$DOCKERFILE" | head -n1 | cut -d: -f1) +for signer in "${signers[@]}"; do + path=${signer#signed-by=} + sources_line=$(line_of "$signer") + [ "$sources_line" -gt "$first_verify" ] || fail "$path is trusted before key verification" +done + +echo "PASS: external APT signing keys are pinned by primary-key fingerprint and verified before use"