Files
archy/.planning/phases/10-key-material-hardening/10-03-PLAN.md

24 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
10-key-material-hardening 03 execute 1
image-recipe/_archived/build-auto-installer-iso.sh
tests/first-boot-secrets/run-tests.sh
docs/security/KEY-02-ROOTFS-EVIDENCE.md
false
KEY-02
KEY-04
truths artifacts key_links
A first-boot secret regeneration that fails does NOT set the completion marker, so the oneshot retries on the next boot (D-05)
Each generator is retried with backoff within a single boot before the boot is declared failed (D-05)
A terminal failure is loud: it reaches the console and a durable on-disk failure record, not only a log file nobody reads (D-05)
The shipped rootfs tar contains no SSH host keys, no TLS private key and no populated machine-id, so a regeneration failure degrades to 'no key, service refuses to start' rather than 'fleet-shared key, silently'
The regeneration script is exercised by an automated test that fails when the marker is set on a failed run
path provides contains
image-recipe/_archived/build-auto-installer-iso.sh Fail-closed, retried first-boot secret regeneration and an identity-free rootfs tar FIRST_BOOT_SECRETS_ROOT
path provides min_lines
tests/first-boot-secrets/run-tests.sh Automated harness that extracts the generated script and drives it with stubbed generators 60
path provides contains
docs/security/KEY-02-ROOTFS-EVIDENCE.md Recorded build-host evidence for audit checklist item C-4 C-4
from to via pattern
tests/first-boot-secrets/run-tests.sh image-recipe/_archived/build-auto-installer-iso.sh extracts the first-boot-secrets.sh heredoc body from the builder and executes it against a temp root SECRETSSCRIPT
from to via pattern
image-recipe/_archived/build-auto-installer-iso.sh docs/security/KEY-02-ROOTFS-EVIDENCE.md the Dockerfile strip step is what the C-4 tar listing proves ssh_host
Close F-03 (High) on the build side: make first-boot per-device secret regeneration **retry with backoff and then fail closed** (D-05), and remove the fleet-shared identity material from the rootfs tar so a failure degrades to "no key" instead of "everyone's key".

Purpose: today both regeneration branches log a warning and continue, and touch "$MARKER" runs unconditionally outside both if blocks (image-recipe/_archived/build-auto-installer-iso.sh:1647, :1659, :1663). Combined with ConditionPathExists=!/var/lib/archipelago/.secrets-regenerated (:1605) and the script's own [ -f "$MARKER" ] && exit 0 (:1625), one transient failure leaves that node on the image-wide shared SSH host key and TLS private key permanently and silently — and the ISO is a published artefact, so anyone who downloads it holds those keys.

Output: a fail-closed regeneration script with a real automated test, an identity-free rootfs, and recorded C-4 build-host evidence.

image-recipe/_archived/ is LIVE. image-recipe/build-debian-iso.sh:19-40 copies it to a temp path, rewrites its relative paths and execs it. Do not relocate, rename or tidy it — the audit records that treating it as dead would have hidden F-03 entirely.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/10-key-material-hardening/10-CONTEXT.md @docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md @CLAUDE.md

<build_cache_note> RECIPE_HASH (build-auto-installer-iso.sh:265) hashes only the region between # STEP 1: Build complete root filesystem (line 252) and # STEP 2: Build minimal installer (line 732), and the rootfs tar is rebuilt only when that hash changes. Consequences the executor must plan around:

  • Task 1 edits the first-boot script heredoc at ~1590-1670, which is in STEP 3 — outside the hashed region. It does not and should not force a rootfs rebuild; it is installer-side content.
  • Task 2 edits the Dockerfile inside STEP 1, so the hash changes and the next build rebuilds the rootfs automatically. That is required for Task 3's C-4 evidence to mean anything: a cached tar would still contain the baked keys and the check would fail for the wrong reason. </build_cache_note>
Task 1: Fail-closed, retried first-boot regeneration — proven end to end by a real test image-recipe/_archived/build-auto-installer-iso.sh, tests/first-boot-secrets/run-tests.sh - image-recipe/_archived/build-auto-installer-iso.sh (lines 1590-1675 — the unit definition and the whole first-boot-secrets.sh heredoc being rewritten) - image-recipe/_archived/build-auto-installer-iso.sh (lines 2580-2600 and 3330-3345 — where the script and unit are installed and enabled, so the executor can confirm nothing else needs changing) - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (finding F-03 and remediation R-02) - tests/lifecycle/TESTING.md (house conventions for a repo test harness — exit codes, output shape) - scripts/first-boot-containers.sh (house style for a first-boot script on this project) Rewrite the `first-boot-secrets.sh` heredoc body inside `image-recipe/_archived/build-auto-installer-iso.sh` (currently lines ~1616-1665) so it is retried-then-fail-closed, and add one testability seam.

Testability seam (required, and the reason the rest of this task is verifiable at all): introduce ROOT="${FIRST_BOOT_SECRETS_ROOT:-}" at the top and prefix every absolute path with $ROOT$ROOT/etc/archipelago/ssl, $ROOT/etc/ssh, $ROOT/var/lib/archipelago, $ROOT/var/log. With the variable unset the expansion is empty and production behaviour is byte-identical to today. This is the same move the audit made for the RNG: create a seam so the property can be tested, rather than asserting it in a comment.

Retry with backoff (D-05): wrap each generator in a loop of 3 attempts with sleeps of 2, 8 and 20 seconds between them. Track TLS_OK and SSH_OK as 0/1. Keep the existing staging-then-swap structure for both — generate to .new / a mktemp -d staging tree and only swap on success — because that is what guarantees the node is never left mid-swap.

Fail closed (D-05): move touch "$MARKER" inside a branch that requires TLS_OK = 1 && SSH_OK = 1. On any other outcome: do not create the marker (so ConditionPathExists=! lets the oneshot run again on the next boot), write a durable failure record to $ROOT/var/lib/archipelago/first-boot-secrets.failed containing the timestamp and which generator failed, emit the failure to the console with tee -a /dev/console (guarded so a missing /dev/console in a test root cannot itself fail the script) and to the journal via logger -t archipelago-first-boot-secrets, and exit 1 so the unit lands in failed rather than active. Delete the first-boot-secrets.failed record on a successful run so a node that recovers on its second boot does not carry a stale alarm.

Unit ordering: add After=systemd-random-seed.service to archipelago-first-boot-secrets.service (line ~1603) alongside the existing After=local-fs.target. It is a no-op today — no seed file is baked, which the audit verified — and correct if one is ever introduced. Leave DefaultDependencies=no, Before=ssh.service nginx.service archipelago.service and the ConditionPathExists line as they are.

State the operational consequence in a comment at the top of the script, in plain words: after Task 2 strips the baked material, a terminal failure means the node has no SSH host key and no TLS key, so sshd and the nginx TLS listener will not start and recovery requires the physical console. That is the deliberate trade D-05 chose over running on fleet-shared keys, and the next person to read this script deserves to see it stated rather than discover it.

Then create tests/first-boot-secrets/run-tests.sh (executable, set -euo pipefail). It extracts the heredoc body from the builder with awk between the SECRETSSCRIPT delimiters, writes it to a temp file, and runs it three times against a fresh temp root with a stub PATH that shadows openssl, ssh-keygen, systemctl and logger:

  • both succeed — assert exit 0, marker file present, no first-boot-secrets.failed, and the swapped TLS key and host keys present at their final paths.
  • openssl fails every attempt — assert exit non-zero, marker file ABSENT, first-boot-secrets.failed present and naming TLS, and no .new leftovers.
  • ssh-keygen fails twice then succeeds — assert exit 0 and marker present, proving the backoff retry actually recovers rather than just delaying a failure. Have the stub use a counter file so the third invocation succeeds, and shorten the waits for the test by driving the sleeps through a FIRST_BOOT_SECRETS_BACKOFF variable defaulting to 2 8 20.

Print a PASS/FAIL line per case and exit non-zero if any case fails. bash tests/first-boot-secrets/run-tests.sh <acceptance_criteria> - bash tests/first-boot-secrets/run-tests.sh exits 0 and prints three PASS lines. - The failure case asserts the marker is absent; the executor records a scratch run with touch "$MARKER" moved back outside the success branch, which MUST make that case fail — pasted into the SUMMARY, then reverted. - bash -n is clean on the builder: bash -n image-recipe/_archived/build-auto-installer-iso.sh. - Extracting the heredoc and running bash -n on the extracted body is clean (the harness does this as its first step). - grep -c 'FIRST_BOOT_SECRETS_ROOT' image-recipe/_archived/build-auto-installer-iso.sh is at least 1, and grep -c 'After=systemd-random-seed.service' image-recipe/_archived/build-auto-installer-iso.sh is exactly 1. - image-recipe/_archived/ is not moved, renamed, or referenced from a new location: git status --porcelain image-recipe/ shows only a modification to build-auto-installer-iso.sh. </acceptance_criteria> A failed regeneration leaves no marker, writes a durable failure record, reaches the console, and exits non-zero — and a transient failure recovers via backoff within the same boot, all proven by an automated harness.

Task 2: Strip fleet-shared identity material from the rootfs tar at build time image-recipe/_archived/build-auto-installer-iso.sh - image-recipe/_archived/build-auto-installer-iso.sh (lines 252-272 for the RECIPE_HASH cache condition; 330-355 for the package list that installs openssh-server; 455-470 for the baked TLS keypair; 710-726 for the container export that becomes the shipped tar) - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (finding F-03 and remediation R-03; and the ARCHY-3 table row on machine-id, which is the remaining UNVERIFIED item this task also closes on the build side) - image-recipe/configs/nginx-archipelago.conf (confirms nginx's TLS server block depends on /etc/archipelago/ssl/archipelago.key, i.e. what "fail closed" actually costs) Add a final `RUN` layer to the rootfs `Dockerfile.rootfs` heredoc inside STEP 1 (after the TLS generation at ~line 463-469 and after every package install, so nothing regenerates them afterwards) that removes the identity material Debian's `openssh-server` postinst and the `openssl req` step bake into the shared image:
  • delete every /etc/ssh/ssh_host_* file (private keys and .pub alike),
  • delete /etc/archipelago/ssl/archipelago.key and /etc/archipelago/ssl/archipelago.crt, keeping the /etc/archipelago/ssl directory itself so the first-boot script's mkdir -p and the later swap have somewhere to land,
  • truncate /etc/machine-id to zero length (: > /etc/machine-id), which is systemd's documented "generate on next boot" state and is what makes two nodes flashed from one ISO have different machine-ids.

Keep the openssl req step where it is rather than deleting it — leaving it means the build still proves openssl is present and the SAN template still lives next to the code that uses it; the strip layer is what makes the output non-shared. Add a comment on the strip layer naming F-03 and stating that its purpose is to make a first-boot regeneration failure degrade to "no key, service refuses to start" instead of "fleet-shared key, silently".

Also write a build-time provenance line: have the strip layer create /opt/archipelago/rootfs-identity-stripped containing the strings it removed, so a node can answer after the fact whether its rootfs came from a stripped build. Do not put a build timestamp in it — that would defeat the reproducibility the RECIPE_HASH cache depends on.

Note in the SUMMARY that this edit is inside the hashed region and therefore forces the next build to rebuild the rootfs tar, which Task 3 depends on. bash -n image-recipe/_archived/build-auto-installer-iso.sh && sed -n '/^# STEP 1: Build complete root filesystem/,/^# STEP 2: Build minimal installer/p' image-recipe/_archived/build-auto-installer-iso.sh | grep -c 'rootfs-identity-stripped' <acceptance_criteria> - bash -n image-recipe/_archived/build-auto-installer-iso.sh exits 0. - The strip layer is inside the hashed region: the sed range extraction above finds rootfs-identity-stripped at least once, so the next build invalidates the cached tar. - The strip layer removes all four artefact classes; grep -c 'ssh_host' image-recipe/_archived/build-auto-installer-iso.sh increases by at least 1 relative to the pre-plan count, which the executor records in the SUMMARY. - bash tests/first-boot-secrets/run-tests.sh still exits 0 (Task 1's harness must not regress). - The openssl req block at ~line 463 is still present and unmodified. </acceptance_criteria> The rootfs tar the installer extracts onto every disk carries no SSH host keys, no TLS private key and no populated machine-id, and the next build is forced to rebuild it.

Task 3: C-4 — prove the shipped rootfs tar is identity-free on the build host An ISO build host with the image-recipe prerequisites (podman or docker, and enough disk for a full rootfs rebuild) is available; the repo checkout on it contains Task 1 and Task 2's commits. docs/security/KEY-02-ROOTFS-EVIDENCE.md - docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (section 6, checklist item C-4 — the exact tar listing and its expected result, which THIS plan deliberately inverts) - image-recipe/_archived/build-auto-installer-iso.sh (Task 2 output — the strip layer whose effect is being measured) - image-recipe/build-debian-iso.sh (lines 15-40 — the wrapper that execs the archived builder, and the `UNBUNDLED=1` convention from CLAUDE.md/project memory) Claude prepares the exact command sequence and, after the operator responds, records the raw tar listing and verdict into `docs/security/KEY-02-ROOTFS-EVIDENCE.md` under a `## C-4 — rootfs tar contents` heading, together with the build-host label, the builder commit sha and the RECIPE_HASH observed.

The expectation is deliberately the INVERSE of the audit's. The audit expected SSH host keys and the TLS key present (they were baked) and recorded that "anything else changes F-03's severity". After Task 2 they must be absent — so the audit's stated expectation is now the failure condition. Say that explicitly in the evidence document so a future reader comparing the two does not conclude the check regressed. A rootfs Dockerfile that strips baked SSH host keys, the TLS keypair and machine-id from the shared image, plus a fail-closed regeneration script that recreates them per node. 1. On the build host, from the repo root, force a full rebuild so the cached tar cannot mask the change: UNBUNDLED=1 bash image-recipe/build-debian-iso.sh --rebuild 2. Locate the produced tar (the builder prints its path; it is the $ROOTFS_TAR it exported) and list the identity artefacts: tar -tvf <path>/archipelago-rootfs.tar | grep -E 'etc/ssh/ssh_host|etc/machine-id|var/lib/systemd/random-seed|archipelago/ssl/archipelago' 3. Expected after this plan: no etc/ssh/ssh_host_* entries at all; no archipelago/ssl/archipelago.key or .crt; no var/lib/systemd/random-seed; etc/machine-id present with size 0. 4. Confirm the provenance file rode along: tar -tvf <path>/archipelago-rootfs.tar | grep rootfs-identity-stripped 5. Confirm the first-boot pieces are still shipped onto the installer media: ls -l <build-dir>/installer-iso/archipelago/scripts/first-boot-secrets.sh <build-dir>/installer-iso/archipelago/scripts/archipelago-first-boot-secrets.service 6. Paste the full output of steps 2, 3, 4 and 5. <acceptance_criteria> - The step-2 listing shows zero etc/ssh/ssh_host entries and zero archipelago/ssl/archipelago.key entries. - etc/machine-id appears with size 0, or is recorded as absent with that stated explicitly — either satisfies "not shared", and the evidence document must say which was observed rather than generalising. - var/lib/systemd/random-seed is absent, re-confirming the audit's negative finding against the rebuilt tar rather than inheriting it. - Steps 4 and 5 both succeed, proving the strip layer ran and the regeneration script is still installed — a stripped rootfs with no regeneration script would be a brick, and this criterion is what catches that. - docs/security/KEY-02-ROOTFS-EVIDENCE.md marks audit item C-4 as VERIFIED with the date, build-host label and builder commit sha, and states the inverted expectation explicitly. </acceptance_criteria> Paste the tar listings from steps 2-5, then type "approved" — or describe what was still present. Audit item C-4 is no longer UNVERIFIED, and the recorded evidence shows the shipped rootfs is identity-free while the regeneration path is still installed.

<threat_model>

Trust Boundaries

Boundary Description
Published ISO -> any downloader The ISO is a public artefact. Anything identity-shaped inside it is known to every attacker who fetches it. This is the boundary F-03 crosses.
Shared rootfs tar -> every flashed node tar -xf "$ROOTFS_TAR" -C /mnt/target (:2303) puts a byte-identical filesystem on every disk.
First boot -> network-facing services archipelago-first-boot-secrets.service runs Before=ssh.service nginx.service archipelago.service; whatever it leaves behind is what those services present to the network.

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-10-21 Spoofing Fleet-shared SSH host key from the published ISO enables undetectable host impersonation high mitigate Task 2 strips the baked host keys; Task 1 makes regeneration fail closed so a failure cannot silently restore the shared state
T-10-22 Information disclosure Fleet-shared TLS private key from the published ISO enables transparent MITM of the web UI high mitigate Task 2 strips the baked TLS keypair; Task 1's staging-then-swap keeps the swap atomic
T-10-23 Tampering The completion marker is set on a failed run, so the failure is permanent and unretried (:1663) high mitigate Task 1 moves touch "$MARKER" inside a both-succeeded branch and writes a durable failure record instead
T-10-24 Denial of service Fail-closed leaves a node with no SSH and no TLS after a terminal failure, unrecoverable remotely high mitigate Three attempts with 2/8/20s backoff within the boot, then retry on every subsequent boot because the marker is absent; the trade is stated in the script header and is D-05's explicit choice; physical console recovery exists on these nodes
T-10-25 Repudiation The only record of a failure is a log file that surfaces nowhere medium mitigate Task 1 adds /var/lib/archipelago/first-boot-secrets.failed, a console write and a logger line; surfacing it in the daemon's status output is 10-04's job
T-10-26 Spoofing Correlated machine-id across nodes flashed from one ISO medium mitigate Task 2 truncates /etc/machine-id so systemd regenerates per node; the observed result is recorded in Task 3 rather than assumed
T-10-27 Tampering A cached rootfs tar masks the strip layer, so C-4 passes against stale output medium mitigate The strip layer is inside the RECIPE_HASH region and Task 3 additionally passes --rebuild; the acceptance criterion checks the extraction range, not just the file
T-10-28 Denial of service A stripped rootfs ships without the regeneration script, bricking every flashed node high mitigate Task 3 step 5 explicitly checks that first-boot-secrets.sh and its unit are present on the installer media, and that check is an acceptance criterion
T-10-SC Tampering npm/pip/cargo installs low accept This plan installs no packages; it edits a shell builder and adds a bash test harness. The Debian package list in the rootfs Dockerfile is not modified. Executor MUST halt and raise a checkpoint if a package addition appears necessary.
</threat_model>

<artifacts_this_phase_produces>

Artifacts this plan produces

Modified: image-recipe/_archived/build-auto-installer-iso.sh

Symbol Kind Contract
FIRST_BOOT_SECRETS_ROOT env var read by the generated first-boot-secrets.sh path prefix for every absolute path; unset in production, set by the test harness
FIRST_BOOT_SECRETS_BACKOFF env var read by the generated script space-separated backoff seconds; default 2 8 20
/var/lib/archipelago/first-boot-secrets.failed new on-disk file durable failure record: timestamp plus which generator failed; deleted on a later successful run
/opt/archipelago/rootfs-identity-stripped new on-disk file build-time provenance: the artefact classes removed from the rootfs
/var/lib/archipelago/.secrets-regenerated existing marker, contract changed now written ONLY when both TLS and SSH regeneration succeeded
After=systemd-random-seed.service unit ordering added to archipelago-first-boot-secrets.service

New file: tests/first-boot-secrets/run-tests.sh (mode 755) — three cases: both-succeed, openssl-always-fails, ssh-keygen-fails-twice-then-succeeds. Exit 0 only if all three pass.

New file: docs/security/KEY-02-ROOTFS-EVIDENCE.md — heading ## C-4 — rootfs tar contents. </artifacts_this_phase_produces>

- `bash tests/first-boot-secrets/run-tests.sh` exits 0 with three `PASS` lines. - `bash -n image-recipe/_archived/build-auto-installer-iso.sh` is clean. - Task 3's checkpoint resolved with a pasted tar listing from a `--rebuild` build. - Commit stages only `image-recipe/_archived/build-auto-installer-iso.sh`, `tests/first-boot-secrets/run-tests.sh` and `docs/security/KEY-02-ROOTFS-EVIDENCE.md` by explicit path — never `git add -A`; another agent shares this tree.

<success_criteria>

  • A failed regeneration leaves no completion marker, writes a durable failure record, reaches the console and exits non-zero — pinned by an automated test that fails if the marker moves back out.
  • A transient failure recovers via backoff inside the same boot.
  • The shipped rootfs tar contains no SSH host keys, no TLS private key and no populated machine-id, verified against a forced rebuild on a real build host.
  • The regeneration script and its unit are still installed onto the installer media.
  • Audit item C-4 is recorded as VERIFIED with the inverted expectation stated explicitly. </success_criteria>
Create `.planning/phases/10-key-material-hardening/10-03-SUMMARY.md` when done, carrying the three harness results, the scratch-run evidence that moving `touch "$MARKER"` back out fails the test, the C-4 tar listing, and an explicit note that the RECIPE_HASH changed.