29 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 | 04 | execute | 2 |
|
|
false |
|
|
Purpose: 10-03 stops the exposure growing. It does nothing for the nodes that are already live, which is exactly where the exposure sits. D-06 rejected builder-only for that reason.
Output: an on-node audit script delivered by the existing OTA runtime-asset promotion, a boot
unit, a system.stats field, an automated harness, and recorded C-3 evidence from two real nodes.
D-06 is rated one-way and has its own decision checkpoint (Task 1) before the task that
implements rotation. Rotating a host key invalidates every known_hosts entry for that node
fleet-wide, including the Tailscale-reached nodes this project depends on for access. There is no
going back to the old key.
<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 @.planning/phases/10-key-material-hardening/10-03-SUMMARY.md @CLAUDE.md<detection_method> How a node decides, from its own disk alone, whether its host secrets are fleet-shared.
No external data is available — the baked fingerprints live only in the ISO the node was flashed from, and older ISOs are not enumerable. Four on-disk signals, in priority order:
- mtime against the first-boot anchor (primary, independent of any log).
/var/lib/archipelago/.secrets-regeneratedis touched at first boot on every node that ran the regeneration unit — including the fail-open path — so its mtime is a reliable "this node's first boot" anchor. Host keys regenerated at first boot carry an mtime within seconds of it; keys baked into the image carry the image build time, typically days or weeks earlier. A host key whose mtime is more than 300 seconds OLDER than the anchor came from the image and is therefore shared. Fall back to/root/.luks-archipelago.key(written by the installer withdd if=/dev/urandom,install-to-disk.sh) and then/etc/machine-idwhen the marker is absent; reportunknownwhen no anchor exists rather than guessing. - The fail-open fingerprint (corroborating, and the audit's own C-3 criterion).
.secrets-regeneratedpresent AND/var/log/archipelago-first-boot-secrets.logcontaining aWARNING:line is precisely the combination the fail-open path at:1647/:1659/:1663produces. - 10-03's durable failure record.
/var/lib/archipelago/first-boot-secrets.failedpresent means a post-10-03 node failed regeneration and did not silently continue. - Rootfs provenance. Absence of
/opt/archipelago/rootfs-identity-strippedmeans the node was flashed from a pre-10-03 ISO whose rootfs did carry baked material, so signal 1 is meaningful for it. Its presence means the rootfs shipped identity-free, so a missing host key is a fail-closed state rather than a shared one — a materially different verdict.
The verdict is one of per-node, shared, fail-closed-missing or unknown, and the evidence
for it is always recorded alongside it. Never report per-node on the strength of an absent
signal.
</detection_method>
Modes: --detect (default, read-only) and --apply (rotates). --apply without --yes prints
what it would do and exits 0 without touching anything, so a mistyped invocation is inert.
--detect evaluates the four signals from the detection_method section above, writes
$ROOT/var/lib/archipelago/host-secrets-audit.json with fields verdict
(per-node|shared|fail-closed-missing|unknown), evidence (an array of the signal strings
that fired, each naming the file it read), checked_at (ISO-8601), ssh_host_key_fingerprints
(the ssh-keygen -lf output for each public key — public data, safe to record) and
tls_cert_sha256 (from openssl x509 -noout -fingerprint -sha256). Write it 0644 so the daemon
can read it without privilege. Print a one-line human verdict to stdout. Exit 0 on any verdict —
detection is informational and must never fail a boot.
--apply --yes rotates only the classes the detect pass flagged as shared, in this order,
which is the access-preserving sequence and is the reason ordering is specified rather than left
to the implementer:
- Generate the replacement TLS keypair and the full SSH host-key set into staging directories. If any generation fails, abort before touching anything live and exit non-zero — a partial rotation is the failure mode that loses access.
- Record the OLD fingerprints into
$ROOT/var/lib/archipelago/host-key-rotation.json(0644:rotated_at,old_ssh_fingerprints,old_tls_sha256) BEFORE the swap, so an operator who loses access can still identify what changed. - Swap the TLS pair, then
systemctl reload nginx. - Swap the SSH host keys, then
systemctl reload ssh— reload, never restart. A reload re-execs the listener while already-forked session children keep running, so the operator's current SSH session survives its own rotation. Note that in the script comment; it is the single most important line in the file. - Append the NEW fingerprints to
host-key-rotation.json, print them to stdout and to/dev/console(guarded so a missing console cannot fail the run), and re-run the detect pass sohost-secrets-audit.jsonreflects the post-rotation state. Never delete a key without a successfully staged replacement in hand.
b. image-recipe/configs/archipelago-host-secrets-audit.service — Type=oneshot,
After=archipelago-first-boot-secrets.service network.target, ExecStart pointing at
/opt/archipelago/scripts/security/host-secrets-audit.sh with the mode Task 1's decision chose,
WantedBy=multi-user.target. Follow image-recipe/configs/archipelago-doctor.service's shape.
c. core/archipelago/src/bootstrap.rs — add "archipelago-host-secrets-audit.service" to the
for unit in [...] array at line ~361 so the OTA runtime-asset promotion installs it, exactly as
archipelago-doctor.service is installed today. The scripts directory promotion at line ~320
already carries scripts/security/ along with it; confirm that by reading
replace_dir_from_runtime rather than assuming, and record the confirmation in the SUMMARY.
Enable the unit after install (systemctl enable --now via the existing host_sudo helper) in
the same if changed block that already runs daemon-reload.
d. core/archipelago/src/api/rpc/system/handlers.rs — extend handle_system_stats's JSON with
a host_secrets object read from /var/lib/archipelago/host-secrets-audit.json: verdict,
checked_at, evidence, and rotated_at from host-key-rotation.json when present. A missing
or unparseable file yields {"verdict":"unknown"} — never an error, because system.stats is
cacheable and polled by the dashboard. Do not include the raw fingerprints in system.stats;
expose them only in the on-disk record, so the polled dashboard payload stays small.
e. tests/first-boot-secrets/rotation-tests.sh (executable) — same stub-PATH pattern as
10-03's harness, driving host-secrets-audit.sh against temp roots:
- host keys newer than the anchor -> verdict
per-node, JSON written, no files changed; - host keys 30 days older than the anchor -> verdict
shared; - marker present plus a
WARNING:line in the log -> verdictsharedwith both signals inevidence; rootfs-identity-strippedpresent and host keys absent -> verdictfail-closed-missing, notshared;- no anchor at all -> verdict
unknown; --applywithout--yes-> no file in the tree changes (compare afind … -newersnapshot);--apply --yeson asharedtree -> old fingerprints recorded before the swap, new keys present,host-key-rotation.jsoncontains both, and at no point in the run is the tree left with zero SSH host keys (assert by having thessh-keygenstub fail and checking the live keys are untouched). bash tests/first-boot-secrets/rotation-tests.sh && cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago system:: -- --nocapture <acceptance_criteria>bash tests/first-boot-secrets/rotation-tests.shexits 0 with aPASSline for each of the seven cases.- The abort-before-swap case is proven: with the
ssh-keygenstub failing, the pre-existing host keys in the temp tree are byte-identical after the run (harness asserts withsha256sum). cd core && CARGO_INCREMENTAL=0 cargo build -p archipelagosucceeds andcargo clippy -p archipelago -- -D warningsis clean for the two touched Rust files.- A Rust test asserts
handle_system_statsyieldshost_secrets.verdict == "unknown"when the JSON file is absent, and the recorded verdict when it is present. grep -c 'archipelago-host-secrets-audit' core/archipelago/src/bootstrap.rsis at least 1.grep -n 'systemctl reload ssh' scripts/security/host-secrets-audit.shmatches and there is nosystemctl restart sshin the file.bash -n scripts/security/host-secrets-audit.shexits 0; ifshellcheckis available,shellcheck -S erroris clean, otherwise its absence is recorded. </acceptance_criteria> A deployed node writes a verdict with its evidence to disk and tosystem.stats, and a guarded rotation exists that stages everything before touching anything live and reloads rather than restarts sshd.
Pick the two nodes deliberately: use the dev pair (archi-dev-box + x250-dev) or another
disposable pair. Do not run --apply against .228 or any node in real use as part of this
checkpoint; if the audit verdict on such a node comes back shared, record it as a finding and
raise it rather than rotating it inside a verification task.
An on-node audit that reports whether this node's SSH host keys and TLS key are image-baked, and a guarded rotation that preserves the operator's own session.
1. On EACH node: sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --detect
then cat /var/lib/archipelago/host-secrets-audit.json
2. On EACH node, capture the fingerprints directly, independently of the script:
for f in /etc/ssh/ssh_host_*_key.pub; do ssh-keygen -lf "$f"; done
and openssl x509 -in /etc/archipelago/ssl/archipelago.crt -noout -fingerprint -sha256
3. Compare the two nodes' outputs from step 2. ANY fingerprint appearing on both nodes is a
confirmed F-03 instance — record it as the C-3 FAIL result, which is a finding, not an error.
4. If Task 1 chose detect-report-then-apply AND either node's verdict is shared: on ONE
disposable node, from a session you are willing to lose, run
sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --apply --yes
Then, WITHOUT closing that session, confirm it is still alive (echo still-here), open a
SECOND connection and confirm the expected host-key-mismatch warning, accept the new key,
and paste the new fingerprints from /var/lib/archipelago/host-key-rotation.json.
5. Confirm the web UI still loads over HTTPS on the rotated node (new self-signed cert, so a
fresh browser trust prompt is expected and is the correct outcome).
6. Confirm the verdict propagated: call system.stats on the rotated node and paste the
host_secrets object.
<acceptance_criteria>
- Step 3's comparison is recorded for both nodes: either "no fingerprint appears on both nodes" (C-3 PASS) or the exact shared fingerprint classes (C-3 FAIL, recorded as a live F-03 instance with the node labels).
- If step 4 ran: the operator confirms the original session survived the rotation, the second connection showed the expected mismatch, and host-key-rotation.json contains both old and new fingerprints.
- Step 6's host_secrets.verdict is per-node after a rotation, proving the detect pass re-ran and the surfacing works end to end.
- docs/security/KEY-02-FLEET-ROTATION.md marks audit item C-3 VERIFIED (or FAILED-with-finding) with the date and node labels, and records every node whose verdict came back shared but which was deliberately NOT rotated, so none is quietly forgotten.
</acceptance_criteria>
Paste the per-node JSON, the fingerprint listings from both nodes, and (if run) the rotation output, then type "approved" — or describe what failed.
Audit item C-3 is no longer UNVERIFIED: two real nodes are compared, any shared material is named, and a rotation has been demonstrated to preserve the operator's own session.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| OTA runtime payload -> node filesystem | run_runtime_assets (bootstrap.rs:307-430) promotes scripts/ into /opt/archipelago/scripts and installs units into /etc/systemd/system using host_sudo. Anything shipped here runs as root at boot on every fleet node. |
| Rotation script -> live remote access | The script rewrites the credentials the operator's own SSH session and the web UI's TLS depend on. |
system.stats -> dashboard |
An authenticated, cacheable, frequently-polled read. |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-10-31 | Denial of service | Rotation loses remote access mid-flight, on a remote node, with no console | high | mitigate | Task 2 stages every replacement before touching anything live and aborts on any generation failure; swaps TLS then SSH; reloads rather than restarts sshd so forked session children survive; Task 3 step 4 proves it on a live session before it is trusted |
| T-10-32 | Denial of service | Fleet-wide simultaneous known_hosts breakage during an OTA |
high | mitigate | Task 1's blocking decision, with detect-report-then-apply available specifically to bound this; old and new fingerprints recorded before and after so operators can update known_hosts deliberately |
| T-10-33 | Spoofing | A node keeps running a fleet-shared SSH host key that anyone with the published ISO holds | high | mitigate | Detection runs at boot and the verdict reaches system.stats, so an exposed node is visible without shell access; rotation closes it |
| T-10-34 | Tampering | The audit script runs as root at boot from a directory replaced wholesale by the OTA payload | medium | mitigate | Delivery reuses the existing, already-trusted run_runtime_assets path and adds no new trust source; the script performs no network I/O and takes no input from the network; --apply requires --yes |
| T-10-35 | Information disclosure | Host-key fingerprints and TLS digests written to disk and into a polled RPC payload | low | accept | Fingerprints of PUBLIC keys are public data; the private keys are never read by the script beyond regeneration. system.stats deliberately carries only the verdict, not the fingerprints |
| T-10-36 | Repudiation | A rotation happens with no record of what the key used to be | medium | mitigate | Old fingerprints are written to host-key-rotation.json BEFORE the swap; new ones appended after; both echoed to console |
| T-10-37 | Spoofing | A false per-node verdict from a missing signal leaves an exposed node looking clean |
high | mitigate | The detection method reports unknown when no anchor exists and never infers per-node from an absent signal; every verdict carries the evidence strings that produced it |
| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | No package-manager install occurs; the script uses openssl, ssh-keygen, stat and systemctl, all already present on fleet nodes, and the Rust change adds no crate. Executor MUST halt and raise a checkpoint if a new dependency appears necessary. |
| </threat_model> |
<artifacts_this_phase_produces>
Artifacts this plan produces
New file: scripts/security/host-secrets-audit.sh (mode 755, promoted to
/opt/archipelago/scripts/security/host-secrets-audit.sh by the OTA runtime payload)
| Symbol | Kind | Contract |
|---|---|---|
--detect |
CLI flag | default; read-only; writes the verdict JSON; always exits 0 |
--apply |
CLI flag | inert without --yes |
--yes |
CLI flag | required confirmation for a real rotation |
HOST_SECRETS_ROOT |
env var | path prefix for the whole script; unset in production |
/var/lib/archipelago/host-secrets-audit.json |
new on-disk file (0644) | verdict, evidence[], checked_at, ssh_host_key_fingerprints[], tls_cert_sha256 |
/var/lib/archipelago/host-key-rotation.json |
new on-disk file (0644) | rotated_at, old_ssh_fingerprints[], old_tls_sha256, new_ssh_fingerprints[], new_tls_sha256 |
New file: image-recipe/configs/archipelago-host-secrets-audit.service — Type=oneshot,
After=archipelago-first-boot-secrets.service network.target, WantedBy=multi-user.target.
Modified: core/archipelago/src/bootstrap.rs — archipelago-host-secrets-audit.service added
to the runtime-asset unit install list.
Modified: core/archipelago/src/api/rpc/system/handlers.rs — system.stats gains a
host_secrets object: { verdict, checked_at, evidence, rotated_at }. Absent file yields
{"verdict":"unknown"}.
New file: tests/first-boot-secrets/rotation-tests.sh (mode 755) — seven cases.
New file: docs/security/KEY-02-FLEET-ROTATION.md — headings
## D-06 rotation trigger, ## C-3 — per-node host key and TLS uniqueness.
</artifacts_this_phase_produces>
<success_criteria>
- Every deployed node writes a verdict with its evidence and exposes it via
system.stats. - The rotation path stages everything before touching anything live, reloads rather than restarts sshd, and records old and new fingerprints on both sides of the swap.
- Rotation cannot happen by accident: detect-only default,
--applyinert without--yes, and the auto-versus-manual trigger is a recorded human decision. - Audit item C-3 is recorded as VERIFIED or FAILED-with-finding against two real nodes. </success_criteria>