supply chain: verify signatures and pins across image + kernel builds

Three independent hardenings, addressing a review finding that the
kernel and image build pipelines were relying on HTTPS alone for
artifact integrity.

scripts/make-generic-kernel.sh
- Fetch the detached PGP signature (linux-<ver>.tar.sign) alongside
  the tarball and verify it with gpg before extraction. An isolated
  $GNUPGHOME under the tempdir keeps the kernel signers out of the
  invoking user's keyring.
- Import the three kernel.org release signing keys (Greg KH / Linus /
  Sasha Levin) from keyserver.ubuntu.com, falling back to
  keys.openpgp.org. Ubuntu comes first because keys.openpgp.org strips
  unverified UIDs on upload, leaving gpg with UID-less keys it
  refuses to trust.
- Require VALIDSIG (cryptographic proof) rather than GOODSIG
  (printed even for expired keys) before proceeding. Verified
  end-to-end against a clean tarball (accepts) and a byte-flipped
  tampered copy (rejects with BADSIG).
- gpg + gpgv + xz added to the required-tools check.

images/golden/Dockerfile
- Pin Docker's apt signing key by fingerprint. After downloading
  /etc/apt/keyrings/docker.asc we gpg --show-keys --with-colons it,
  extract the fpr, and compare against the expected
  9DC858229FC7DD38854AE2D88D81803C0EBFCD88. A tampered or swapped key
  aborts the build before any apt repo metadata is fetched.
- Replace `curl https://mise.run | sh` with a pinned GitHub release
  binary (mise v2026.4.18, linux-x64) verified against its published
  sha256. Refuses to build on unknown architectures rather than
  silently installing a binary we have no hash for.
- Add gnupg to the ESSENTIAL apt-get install so the fingerprint check
  has gpg available.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-21 19:38:13 -03:00
parent 011b59a72f
commit 25a1466947
No known key found for this signature in database
GPG key ID: 33112E6833C34679
2 changed files with 109 additions and 23 deletions

View file

@ -17,8 +17,9 @@ ENV DEBIAN_FRONTEND=noninteractive \
# -------- 1. ESSENTIAL -------- # -------- 1. ESSENTIAL --------
# Banger needs: an init (systemd + udev + dbus), sshd (the only # Banger needs: an init (systemd + udev + dbus), sshd (the only
# control channel), TLS roots + curl (first-boot installs + mise # control channel), TLS roots + curl (first-boot installs + mise
# installer), iproute2 (debugging; `ip` is still useful even when # installer), gnupg (build-time signing-key verification for the
# the kernel sets IP via cmdline). # Docker apt repo), iproute2 (debugging; `ip` is still useful even
# when the kernel sets IP via cmdline).
# #
# udev is a Recommends of the systemd package on Debian. With # udev is a Recommends of the systemd package on Debian. With
# --no-install-recommends it's skipped — and without it systemd never # --no-install-recommends it's skipped — and without it systemd never
@ -33,6 +34,7 @@ RUN apt-get update \
openssh-server \ openssh-server \
ca-certificates \ ca-certificates \
curl \ curl \
gnupg \
iproute2 \ iproute2 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@ -55,25 +57,57 @@ RUN apt-get update \
# Docker CE (with Compose v2 + buildx) from the official apt repo. # Docker CE (with Compose v2 + buildx) from the official apt repo.
# Nested-VM docker gives Compose workflows hostname/port isolation # Nested-VM docker gives Compose workflows hostname/port isolation
# per banger VM, which is a big part of the sandbox story. # per banger VM, which is a big part of the sandbox story.
RUN install -m 0755 -d /etc/apt/keyrings \ #
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ # The apt key is verified against its published fingerprint before
&& chmod a+r /etc/apt/keyrings/docker.asc \ # we commit it to the signed-by keyring, so a tampered download (or
&& printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable\n' \ # a TLS compromise against download.docker.com) cannot silently
"$(dpkg --print-architecture)" > /etc/apt/sources.list.d/docker.list \ # swap in an attacker-controlled signing key. Fingerprint source:
&& apt-get update \ # https://docs.docker.com/engine/install/debian/#install-using-the-repository
&& apt-get install -y --no-install-recommends \ RUN set -eu; \
expected_fpr=9DC858229FC7DD38854AE2D88D81803C0EBFCD88; \
install -m 0755 -d /etc/apt/keyrings; \
curl -fsSL https://download.docker.com/linux/debian/gpg -o /tmp/docker.asc; \
got="$(gpg --with-colons --show-keys --fingerprint /tmp/docker.asc | awk -F: '/^fpr:/ {print $10; exit}')"; \
if [ "$got" != "$expected_fpr" ]; then \
echo "docker apt key fingerprint mismatch: got $got, want $expected_fpr" >&2; \
exit 1; \
fi; \
mv /tmp/docker.asc /etc/apt/keyrings/docker.asc; \
chmod a+r /etc/apt/keyrings/docker.asc; \
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable\n' \
"$(dpkg --print-architecture)" > /etc/apt/sources.list.d/docker.list; \
apt-get update; \
apt-get install -y --no-install-recommends \
docker-ce docker-ce-cli containerd.io \ docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin \ docker-buildx-plugin docker-compose-plugin; \
&& rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# mise — per-repo version manager. Installed system-wide so the # mise — per-repo version manager. Installed from a pinned GitHub
# bashrc activation reaches every shell. # release asset rather than `curl https://mise.run | sh` so a compromise
RUN curl -fsSL https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh \ # of the installer endpoint can't silently push arbitrary code into
&& chmod 0755 /usr/local/bin/mise \ # the golden image.
&& install -d /etc/profile.d \ #
&& printf '%s\n' 'if [ -x /usr/local/bin/mise ]; then eval "$(/usr/local/bin/mise activate bash)"; fi' \ # Update protocol: bump MISE_VERSION + MISE_SHA256 together. Source
> /etc/profile.d/mise.sh \ # for the hash is the `digest` field on the release asset from
&& chmod 0644 /etc/profile.d/mise.sh # `gh release view --repo jdx/mise --json assets`, or compute from
# the downloaded file and cross-reference against SHASUMS256.txt on
# the release page.
ARG MISE_VERSION=v2026.4.18
ARG MISE_SHA256_AMD64=6ae2d5f0f23a2f2149bc5d9bf264fe0922a1da843f1903e453516c462b23cc1f
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
if [ "$arch" != "amd64" ]; then \
echo "mise pin only tracks amd64; add a ${arch} hash to refresh" >&2; \
exit 1; \
fi; \
curl -fsSL -o /tmp/mise "https://github.com/jdx/mise/releases/download/${MISE_VERSION}/mise-${MISE_VERSION}-linux-x64"; \
echo "${MISE_SHA256_AMD64} /tmp/mise" | sha256sum -c -; \
install -m 0755 /tmp/mise /usr/local/bin/mise; \
rm /tmp/mise; \
install -d /etc/profile.d; \
printf '%s\n' 'if [ -x /usr/local/bin/mise ]; then eval "$(/usr/local/bin/mise activate bash)"; fi' \
> /etc/profile.d/mise.sh; \
chmod 0644 /etc/profile.d/mise.sh
# Default branch for any git init inside the sandbox. # Default branch for any git init inside the sandbox.
RUN git config --system init.defaultBranch main RUN git config --system init.defaultBranch main

View file

@ -44,18 +44,70 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
for tool in curl tar make gcc; do for tool in curl tar xz make gcc gpg gpgv; do
command -v "$tool" >/dev/null 2>&1 || { log "missing required tool: $tool"; exit 1; } command -v "$tool" >/dev/null 2>&1 || { log "missing required tool: $tool"; exit 1; }
done done
[[ -f "$CONFIG" ]] || { log "config not found: $CONFIG"; exit 1; } [[ -f "$CONFIG" ]] || { log "config not found: $CONFIG"; exit 1; }
# kernel.org release signing keys. Stable (Greg KH) signs most point
# releases; mainline (Linus) signs .0 drops; Sasha Levin sometimes
# signs longterm backports. Listing all three keeps the script
# working across every release channel the user might pick. Rotations
# are rare and announced; update this list if gpg complains.
#
# Fingerprints verified against kernel.org:
# https://www.kernel.org/signature.html
KERNEL_SIGNING_KEYS=(
647F28654894E3BD457199BE38DBBDC86092693E # Greg Kroah-Hartman
ABAF11C65A2970B130ABE3C479BE3E4300411886 # Linus Torvalds
E27E5D8A3403A2EF66873BBCDEA66FF797772CDC # Sasha Levin
)
TARBALL="linux-${KERNEL_VERSION}.tar.xz" TARBALL="linux-${KERNEL_VERSION}.tar.xz"
URL="https://cdn.kernel.org/pub/linux/kernel/v${KERNEL_MAJOR}.x/$TARBALL" SIGNATURE="linux-${KERNEL_VERSION}.tar.sign"
BASE_URL="https://cdn.kernel.org/pub/linux/kernel/v${KERNEL_MAJOR}.x"
SRC_DIR="$(mktemp -d)" SRC_DIR="$(mktemp -d)"
trap 'rm -rf "$SRC_DIR"' EXIT trap 'rm -rf "$SRC_DIR"' EXIT
log "downloading kernel $KERNEL_VERSION from $URL" # Isolated GNUPGHOME so the verification step can't accidentally
curl -fSL --progress-bar -o "$SRC_DIR/$TARBALL" "$URL" # trust whatever the invoking user already has in their keyring. The
# trap above cleans the whole SRC_DIR, including this.
GPG_HOME="$SRC_DIR/gnupg"
install -d -m 0700 "$GPG_HOME"
export GNUPGHOME="$GPG_HOME"
log "importing kernel.org signing keys"
# keyserver.ubuntu.com first: it returns keys with user IDs intact,
# which gpg needs to mark the key as usable. keys.openpgp.org (the
# current SKS successor) strips unverified UIDs on upload, and the
# kernel.org devs haven't all completed its email verification flow,
# so pulling from there returns UID-less keys that gpg then refuses
# to trust. We fall back to it anyway in case ubuntu is unreachable.
if ! gpg --batch --keyserver hkps://keyserver.ubuntu.com --recv-keys "${KERNEL_SIGNING_KEYS[@]}" 2>/dev/null; then
log "key fetch from keyserver.ubuntu.com failed; trying keys.openpgp.org"
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "${KERNEL_SIGNING_KEYS[@]}"
fi
log "downloading kernel $KERNEL_VERSION from $BASE_URL/$TARBALL"
curl -fSL --progress-bar -o "$SRC_DIR/$TARBALL" "$BASE_URL/$TARBALL"
curl -fSL --progress-bar -o "$SRC_DIR/$SIGNATURE" "$BASE_URL/$SIGNATURE"
log "verifying signature"
# The .tar.sign is a detached signature over the *uncompressed* tar,
# per kernel.org convention. Pipe the xz-decompressed stream into
# gpg --verify so we never materialise an unverified tarball on disk.
# Require VALIDSIG (the cryptographic proof — GOODSIG alone is
# printed even for expired/revoked keys, VALIDSIG requires a usable
# key and a mathematically valid signature).
VERIFY_STATUS="$SRC_DIR/verify.status"
xz -cd "$SRC_DIR/$TARBALL" | gpg --batch --status-fd 3 --verify "$SRC_DIR/$SIGNATURE" - 3>"$VERIFY_STATUS" 2>/dev/null || true
if ! grep -qE '^\[GNUPG:\] VALIDSIG' "$VERIFY_STATUS"; then
log "signature verification FAILED — refusing to build"
log "gpg status:"
cat "$VERIFY_STATUS" >&2 || true
exit 1
fi
log "signature OK (signed by $(awk '/^\[GNUPG:\] VALIDSIG/ {print $3}' "$VERIFY_STATUS"))"
log "extracting" log "extracting"
tar -xf "$SRC_DIR/$TARBALL" -C "$SRC_DIR" --strip-components=1 tar -xf "$SRC_DIR/$TARBALL" -C "$SRC_DIR" --strip-components=1