36 KiB
phase, plan, subsystem, tags, requires, provides, affects, tech-stack, key-files, key-decisions, requirements-completed, coverage, duration, completed, status
| phase | plan | subsystem | tags | requires | provides | affects | tech-stack | key-files | key-decisions | requirements-completed | coverage | duration | completed | status | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 10-key-material-hardening | 04 | fleet-host-secrets |
|
|
|
|
|
|
|
|
3h | 2026-08-02 | complete |
Phase 10 Plan 04: Fleet host-secret detection and guarded rotation — Summary
Every deployed node can now say, from its own disk alone, whether it is running the SSH host keys and TLS private key baked into its ISO — the ones every downloader of that ISO also holds — and can be fixed once, by an operator, without losing remote access in the middle. Closes the deployed half of audit finding F-03.
C-3 FAILED, and that is the most important line in this document. Three live fleet nodes —
archipelago-1,archy-x250-betaandarchipelago— share all three SSH host key fingerprints. Two of them also share their TLS certificate, and therefore their TLS private key. F-03 is not theoretical on this fleet. None was rotated; all three are registered indocs/security/KEY-02-FLEET-ROTATION.mdwith the reason and the next step.
Commits
| Commit | Task | What |
|---|---|---|
96dba73a |
1 | D-06 recorded as detect-report-then-apply, with what the decision binds |
0ed9334f |
2 | The audit script, the boot unit, the OTA wiring, the system.stats field, the 8-case harness |
373c3bb3 |
2 (deviation) | Ship the unit in the OTA runtime payload — without this the whole plan was inert on arrival |
a806a658 |
3 | C-3 result: FAILED, with the finding, the method, and everything it does not establish |
Nothing was pushed, tagged, built or deployed. No node was logged into, written to, or rotated.
Task 1 — D-06
detect-report-then-apply, recorded verbatim in docs/security/KEY-02-FLEET-ROTATION.md
under ## D-06 rotation trigger.
The argument that settled it is not the one the plan anticipated. Both options were weighed on
blast radius, but the decisive point is that auto-on-boot cannot be dev-paired. This
project's standing policy is that nothing reaches the fleet before it is verified on
archi-dev-box + x250-dev. A rotation that fires unattended on the first boot after an OTA has, by
the time you have watched it happen on the dev pair, already happened everywhere. There is no
observation point before the irreversible act.
Its cost is real and is written down rather than softened: any node whose operator does not act
stays exposed indefinitely. It is bounded by making the verdict visible — system.stats, so an
exposed node shows up without shell access — and by a written register of every node that
reported shared and was not rotated. That register now has three entries in it, added by this
plan's own verification.
Task 2 — detection, rotation, delivery, surfacing
How a node decides
Four signals in a fixed precedence, which matters more than the signals do:
- Missing material can never be shared material. Checked first. On a 10-03-or-later node
(
/opt/archipelago/rootfs-identity-strippedpresent) an absent host key means generation never succeeded —fail-closed-missing, a materially different verdict, and rotation is not the remedy. Without the provenance marker the material is still absent, and the evidence says so rather than guessing. - The fail-open fingerprint outranks timestamps, because it is direct evidence rather than
inference:
.secrets-regeneratedpresent plus aWARNING:line in/var/log/archipelago-first-boot-secrets.log. The two literal strings the pre-10-03 script emitted (WARNING: TLS regeneration failed, keeping baked keyandWARNING: ssh-keygen -A failed, keeping baked host keys) also say which class survived, so the rotation narrows to it. An unrecognisedWARNING:widens to both rather than guessing. - mtime against a first-boot anchor —
.secrets-regenerated, falling back to/root/.luks-archipelago.key(written by the installer withdd if=/dev/urandom) and then/etc/machine-id. A key more than 300s older than the anchor carries the image build time. - The durable failure record (
first-boot-secrets.failed) can only ever withhold a verdict, never grant one.
per-node is never inferred from an absent signal. No anchor → unknown. A standing failure
record → unknown, even when every mtime looks clean. That is T-10-37: a false per-node leaves
an exposed node looking clean, which is strictly worse than no verdict.
Every verdict carries the evidence strings that produced it, each naming the file it was read from, and the provenance signal is recorded on every run regardless of verdict because it changes what the other signals mean.
Judged per key class, not per node
This turned out to matter, and the C-3 scan is what proved it — see the archipelago finding
below. SSH_SHARED and TLS_SHARED are tracked separately through detection and into rotation,
so a node with a unique cert and shared host keys has only its host keys rotated.
The rotation, and why the order is specified
- Stage everything first. Both the TLS pair and the full host-key set are generated into staging before anything live is touched, and any generation failure aborts with the tree untouched. A partial rotation is the failure mode that loses a node.
- Record the OLD fingerprints before the swap. After the swap the old material is gone and unrecoverable; an operator who loses access anyway can still identify what changed.
- TLS, then reload nginx. A dead web UI is recoverable over SSH. The converse is not. Do the recoverable one first.
- SSH, then
systemctl reload ssh— never restart. A reload re-execs the listener while already-forked session children keep running, so the operator survives their own rotation. Host keys are replaced bymvonto the existing path rather thanrmthenmv: the rm-then-mv shape opens a window in which the node has zero host keys on disk, and sshd restarting into that window is unrecoverable remotely. Stale key types the new set does not include are removed only after every staged key has landed. - New fingerprints to the record, to stdout and to
/dev/console(guarded), then the detect pass re-runs so the verdict file reflects the post-rotation state.
Safety gates, in order of how likely each is to be the one that saves a node
--detectis the default and is read-only.--applywithout--yeswrites nothing at all, not even its own verdict file.--apply --yesrefuses unless the verdict isshared. On aper-nodenode the command is inert even when typed deliberately and confirmed. This is the guard against running it on the wrong node, and it is structural rather than procedural.- The shipped unit contains no apply path at all, and says in a comment that adding one is a decision rather than a configuration change.
ExecStart=-on the unit: a failed audit must never fail a boot.
A third key producer, declared
Producer 1 is gen_tls()/gen_ssh() in the ISO builder; producer 2 is TlsMaterial::regenerate
in handlers.rs. Neither can do this job: producer 1 is not present on a deployed node, and
producer 2 does TLS only — nothing in the daemon has ever rotated an SSH host key. So a third
exists, and the script header names all three and pins what they must keep in common (rsa:2048,
3650 days, the same subject and SAN set, stage → parse both halves → prove they are a pair →
swap) rather than leaving that to be rediscovered. tls_pair_matches() is carried over verbatim
in intent from dad40c23.
Delivery — replace_dir_from_runtime confirmed by reading, not assumed
The plan asked for this to be confirmed rather than assumed. It was:
replace_dir_from_runtime does cp -a "$src/." "$tmp" then cp -a "$tmp/." "$dest" — both
recursive, so scripts/security/ rides along with the rest of scripts/. The executable sweep
is find "$dest" -type f -name '*.sh' -exec chmod 755 {} + with no -maxdepth, so the script
lands executable at /opt/archipelago/scripts/security/host-secrets-audit.sh.
The unit is added to the existing for unit in [...] array and enabled with --now, so the
verdict lands with the OTA rather than at the next reboot.
Surfacing
handle_system_stats gains a host_secrets object read from the on-disk verdict. Three
properties, because system.stats is in CACHEABLE_METHODS and the dashboard polls it: it never
errors (absent, truncated or unparseable all yield {"verdict":"unknown"} — and every fleet
node is in the absent case until the OTA lands, so that is the common path, not the edge one);
it is two small file reads with no process spawn; and it carries no fingerprints. A unit test
fails if a future edit forwards the whole file.
Harness — 8 cases, all green
host-secrets-audit.sh: 567 lines; bash -n clean
sshd handling: reload present, restart absent
PASS: host keys newer than the anchor -> per-node, JSON written, nothing else changed
PASS: host keys 30 days older than the anchor -> shared, evidence names both key classes
PASS: marker plus a WARNING: line -> shared, with both signals in evidence, despite per-node mtimes
PASS: identity-stripped rootfs with no host keys -> fail-closed-missing, not shared
PASS: no first-boot anchor -> unknown, never per-node
PASS: --apply without --yes -> exits 0 and not one byte of the tree changes
PASS: --apply --yes -> old fingerprints recorded BEFORE the swap, keys replaced, sshd reloaded not restarted, verdict re-derived
PASS: generation failure -> aborts before any swap; live keys byte-identical, no service reloaded
──────── host-secrets-audit summary ────────
passed: 8 failed: 0
Case 3 is dated so that the mtime signal alone would say per-node; if it passes it is because
signal 2 fired. Case 7b forces the SSH generator to fail after TLS staging succeeded — the exact
interleaving in which a naive implementation has already swapped the TLS pair.
Ordering is asserted by observation, not by content. Comparing the recorded old fingerprints
against the pre-rotation keys proves the right values were written; it cannot prove they were
written first. The systemctl stub therefore records, alongside each call, whether the rotation
record existed at that moment. The first reload happens after the first swap, so rotjson=yes on
that line is the ordering fact.
Negative controls — each reddens exactly one case
Each defect was reintroduced, the suite run, and the defect reverted.
Control A — the dry run writes its own verdict file ("one harmless file"):
FAIL: --apply without --yes -> STATE-DIR-CHANGED
passed: 7 failed: 1
Control B — old fingerprints recorded after the swap instead of before:
FAIL: --apply --yes -> OLD-FINGERPRINTS-NOT-RECORDED-BEFORE-THE-SWAP[reload nginx rotjson=no]
passed: 7 failed: 1
Control C — a failed SSH generation tolerated instead of aborting:
FAIL: generation failure -> exit-zero-on-aborted-rotation
LIVE-MATERIAL-CHANGED-ON-AN-ABORTED-ROTATION
rotation-record-written-for-a-rotation-that-never-happened
no-loud-abort-on-stderr reloaded-a-service-during-an-aborted-rotation
passed: 7 failed: 1
Control D — per-node claimed with no anchor at all:
FAIL: no first-boot anchor -> verdict=per-node CLAIMED-PER-NODE-WITHOUT-EVIDENCE
passed: 7 failed: 1
Control B is the one worth noting: it reddens only because of the ordering observation. Every content-based assertion in case 7a still passes against that defect, because the right fingerprints do end up in the file — just too late to be of any use to someone who has lost access.
Control C also exposed a bug in my own harness (below).
Rust
running 4 tests
test ...host_secrets_tests::verdict_is_unknown_when_the_audit_file_is_absent ... ok
test ...host_secrets_tests::rotated_at_is_surfaced_only_when_a_rotation_was_recorded ... ok
test ...host_secrets_tests::verdict_is_unknown_when_the_audit_file_is_unparseable ... ok
test ...host_secrets_tests::recorded_verdict_and_evidence_are_surfaced ... ok
test result: ok. 4 passed; 0 failed
CARGO_INCREMENTAL=0 cargo build -p archipelago succeeds. cargo clippy -p archipelago produces
zero diagnostics for bootstrap.rs and system/handlers.rs. Three warnings exist elsewhere
in the crate (federation/handlers.rs unused import, mesh/flash.rs unused assignment,
package/dependencies.rs dead const) — all pre-existing, all in other agents' files, none
touched.
shellcheck is not installed on this machine, so shellcheck -S error was not run. Recorded
rather than skipped silently. bash -n is clean on both new shell files.
Real run on this node
$ sudo scripts/security/host-secrets-audit.sh --detect --json
host-secrets: per-node — this node's SSH host keys and TLS key were generated here.
{
"verdict": "per-node",
"checked_at": "2026-08-02T18:57:08Z",
"evidence": ["provenance: /opt/archipelago/rootfs-identity-stripped absent — this rootfs
predates the 10-03 identity strip, so baked material is possible",
"anchor: /etc/machine-id (machine-id, populated on this node's first boot), mtime
2026-04-09T18:25:45Z",
"per-node: every SSH host key and the TLS key is newer than the anchor, so all of it was
generated on this node"],
...
}
$ ls -l /var/lib/archipelago/host-secrets-audit.json
-rw-r--r-- 1 root root 959 ...
archi-dev-box was installed from Debian directly, not flashed from the ISO, so it has no
.secrets-regenerated marker and no first-boot log — it exercises the third fallback anchor. Its
three fingerprints match an independent ssh-keyscan of the same host exactly, which is the
only cheap cross-check available that the script's fingerprint extraction is correct against real
tools.
--apply was never run outside a temp root, on this or any other machine.
Task 3 — C-3: FAILED, with finding
What was found
Three distinct live fleet nodes present byte-identical ECDSA, ED25519 and RSA host key fingerprints. Two of them also present the same TLS certificate, so they share the TLS private key.
| Node | SSH host keys | TLS cert | Cert CN |
|---|---|---|---|
archipelago-1 |
8WJplzKW… / lQgRXZ1n… / ym+gMOio… |
62:F6:A6:02… |
archipelago |
archy-x250-beta |
same three | same cert | archipelago |
archipelago |
same three | 7C:6B:CD:98… |
austin-sapien |
archipelago-5, archi-dev-box, archy-dev-pa, framework-pt and shorty-s (.228) are each
distinct from every other node and from each other. archy-x250-dev, archy-x250-pa,
archy-x250-r2 and quantumterminal were unreachable and are UNVERIFIED.
Method, and why it is not the checklist's method
Gathered remotely and read-only: ssh-keyscan -T 6 <node> | ssh-keygen -lf -, and an
anonymous TLS handshake for the certificate. No node was logged into, nothing was written, nothing
was rotated.
This is a weaker instrument than C-3's on-node commands — it cannot read .secrets-regenerated,
the first-boot log, or the ISO provenance. It was chosen because it needs no access and therefore
covers the whole reachable fleet rather than two nodes, and because it is sufficient for the FAIL
condition, which is any fingerprint appearing on two nodes.
Ruling out the boring explanation
Identical host keys are also what you would see if one machine were registered on the tailnet
three times. Ruled out: all three answered a live TCP connection on port 22 within the same
minute (one tailscaled serves one identity, so three simultaneously-live addresses are three
hosts), they are owned by different tailnet accounts, and tailscale ping resolves them to
different physical endpoints — archy-x250-beta answers from a different continent than the
other two, which answer from the same NAT on different source ports.
The finding inside the finding
archipelago has a unique TLS cert and shared SSH host keys. Its cert CN is austin-sapien,
not the image default — the signature of a node renamed through server.set-name, which re-mints
the certificate via regenerate_tls_cert() so the SAN matches, and touches nothing else.
TLS uniqueness is therefore not evidence that a node's key material is per-node. Any renamed node gets a unique certificate for free while its SSH host keys stay exactly as the image shipped them. Had C-3 been checked on certificates alone, this node would have looked clean. This is the concrete justification for judging and reporting the two key classes separately rather than issuing one node-level verdict — a design choice made before the scan, and vindicated by it.
Deliberately not rotated
All three are registered in docs/security/KEY-02-FLEET-ROTATION.md under
"Nodes with a shared verdict, deliberately not rotated", with the reason and the next step.
A checkpoint that remediates is a checkpoint that takes a node offline; archy-x250-beta in
particular is reached over a DERP relay from another continent and is the least recoverable node
in the set.
Deviations from Plan
1. [Rule 3 — Blocking] The unit could never have reached a node (373c3bb3)
- Found during: Task 2, tracing the delivery path end to end rather than trusting the plan's key_link.
- Issue:
bootstrap.rsinstalls units fromimage-recipe/configs/inside the OTA runtime payload, butscripts/create-release-manifest.shcopies onlyarchipelago-doctor.serviceand.timerinto that directory.archipelago-host-secrets-audit.servicewould never have existed on any node —src.exists()false, install skipped, no error and no log line. The entire deployed-node half of this plan would have shipped inert, and nothing would have said so. - Fix: added the unit to that loop. The redundant
if [ -f doctor.service ] || [ -f doctor.timer ]wrapper was removed at the same time — the per-unit-ftest inside the loop already does that job, and the wrapper would have skipped the whole block on a tree carrying the new unit but not the doctor ones. AKEEP IN SYNCcomment now names the array inbootstrap.rs, since two enumerations of one list in two languages in two files is what caused this. - Scope:
scripts/create-release-manifest.shis outside this plan'sfiles_modified. Taken because the alternative was to ship a deliverable that cannot reach its target and file the gap as a follow-up. Staged by path;git status --porcelainconfirmed no other agent had uncommitted work in that file.
2. [Rule 1 — Bug in my own harness] set -o pipefail swallowed the summary
- Found during: negative control C.
- Issue: the failure-reporting path does
diff <(…) <(…) | head -10.diffexits 1 when it finds differences, and underset -o pipefailthat aborted the whole harness — so a case that failed by changing the tree killed the run before the summary line and before the remaining cases. Control A did not expose it, because its failure was a state-dir comparison rather than a tree diff, sodiffexited 0. - Why it matters more than it looks: the suppressed case is the one that detects a live rotation having modified files it should not have. A harness that dies silently on its most serious failure mode is worse than one that reports it noisily.
- Fix:
|| trueon both reporting pipelines, with a comment naming the cause. Control C was re-run afterwards and the harness now exits 1 with the summary intact.
3. [Rule 2 — Correctness] --apply writes nothing at all, not just "nothing live"
- Issue: the natural implementation runs the detect pass and writes the verdict file before
branching on mode, so
--applywithout--yesrewrites one file. Defensible, and it makes "touches nothing" a claim with a footnote. - Fix: the write moved inside the
--detectbranch.--applyis now read-only in every path that does not reach a real rotation, and case 6 became an exact whole-tree comparison rather than one with a carve-out. Control A pins it.
4. [Rule 2 — Access preservation] mv onto the path instead of rm then mv
- Issue: the plan says "never delete a key without a successfully staged replacement in
hand", which the ISO builder's
gen_sshsatisfies withrm -fthenmv. On a deployed node that still opens a window — small, but real — in which/etc/sshholds zero host keys. - Fix: each staged key is
mv'd onto its live path (arename(2), so atomic per key, and the directory is never empty), and only afterwards are key types the new set does not include removed — because leaving a stalessh_host_dsa_keywould leave shared material behind, which is the entire point of rotating.
Known Stubs
None. No placeholder values, no TODOs, no unwired code paths. Every path in the script is reached by at least one harness case.
Threat Flags
None. No new network endpoint, no new auth path, no schema change at a trust boundary. The script
performs no network I/O and takes no input from the network; delivery reuses the existing,
already-trusted run_runtime_assets path and adds no new trust source (T-10-34). No package was
installed and no crate was added (T-10-SC: accept).
system.stats gains a field on an already-authenticated method (T-10-35: accept) and deliberately
carries no fingerprints.
UNVERIFIED — exact evidence needed
Nothing below was observed. None of it is claimed as verified anywhere in this plan's output.
| # | Item | Evidence needed |
|---|---|---|
| 1 | A rotation preserves the operator's own SSH session. The single most important behavioural claim in the plan, and it is proven by design only. | On ONE disposable node, from a session you are willing to lose: sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --apply --yes; then, WITHOUT closing it, echo still-here; then a SECOND connection showing the expected host-key mismatch; then cat /var/lib/archipelago/host-key-rotation.json showing both old and new. The harness proves ordering and the abort path; it cannot prove systemctl reload ssh keeps a forked child alive. |
| 2 | host_secrets reaches system.stats on a real node. |
A build carrying this plan deployed to the dev pair (archi-dev-box + x250-dev), then a system.stats call, then the same call after a rotation to confirm verdict flips to per-node and rotated_at appears. Proven against the file contract in unit tests only. |
| 3 | The OTA actually delivers script and unit. | A real scripts/create-release-manifest.sh run, then tar -tf the frontend tarball for archipelago-runtime/scripts/security/host-secrets-audit.sh and archipelago-runtime/image-recipe/configs/archipelago-host-secrets-audit.service; then on a node after the OTA: ls -l /opt/archipelago/scripts/security/host-secrets-audit.sh (expect mode 755) and systemctl status archipelago-host-secrets-audit.service. |
| 4 | The audit script's own verdict on the three shared-key nodes. Predicted shared; predicted is not observed. |
sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --detect on archipelago-1, archy-x250-beta and archipelago, plus cat /var/lib/archipelago/host-secrets-audit.json. Needs the OTA, or the script hand-staged. |
| 5 | Same-ISO provenance for those three nodes. Not needed for the C-3 FAIL, but it bounds how many other downloads carry the same keys. | On-node: ls -l /opt/archipelago/rootfs-identity-stripped, cat /var/lib/archipelago/.secrets-regenerated, grep -i warning /var/log/archipelago-first-boot-secrets.log, plus whatever build id the installer recorded. |
| 6 | The four unreachable nodes (archy-x250-dev, archy-x250-pa, archy-x250-r2, quantumterminal). |
Re-run the C-3 scan when they are online. archy-x250-dev is half the dev pair and has been offline 2 days. |
| 7 | The /dev/console leg of the rotation shout. |
A real node or a VM console. Cannot be exercised in a temp root — the same limitation 10-03 recorded for its failure shout. |
| 8 | shellcheck -S error on both new shell files. |
shellcheck is not installed on this machine. Install it and run it. |
| 9 | systemctl enable --now behaviour of the new unit. |
A node. systemctl is-enabled archipelago-host-secrets-audit.service after an OTA. |
Items 1, 2 and 4 are also recorded in .planning/WINDOWS.md (entries 11–13) so they remain
visible at ship time.
Self-Check
scripts/security/host-secrets-audit.sh— FOUND, mode 755, 567 lines,bash -nclean, containsHOST_SECRETS_ROOTimage-recipe/configs/archipelago-host-secrets-audit.service— FOUNDtests/first-boot-secrets/rotation-tests.sh— FOUND, mode 755, 458 lines, exits 0 with 8 PASSdocs/security/KEY-02-FLEET-ROTATION.md— FOUND, contains## D-06 rotation triggerand## C-3 — per-node host key and TLS uniquenessgrep -c 'archipelago-host-secrets-audit' core/archipelago/src/bootstrap.rs→ 7 (≥1 required)grep -c 'host_secrets' core/archipelago/src/api/rpc/system/handlers.rs→ 11grep -c 'host-secrets-audit.json'→ 1 inhandlers.rs(viaHOST_SECRETS_AUDIT_FILE), 1 in the script — the key_link holds on both endsgrep -n 'systemctl reload ssh'→ line 415;grep -c 'systemctl restart ssh'→ 0- 10-03's harness re-run and still 9/9 green;
image-recipe/_archived/build-auto-installer-iso.shwas not modified by this plan - Commits
96dba73a,0ed9334f,373c3bb3,a806a658— all FOUND git diffon the two shared Rust files inspected hunk by hunk before staging: additions only, all withinhost_secrets/ the audit unit. No file belonging to plans 10-02, 10-06 or 01-18 (credentials/store.rs,device_tokens.rs,main.rs,seed.rs,session.rs,storage_crypto.rs,entropy.rs) was staged in any commit.planning/STATE.mdand.planning/ROADMAP.mddeliberately not updated — both carry other agents' uncommitted work in this shared tree, and the orchestrator owns them for this wave- Nothing pushed, per the execution brief