docs(10,01): record the summaries for the four completed plans
Demo images / Build & push demo images (push) Successful in 3m15s

Written by the previous session's executors for 01-17, 10-01, 10-03 and
10-05, all of which are complete and whose code is already committed. The
session was cut off by a dropped SSH connection before these were staged,
so they were sitting untracked. Recording them so the phase history is not
lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 14:16:46 -04:00
co-authored by Claude Opus 5
parent c5a82cba06
commit cdad880629
4 changed files with 1420 additions and 0 deletions
@@ -0,0 +1,243 @@
---
phase: 01-federation-mesh-hardening
plan: 17
subsystem: ui
tags: [vue, responsive, tailwind, vitest, transport, security-signal]
status: complete
requires:
- phase: 01-federation-mesh-hardening
plan: 14
provides: "Paid Files row treatment and the Cloud.vue/PeerFiles.vue shapes this plan audited"
provides:
- "A recorded, browser-measured mobile verdict for all five transport-pill render sites in the cloud surfaces"
- "flex-wrap + shrink-0 on the Cloud.vue peer-card badge row, so the transport badge drops to a second line intact instead of having its own text broken mid-label at 320px"
- "neode-ui/src/views/__tests__/TransportPills.test.ts — a site-specific pin that fails the build if any transport pill is removed"
- "A stated, reasoned decision (with evidence) that the file-level rows do NOT carry a per-file transport pill"
affects: [cloud, peer-files]
tech-stack:
added: []
patterns:
- "Overflow-only responsive fix: flex-wrap on a badge row is inert whenever the row already fits, so it changes nothing at any width where the layout was already correct. Verified by diffing measured pill geometry pre/post at 390/320/1440 — identical everywhere except the case being fixed."
- "shrink-0 on the security-relevant badge: when a row must degrade, degrade by wrapping the whole badge rather than by compressing the badge until its label breaks. The transport WORD is the signal; the milliseconds are not."
- "Pin tests keyed to a render site, not to a string: each assertion targets a shape only that site produces (the peer card's `FIPS · 0.4s` form, the `hidden md:block` title block, the `md:hidden` copy), proven bidirectionally by deleting one pill at a time."
key-files:
created:
- neode-ui/src/views/__tests__/TransportPills.test.ts
modified:
- neode-ui/src/views/Cloud.vue
key-decisions:
- "Cloud.vue's aggregated Peer Files rows do NOT get a per-file transport pill. Transport is measured per peer per browse (`cloud.peer-browse:<onion>.transport`), never per file; the aggregated list interleaves files from many peers sorted by filename, so a per-row pill would repeat one peer-level reading dozens of times while implying a per-file measurement the app never took (T-01-78). Each row already carries the peer-name pill and taps straight through to PeerFiles.vue, where the peer-level pill is shown once, correctly."
- "Cloud.vue's Paid Files rows do NOT get a transport pill — the stronger case. These rows come from the local purchase cache (content.owned-list); the bytes are already on this node and the current session may never have browsed that seller at all. Any pill here would be a stale or fabricated claim, a direct violation of the transparency prohibition."
- "PeerFiles.vue's per-file card bodies do NOT get a transport pill. Every file on that page came from the same peer over the same transport, which the header pill already states once."
- "Only Cloud.vue was modified. PeerFiles.vue passed the audit at both narrow widths and was left untouched — the plan forbids changing a site the audit passed."
requirements-completed: [UIFIX-01]
metrics:
duration: ~1h20m
tasks: 2
files-changed: 2
tests-added: 13
---
# Phase 1 Plan 17: Keep the FIPS/Tor pills forever, and make sure a phone shows them — Summary
Audited all five transport-pill render sites in a real Chromium at 390×740 and 320×640 with RPC
interception supplying transport data; fixed the one site that degrades badly at the narrowest
supported width; and pinned every pill with a site-specific vitest suite so removing one fails the build.
## Method
The mock backend's `content.browse-peer` returns no `transport` field, so no pill renders against it.
Rather than change the mock (out of this plan's file boundary), the audit drove the already-running
`:8100` dev preview through Playwright with `page.route()` interception on `/rpc/v1`, injecting
`transport: 'fips' | 'tor' | <absent>` per peer. That renders the real components with the real CSS
and gives measurable geometry (`getBoundingClientRect`, `scrollWidth` vs `clientWidth`, computed
display) instead of a subjective look. Harness lived in the scratchpad; nothing was written to the repo.
## Per-site audit table
| # | Site | File and line | Renders on mobile | Legible | Action |
|---|------|---------------|-------------------|---------|--------|
| S1 | Peer card badge row, Folders tab | `Cloud.vue` 311331 (pre-fix) | **Yes** — 90×24 px pill, in viewport at both 390 and 320 | **Conditionally** — fine with real trust labels (3669 px slack at 320), but the row cannot wrap, so a longer trust label squeezes the badge until *its own* text breaks mid-label | **FIXED**`flex-wrap` on the row, `shrink-0` on the transport badge |
| S2 | Peer Files aggregated rows | `Cloud.vue` 199218 | n/a — **no transport pill exists at any width** | n/a | **No change** — decision recorded below |
| S3 | Paid Files rows | `Cloud.vue` 150178 | n/a — **no transport pill exists at any width** | n/a | **No change** — decision recorded below |
| S4 | Header pill (desktop copy + `md:hidden` mobile copy) | `PeerFiles.vue` 838 | **Yes** — the `md:hidden` copy renders at 390 and 320 (41×20 px, 183253 px of slack); the `hidden md:block` desktop copy correctly does not | **Yes** — labels are ≤ 4 chars (`FIPS`/`Mesh`/`LAN`/`Tor`), no truncation, no overlap | **No change** — site passed |
| S5 | Per-file card body | `PeerFiles.vue` 152232 | n/a — **no transport pill exists at any width** | n/a | **No change** — decision recorded below |
Long-name robustness was exercised for S1/S2/S3/S4 with 5456 character peer names and 6570
character filenames: the peer name truncates in its own row above the badge row and never touches
the pills, and the measured pill geometry was identical to the short-name run.
`grep -rn "FIPS" src --include=*.vue` confirms S1 and S4 are the *only* cloud-surface transport-pill
render sites — `CloudFolder.vue`, named speculatively in the original todo, has none.
### The S1 defect, measured
At 320×640 the badge row's container is 238 px. With real trust values the row fits:
| Case | trust label | badge | row height | badge wrapped? | verdict |
|---|---|---|---|---|---|
| baseline | `trusted` | `TOR · 0.1s` (90 px) | 24 px | no | fits, 69 px slack |
| slow peer | `trusted` | `TOR · 120.0s` (105 px) | 24 px | no | fits, 54 px slack |
| observer | `observer` | `TOR · 120.0s` | 24 px | no | fits, 44 px slack |
| unverified | `unverified` | `TOR · 120.0s` | 24 px | no | fits, 36 px slack |
| **longer trust label** | `pending verification` | `TOR · 120.0s` | **40 px** | **yes** | **FAILS** — badge compressed to 96 px, its text broken across two lines as `TOR ·` / `120.0s` |
The row had `flex items-center gap-2` with no wrapping and no `shrink-0`, so flexbox's only degradation
path was to shrink both pills until their labels wrapped internally. That is the "truncates into
meaninglessness" failure the plan forbids, and it lands on the transport badge — the security signal —
not on something decorative.
### The fix, measured
`flex-wrap` on the row + `shrink-0` on the transport badge. Same case, after:
| Case | badge width | badge wrapped? | on same line as trust? | row height |
|---|---|---|---|---|
| `pending verification` + `TOR · 120.0s` | **105 px** (full natural width) | **no** | **no** — dropped to line 2 intact | 56 px |
Every other case is byte-identical before and after (same row width, row height, trust width, badge
width, same line). `flex-wrap` only takes effect when the row would otherwise overflow, which is
exactly why it is safe.
## Site decisions (the two open questions, settled)
**Peer Files aggregated rows (S2): NO per-file pill.**
Transport in this codebase is a *peer-level, per-browse* reading — `cloud.peer-browse:<onion>` stores
one `transport` and one `latencyMs` for the whole browse, and `peerTransport(onion)` reads exactly
that. The aggregated list merges files from every peer and sorts by filename, so a per-row pill would
render the same peer-level fact once per file (40 files from one FIPS peer ⇒ 40 identical pills) while
implying a per-file measurement that was never taken — the precise claim threat T-01-78 forbids. At
320 px it would also have to compete with a filename that already truncates and the existing peer-name
pill. The row already names its peer and taps through to `PeerFiles.vue`, where the peer-level pill is
shown once and correctly. **The existing peer-level pill is sufficient for these rows.**
**Paid Files rows (S3): NO pill.** Stronger still. These rows come from the local purchase cache
(`content.owned-list`) — the bytes are already on this node and were filed into Photos/Music/Documents
at purchase time. There is no live transport for them, and the session may never have browsed that
seller at all. Any pill here would be stale or fabricated. The honest treatment is the one already
shipping: none.
**PeerFiles per-file cards (S5): NO pill,** for the same reason at smaller scale — every file on that
page came from one peer over one transport, already stated once in the header.
All three decisions are pinned as *absence* assertions in the test file, with a comment stating that
they encode a recorded decision and that deliberately adding a pill means updating this summary and
the test together.
## The pin (`TransportPills.test.ts`, 13 tests)
Nothing in the repo pinned these pills before. The suite opens with a plain-English header stating
that the pills are a permanent, user-requested feature and that a failure here most likely means
someone removed one, not that the test is stale.
Assertions are keyed to shapes only one site produces: the peer card's `FIPS · 0.4s` form (S1), the
`hidden md:block` title block (S4 desktop), the `md:hidden` class (S4 mobile). Labels and colours are
asserted against `PeerFiles.vue`'s canonical `transportPill` mapping across all four transports
(`fips`/`mesh`/`lan`/`tor`) rather than a duplicated table — `grep -c 'transportPill'
neode-ui/src/views/PeerFiles.vue` is **9**, unchanged (that file was not edited at all).
Unknown-transport cases assert no pill is fabricated at either site and that S1 keeps its existing
`Peer Node` not-known treatment.
One test-isolation subtlety worth knowing: `cloud.peer-browse:<onion>` is a `persist: true` key that
snapshots into `sessionStorage`, which outlives a per-test `createPinia()`. Without `sessionStorage.clear()`
in `beforeEach` the transport from an earlier test leaks forward and the unknown-transport case passes
against a stale FIPS reading. That is now explicit in the file.
## Task 2 re-check
**390×740 and 320×640, fixed site (S1):** re-measured after the fix. In the fitting cases the pill
geometry is identical to pre-fix; in the overflow case the badge now renders at its full 105 px on a
second line with `TOR · 120.0s` intact and legible. Long-name case forced by injecting 5456 character
peer names through the RPC interceptor, and the worst-case trust label (`pending verification`) forced
by editing the rendered text node in the page — neither the mock backend nor the source was changed
to produce them, exactly as the plan directs.
**1440×900 desktop, per changed site:** measured pill geometry was diffed pre-change vs post-change
for every captured surface. `Cloud.vue` S1 at 1440: pill identical at 90×24 px, x=771 / x=1159, y=331 —
the only textual difference is the live latency figure (`0.1s` vs `0.2s`), which is a measurement, not
layout. S2, S3 and `PeerFiles.vue` at 390/320 were byte-identical. **Desktop rendering at the one
changed site is unchanged.** `PeerFiles.vue` was never modified.
**Deliberate-removal check — the pin proven, bidirectionally:**
| Pill removed | Result | Site-specificity |
|---|---|---|
| `Cloud.vue` peer-card transport badge (706 chars, plus de-branching the orphaned `v-else` so the file still compiles) | **3 tests failed**, message: `peer card transport pill is missing — see the header of this file` | S4 PeerFiles tests still **passed** |
| `PeerFiles.vue` `md:hidden` mobile pill copy (236 chars) | **5 tests failed**, message: `the md:hidden mobile transport pill is missing — a phone would show no transport here` | S1 Cloud tests still **passed** |
Both files were then restored and the suite passed again, 13/13.
`git status --short -- neode-ui/src/views/Cloud.vue neode-ui/src/views/PeerFiles.vue` is **empty**
no leftover deliberate-removal edit.
A third variant is worth recording: deleting the pill *without* de-branching the `v-else` fails the
suite as a Vue compile error (`v-else/v-else-if has no adjacent v-if`) rather than an assertion, so
the careless version of the removal is caught too.
## Verification
- `cd neode-ui && npx vitest run src/views/__tests__/TransportPills.test.ts`**13 passed**.
- `cd neode-ui && npx vitest run`**104 files, 845 tests, all passed**, including `keepAliveTabs.test.ts`.
- `cd neode-ui && npm run build`**green** (`vue-tsc -b && vite build`, built in 35.87 s).
- Build is not a silent no-op: `flex flex-wrap items-center gap-2 text-xs` and
`inline-flex shrink-0 items-center gap-1.5 px-2 py-1 rounded-full` are both present in the built
`Cloud-*.js` chunk, the `md:hidden` pill class string is intact in `PeerFiles-*.js`, and `.flex-wrap{`
and `.shrink-0{` are both emitted in the main CSS bundle.
- RED/GREEN was demonstrated explicitly: with the fix reverted, the responsive assertion fails
(`transport pill must not be compressible`) while the 12 pill-existence assertions still pass;
with the fix applied, all 13 pass.
## Deviations from Plan
**None affecting behaviour.** Two process notes:
1. The plan's `<output>` says to `git push gitea-ai main`. The execution brief for this run explicitly
forbids pushing, tagging and deploying. **Not pushed** — commit `8255b69a` is local on `main`.
Per CLAUDE.md's commit-and-push rule this work is not "done" until someone pushes it.
2. Task 2 made no source changes (it is a verification task), so it has no commit of its own. Its
acceptance criterion — a clean `git status` for both views — is satisfied.
## Known Stubs
None. No placeholder, mock or empty-value path was introduced.
## Threat Flags
None. No new network endpoint, auth path, file-access pattern or schema change. The one source change
is two Tailwind utility classes and a comment.
Threat register status:
- **T-01-78** (pill claiming an unobserved transport) — mitigated: unknown-transport assertions at
both render sites, canonical mapping reused, no per-file pill fabricated for the rows that have no
live reading.
- **T-01-79** (mobile user cannot see a Tor arrival) — mitigated: every render site has a recorded
verdict at 390 and 320.
- **T-01-80** (later cleanup silently deleting the pills) — mitigated and *proven*: the pin fails on
removal at both sites, with site-specific failure messages.
- **T-01-81** (long peer name pushing the pill off screen) — mitigated: exercised deliberately at both
narrow widths; the name truncates in its own row and never reaches the badge row.
- **T-01-SC** (package installs) — no dependency added or installed.
## Field note (not in scope, recorded for whoever picks up UIFIX-01 follow-up)
The user's report is dated 2026-07-30. Both pills predate it: the `PeerFiles.vue` mobile copy landed
`9e3ac9ba` (2026-07-20) and the `Cloud.vue` peer-card badge landed `c83bade0` (2026-07-27). Since both
render correctly at phone widths, the most likely reason a pill was *absent* on the day of the report
is not layout but data: `peerTransport()` returns `null` and the pill does not render whenever the
`content.browse-peer` fan-out never resolves. That first-visit stall was root-caused and fixed on the
same day by `e1a3f31a` and `8fe6217b` ("cap content.browse-peer fan-out — root cause of Cloud
first-visit hang"), and it would bite harder on a phone than on a desktop. If the user still reports a
missing pill after this ships, look at whether the browse resolved, not at the CSS.
## Self-Check: PASSED
- `neode-ui/src/views/__tests__/TransportPills.test.ts` — FOUND
- `neode-ui/src/views/Cloud.vue` — FOUND, contains `flex flex-wrap items-center gap-2 text-xs` and `inline-flex shrink-0`
- commit `8255b69a` — FOUND in `git log`
- `neode-ui/src/views/PeerFiles.vue` — unmodified, `git status` clean
@@ -0,0 +1,256 @@
---
phase: 10-key-material-hardening
plan: 01
subsystem: rpc-auth
tags: [security, onboarding, identity, rate-limit, F-01, KEY-01]
requires: []
provides:
- "api::rpc::onboarding_gate::ensure_onboarding_open — the shared onboarding-posture gate"
- "api::rpc::onboarding_gate::ensure_user_account_exists — the inverse guard for auth.onboardingComplete"
- "api::rpc::seed_rpc::restore_node_identity_from_words — the gated, testable seed.restore body"
- "Per-method rate limits for the four unauthenticated onboarding mutators"
affects:
- "seed.generate / seed.restore / seed.save-encrypted / backup.restore-identity / auth.setup / auth.onboardingComplete"
tech-stack:
added: []
patterns:
- "Posture gate instead of authentication for legitimately pre-auth endpoints"
- "Source-guard test (include_str! + brace-matched fn body) as anti-drift for security calls"
key-files:
created:
- core/archipelago/src/api/rpc/onboarding_gate.rs
modified:
- core/archipelago/src/api/rpc/mod.rs
- core/archipelago/src/api/rpc/seed_rpc.rs
- core/archipelago/src/api/rpc/backup_rpc.rs
- core/archipelago/src/api/rpc/auth.rs
- core/archipelago/src/rate_limit.rs
decisions:
- "D-03a signal set implemented (is_setup / is_onboarding_complete / seed_exists); key_exists and fips_key_exists rejected with recorded evidence"
- "auth.onboardingComplete takes the inverse guard so the gate cannot be weaponised into a fresh-node lockout"
- "Rate limits sized ~6x the measured client retry budget rather than minimally"
metrics:
duration: "~3h (dominated by shared-tree cargo contention)"
completed: 2026-08-02
status: complete
---
# Phase 10 Plan 01: Onboarding Identity Gate (KEY-01 / F-01) Summary
Every unauthenticated RPC that can rewrite node key material now hard-refuses once the node is
provisioned, behind one shared gate proven by a regression suite that fails the moment the gate
is removed — while first-boot onboarding on a fresh node is untouched.
## What Was Built
**`core/archipelago/src/api/rpc/onboarding_gate.rs`** (new, ~430 lines with tests)
| Symbol | Purpose |
|---|---|
| `ensure_onboarding_open(data_dir, auth)` | Refuses with a `Not supported:` error once ANY provisioning signal is true |
| `ensure_user_account_exists(auth)` | The inverse guard, for `auth.onboardingComplete` only |
| `IDENTITY_MUTATING_ONBOARDING_METHODS` | The D-04 sweep set; the anti-drift anchor the source-guard test reads |
The refusal message is
`"Not supported: this node is already provisioned. Re-keying requires the authenticated
system.factory-reset, after which the normal onboarding restore flow works."` — the
`Not supported:` prefix is load-bearing (`middleware.rs:47-71` otherwise collapses it to
"Operation failed. Check server logs for details."), and it is under the sanitizer's 200-char
truncation so the D-02 recovery path survives intact. A test pins both properties.
I/O errors from any signal are treated as *provisioned* (fail safe). The refusal does not say
which signal fired.
## D-04 Verdicts (all recorded in-code as doc comments)
| Method | Verdict | Evidence |
|---|---|---|
| `seed.restore` | **gated** | `restore_node_identity_from_words``NodeIdentity::from_seed` (`identity.rs:79-114`) overwrites `node_key`, `nostr_secret`, `fips_key` unconditionally |
| `seed.generate` | **gated, before the lock/TTL fast path** | the fast path returns the 24 words to an unauthenticated caller (T-10-07) |
| `seed.save-encrypted` | **gated** | no UI caller today (`rpc-client.ts:334` exposes it, no view calls it); the real save is `save_pending_seed_encrypted` called *inside* `auth.setup` (`api/rpc/auth.rs:239`), which is behind `auth.setup`'s own gate |
| `backup.restore-identity` | **gated** | reaches `backup::identity::restore_encrypted_backup`, which writes `identity/node_key` unconditionally at `backup/identity.rs:113-117` — the same primitive, a different door |
| `auth.setup` | **gated, in addition to the existing `is_setup()` check** | the `is_setup()` guard fails open on a provisioned node whose `user.json` was deleted, and the handler rewrites the OS login password via `crate::auth::change_ssh_password` (`api/rpc/auth.rs:230` pre-edit) — unauthenticated privilege escalation (T-10-08) |
| `auth.onboardingComplete` | **inverse guard** | unauthenticated *and* sets the flag the gate reads; one call against a fresh node would lock it out of onboarding permanently (T-10-04) |
| `seed.verify` | **NOT gated** | compares submitted words against the in-memory copy and re-derives a DID/npub for display; writes no file, mutates no identity (`seed_rpc.rs` `handle_seed_verify`). Gating it would break a legitimate retry after the client's 15s abort |
| `NodeIdentity::key_exists` | **rejected as a signal** | `server.rs:63-71` calls `load_or_create` on *both* branches, and `identity.rs:47-67` writes a random temporary key when none exists — so it is true on every node that has booted even once. A gate keyed on it refuses `seed.generate` on a never-onboarded node (T-10-05) |
| `identity::fips_key_exists` | **rejected as a signal** | written by `NodeIdentity::from_seed` (`identity.rs:108`), i.e. by the *first* seed step — already true mid-wizard, which would break a generate-then-restore switchback |
## The `auth.onboardingComplete` Ordering Check (Task 2's checkpoint condition)
Task 2 required stopping if the UI calls `auth.onboardingComplete` before `auth.setup`. Verified
against the real wizard; the guard does not break it, on three independent grounds:
1. **The live flow never calls it.** Routing is `/onboarding/intro → path → seed → seed-verify →
identity → done → /login`, and `views/Login.vue:405-425` posts `auth.setup` from that last
screen (`OnboardingIdentity.vue:124` → `/onboarding/done`, `OnboardingDone.vue:127` →
`/login`). The onboarding flag is set afterwards by `auth.rs:203-217`'s auto-heal inference,
not by this RPC.
2. **Its only caller is unreachable.** `completeOnboarding` is called from
`OnboardingVerify.vue:157` on `/onboarding/verify`, which is reachable only from
`/onboarding/backup` (`OnboardingBackup.vue:159`) — and nothing in the app navigates to
`/onboarding/backup`. (`saveOnboardingStep` is defined but never called, so the router's
resume path at `router/index.ts:357` always yields `intro`.)
3. **Even on that dead path the refusal is invisible.** `completeOnboarding`
(`useOnboarding.ts:64-68`) wraps the call in `callWithRetry`, which returns `null` on a
non-retryable error instead of throwing, and `OnboardingVerify.vue`'s `proceed()` catches
anyway before navigating.
Additionally, the guard is *required* for the gate's own safety: without it, a caller reaching
`/onboarding/verify` on a fresh node would write `onboarding.json` before `auth.setup`, and the
gate would then refuse `auth.setup` — bricking onboarding. The guard prevents that state from
being created.
## Rate-Limit Budget Derivations (`rate_limit.rs`)
| Method | Limit | Derivation |
|---|---|---|
| `seed.generate` | 20 / 300s | The view's 4s silent retry loop (`OnboardingSeedGenerate.vue:265-268`) fires only on transient/network errors — i.e. the daemon is not answering, so the limiter never sees those. What reaches the limiter is 30s-timeout aborts plus `rpc-client.ts`'s internal retries: ~1 user-visible attempt per 30s, ~10 per 300s worst case. 20/300s is ~6x the realistic budget |
| `seed.restore` | 10 / 300s | The audit's suggested 3/300s (matching `auth.changePassword`) was **rejected with cause**: `rpc-client.ts:196-215` retries a single call up to 3 times, so 3/300s burns a user's whole budget on one submit of a mistyped phrase |
| `seed.save-encrypted` | 10 / 300s | same class, no UI caller |
| `backup.restore-identity` | 10 / 300s | same class |
Generous rather than minimal because a 429 is a hard, user-visible failure at the DID-creation
screen: it returns HTTP 429 with `{"error":{"code":429,...}}` (`api/rpc/mod.rs:506-519`), and
neither `OnboardingSeedGenerate.vue:243`'s transient regex nor `rpc-client.ts`'s retryable check
(502/503 only) matches it. That is precisely the failure the in-memory generate lock was written
to prevent, so the limits must not reintroduce it.
## Verification
### Scratch-run evidence (the tests fail without the gate)
With `ensure_onboarding_open(...)` removed from `restore_node_identity_from_words`
(`cargo test -p archipelago onboarding_gate::`):
```
test ... every_identity_mutating_method_still_carries_its_guard ... FAILED
test ... provisioned_node_refuses_restore_and_identity_bytes_are_unchanged ... FAILED
panicked at onboarding_gate.rs:327: seed.restore: async fn restore_node_identity_from_words
no longer calls ensure_onboarding_open — the F-01 gate was removed
panicked at onboarding_gate.rs:398: a provisioned node must refuse seed.restore
test result: FAILED. 7 passed; 2 failed
```
With the gate restored: `test result: ok. 9 passed; 0 failed`. The scratch edit was reverted
before committing (`grep -n SCRATCH core/archipelago/src/api/rpc/*.rs` → no matches).
One scratch run covers both acceptance criteria: removing that single call proves the
byte-identity regression **and** the source-guard test, since the guard test brace-matches the
handler's own body.
### Test counts
- **Baseline (pre-plan):** 1011 passed; 1 failed; 2 ignored — the failure is the pre-existing
timing-flaky `container::boot_reconciler::tests::second_pass_fires_after_interval`.
- **After:** 1036 passed; 1 failed; 2 ignored. The baseline's `boot_reconciler` failure passed
this time (confirming it is timing-flaky). The one failure is
`credentials::operations::tests::test_list_credentials_no_filter`, which is **not** mine and
**not** the baseline failure: `credentials/store.rs:29` sniffs the first byte of the stored
blob for `[`/`{` to detect a legacy plaintext store, so roughly 1 run in 128 misreads
encrypted ciphertext as plaintext JSON and fails `String::from_utf8`. `credentials/` is
unmodified by this plan (`git status` clean for it). Logged in `deferred-items.md`, not fixed
(scope boundary).
- The `+25` net new passing tests are 12 mine (9 gate + 3 rate-limit) plus tests other agents
landed in the shared tree during the same window.
### Gate suite (9 tests)
`allows_on_fresh_node`, `allows_on_fresh_temp_dir_even_though_node_key_exists` (pins the D-03a
correction as a test, not a comment), `refuses_when_user_json_exists`,
`refuses_when_onboarding_flag_set`, `refuses_when_encrypted_seed_on_disk`,
`refusal_survives_the_error_sanitizer_and_names_the_recovery_path`,
`onboarding_complete_guard_requires_a_user_account`,
`every_identity_mutating_method_still_carries_its_guard`,
`provisioned_node_refuses_restore_and_identity_bytes_are_unchanged`.
Plus 3 new `rate_limit` tests: `seed_generate_allows_twenty_then_limits`,
`seed_restore_allows_a_full_submit_with_its_retries`, `onboarding_mutators_are_registered`.
## Deviations from Plan
**1. [Rule 3 — Blocking] Tasks batched into one commit rather than three**
- **Found during:** Task 1 verification.
- **Issue:** Three other agents were running `cargo test` in the shared tree; each build took
3045 minutes wall-clock, and for ~40 minutes the crate did not compile at all because a
concurrent agent was mid-TDD on `federation/storage.rs` / `federation/types.rs` (tests
referencing `record_sync_result` and `last_sync_error` before the impl landed). Per-task
verify-then-commit cycles were not affordable.
- **Fix:** Wrote all three tasks, then verified once. This matches the plan's own success
criterion ("All six touched files are committed in one commit staged explicitly by path") and
Task 3's staging instruction, so the commit shape is unchanged.
- **Note:** I waited for the other agent's work to land rather than working around it, per
`feedback_concurrent_agent_tree`. Nothing of theirs was staged or modified.
**2. [Rule 2 — Missing critical functionality] `ensure_user_account_exists` extracted into the
gate module**
- **Found during:** Task 2.
- **Issue:** The plan put the `auth.onboardingComplete` guard inline in `handle_auth_onboarding_complete`,
but its acceptance criterion requires a test asserting `Err` without `user.json` and `Ok` with
it — and `RpcHandler` cannot be constructed in a unit test (it needs an orchestrator, port
allocator, session store and metrics store).
- **Fix:** The guard lives in `onboarding_gate.rs` as `ensure_user_account_exists` and the
handler calls it. Same behaviour, directly testable, and it keeps both guards reviewable in one
file.
## Known Risk (recorded, not fixed — needs a decision, not a patch)
Gating `auth.setup` on the full three-signal set means a node with `onboarding.json =
{"complete": true}` but **no** `user.json` and **no** `master_seed.enc` can no longer set a
password: `auth.setup` is refused, and the recovery path (`system.factory-reset`) requires a
session that cannot be created. That state is only reachable on a node running a pre-`19dcfd4f`
frontend that routed through `/onboarding/backup → /onboarding/verify` (which called
`auth.onboardingComplete` before the password screen) **and** that never finished onboarding.
Any such node that did finish is unaffected, because `user.json` exists.
I followed the plan here rather than carving out an exception, because D-03 is explicit that the
gate refuses if *any* signal says onboarded and must fail safe when signals disagree. The new
`auth.onboardingComplete` guard means no new node can enter this state. Recovery for a legacy
node in it is one SSH command: `rm /var/lib/archipelago/onboarding.json`.
**This belongs in 10-02's on-node verification:** confirm no fleet node has `onboarding.json`
complete-true without `user.json` before the OTA ships (D-10).
## Not Done
- On-node verification (fresh-node onboarding survives the gate; a live node refuses a LAN
`seed.restore`) is **10-02's job** and a precondition of the OTA (D-10), not of this commit.
- `cargo clippy -p archipelago -- -D warnings`: **clean for all six touched files** — no
diagnostic's `-->` line points at `onboarding_gate.rs`, `seed_rpc.rs`, `backup_rpc.rs`,
`api/rpc/auth.rs`, `api/rpc/mod.rs` or `rate_limit.rs`. The crate as a whole still has ~30
pre-existing clippy errors in other modules (`DeviceProbe` unused import, `ELECTRUM` never
used, various style lints); that is pre-existing debt owned by other files and out of scope.
- Narrowing `is_peer_allowed_path` by method so FIPS mesh peers cannot reach `/rpc/v1` seed
endpoints at all remains out of scope (T-10-09, accepted in the plan's threat model).
## Threat Flags
None. This plan adds no network endpoint, no dependency (`Cargo.toml` untouched, so the Package
Legitimacy Gate was not triggered), and no new file-access or schema surface — it only narrows
existing surface.
## Commits
- `879de59e` — `fix(10-01): gate identity-mutating onboarding RPCs on provisioned nodes (F-01)`
— all six files, staged explicitly by path. No deletions
(`git diff --diff-filter=D HEAD~1 HEAD` empty). Two other agents' in-flight files
(`core/archipelago/src/server.rs`, `core/archipelago/src/api/rpc/system/handlers.rs`) were
left unstaged and untouched.
Not pushed — this run was scoped to commit only.
## Self-Check: PASSED
All six files exist on disk; commit `879de59e` exists in `git log`; `onboarding_gate.rs` is 413
lines (min 120) and contains `Not supported:`; `ensure_onboarding_open` appears in `seed_rpc.rs`
(3), `backup_rpc.rs` (1) and `api/rpc/auth.rs` (2); all four rate-limit keys are present in
`rate_limit.rs`; no `SCRATCH` residue remains in any RPC source file.
**Not done by this agent (left to the orchestrator, deliberately):** `STATE.md` / `ROADMAP.md` /
`REQUIREMENTS.md` updates and the docs commit. `.planning/STATE.md` carries another agent's
uncommitted edit and several phase-10 plans are executing concurrently in this shared tree, so
mutating shared planning state here would entangle their work.
@@ -0,0 +1,551 @@
---
phase: 10-key-material-hardening
plan: 03
subsystem: iso-build
tags: [security, iso, first-boot, systemd, ssh-host-keys, tls, machine-id, bash]
requires: []
provides:
- "Fail-closed first-boot per-device secret generation: the completion marker is written only when both TLS and SSH generation succeeded"
- "A single producer per secret — gen_tls()/gen_ssh() are the only code in the ISO build that create the TLS keypair and SSH host keys"
- "Retry-with-backoff inside a single boot, so a transient first-boot condition recovers without a reboot"
- "archipelago-first-boot-secrets.timer — unattended self-heal every 15 minutes until generation succeeds"
- "A build-time assertion that fails the ISO build if openssl or ssh-keygen is missing from the rootfs"
- "Parse-back validation (openssl pkey / openssl x509) before the staging swap, so no service ever reads a truncated artefact"
- "A durable failure record at /var/lib/archipelago/first-boot-secrets.failed plus console + journal + stderr on failure"
- "FIRST_BOOT_SECRETS_ROOT / FIRST_BOOT_SECRETS_BACKOFF test seams on the generated first-boot script"
- "An identity-free rootfs tar: no SSH host keys, no TLS keypair, machine-id truncated"
- "/opt/archipelago/rootfs-identity-stripped build-time provenance marker"
- "tests/first-boot-secrets/run-tests.sh — 6-case harness driving the shipped heredoc body against a temp root with stubbed generators"
affects: [image-recipe, first-boot, sshd, nginx-tls]
tech-stack:
added: []
patterns:
- "Seam-for-testability over assertion-in-a-comment: FIRST_BOOT_SECRETS_ROOT prefixes every absolute path so the NEGATIVE property (on failure the marker is NOT created) can be forced and asserted. Same move the entropy fix in 8b51b7e2 made for the RNG."
- "Test the shipped bytes, not a copy: the harness extracts the first-boot script from the builder heredoc between the SECRETSSCRIPT delimiters, so the test and the artefact cannot drift."
- "Strip identity material in the last Dockerfile layer so fail-closed is structural (no key exists) rather than procedural (a script promises to replace it)."
key-files:
created:
- tests/first-boot-secrets/run-tests.sh
- docs/security/KEY-02-ROOTFS-EVIDENCE.md
modified:
- image-recipe/_archived/build-auto-installer-iso.sh
key-decisions:
- "UNIFY, DO NOT DELETE. The defect in F-03 was never that a second attempt to create a key existed — it was that failure was silent and the marker lied about it. A second attempt is only dangerous when it is an unaudited second PRODUCER with its own idea of success, its own absent retry policy and its own absent failure record. So both secondary producers were folded out (the Dockerfile bake and the installer fallback) leaving one generator per secret, rather than 'keep the fallback' (leaves a silent second source) or 'delete the fallback and accept a dead node' (a false trade between security and UX)."
- "Fail-closed governs SERVING; self-heal governs RECOVERING. These are separate properties and both must hold. Nothing serves on a key we did not generate; nothing dead-ends waiting for a human at a console."
- "Self-heal uses a systemd timer, not a sleep loop in the script. A loop would hold a Type=oneshot open for hours and hide the failure from systemctl; the timer plus the service's existing ConditionPathExists=! costs a healthy node nothing and needs no teardown."
- "The timer's enable uses a hand-written symlink fallback. Every other `chroot systemctl enable` here ends in `2>/dev/null || true`, which would silently drop the self-heal path — the one thing whose absence is invisible until a node is already broken."
- "Consumers in `failed` state are explicitly restarted on success. try-reload-or-restart is a no-op on a failed unit, so without this a self-healed node would have valid keys on disk and nginx still down — recovery that isn't."
- "Retry semantics: attempt count equals the number of FIRST_BOOT_SECRETS_BACKOFF entries, and the wait after the final attempt is skipped (a failed last attempt is terminal). With the default `2 8 20` that is 3 attempts at t=0s/2s/10s per generator; the trailing 20 is the ceiling if the list is lengthened. Documented in the script rather than left as a puzzle."
- "`After=systemd-random-seed.service` added as its own unit line rather than appended to the existing After=local-fs.target, both because systemd accumulates After= lines and because the plan's acceptance criterion greps for exactly that string."
- "/var/lib/dbus/machine-id is removed only when it is a real file, not when it is the symlink to /etc/machine-id that Debian normally ships. Deleting a live symlink risks a boot-time surprise for no gain; a real copy would be genuine shared state."
requirements-completed: []
coverage:
- id: D1
description: "A first-boot secret regeneration that fails does NOT set the completion marker, so the oneshot retries on the next boot (D-05)"
requirement: KEY-02
verification:
- kind: unit
ref: "tests/first-boot-secrets/run-tests.sh#openssl fails every attempt -> exit non-zero, NO marker, failure record names TLS"
status: pass
- kind: other
ref: "Negative control: moving `touch \"$MARKER\"` back outside the success branch makes that case fail with MARKER-SET-ON-FAILURE (transcript below)"
status: pass
human_judgment: false
- id: D2
description: "Each generator is retried with backoff within a single boot before the boot is declared failed (D-05)"
requirement: KEY-02
verification:
- kind: unit
ref: "tests/first-boot-secrets/run-tests.sh#ssh-keygen fails twice then succeeds -> backoff recovers within one boot (3 attempts)"
status: pass
human_judgment: false
- id: D3
description: "A terminal failure is loud: console + durable on-disk record + journal, not only a log file nobody reads (D-05)"
requirement: KEY-02
verification:
- kind: unit
ref: "tests/first-boot-secrets/run-tests.sh#tls-fail case asserts first-boot-secrets.failed exists, names TLS, and stderr carries a FAILED line"
status: pass
- kind: other
ref: "logger + tee -a $ROOT/dev/console emitted by shout(); the console leg cannot be exercised in a temp root and is UNVERIFIED on hardware"
status: partial
human_judgment: false
- id: D4
description: "The shipped rootfs tar contains no SSH host keys, no TLS private key and no populated machine-id"
requirement: KEY-04
verification:
- kind: other
ref: "docs/security/KEY-02-ROOTFS-EVIDENCE.md — requires an ISO build host; commands recorded, not yet run"
status: blocked
human_judgment: true
- id: D5
description: "The regeneration script is exercised by an automated test that fails when the marker is set on a failed run"
requirement: KEY-02
verification:
- kind: unit
ref: "tests/first-boot-secrets/run-tests.sh — 6/6 PASS; negative control reproduced"
status: pass
human_judgment: false
- id: D6
description: "Exactly one producer per secret: no second code path anywhere in the ISO build can mint a TLS key or SSH host key with its own accounting"
requirement: KEY-02
verification:
- kind: unit
ref: "tests/first-boot-secrets/run-tests.sh#single-producer invariant: every key-creating invocation is inside gen_tls/gen_ssh"
status: pass
- kind: other
ref: "Negative control: reintroducing the installer's chroot openssl req block turns case 6 red naming the line, and nothing else"
status: pass
human_judgment: false
- id: D7
description: "A failure self-heals unattended — it never dead-ends a node whose only exit is physical access"
requirement: KEY-02
verification:
- kind: unit
ref: "tests/first-boot-secrets/run-tests.sh#self-heal: failed run then a later successful run -> key present, marker set, failed units restarted"
status: pass
- kind: other
ref: "archipelago-first-boot-secrets.timer installed + enabled (symlink fallback) — UNVERIFIED on hardware; the harness proves the script half, not systemd's scheduling"
status: partial
human_judgment: false
- id: D8
description: "The deterministic total-failure cause (missing generator binary) fails the BUILD, not the fleet"
requirement: KEY-04
verification:
- kind: other
ref: "Rootfs RUN assertion on /usr/bin/openssl and /usr/bin/ssh-keygen; fires during the container build. UNVERIFIED until a build host runs it — see KEY-02-ROOTFS-EVIDENCE.md step 5b"
status: blocked
human_judgment: true
duration: 1h
completed: 2026-08-02
status: complete
---
# Phase 10 Plan 03: Fail-closed first-boot secrets + identity-free rootfs — Summary
First-boot per-device secret regeneration now retries with backoff and then fails closed, and
the rootfs tar it repairs no longer carries the fleet-shared SSH host keys, TLS keypair or
machine-id it was silently papering over. Closes the build side of audit finding **F-03**.
> **Task 3 (C-4 build-host evidence) is a blocking checkpoint and is NOT done.** It needs an
> ISO build host. `docs/security/KEY-02-ROOTFS-EVIDENCE.md` carries the exact command sequence
> and is marked UNVERIFIED. Nothing in this plan claims the tar listing was observed.
## What was wrong
`first-boot-secrets.sh` (a heredoc inside `image-recipe/_archived/build-auto-installer-iso.sh`,
which is **live**`image-recipe/build-debian-iso.sh` execs it) had two fail-open branches that
logged `WARNING: ... keeping baked key` and continued, and `touch "$MARKER"` ran unconditionally
**outside both `if` blocks**. Combined with the unit's
`ConditionPathExists=!/var/lib/archipelago/.secrets-regenerated` and the script's own
`[ -f "$MARKER" ] && exit 0`, one transient failure at first boot left 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 every downloader holds those keys.
## Commits
| Commit | Task | What |
|---|---|---|
| `21043096` | 1 | Fail-closed, retried regeneration + `tests/first-boot-secrets/run-tests.sh` |
| `408b328c` | 2 | Rootfs identity-strip layer + two comment corrections that follow from it |
| `201ef474` | 3 (prep) | `docs/security/KEY-02-ROOTFS-EVIDENCE.md`, marked UNVERIFIED |
| `2efab5f2` | follow-up | Single-producer unification, build-time generator assertion, self-heal timer, 3 new test cases |
| `d9b3a7d5` | follow-up | Quote the `Dockerfile.rootfs` heredoc so comments cannot execute (closes deferred D1) |
| `40b77e39` | follow-up | Refuse to bless a cert minted under an untrustworthy clock; backdate `notBefore` |
Nothing was pushed, tagged, built or deployed, per the execution brief.
## Task 1 — fail-closed regeneration
- `ROOT="${FIRST_BOOT_SECRETS_ROOT:-}"` prefixes every absolute path. Unset in production the
expansion is empty and behaviour is byte-identical; set, it is what makes the negative
property assertable at all.
- `retry()` runs each generator up to N times with waits from `FIRST_BOOT_SECRETS_BACKOFF`
(default `2 8 20`). Staging-then-swap is preserved for both generators, with `.new` files
removed on failure so no half-keypair is left behind.
- `touch "$MARKER"` now lives inside a `TLS_OK == 1 && SSH_OK == 1` branch. Any other outcome
writes `/var/lib/archipelago/first-boot-secrets.failed` (timestamp, which generator failed,
both flags), shouts to console + `logger` + stderr, and `exit 1` so the unit lands in `failed`
rather than `active`. A later successful boot deletes the record so a recovered node does not
carry a stale alarm.
- `After=systemd-random-seed.service` added to the unit. A no-op today (no seed file is baked,
which the audit verified) and correct if one is ever introduced.
- The script header states the operational trade in plain words, including that recovery from a
terminal failure needs the physical console.
### Harness results (final, all six cases)
```
extracted 236 lines from the builder; bash -n clean
PASS: both generators succeed -> exit 0, marker set, keys swapped in
PASS: openssl fails every attempt -> exit non-zero, NO marker, failure record names TLS
PASS: ssh-keygen fails twice then succeeds -> backoff recovers within one boot (3 attempts)
PASS: TLS fails every attempt on a stripped root -> NO key, NO marker, non-zero exit, record names TLS
PASS: self-heal: failed run then a later successful run -> key present, marker set, failed units restarted
PASS: single-producer invariant: every key-creating invocation is inside gen_tls/gen_ssh
──────── first-boot-secrets summary ────────
passed: 6 failed: 0
```
### Negative control (required by the plan's acceptance criteria)
`touch "$MARKER"` moved back outside the success branch, harness re-run, then reverted:
```
SCRATCH APPLIED: marker touch moved back outside the success branch
--- harness against the fail-open variant ---
extracted 175 lines from the builder; bash -n clean
PASS: both generators succeed -> exit 0, marker set, keys swapped in
FAIL: openssl fails every attempt -> MARKER-SET-ON-FAILURE
exit=1 root=/tmp/tmp.Ta1YFhHWdi/root-tls-fail
stderr: ARCHIPELAGO FIRST BOOT FAILED: could not generate this device's TLS key material. ...
PASS: ssh-keygen fails twice then succeeds -> backoff recovers within one boot (3 attempts)
──────── first-boot-secrets summary ────────
passed: 2 failed: 1
EXIT=1
```
The test fails on exactly the regression it exists to pin, and only that case.
## Task 2 — identity-free rootfs
Final `RUN` layer added to `Dockerfile.rootfs`, after every package install and after the
`openssl req` layer, so nothing regenerates the material afterwards:
- `rm -f /etc/ssh/ssh_host_*` (private keys and `.pub` alike)
- `rm -f` the archipelago TLS key and crt, keeping the `/etc/archipelago/ssl` directory
- `: > /etc/machine-id` (systemd's documented regenerate-on-next-boot state)
- `/var/lib/dbus/machine-id` removed only if it is a real file, not the usual symlink
- writes `/opt/archipelago/rootfs-identity-stripped` listing what it removed, with **no**
timestamp so RECIPE_HASH reproducibility is unaffected
The `openssl req` layer is deliberately unmodified.
**RECIPE_HASH changed.** The strip layer is inside the hashed region
(`sed -n '/^# STEP 1.../,/^# STEP 2.../p' | grep -c rootfs-identity-stripped` → 1), so the next
build is forced to rebuild the rootfs tar. Task 3's evidence would be meaningless against a
cached tar, and `--rebuild` is specified as well.
`grep -c 'ssh_host'` on the builder went **3 → 6**.
## Deviations from Plan
### 1. [Rule 1 — Bug] Backticks in my own strip-layer comment would have hung every ISO build
- **Found during:** Task 2, self-check of the added block.
- **Issue:** `Dockerfile.rootfs` is written with an **unquoted** heredoc (`<<DOCKERFILE`), so
backticks in its body are command substitution evaluated by the build shell. Two comment
lines I wrote contained `` `openssl req` ``. Reproduced in isolation: the heredoc hung for the
full 2-minute timeout as `openssl req` waited on stdin. `bash -n` is clean on this — syntax
checking cannot catch it.
- **Fix:** replaced with double quotes, and added an explicit `NOTE:` in the block warning that
the heredoc is unquoted and backticks must never appear there.
- **Commit:** `408b328c`
### 2. [Rule 1 — Correctness] Script header claim about TLS, twice corrected
- **Found during:** Task 2, after discovering the installer's TLS fallback.
- **Issue:** the Task 1 header claimed "the nginx TLS listener will not start". With the
installer fallback in place that was false — the web UI would still come up. Shipping a
confident false statement in a security-critical script is worse than shipping none.
- **First fix (`408b328c`):** narrowed the claim to SSH only, and described the TLS fallback
honestly as per-install, never image-wide.
- **Second fix (`2efab5f2`):** the fallback is gone, so the original claim is true again for
both. Restored, with the reasoning attached rather than left implicit. No comment anywhere in
the builder now implies a TLS fallback exists.
### 3. [Rule 2 — Threat coverage] `/var/lib/dbus/machine-id`
- **Issue:** T-10-26 is machine-id correlation across nodes. The plan named `/etc/machine-id`
only. If dbus ships a real copy rather than the usual symlink, truncating `/etc/machine-id`
alone leaves correlated state.
- **Fix:** guarded removal — symlinks are left alone, real files are removed.
- **Commit:** `408b328c`
## Follow-up: unify to a single producer (`2efab5f2`)
The installer's TLS fallback prompted a decision cycle worth recording, because the reasoning
matters more than the outcome.
**The false trade.** The question was framed as "keep the fallback (a second source of keys) or
delete it (a first-boot failure costs the user the web UI, recoverable only at the console)".
Both options were wrong, and the framing was wrong. **The defect in F-03 was never that a second
attempt to create a key existed. It was that failure was silent and the completion marker lied
about it.** A second attempt is only dangerous when it is an unaudited second *producer* —
carrying its own idea of success, its own absent retry policy, its own absent failure record.
So the fix is to unify, not to delete and accept a dead node.
**What shipped:**
1. **One producer per secret.** `gen_tls()` and `gen_ssh()` are the only code in the ISO build
that create the TLS keypair and the SSH host keys. Two secondary producers were folded out:
the Dockerfile's `openssl req` layer (which baked a keypair the strip layer deleted moments
later in the same build) and the installer's "ensure SSL cert exists" block. The invariant is
checked mechanically, not asserted in prose — case 6 of the harness fails if any executable
`openssl req` / `ssh-keygen -A` invocation appears outside the generator heredoc.
2. **The deterministic failure is caught at build time.** The one realistic way generation fails
on every retry forever is a missing generator binary, and that is deterministic — no retry or
reboot fixes it. A rootfs `RUN` layer now fails the build if `/usr/bin/openssl` or
`/usr/bin/ssh-keygen` is missing or non-executable. **The build already guaranteed these**
(`openssl` and `openssh-server` are both in the package list, and `openssh-server`
hard-depends `openssh-client`, which ships `ssh-keygen`), so this is cheap insurance rather
than a fix. It earns its place the first time someone edits that package list.
3. **Failure self-heals; it never dead-ends.** `archipelago-first-boot-secrets.timer`
(`OnBootSec=5min`, `OnUnitActiveSec=15min`) re-runs the service until it succeeds. The
service's existing `ConditionPathExists=!` makes every trigger a no-op once the marker
exists, so a healthy node pays nothing and no teardown is needed. Two details that would have
made this theatre if missed:
- `chroot systemctl enable` can fail silently, and every other enable in this file ends in
`|| true`. The timer's enable has a hand-written symlink fallback, because the absence of
self-heal is invisible until a node is already broken.
- `try-reload-or-restart` is a **no-op on a failed unit**. Without special handling, a
self-healed node would have valid keys on disk and nginx still down. Consumers found in
`failed` are now explicitly restarted (`--no-block`, to avoid a boot-transaction deadlock
at first boot, where we are ordered `Before=` them).
4. **Never serve a bogus key.** `gen_tls` now parses both halves back (`openssl pkey`,
`openssl x509`) before the staging swap, so a truncated or half-written artefact is never
what nginx reads. Fail-closed governs *serving*; retry-and-self-heal governs *recovering*.
They are different properties and both hold.
### Negative controls for the three new cases
Each defect was reintroduced, the suite run, and the defect reverted. Each lights up **exactly
one** case — a test that goes red for several reasons at once is not pinning any of them.
**Control A — reintroduce a fallback-style key creation on the failure path** (the deleted
installer block's behaviour, moved into the script):
```
FAIL: TLS fails every attempt on a stripped root -> TLS-KEY-EXISTS-AFTER-FAILURE TLS-CRT-EXISTS-AFTER-FAILURE
passed: 5 failed: 1
```
*(First run of this control also reddened case 5, because case 5's run-1 block redundantly
re-asserted case 4's property. That assertion was removed — case 5 now tests recovery only —
and the control re-run to confirm it is isolated. The transcript above is the re-run.)*
**Control B — dead-end a node that has already failed once** (`exit 0` early if the failure
record exists, a plausible "don't retry a known-bad node" optimisation):
```
FAIL: self-heal -> run2-marker-missing run2-key-missing run2-crt-missing run2-stale-failure-record run2-did-not-restart-failed-nginx
passed: 5 failed: 1
```
**Control C — reintroduce the installer's `chroot ... openssl req` block verbatim:**
```
FAIL: single-producer invariant -> SECOND-PRODUCER-at-line-3586
generator heredoc spans lines 1713-1950 of image-recipe/_archived/build-auto-installer-iso.sh
passed: 5 failed: 1
```
All three reverted; suite back to 6/6.
## Residual operational risk — stated plainly
**A machine on which secret generation can never succeed ends up with no SSH host key and no
TLS key. sshd will not start, nginx will not serve the web UI, and that node needs physical
console access.** That is the honest worst case and it is not softened anywhere in the code
comments either.
What shrinks it to genuinely-broken-hardware:
- **The deterministic cause is gone before shipping.** A missing `openssl` or `ssh-keygen` fails
the ISO build, so it cannot reach a node.
- **Transient causes are absorbed.** Three attempts with backoff inside the boot (proven by
harness case 3, which shows a generator failing twice and succeeding on the third), then every
15 minutes on the timer, then again on every boot — indefinitely, because the marker is never
written on failure.
- **Recovery completes itself.** On a later success the script restarts the units that refused
to start, so the node comes back without a reboot and without a human (harness case 5).
What is left is a machine where `openssl` or `ssh-keygen` is present but cannot ever produce a
key — a disk that is permanently full, or failing hardware. On that machine the node refuses to
serve rather than serving on a key nobody generated, which is the trade this phase exists to
make. It says so on the console, in the journal, and in
`/var/lib/archipelago/first-boot-secrets.failed`.
## Follow-up: quote the Dockerfile heredoc (`d9b3a7d5`) — closes deferred D1
`cat > "$WORK_DIR/Dockerfile.rootfs" <<DOCKERFILE` was **unquoted**, so the build shell
performed command substitution on the Dockerfile body: a backtick inside a Dockerfile *comment*
was executed on the build host and its output spliced into the generated file. Six comments did
this, and one of them ran `systemctl start archipelago-fips.service` against the build machine
on every ISO build.
**Boundary checked before editing.** Only lines inside the heredoc body are at risk. The other
backticked comments in this file (`:264`, `:809`, `:1188`, `:1289`, `:1506`, `:1605`, `:3597`,
`:3651`) are ordinary shell comments outside any unquoted heredoc, plus one inside the *quoted*
`SECRETSSCRIPT` heredoc — none were ever evaluated, and none were touched.
**Fixed the class, not the instances.** The body needs exactly four build-time values, all
package names (`LINUX_IMAGE_PKG`, `GRUB_EFI_PKG`, `GRUB_EFI_SIGNED_PKG`, `GRUB_PC_PKG`), on four
consecutive lines — so quoting was entirely practical. The heredoc is split into
`DOCKERFILE_HEAD` and `DOCKERFILE_TAIL`, both quoted, with one explicit `printf` interpolating
those four names between them. Escapes that existed *only* because the heredoc was unquoted were
undone in the same pass: six trailing `\\` → `\` (Docker line continuations) and four `\$``$`
(RUN arguments reach the shell verbatim; Docker does not substitute variables in RUN).
**Substance verified by rendering, not by inspection.** The generated Dockerfile was rendered
before and after with identical inputs and diffed *normalised* (continuations joined, whitespace
collapsed). Both are 190 normalised lines and the only differences are the six comments regaining
their text — every instruction byte-identical:
```
< # the archipelago backend calls
> # the archipelago backend calls `systemctl start archipelago-fips.service`
< # fips-gateway is gated behind the Cargo feature (depends on
> # fips-gateway is gated behind the `gateway` Cargo feature (depends on
```
**Case 7** asserts every heredoc writing `Dockerfile.rootfs` has a quoted delimiter, and when one
does not, reports which body lines would execute. The assertion is on the **delimiter, not on
backticks** — with quoting a backticked comment is legal, and six of them are back in the body on
purpose; flagging backticks would flag a non-bug and fail on the very comments this restored.
Controls:
```
Control D — unquote the delimiter (the real regression):
FAIL: Dockerfile heredoc quoting -> UNQUOTED-DELIMITER-at-line-287 would-execute-at-lines:317,318
passed: 6 failed: 1
Control E — add a backticked comment, delimiter still quoted:
PASS: Dockerfile heredoc delimiters are quoted — a backticked comment cannot execute
passed: 7 failed: 0
and it renders intact:
175:# Control E: a backticked `systemctl start archipelago-fips.service` comment
```
Control E is the more informative of the two: the backtick that used to be a build-host RCE is
now inert and renders as written. That is what "fixed the class" means, and it is why a bare
backtick reintroduction correctly reddens nothing.
`deferred-items.md` held D1 as its only entry and has been **deleted** — nothing was left that is
genuinely out of scope.
## Follow-up: untrustworthy clock at cert-minting time (`40b77e39`)
The failure fail-closed cannot catch, **because generation succeeds**. This unit runs before time
has synced; `openssl req -x509` stamps `notBefore` from whatever the clock says. Dead RTC or flat
CMOS battery → clock ahead gives "not yet valid" (harder to diagnose than a self-signed warning),
clock behind gives an already-expired cert once time syncs. The marker was then set and never
revisited: a node permanently serving a cert nothing accepts.
**Finding, established rather than assumed:** this image does **not** use `systemd-timesyncd`. It
installs and enables **chrony** (`:388`, `:575`), and `chrony-wait.service` — the unit that is
`Before=time-sync.target` — is **not** enabled. So `time-sync.target` is inert here and ordering
`After=` it would buy nothing. Enabling `chrony-wait` to make it meaningful would stall boot
behind NTP on a node with no network, and these nodes are routinely offline at first boot.
**Decision: no ordering change.** Not deadlocking boot outranks cert-date elegance (constraint 3).
Fixed locally instead:
1. **Backdate `notBefore` by 24h** so ordinary node/client skew cannot invalidate a fresh cert.
This does not weaken a self-signed cert — `notBefore` is not a security control here.
`-not_before`/`-not_after` arrived in OpenSSL 3.5 and the rootfs is `debian:trixie` which
ships it, but the capability is **probed, not assumed**: guessing wrong would fail every
attempt and brick the node, the exact outcome all of this exists to prevent. Without the flags
we do not backdate, and rule 2 still covers the dangerous case.
2. **Refuse to bless a cert dated outside a plausible window** (2026-01-01 … 2056-01-01). The
material stays installed — the node is usable, sshd comes up — but the dates are recorded as
`failed=cert-dates` and the cert is regenerated automatically once time syncs.
Generation is now driven by **need** rather than "is the marker absent", and
`ConditionPathExists=!` was removed from the unit so a node that already completed can still be
re-examined. Skipping the unit is precisely how such a node would stay broken forever. On a
healthy node the script exits in milliseconds.
**Anti-spin is one condition:** a date-driven regeneration happens *only* when the clock is
currently plausible. A node whose clock is still wrong re-checks and mints nothing.
### Regression I introduced and caught
Driving generation purely by content made `needs_ssh()` false whenever *any* host key existed —
which would have left an image-baked, fleet-shared host key in place forever. **That is F-03
reopened.** The marker check is back in both `needs_` functions. Case 1 — which prestages a baked
key and asserts it was replaced — is what caught it.
### Controls
```
Control F — bless the cert regardless of clock (the pre-fix behaviour):
FAIL: wrong clock -> run1-BAD-DATES-NOT-RECORDED
passed: 7 failed: 1
Control G — remove the anti-spin guard:
FAIL: wrong clock -> SPINNING-reminted-while-clock-still-wrong(1->2)
passed: 7 failed: 1
```
**Control G first passed against a deliberately broken guard**, which was a flaw in my test, not
in the fix: the assertion compared certificate dates, and a re-mint under a frozen fake clock
produces a byte-identical `notBefore`. Dates cannot distinguish "left alone" from "regenerated
again". The assertion now counts `openssl req` invocations, which can — and only then did the
control redden. Worth recording as the second time in this plan that a first-draft assertion
looked green for the wrong reason.
### Not covered here
Nodes already deployed from earlier ISOs **never receive this script** — it is installed by the
installer, not shipped by OTA. Fleet remediation for those nodes is 10-04/OTA work in `core/**`,
which is held by other executors, so per the standing constraint it is reported rather than
attempted.
## Known Stubs
None. No placeholder values, no TODOs, no unwired code paths.
## Threat Flags
None. No new network endpoint, auth path, file-access pattern or schema change at a trust
boundary. The plan installs no packages (T-10-SC: accept); none were added.
## UNVERIFIED — needs hardware
Task 3's C-4 checkpoint is now **more** important, not less: with the rootfs stripped and no
install-time fallback, the tar listing is the only pre-hardware evidence that the shipped image
is identity-free.
| Item | Audit ref | What it needs | Command |
|---|---|---|---|
| Rootfs tar is identity-free after a forced rebuild | **C-4** | ISO build host with podman/docker and disk for a full rootfs rebuild | `UNBUNDLED=1 bash image-recipe/build-debian-iso.sh --rebuild`, then the tar listings in `docs/security/KEY-02-ROOTFS-EVIDENCE.md` steps 2/4/5/5b/6 |
| The build-time generator assertion actually fires | **C-4** | same build host | `grep 'first-boot secret generators present' <build log>` — evidence doc step 5b |
| The self-heal timer ships and is enabled on the target | — | same build host, then a node | evidence doc step 5 (timer present on installer media); `systemctl status archipelago-first-boot-secrets.timer` on a node |
| Two nodes flashed from one ISO get different keys | **C-3** | two physical machines | audit §779; both SSH and TLS fingerprints are now equally sharp signals — see the C-3 section of the evidence doc |
| The console leg of the failure shout reaches a real screen | — | a real node, or a VM console | force a first-boot failure and observe `/dev/console` |
The harness proves the *script* half of self-heal (a failed run followed by a successful run
recovers the node and restarts the failed units). It does not and cannot prove systemd's
scheduling — that the timer is enabled and actually fires at 5min/15min. That is hardware
verification.
## Self-Check
- `image-recipe/_archived/build-auto-installer-iso.sh` — FOUND, `bash -n` clean
- `tests/first-boot-secrets/run-tests.sh` — FOUND, mode 755, exits 0 with 8 PASS
- `docs/security/KEY-02-ROOTFS-EVIDENCE.md` — FOUND, contains `C-4`
- `deferred-items.md` — DELETED; its only entry (D1) is fixed, not filed
- Commits `21043096`, `408b328c`, `201ef474`, `2efab5f2`, `d9b3a7d5`, `40b77e39` — all FOUND
- Generated `Dockerfile.rootfs` rendered before/after the heredoc change and diffed normalised:
190 lines each, only the six comment restorations differ
- Single-producer grep: the only executable key-creating invocations in the builder are
`openssl req` and `ssh-keygen -A` inside the generator heredoc; every other match is a comment
- `git status --porcelain image-recipe/` — clean; `_archived/` not moved or renamed
- No file authored by a concurrent agent (`core/archipelago/src/**`, `neode-ui/**`,
`.planning/STATE.md`) was staged in any commit
## Self-Check: PASSED
@@ -0,0 +1,370 @@
---
phase: 10-key-material-hardening
plan: 05
subsystem: bitcoin-signing
tags: [security, key-material, psbt, lnd, bitcoin-core, F-13, KEY-03]
status: complete
requires:
- "10-CONTEXT.md D-07b (delete, do not migrate) and D-07c (deferred BDK cold vault)"
- "docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md F-13 / R-04"
provides:
- "No daemon code path writes the BIP-84 account private key into Bitcoin Core"
- "psbt_key_origin_report + the key_origin field on lnd.create-psbt"
- "docs/security/KEY-03-SIGNING-POSTURE.md — the evidence-backed signing-posture record"
affects:
- "core/archipelago/src/api/rpc/bitcoin.rs"
- "core/archipelago/src/api/rpc/dispatcher.rs"
- "core/archipelago/src/api/rpc/lnd/wallet.rs"
- "core/archipelago/src/seed.rs"
- "docs/security/PSBT-SIGNING-ARCHITECTURE.md"
tech-stack:
added: []
patterns:
- "Best-effort inspection that degrades to null, never to an error, on a funds path"
- "Programmatically-built PSBT test fixtures instead of pasted opaque base64"
- "Tombstone comments that deliberately omit the deleted symbol name so grep-based regression checks stay durable"
key-files:
created:
- "docs/security/KEY-03-SIGNING-POSTURE.md"
modified:
- "core/archipelago/src/api/rpc/bitcoin.rs"
- "core/archipelago/src/api/rpc/dispatcher.rs"
- "core/archipelago/src/api/rpc/lnd/wallet.rs"
- "core/archipelago/src/seed.rs"
- "docs/security/PSBT-SIGNING-ARCHITECTURE.md"
decisions:
- "F-13 closed by deleting the Core wallet path outright rather than rewriting it watch-only (D-07b)"
- "derive_bitcoin_xprv retained with #[allow(dead_code)] and a stated D-07c reason rather than deleted as cruft"
- "Verdict recorded: no fleet node is provisioned watch-only, so an external signer cannot meaningfully sign a default node's PSBT today"
- "Census conclusion scoped to examined nodes only — not generalised to the fleet while 6 nodes are unreachable"
metrics:
duration: "~3h15m (dominated by cargo target-dir contention with three concurrent agents)"
completed: 2026-08-02
tasks_completed: 3
tasks_total: 3
---
# Phase 10 Plan 05: Key-Material Hardening (KEY-03) Summary
Deleted the uncalled Bitcoin Core wallet handler that imported the BIP-84 account **xprv** into
`wallet.dat` (F-13), and made LND's PSBT round trip report the BIP-32 key-origin data an external
signer needs — with an honest, evidence-backed record of what that round trip does and does not
deliver.
**Status: 3 of 3 tasks complete.** Task 3's blocking `checkpoint:human-verify` was satisfied by
operator-run verification (the plan is `autonomous: false`; the checkpoint was not self-approved —
execution stopped, the operator ran the census, and the result was recorded).
## Commits
| # | SHA | Task | Message |
|---|---|---|---|
| 1 | `96229268` | Task 1 (tracer) | `fix(10-05): delete the Bitcoin Core wallet path that duplicated the spending key (F-13, D-07b)` |
| 2 | `26299874` | Task 2 | `feat(10-05): report BIP-32 key origin on lnd.create-psbt, and record the honest signing posture (D-07b/D-09)` |
| 3 | `0d513a0e` | Task 3 | `docs(10-05): record the Core-wallet fleet census — 4 nodes clear, 6 unchecked (D-07b)` |
Not pushed, not tagged, not deployed, per the execution brief. The SUMMARY itself is deliberately
uncommitted.
## Task 1 — Core wallet path deleted
### No-caller search output (re-established, not inherited)
```
$ grep -rn 'bitcoin\.init-wallet-from-seed' core/ neode-ui/src scripts/ web/ apps/ tests/ docs/
core/archipelago/src/api/rpc/dispatcher.rs:122: "bitcoin.init-wallet-from-seed" => {
$ grep -rn 'handle_bitcoin_init_wallet_from_seed' core/ neode-ui/src scripts/ web/ apps/ tests/ docs/
core/archipelago/src/api/rpc/bitcoin.rs:161: pub(super) async fn handle_bitcoin_init_wallet_from_seed(
core/archipelago/src/api/rpc/dispatcher.rs:123: self.handle_bitcoin_init_wallet_from_seed(params).await
docs/UNIFIED-TASK-TRACKER.md:208: §8 Phase 1). `handle_bitcoin_init_wallet_from_seed` passes
docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md:607:(`handle_bitcoin_init_wallet_from_seed`):
docs/security/PSBT-SIGNING-ARCHITECTURE.md:147: `handle_bitcoin_init_wallet_from_seed`, `core/archipelago/src/api/rpc/bitcoin.rs:161-294`).
```
Exactly the expected result: one occurrence of the method name (its own dispatcher registration),
two of the symbol in code (definition + dispatch call). The three remaining symbol hits are prose
in documentation, not callers. **No third caller — the deletion's premise held**, so no checkpoint
was raised.
Also re-verified independently:
- **Across all of `neode-ui/src`, every `bitcoin.*` RPC call is read-only status**: `bitcoin.getinfo`
(14 sites), `bitcoin.prune-status` (3), `bitcoin.onion` (1). Zero `bitcoin.*` wallet operations.
- **The endpoint is absent from `UNAUTHENTICATED_METHODS`** (`middleware.rs:5-40`) and additionally
called `verify_password` (`bitcoin.rs:176-179`) — authenticated *and* password-gated, so F-13
was key-at-rest duplication, never a remotely reachable endpoint.
### What changed
- Deleted `handle_bitcoin_init_wallet_from_seed` (`bitcoin.rs:161-295`) and the
`"bitcoin.init-wallet-from-seed"` dispatch arm (`dispatcher.rs:122-124`).
- Removed the now-unused `use zeroize::Zeroize;` from `bitcoin.rs`.
- `seed::derive_bitcoin_xprv` retained with `#[allow(dead_code)]` and a doc line naming **D-07c**
as the reason (deferred BDK cold vault), so the next reader does not remove it as cruft.
- Created `docs/security/KEY-03-SIGNING-POSTURE.md`.
### Acceptance criteria
| Criterion | Result |
|---|---|
| `cargo build -p archipelago` succeeds | **PASS** (1m47s, 3 pre-existing warnings, none in this plan's files) |
| `grep -rn 'handle_bitcoin_init_wallet_from_seed' core/ --include=*.rs` → no matches | **PASS** (see deviation 1) |
| `grep -n 'init-wallet-from-seed' dispatcher.rs` → only the `lnd.` arm | **PASS**`145: "lnd.init-wallet-from-seed"` only |
| `cargo test -p archipelago seed::` still passes | **PASS** — 25 passed, 0 failed, incl. `test_bitcoin_xprv_deterministic` and `test_full_derivation_from_known_mnemonic` |
| Doc exists, cites D-07b and D-07c, carries the search output | **PASS** — 397 lines; 6× D-07b, 4× D-07c |
| `cargo clippy -p archipelago -- -D warnings` clean | **PARTIAL** — see deviation 2 |
## Task 2 — LND PSBT key-origin reporting
### What was added
`core/archipelago/src/api/rpc/lnd/wallet.rs`:
| Symbol | Line | Kind |
|---|---|---|
| `PsbtKeyOriginReport` | `:1169` | struct `{ input_count, inputs_with_key_origin, all_inputs_have_key_origin }` |
| `psbt_key_origin_report` | `:1186` | `fn(&str) -> Result<PsbtKeyOriginReport>` |
| call site + warn | `:705` | best-effort, degrades to `null` |
| response field | `:737` | `"key_origin": { … } \| null` |
An input counts as carrying key origin when either `bip32_derivation` or `tap_key_origins` is
non-empty. A zero-input PSBT reports `all_inputs_have_key_origin: false` rather than vacuous truth,
since an inputless PSBT cannot be signed and "yes, a signer has everything it needs" would be
actively misleading.
### Tests (new, 3 passing)
```
running 3 tests
test api::rpc::lnd::wallet::tests::psbt_with_derivations_reports_key_origin ... ok
test api::rpc::lnd::wallet::tests::psbt_without_derivations_reports_no_key_origin ... ok
test api::rpc::lnd::wallet::tests::malformed_psbt_is_an_error_not_a_panic ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 1014 filtered out; finished in 0.00s
```
Fixtures are built programmatically with the `bitcoin` crate (`Psbt::from_unsigned_tx` over a
one-input `Transaction`, then a `(Fingerprint, DerivationPath)` inserted on input 0) rather than
pasted as opaque base64, so the tests explain themselves.
### Round-trip coverage map (recorded in the doc)
| # | Step | `file:line` | Tested? |
|---|---|---|---|
| 1 | Fund — `lnd.create-psbt``/v2/wallet/psbt/fund` | `lnd/wallet.rs:605`, `dispatcher.rs:136` | **No** |
| 1a | Inspect — key origin | `lnd/wallet.rs:1186`, `:1169`, `:705`, `:737` | **Yes** (3 tests) |
| 2 | Export — base64 to UI | `rpc-client.ts:407-423`, `Web5SendReceiveModals.vue:308` | **Partial** (`rpc-client.test.ts:319-323` asserts the method name only) |
| 3 | Sign offline | not in this repo | N/A |
| 4 | Import — paste signed PSBT | `Web5SendReceiveModals.vue:102`, `:419-424` | **No** |
| 5 | Finalize — `/v2/wallet/psbt/finalize` | `lnd/wallet.rs:743`, `dispatcher.rs:137` | **No** |
| 6 | Broadcast — `/v2/wallet/tx` | `lnd/wallet.rs:795` | **No** |
| — | Rate limits 5/300s | `rate_limit.rs:68-69` | **No** |
**One of six steps has automated coverage.** There is also **no air-gap transport** — no animated
QR, no `.psbt` file exchange; export/import is copy-paste of base64 in a textarea. Nothing has
been verified against real signing hardware. The doc states all of this plainly rather than
describing an untested path as verified.
### The watch-only verdict (the question that decides whether this is an air gap)
**Verdict: NO — on a default Archipelago node an externally-held signer cannot meaningfully sign
a PSBT from `lnd.create-psbt`, because LND holds the private keys for every input it selects.**
Evidence:
1. The PSBT is funded from **LND's own wallet**`/v2/wallet/psbt/fund` (`lnd/wallet.rs:672`)
selects LND's UTXOs.
2. **LND's wallet on every node is a full key-holding wallet, created locally.**
`container::lnd::ensure_wallet_initialized` (`container/lnd.rs:86`) → `init_wallet_via_rest`
POSTs `/v1/initwallet` with a `cipher_seed_mnemonic` (`container/lnd.rs:504-516`) and persists
the aezeed backup (`:523-525`).
3. **The generated `lnd.conf` carries no `remotesigner.*` block**`container/lnd.rs:64-79` writes
`bitcoin.node=bitcoind` plus bitcoind RPC settings and nothing else.
4. **Nothing in the repo provisions watch-only LND.** A search of `apps/`, `scripts/`,
`core/archipelago/src` and `image-recipe/` for `remotesigner`, `createwatchonly` and
`nochainbackend` returns **zero matches**.
So what ships today is the PSBT **transport**, complete and rate-limited, **not air-gapped
custody**. The gap between here and D-08's opt-in path is **provisioning, not plumbing**
(PSBT-SIGNING-ARCHITECTURE §8 Phase 6, out of scope for Phase 10).
### Honesty statement (its own subsection in the doc)
Lightning channel, revocation and HTLC keys are **not air-gappable at all** — they must sign in
real time to answer counterparty commitments; a routing node cannot tolerate human-in-the-loop
signing. LND remote signing **relocates** them to a hardened host; it does **not** cool them. No
wording in either document implies otherwise.
### Acceptance criteria
| Criterion | Result |
|---|---|
| ≥3 new tests including the with/without pair | **PASS** — 3 passed |
| `cargo clippy -p archipelago` clean for this plan's files | **PASS** — zero diagnostics in `bitcoin.rs`, `dispatcher.rs`, `seed.rs`, `lnd/wallet.rs` |
| `git diff core/archipelago/Cargo.toml` empty | **PASS** — no dependency added |
| `grep -c 'key_origin' lnd/wallet.rs` ≥ 4 | **PASS** — 26 |
| `handle_lnd_create_raw_tx` unchanged | **PASS** — diff hunks at `+701`, `+737`, `+1161`, `+1211`; `create_raw_tx` starts at `:825` and `finalize_psbt` spans `:743-823`, so no hunk falls inside either |
| PSBT-SIGNING-ARCHITECTURE diff confined to the banner; §5.4 byte-identical | **PASS** — single hunk `@@ -2,0 +3,28 @@`; `diff` of §5.4 against HEAD reports IDENTICAL |
## Task 3 — Fleet census: **RUN 2026-08-02, no escalation**
`type="checkpoint:human-verify" gate="blocking"`, plan `autonomous: false`. Execution stopped at
the checkpoint; the operator ran the read-only procedure across the Tailscale fleet and supplied
the results, which are recorded in `docs/security/KEY-03-SIGNING-POSTURE.md` § *Fleet census*.
### Examined — 4 nodes, all CLEAR
| Node | Tailscale IP | Container | `listwalletdir` | `archipelago` wallet? | Default wallet |
|---|---|---|---|---|---|
| archi-dev-box | `100.69.68.39` | `bitcoin-knots` | 2× `gatewayd-*`, `""` | **No** | `blank: true`, keypool 0, txcount 0, balance 0 |
| shorty-s (`.228`) | `100.64.204.114` | `bitcoin-knots` | 1× `gatewayd-*`, `""` | **No** | same |
| archy-x250-beta | `100.72.136.5` | `bitcoin-core` | 1× `gatewayd-*`, `""` | **No** | same |
| archy-x250-pa | `100.89.209.89` | `bitcoin-core` | 1× `gatewayd-*`, `""` | **No** | same |
`listwallets``[""]` on every node. The only named wallets are Fedimint `gatewayd-*`. The one
loaded (unnamed, default) wallet does report `private_keys_enabled: true`, but also `blank: true`,
`keypoolsize: 0`, `txcount: 0`, `balance: 0.00000000`, `descriptors: true` — Core's own statement
that **no key was ever imported into it and no transaction ever touched it.**
**The result holds across two container vintages** (`bitcoin-knots` ×2, `bitcoin-core` ×2), so it
is a property of the fleet rather than four copies of one image behaving identically.
**No key material appeared in any output; `listdescriptors true` was never run.**
Supporting history: `git log -S "init-wallet-from-seed"` scoped to `dispatcher.rs` and
`neode-ui/src` returns exactly one commit — `19dcfd4f`, the commit that **added** it. No frontend
wrapper was ever written.
### Not examined — 6 nodes, recorded with reasons
| Node | Tailscale IP | Why |
|---|---|---|
| framework-pt | `100.65.115.109` | `Permission denied (publickey,password)` — SSH password rotated, not held |
| archipelago-1 | `100.82.34.38` | `Permission denied (publickey,password)` |
| archipelago | `100.70.96.88` | `Permission denied (publickey,password)` |
| archy-dev-pa | `100.64.83.15` | `Permission denied (publickey,password)` |
| archipelago-5 | `100.114.134.21` | Timed out during SSH banner exchange |
| archy-x250-dev | `100.113.100.55` | Offline — Tailscale last seen 2 days prior |
Password auth was **deliberately not attempted** on any of these: several fleet nodes lock PAM
quickly on a wrong password, and locking out an in-use production node is a worse outcome than an
incomplete census.
### Conclusion, at the strength the evidence supports
> **No examined node holds a wallet created by the deleted handler, and no examined node holds any
> wallet with keys or funds.**
Deliberately **not** generalised to "the fleet is clear" while six nodes are unknown — an
unexamined node is unknown, not safe. F-13 is closed **by deletion** regardless: the code that
could create such a wallet is gone from every future build. The census adds that no such wallet
was found anywhere anyone could look. **Nothing to escalate; the stop-on-finding rule stands** for
the remaining nodes.
### Standing item
The six unchecked nodes are homed in **`docs/UNIFIED-TASK-TRACKER.md`** as *"Finish the
Core-wallet fleet census — 6 nodes unchecked"*, not only in the security doc, so it is visible to
someone who is not already reading one. Flagged there as a natural fold-in for **KEY-04's on-node
work** (which needs node access anyway) but tracked independently so it does not vanish if KEY-04
is re-scoped. That file's stale R-04/F-13 entry — which still described the deleted handler and a
watch-only migration as pending work — was corrected to done-by-deletion in the same commit.
## Deviations from Plan
### 1. [Rule 2 — preserved a durable regression check] Tombstone comments reworded to omit the deleted symbol name
**Found during:** Task 1 acceptance verification.
**Issue:** I first wrote tombstone comments in `bitcoin.rs`, `dispatcher.rs` and `seed.rs` that
named `handle_bitcoin_init_wallet_from_seed` / `bitcoin.init-wallet-from-seed` verbatim. That
broke two acceptance criteria (`grep … → no matches`) — and, more importantly, it would have
**permanently defeated the greps as a regression check**: any future reintroduction of the symbol
would be masked by the comment that warns against reintroducing it.
**Fix:** Reworded all three to describe the deleted thing ("the Bitcoin Core wallet-init handler
that used to live here") and point at `docs/security/KEY-03-SIGNING-POSTURE.md`, which carries the
full symbol name. Guidance preserved, greps clean, regression check durable.
**Commit:** `96229268`
### 2. [Out of scope — pre-existing] `cargo clippy -- -D warnings` fails in `archipelago-openwrt`
**Found during:** Task 1 verification.
**Issue:** `cargo clippy -p archipelago -- -D warnings` fails with 4 lint errors — 2×
`consider using sort_by_key`, 1× `str::trim` before `str::split_whitespace`, 1× `creates an owned
instance just for comparison`**all in `archipelago-openwrt`**, a crate this plan does not touch.
**Assessment:** Pre-existing and out of scope under the scope boundary rule. Neither of my commits
touches that crate (`git log 96229268^..HEAD -- core/archipelago-openwrt` → 0 commits), and my
crate is not a dependency of it, so the failure is independent of this work by construction.
**Action:** Not fixed. `cargo clippy -p archipelago --message-format=short` reports **zero
diagnostics** in this plan's four files, which is the criterion that speaks to this work.
**Recommend:** a separate cleanup task for `archipelago-openwrt`'s lints so `-D warnings` can be
used as a gate again.
### 3. [Process — atomicity preserved] SUMMARY not committed, and the doc split across commits
`docs/security/KEY-03-SIGNING-POSTURE.md` is a single file carrying all three tasks' content. To
keep the commits genuinely atomic, it was staged truncated to its Task 1 sections for commit
`96229268`, restored in full for `26299874`, and extended with the census for `0d513a0e`.
`.planning/phases/10-key-material-hardening/10-05-SUMMARY.md` is left uncommitted per the
execution brief.
### 4. [Rule 2 — corrected a record this change invalidated] Updated `docs/UNIFIED-TASK-TRACKER.md`
**Found during:** Task 3 write-up.
**Issue:** the tracker's R-04/F-13 entry still described `handle_bitcoin_init_wallet_from_seed`,
its `disable_private_keys = false` and a watch-only migration with balance/UTXO parity as pending
work — all of which now describe code that does not exist. A stale open item pointing at deleted
line numbers actively misleads the next reader.
**Fix:** marked it done-by-deletion with a pointer to `KEY-03-SIGNING-POSTURE.md`, and added the
six unchecked census nodes as a new standing item.
**Scope note:** `docs/UNIFIED-TASK-TRACKER.md` is not in the plan's `files_modified`. It was
verified clean (`git status --porcelain`) before editing, and staged by path.
**Commit:** `0d513a0e`
## Known Stubs
None. No placeholder values, mock data or unwired components were introduced.
Two *absences* are documented rather than stubbed, because they are honest statements of scope
rather than placeholders: there is no air-gap transport (QR / file exchange) and no automated
coverage for round-trip steps 1, 4, 5 and 6. Both are recorded in
`docs/security/KEY-03-SIGNING-POSTURE.md` as untested/unimplemented, and neither is presented as
working.
## Threat Flags
None. No new network endpoint, auth path, file-access pattern or schema change at a trust boundary
was introduced. The plan's threat register is addressed as follows:
| Threat | Disposition |
|---|---|
| T-10-41 (xprv in `wallet.dat`) | **Mitigated** — the only code path that wrote it is deleted; census found no pre-existing wallet on 4 examined nodes, 6 remain unknown and are tracked |
| T-10-42 (census prints a private key) | **Mitigated**`listdescriptors true` banned by name in the doc and never run; only read-only RPCs used; no key material appeared in any output. Password auth was not attempted on locked-out nodes, so the census also avoided locking a production node out |
| T-10-43 (automated migration rewrites a funded wallet) | **Mitigated** — no migration built, none run; stop-on-finding rule recorded and never triggered |
| T-10-44 (opaque signer refusal) | **Mitigated**`key_origin` on the response plus a `warn!` names the condition before the user reaches the device |
| T-10-45 (docs claim custody they don't deliver) | **Mitigated** — watch-only verdict recorded with 4 evidence points; Lightning-keys subsection added; PSBT-SIGNING-ARCHITECTURE banner records Phase 1 superseded |
| T-10-46 (inspection breaks a send) | **Mitigated** — best-effort, degrades to `null`; finalize and `create_raw_tx` untouched, asserted by diff scope |
| T-10-47 (RPC surface change) | **Accepted** — no-caller search re-run, not inherited |
| T-10-SC (dependency install) | **Accepted** — no dependency added; `Cargo.toml` diff empty |
## Notes for the next agent
- **The tree is shared with three other agents.** All staging was explicit by path;
`.planning/STATE.md` (another agent's uncommitted edit) was never staged. `cargo` runs contended
heavily (load average 25-30, one test build took 34 minutes); one intermediate test build failed
with 16 errors in `federation/*` from another agent's mid-edit state, which resolved on its own.
- **`STATE.md` / `ROADMAP.md` / `REQUIREMENTS.md` were deliberately not updated.** Another agent
holds an uncommitted edit to `STATE.md` throughout, and the execution brief scoped this run to
commits only. KEY-03's requirement should be marked complete by whoever reconciles phase state,
noting that the census's six unchecked nodes are tracked separately and are not a blocker on
KEY-03 itself (F-13 is closed by deletion, which is build-wide and does not depend on the
census).
## Self-Check: PASSED
- Files verified present: `docs/security/KEY-03-SIGNING-POSTURE.md`,
`docs/security/PSBT-SIGNING-ARCHITECTURE.md`, `docs/UNIFIED-TASK-TRACKER.md`,
`core/archipelago/src/api/rpc/lnd/wallet.rs`, this SUMMARY.
- Commits verified in git: `96229268`, `26299874`, `0d513a0e`.
- No file belonging to this plan is left uncommitted (the SUMMARY is uncommitted deliberately,
per the execution brief).