docs(10-04): complete fleet host-secret detection and rotation plan
SUMMARY for 10-04, plus three WINDOWS.md entries (11-13) so the unverified items stay visible at ship time: the rotation never exercised on real hardware, host_secrets never observed in a live system.stats, and the C-3 finding itself — three live nodes still on shared SSH host keys, two of them also sharing a TLS private key, none of them rotated. STATE.md and ROADMAP.md deliberately not touched: both carry other agents' uncommitted work in this shared tree and the orchestrator owns them for this wave. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a806a658a6
commit
689c4cca1a
+42
-3
@@ -1,10 +1,10 @@
|
||||
---
|
||||
schema_version: 1
|
||||
open_count: 9
|
||||
open_count: 12
|
||||
waived_count: 0
|
||||
fixed_count: 1
|
||||
total_count: 10
|
||||
last_updated: 2026-07-31T10:56:26.933Z
|
||||
total_count: 13
|
||||
last_updated: 2026-08-02T19:07:40.522Z
|
||||
---
|
||||
|
||||
# Broken Windows Ledger
|
||||
@@ -25,6 +25,9 @@ last_updated: 2026-07-31T10:56:26.933Z
|
||||
| 8 | 02 | deviation | neode-ui/src/views/web5/Web5.vue | | Web5 revisit-ms regression (566->709->1329ms, zero overlap across 3 runs) despite confirmed instance survival; confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session) | open | | 2026-07-31T10:56:26.570Z | |
|
||||
| 9 | 02 | deviation | neode-ui/src/views/AppDetails.vue | | AppDetails revisit-ms regression (1204->1510->2668ms across 3 runs); confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session) | open | | 2026-07-31T10:56:26.751Z | |
|
||||
| 10 | 02 | deviation | neode-ui/src/views/server/OpenWrtGateway.vue | | OpenWrtGateway revisit-ms regression (663.5->1148->1460ms across 3 runs); confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session) | open | | 2026-07-31T10:56:26.933Z | |
|
||||
| 11 | 10 | unrun-verify | docs/security/KEY-02-FLEET-ROTATION.md | | C-3 FAILED: archipelago-1, archy-x250-beta and archipelago share all three SSH host keys; the first two also share their TLS private key. Not rotated — needs an operator-driven --apply --yes per node. | open | | 2026-08-02T19:07:39.861Z | |
|
||||
| 12 | 10 | unrun-verify | scripts/security/host-secrets-audit.sh | | Rotation never exercised on real hardware: that 'systemctl reload ssh' keeps the operator's own forked session alive is proven only by design, not by observation. Needs --apply --yes on one disposable node from a session the operator is willing to lose. | open | | 2026-08-02T19:07:40.217Z | |
|
||||
| 13 | 10 | unrun-verify | core/archipelago/src/api/rpc/system/handlers.rs | | system.stats host_secrets never observed on a real node — proven against the file contract in unit tests only. Needs a build carrying 10-04 deployed to the dev pair, then a system.stats call. | open | | 2026-08-02T19:07:40.522Z | |
|
||||
|
||||
````json
|
||||
[
|
||||
@@ -147,6 +150,42 @@ last_updated: 2026-07-31T10:56:26.933Z
|
||||
"reason": "",
|
||||
"recorded_at": "2026-07-31T10:56:26.933Z",
|
||||
"resolved_at": null
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "10",
|
||||
"file": "docs/security/KEY-02-FLEET-ROTATION.md",
|
||||
"line": null,
|
||||
"description": "C-3 FAILED: archipelago-1, archy-x250-beta and archipelago share all three SSH host keys; the first two also share their TLS private key. Not rotated — needs an operator-driven --apply --yes per node.",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-08-02T19:07:39.861Z",
|
||||
"resolved_at": null
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "10",
|
||||
"file": "scripts/security/host-secrets-audit.sh",
|
||||
"line": null,
|
||||
"description": "Rotation never exercised on real hardware: that 'systemctl reload ssh' keeps the operator's own forked session alive is proven only by design, not by observation. Needs --apply --yes on one disposable node from a session the operator is willing to lose.",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-08-02T19:07:40.217Z",
|
||||
"resolved_at": null
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "10",
|
||||
"file": "core/archipelago/src/api/rpc/system/handlers.rs",
|
||||
"line": null,
|
||||
"description": "system.stats host_secrets never observed on a real node — proven against the file contract in unit tests only. Needs a build carrying 10-04 deployed to the dev pair, then a system.stats call.",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-08-02T19:07:40.522Z",
|
||||
"resolved_at": null
|
||||
}
|
||||
]
|
||||
````
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
---
|
||||
phase: 10-key-material-hardening
|
||||
plan: 04
|
||||
subsystem: fleet-host-secrets
|
||||
tags: [security, ssh-host-keys, tls, systemd, ota, rotation, bash, rust, f-03]
|
||||
|
||||
requires:
|
||||
- "10-03: fail-closed first-boot secret generation, the /opt/archipelago/rootfs-identity-stripped provenance marker, and the /var/lib/archipelago/first-boot-secrets.failed durable failure record this script keys off"
|
||||
provides:
|
||||
- "scripts/security/host-secrets-audit.sh — on-node detection of image-baked host secrets from the node's own disk alone, and a guarded one-time rotation"
|
||||
- "Four-signal detection with a fixed precedence and per-verdict evidence strings, each naming the file it was read from"
|
||||
- "Verdicts per-node | shared | fail-closed-missing | unknown — per-node is never inferred from an absent signal"
|
||||
- "Per-key-class sharedness: SSH and TLS are judged and rotated independently, because a renamed node has a unique cert and shared host keys"
|
||||
- "Access-preserving rotation: stage everything, abort before any swap, record old fingerprints first, TLS before SSH, mv-onto-path rather than rm-then-mv, reload sshd never restart"
|
||||
- "archipelago-host-secrets-audit.service — detect-only boot unit delivered by the existing OTA runtime-asset promotion"
|
||||
- "system.stats host_secrets object — the verdict visible without shell access"
|
||||
- "tests/first-boot-secrets/rotation-tests.sh — 8-case harness through the HOST_SECRETS_ROOT seam, with four negative controls"
|
||||
- "docs/security/KEY-02-FLEET-ROTATION.md — D-06's recorded decision, the C-3 result, and the not-yet-rotated register"
|
||||
affects: [ota-runtime-assets, systemd, system.stats, sshd, nginx-tls, release-packaging]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "HOST_SECRETS_ROOT path-prefix seam, the same move FIRST_BOOT_SECRETS_ROOT makes in 10-03. Every property worth testing here is negative or ordering — 'touches nothing', 'aborts before any swap', 'records old fingerprints BEFORE the swap' — and none of them is assertable without the ability to force the failure."
|
||||
- "Ordering asserted by observation, not by content. The systemctl stub records whether the rotation record existed AT THE MOMENT of the first reload. Comparing fingerprints proves the right values were written; only this proves they were written first."
|
||||
- "Judge and remediate per key class, never per node. A node renamed via server.set-name has a freshly-minted TLS cert and untouched image-baked SSH host keys; a node-level verdict would call it clean."
|
||||
- "Precedence over accumulation: missing material can never be shared material, so the missing check runs first; direct evidence (the fail-open log line) outranks inference from timestamps."
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- scripts/security/host-secrets-audit.sh
|
||||
- image-recipe/configs/archipelago-host-secrets-audit.service
|
||||
- tests/first-boot-secrets/rotation-tests.sh
|
||||
- docs/security/KEY-02-FLEET-ROTATION.md
|
||||
modified:
|
||||
- core/archipelago/src/bootstrap.rs
|
||||
- core/archipelago/src/api/rpc/system/handlers.rs
|
||||
- scripts/create-release-manifest.sh
|
||||
|
||||
key-decisions:
|
||||
- "D-06 resolved as detect-report-then-apply. auto-on-boot would fire simultaneous fleet-wide known_hosts breakage during an OTA with no operator holding the new fingerprints, and — the argument that settled it — it cannot be dev-paired: by the time the behaviour has been observed on the dev pair it has already run everywhere, which contradicts the project's standing verify-on-the-dev-pair-first policy. The cost of the chosen option (exposure persists on any node nobody revisits) is bounded by visibility in system.stats and by a written register of nodes not yet rotated, not by automation."
|
||||
- "--apply writes NOTHING — not even its own verdict file — until --yes. 'Touches nothing' is worth being able to state without a footnote, and 'except for one file it rewrites' is what the footnote would have been. This also turns the dry-run test into an exact whole-tree comparison rather than one with a carve-out."
|
||||
- "--apply --yes refuses unless the verdict is `shared`. The guard against running it on the wrong node is structural rather than procedural — on a per-node node the command is inert even when typed deliberately and confirmed."
|
||||
- "Host keys are replaced by mv onto the existing path, not rm-then-mv. rm-then-mv opens a window in which the node has zero host keys on disk; 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 — leaving an ssh_host_dsa_key behind would leave shared material behind."
|
||||
- "TLS is swapped before SSH. A dead web UI is recoverable over SSH; dead SSH on a remote node is not. Do the recoverable one first so a failure between the two leaves the recoverable path intact."
|
||||
- "reload sshd, never restart — stated in the script as the single most important line in the file. A reload re-execs the listener while already-forked session children keep running, so the operator survives their own rotation. The harness fails outright, before any case runs, if `systemctl restart ssh` ever appears in the file."
|
||||
- "A third sanctioned key producer is created, and said so loudly rather than quietly. Producer 1 (the ISO builder) is not present on a deployed node; producer 2 (TlsMaterial::regenerate) does TLS only and nothing in the daemon has ever rotated an SSH host key. The script header names all three and pins their shared parameters (rsa:2048, 3650 days, same subject and SAN, stage-parse-pair-check-swap) so they cannot drift apart."
|
||||
- "The verdict is never allowed to be optimistic. No anchor -> unknown; a standing first-boot-secrets.failed record -> unknown even when every mtime looks clean. T-10-37 is that a false per-node verdict leaves an exposed node looking clean, which is strictly worse than no verdict."
|
||||
- "system.stats carries the verdict and the evidence but NOT the fingerprints. They are public data, so this is not confidentiality — it is that a payload polled every few seconds should not carry digests an operator already on the node can read from disk. A unit test fails if a future edit forwards the whole file."
|
||||
|
||||
requirements-completed: []
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "A deployed node determines from its own disk alone whether its SSH host keys and TLS key are image-baked or per-node (D-06)"
|
||||
requirement: KEY-02
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "tests/first-boot-secrets/rotation-tests.sh cases 1-5 — per-node, shared-by-mtime, shared-by-fail-open-fingerprint, fail-closed-missing, unknown"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "Real run on archi-dev-box: `sudo scripts/security/host-secrets-audit.sh --detect --json` -> per-node, anchored on /etc/machine-id, and its three fingerprints match an independent ssh-keyscan of the same host exactly"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "The verdict is surfaced beyond a log file — it appears in system.stats so it is visible without shell access"
|
||||
requirement: KEY-02
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "handlers.rs::host_secrets_tests — 4/4: absent file -> unknown, unparseable file -> unknown, recorded verdict+evidence surfaced, rotated_at only when a rotation was recorded, fingerprints deliberately absent"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "Never observed on a real node — needs a build carrying this plan deployed to the dev pair, then a system.stats call"
|
||||
status: blocked
|
||||
human_judgment: true
|
||||
- id: D3
|
||||
description: "Rotation never loses remote access mid-flight: staging then atomic swap, reload rather than restart, new fingerprints recorded where an operator can read them"
|
||||
requirement: KEY-02
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "rotation-tests.sh case 7a — old fingerprints on disk at the moment of the first reload (ordering observed, not inferred), keys replaced, `reload ssh` present and `restart ssh` absent from the systemctl log, verdict re-derived to per-node"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "rotation-tests.sh case 7b — with the SSH generator failing after TLS staging succeeded, the whole tree is byte-identical, no rotation record is written, and not one service is reloaded"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "That a reload keeps the operator's own forked SSH session alive is proven by design, not by observation. UNVERIFIED on hardware."
|
||||
status: blocked
|
||||
human_judgment: true
|
||||
- id: D4
|
||||
description: "Rotation does not happen by accident: detect-only default, --apply inert without --yes, and the trigger is a resolved human decision (D-06)"
|
||||
requirement: KEY-02
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "rotation-tests.sh case 6 — --apply without --yes exits 0 and not one byte of the tree changes, including the state dir; dry-run output warns that it is one-way"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "docs/security/KEY-02-FLEET-ROTATION.md ## D-06 rotation trigger records `detect-report-then-apply` verbatim with the date and the reason; the shipped unit contains no apply path"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Two real nodes flashed from the same ISO are proven to have distinct SSH host-key and TLS fingerprints (audit C-3)"
|
||||
requirement: KEY-02
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "docs/security/KEY-02-FLEET-ROTATION.md ## C-3 — **FAILED with finding**. Three distinct live nodes share all three SSH host keys; two also share their TLS private key. Gathered read-only via ssh-keyscan + anonymous TLS handshake; distinctness of the hosts confirmed via tailscale ping endpoints."
|
||||
status: fail
|
||||
- kind: other
|
||||
ref: "Same-ISO provenance for those three nodes is UNVERIFIED — not required for the FAIL, but needed to bound how many other downloads carry the same keys"
|
||||
status: blocked
|
||||
human_judgment: true
|
||||
- id: D6
|
||||
description: "The OTA runtime-asset promotion actually delivers both the script and the unit to a fleet node"
|
||||
requirement: KEY-02
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "replace_dir_from_runtime uses `cp -a src/. tmp` then `cp -a tmp/. dest` — recursive, so scripts/security/ rides along; the chmod sweep is `find dest -type f -name '*.sh' -exec chmod 755` with no -maxdepth, so the script lands executable. Read, not assumed."
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "The unit had to be added to create-release-manifest.sh as well — bootstrap would have found nothing and installed nothing, silently. Neither half exercised end-to-end; needs a real release build and an OTA."
|
||||
status: blocked
|
||||
human_judgment: true
|
||||
|
||||
duration: 3h
|
||||
completed: 2026-08-02
|
||||
status: 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-beta` and `archipelago` — 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 in
|
||||
> `docs/security/KEY-02-FLEET-ROTATION.md` with 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:
|
||||
|
||||
1. **Missing material can never be shared material.** Checked first. On a 10-03-or-later node
|
||||
(`/opt/archipelago/rootfs-identity-stripped` present) 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.
|
||||
2. **The fail-open fingerprint outranks timestamps**, because it is direct evidence rather than
|
||||
inference: `.secrets-regenerated` present plus a `WARNING:` 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 key` and
|
||||
`WARNING: ssh-keygen -A failed, keeping baked host keys`) also say *which class* survived, so
|
||||
the rotation narrows to it. An unrecognised `WARNING:` widens to both rather than guessing.
|
||||
3. **mtime against a first-boot anchor** — `.secrets-regenerated`, falling back to
|
||||
`/root/.luks-archipelago.key` (written by the installer with `dd if=/dev/urandom`) and then
|
||||
`/etc/machine-id`. A key more than 300s *older* than the anchor carries the image build time.
|
||||
4. **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
|
||||
|
||||
1. **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.
|
||||
2. **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.
|
||||
3. **TLS, then reload nginx.** A dead web UI is recoverable over SSH. The converse is not. Do the
|
||||
recoverable one first.
|
||||
4. **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 by `mv` **onto** the existing path rather than `rm` then `mv`: 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.
|
||||
5. **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
|
||||
|
||||
- `--detect` is the default and is read-only.
|
||||
- `--apply` without `--yes` writes **nothing at all**, not even its own verdict file.
|
||||
- `--apply --yes` **refuses unless the verdict is `shared`.** On a `per-node` node 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.rs` installs units from `image-recipe/configs/` **inside the OTA runtime
|
||||
payload**, but `scripts/create-release-manifest.sh` copies only `archipelago-doctor.service`
|
||||
and `.timer` into that directory. `archipelago-host-secrets-audit.service` would 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 `-f` test 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. A `KEEP IN SYNC` comment
|
||||
now names the array in `bootstrap.rs`, since two enumerations of one list in two languages in
|
||||
two files is what caused this.
|
||||
- **Scope:** `scripts/create-release-manifest.sh` is **outside this plan's `files_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 --porcelain` confirmed 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`. `diff` exits 1 when it
|
||||
finds differences, and under `set -o pipefail` that 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, so `diff` exited 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:** `|| true` on 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 `--apply` without `--yes` rewrites one file. Defensible, and it makes
|
||||
"touches nothing" a claim with a footnote.
|
||||
- **Fix:** the write moved inside the `--detect` branch. `--apply` is 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_ssh` satisfies with `rm -f` then `mv`. On a deployed node
|
||||
that still opens a window — small, but real — in which `/etc/ssh` holds zero host keys.
|
||||
- **Fix:** each staged key is `mv`'d **onto** its live path (a `rename(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 stale `ssh_host_dsa_key` would 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 -n` clean,
|
||||
contains `HOST_SECRETS_ROOT`
|
||||
- `image-recipe/configs/archipelago-host-secrets-audit.service` — FOUND
|
||||
- `tests/first-boot-secrets/rotation-tests.sh` — FOUND, mode 755, 458 lines, exits 0 with 8 PASS
|
||||
- `docs/security/KEY-02-FLEET-ROTATION.md` — FOUND, contains `## D-06 rotation trigger` and
|
||||
`## C-3 — per-node host key and TLS uniqueness`
|
||||
- `grep -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` → **11**
|
||||
- `grep -c 'host-secrets-audit.json'` → 1 in `handlers.rs` (via `HOST_SECRETS_AUDIT_FILE`), 1 in
|
||||
the script — the key_link holds on both ends
|
||||
- `grep -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.sh`
|
||||
was not modified by this plan
|
||||
- Commits `96dba73a`, `0ed9334f`, `373c3bb3`, `a806a658` — all FOUND
|
||||
- `git diff` on the two shared Rust files inspected hunk by hunk before staging: additions only,
|
||||
all within `host_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.md` and `.planning/ROADMAP.md` deliberately **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
|
||||
|
||||
## Self-Check: PASSED
|
||||
Reference in New Issue
Block a user