From 408b328c3929ca6343bcb89d0aed6c85ac7ae5bd Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 2 Aug 2026 08:55:33 -0400 Subject: [PATCH] fix(10-03): strip fleet-shared identity material from the rootfs tar (F-03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rootfs is a container image exported to a tar and extracted verbatim onto every disk flashed from the ISO, and the ISO is published. It baked two things nobody asked for: Debian's openssh-server postinst generates /etc/ssh/ssh_host_* during the container build, and the `openssl req` layer writes the TLS keypair. Both were therefore identical on every node and known to every downloader. Add a final RUN layer to Dockerfile.rootfs that removes /etc/ssh/ssh_host_*, removes the archipelago TLS keypair (keeping the ssl directory so the first-boot staging swap has somewhere to land), truncates /etc/machine-id to systemd's documented "regenerate on next boot" state, and drops a non-shared /var/lib/dbus/machine-id if one exists as a real file rather than a symlink. It also writes /opt/archipelago/rootfs-identity-stripped so a node can answer after the fact whether its rootfs came from a stripped build; no timestamp, so the RECIPE_HASH cache stays reproducible. This is what makes 10-03's fail-closed regeneration structural instead of procedural: with the material gone, a regeneration failure degrades to "no key, service refuses to start" rather than "fleet-shared key, silently". The `openssl req` layer is deliberately left in place — it keeps proving openssl is present and keeps the SAN template next to its consumer; the strip layer is what makes the output non-shared. Two comment corrections that follow from the strip: - The installer's TLS block is no longer a rarely-taken safety net; it now fires on every install. It is per-install and never image-wide, so it does not reopen F-03, but it does mean a first-boot failure still leaves the web UI with a cert while SSH has nothing. Comment updated to say so. - The first-boot script header overstated the fail-closed cost for TLS for the same reason; corrected to claim certainty only for SSH. This edit is inside the RECIPE_HASH region, so the next build is forced to rebuild the rootfs tar — required for the C-4 evidence to mean anything. Co-Authored-By: Claude Opus 5 (1M context) --- .../_archived/build-auto-installer-iso.sh | 58 +++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index d7a9a44c..0f75c922 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -578,6 +578,42 @@ RUN mkdir -p /var/log/journal && \ # Clean up RUN apt-get clean && \ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +# ─── Strip fleet-shared identity material (audit finding F-03) ────────────── +# +# This image is exported to a tar and extracted VERBATIM onto every disk +# flashed from the resulting ISO, and the ISO is a published artefact. Anything +# identity-shaped left in here is therefore held by every node AND by every +# person who downloaded the ISO. +# +# Two things get baked without anyone asking for them: +# - Debian's openssh-server postinst generates /etc/ssh/ssh_host_* at package +# install time, i.e. inside this container build. +# - the "openssl req" layer above writes /etc/archipelago/ssl/archipelago.key. +# NOTE: this heredoc is UNQUOTED, so backticks here are command substitution +# and would run at build time. Never put backticks in these comments. +# Plus /etc/machine-id, which systemd populates during the build and which +# correlates every node flashed from one ISO. +# +# archipelago-first-boot-secrets.service recreates all of this per device on +# first boot. The point of removing it HERE is to change what a regeneration +# failure costs: with the material stripped, a failure degrades to "no key, the +# service refuses to start" instead of "fleet-shared key, silently" — which is +# the whole of F-03. That makes fail-closed structural rather than procedural. +# +# This must stay the LAST layer: anything that installs packages after it can +# reintroduce host keys. Keep the /etc/archipelago/ssl directory itself so the +# first-boot script's staging swap has somewhere to land. The "openssl req" +# layer above is deliberately left in place — it keeps proving openssl is +# present and keeps the SAN template next to the code that uses it; this layer +# is what makes the shipped output non-shared. +RUN rm -f /etc/ssh/ssh_host_* && \ + rm -f /etc/archipelago/ssl/archipelago.key /etc/archipelago/ssl/archipelago.crt && \ + mkdir -p /etc/archipelago/ssl && \ + : > /etc/machine-id && \ + { [ -L /var/lib/dbus/machine-id ] || rm -f /var/lib/dbus/machine-id ; } && \ + mkdir -p /opt/archipelago && \ + printf 'F-03 identity strip: this rootfs was built with the identity-strip layer.\nRemoved:\n /etc/ssh/ssh_host_*\n /etc/archipelago/ssl/archipelago.key\n /etc/archipelago/ssl/archipelago.crt\nTruncated:\n /etc/machine-id\nRecreated per device by archipelago-first-boot-secrets.service on first boot.\n' > /opt/archipelago/rootfs-identity-stripped DOCKERFILE # Copy nginx snippets for HTTPS (PWA, app proxies) @@ -1631,9 +1667,12 @@ cat > "$WORK_DIR/first-boot-secrets.sh" <<'SECRETSSCRIPT' # SCRIPT IS THE ONLY THING THAT CREATES THEM. That is deliberate. # # The operational consequence, in plain words: if regeneration fails every -# retry, this node has no SSH host key and no TLS key. sshd will not start and -# the nginx TLS listener will not start, so the node cannot be reached over -# the network and recovery requires the physical console. +# retry, this node has no SSH host key, so sshd will not start and the node +# cannot be reached over SSH — recovery requires the physical console. Nothing +# else on the install path creates host keys, so that outcome is certain. +# (TLS is softer: the installer writes a per-node fallback keypair with the +# generic CN=archipelago SAN, so the web UI usually still comes up. That +# fallback is per install, never image-wide, so it does not reopen F-03.) # # That cost was accepted on purpose. The behaviour it replaces was worse: log # a warning, set the completion marker anyway, and run forever on the SSH host @@ -3391,7 +3430,18 @@ RemainAfterExit=yes WantedBy=multi-user.target DIAGSVC -# Ensure SSL cert exists for nginx HTTPS (safety net if rootfs build missed it) +# Ensure SSL cert exists for nginx HTTPS. +# +# Since the F-03 identity-strip layer this is no longer a rarely-taken safety +# net — the rootfs ships with NO TLS keypair, so this branch fires on every +# install. That is fine and deliberate: the installer runs separately on each +# target machine, so the key it writes is per node, not image-wide. It uses the +# generic CN=archipelago SAN; archipelago-first-boot-secrets.service replaces it +# on first boot with one carrying this device's actual hostname. +# +# Consequence worth knowing: if first-boot regeneration fails, the web UI still +# has *a* per-node cert from here, whereas SSH has nothing at all (nothing +# recreates host keys at install time) and sshd will refuse to start. if [ ! -f /mnt/target/etc/archipelago/ssl/archipelago.crt ]; then mkdir -p /mnt/target/etc/archipelago/ssl chroot /mnt/target openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \