fix(10-03): strip fleet-shared identity material from the rootfs tar (F-03)

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) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 08:55:33 -04:00
co-authored by Claude Opus 5
parent 210430967d
commit 408b328c39
@@ -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 \