Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b2c36f095 |
@@ -31,27 +31,9 @@ jobs:
|
||||
- name: Format
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
# KEY-05 layer (b) is enforced HERE, with no step of its own: core/clippy.toml
|
||||
# bans the defaulted RNG entry points, and `-D warnings` already turns a
|
||||
# `disallowed_methods` hit into a build failure. `--all-targets` covers tests
|
||||
# too, deliberately. See docs/security/KEY-05-ENTROPY-ENFORCEMENT.md
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
# KEY-05 layer (c) — see core/deny.toml for the policy and its rationale.
|
||||
#
|
||||
# The version is pinned deliberately. EmbarkStudios/cargo-deny-action exposes
|
||||
# no input to pin the cargo-deny version, and an unpinned supply-chain checker
|
||||
# is a contradiction in terms, so the tool is installed from crates.io — the
|
||||
# source actually vetted at the 10-06 Task 5 legitimacy checkpoint — rather
|
||||
# than by adding another unvetted action to this workflow.
|
||||
#
|
||||
# `check bans` ONLY: the advisories gate is not enabled (bans-only policy).
|
||||
- name: Supply chain (cargo-deny)
|
||||
run: |
|
||||
cargo install --locked cargo-deny --version 0.20.2
|
||||
cargo deny check bans
|
||||
|
||||
- name: Test
|
||||
run: cargo test --all-features
|
||||
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
---
|
||||
context: default
|
||||
phase: 09-botfights-platform-upgrade (already complete — this is off-plan work)
|
||||
task: n/a
|
||||
total_tasks: n/a
|
||||
status: paused
|
||||
last_updated: 2026-08-02T10:34:47.198Z
|
||||
---
|
||||
|
||||
# BLOCKING CONSTRAINTS — Read Before Anything Else
|
||||
|
||||
- [ ] CONSTRAINT: Never assume pushing one repo pushed another — this session pushed `archy` repeatedly via `git push gitea-ai main`, but the `botfight` repo's last 4 commits (the entire security-fix body of work) sat **local-only** the whole time and were only discovered/pushed at the very end of this session, during this handoff step. Structural mitigation: whenever a session touches more than one git repo, explicitly run `git status -sb` (ahead/behind vs. the tracked remote) in **every** repo touched before ending the session — not just the one most recently `git push`ed.
|
||||
|
||||
**Do not proceed until the box above is checked (i.e. verify both repos are still in sync with their remotes before doing anything else).**
|
||||
|
||||
<current_state>
|
||||
This is **not** a GSD plan/task in progress. Phase 09 (BotFights Platform Upgrade) is fully complete — plans 09-01 through 09-07 all have SUMMARY.md files, the last dated 2026-07-31 05:08. Everything described below happened *after* that, as live, user-directed, reactive work preparing for a same-day BotFights demo ("two real fighters playing with cashu"). None of it was tracked against a PLAN.md task list — the original GSD task (execute 09-06-PLAN.md: bump manifest + sign catalog) completed normally and stopped cleanly at its signing checkpoint, exactly as designed. Everything after that was ad hoc.
|
||||
|
||||
**As of this handoff, everything is committed and pushed in both repos, and both demo nodes are deployed and verified healthy.** There is nothing mid-flight to resume — this file exists so a future session (or this one, after compaction) has the full picture instead of re-discovering it.
|
||||
</current_state>
|
||||
|
||||
<completed_work>
|
||||
|
||||
**botfight repo** (`/home/archipelago/Projects/botfight`, pushed to `origin/main` @ `10d4209`):
|
||||
- iframe embedding fix (X-Frame-Options was unconditional), native Archipelago signer bridge (`nostr-provider.js`), "Sign in with Archipelago" docs for app developers
|
||||
- Discoverability fixes: mode-picker guide banner, AI-answer visibility, "Latest Bouts" cut off on short viewports
|
||||
- Fixed a proxy-URL leak (local/Tailscale addresses leaking into AI setup prompts via client-side `window.location.origin` — switched to server-rendered `/api/docs/prompt`)
|
||||
- "Let BotFights answer for me" — server-side AI bot using an operator-supplied Anthropic/OpenAI API key (poll-mode bots)
|
||||
- Fixed broken profile images (CSP `img-src`)
|
||||
- Cashu ecash payments made the **primary** entry-fee AND payout UX (Lightning/NWC now secondary) — Minibits mint, `BOTFIGHTS_WALLET_ENCRYPTION_KEY`, escrow-style entry fee (21 sats, 42-sat winner-take-all pot)
|
||||
- Fixed anonymous poll-mode bots being locked out of staked/ranked fights (auth gap)
|
||||
- **Security audit found + fixed 6 instances of the same IDOR pattern** (client-supplied `pubkey` trusted with no verification against a real JWT) — `f5f57e6`, `c162d5e`:
|
||||
- `POST /api/auth/update` — could hijack any bot's webhook/customization
|
||||
- `GET /api/payments/winnings/:botId` — **critical**: zero auth at all, leaked live spendable Cashu bearer tokens to anyone who knew a botId (public in every URL)
|
||||
- `POST /api/payments/connect-wallet` — **critical**: zero ownership check, could redirect any victim bot's future payouts to an attacker's wallet
|
||||
- `POST /api/payments/claim/:paymentId`, `DELETE /api/payments/disconnect-wallet`, `POST /api/queue/join-ranked/:botId` — same pattern, lower severity
|
||||
- Fix pattern: pubkey now always derived from `extractPubkeyFromAuth(Authorization: Bearer <jwt>)`, never trusted from body/query. Added `verifyBotOwner()` helper in `bot-auth.ts` for routes serving both nostr-owner and anonymous-bot-secret audiences.
|
||||
- Built the two things actually requested when the audit was found: **AI-answer settings reachable for existing bots** (`/api/bots/:name/ai-config`, not just at creation) and a **claim-winnings UI** (Cashu payouts were minted server-side but had zero frontend consumer — `41f1b93`)
|
||||
- `10d4209`: fixed a real `tsc` error the podman build caught that local verification initially missed (misread a wrapper's exit code instead of the actual log content — lesson: always check log *content*, not just the shell wrapper's `$?`)
|
||||
- Built + pushed `146.59.87.168:3000/lfg2025/botfights:1.2.11`
|
||||
|
||||
**archy repo** (pushed to `gitea-ai/main`, my commits at `aea17248`/`b0a08345` — many other agents' commits have landed on top since, this is a busy shared tree):
|
||||
- `apps/botfights/manifest.yml` bumped to 1.2.11; fixed `data_uid` from `1001` to `999` (the container's real internal UID — first attempt copied fedimint-clientd/barkd's value without checking this image's actual `Dockerfile`, which does `useradd --system` with no explicit UID)
|
||||
- `scripts/image-versions.sh` kept in lockstep
|
||||
- Catalog regenerated, signed (user ran `sign-catalog.sh`), published — verified live on `146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json`
|
||||
- Deployed to both nodes via RPC (`package.update`), both verified healthy:
|
||||
- **archi-dev-box** (local): `botfights` container on `1.2.11`, `/api/health` → ok
|
||||
- **x250-beta** (`archy-x250-beta.tail08d8f2.ts.net`): `botfights` container on `1.2.11`, `/api/health` → ok, `/api/bots` confirmed identical to `botfights.archipelago-foundation.org` (arena-proxy forwarding correctly)
|
||||
</completed_work>
|
||||
|
||||
<remaining_work>
|
||||
Nothing blocking the demo. One loose end, likely moot:
|
||||
- Framework PT (`100.65.115.109`) SSH access is still blocked — the password was rotated 2026-07-26 and the current one isn't recorded anywhere. User redirected the demo plan away from Framework PT to x250-beta earlier in the session, so this probably doesn't matter anymore unless the user brings it up again.
|
||||
</remaining_work>
|
||||
|
||||
<decisions_made>
|
||||
- Cashu is now the primary UX for both paying entry fees AND receiving payouts, Lightning/NWC demoted to a secondary "or connect a Lightning wallet instead" option — explicit user instruction.
|
||||
- `data_uid: 999:999` (not 1001) in the botfights manifest — verified against the running container's actual `id` output, not assumed from another app's manifest.
|
||||
- ai-config routes accept EITHER a nostr JWT (new, for browser owners) OR the bot's own secret (existing, for anonymous AI-agent poll-mode bots) — additive, not a replacement, since both audiences are real and pre-existing.
|
||||
</decisions_made>
|
||||
|
||||
<blockers>
|
||||
- Framework PT SSH: password unknown since 2026-07-26 rotation. Not currently blocking anything (user moved to x250-beta).
|
||||
</blockers>
|
||||
|
||||
## Required Reading (in order)
|
||||
1. This file, obviously.
|
||||
2. `.planning/phases/09-botfights-platform-upgrade/09-06-SUMMARY.md` and `09-07-SUMMARY.md` — the actual last GSD-tracked work in this area, for anyone confused about why there's no PLAN.md for tonight's work.
|
||||
3. If continuing security work: re-read the fix pattern in `botfight` repo commits `f5f57e6` and `c162d5e` before touching any other route that reads a pubkey — the same bug class may exist elsewhere in the codebase that wasn't audited (only `auth.ts`, `payments.ts`, and `queue.ts` were checked; `bots.ts`, `tournaments.ts`, `bets.ts` were not re-audited for this exact pattern).
|
||||
|
||||
## Critical Anti-Patterns (do NOT repeat these)
|
||||
- **ANTI-PATTERN: trusting a shell wrapper's exit code instead of the actual command output.** During this session, `tsc --noEmit ... ; echo "EXIT=$?"` was read as "passed" from the *notification summary* (which reports the wrapper's own exit code, always 0 because `echo` always succeeds) rather than the log *content*. This let a real `tsc` compile error through to a `podman build` failure. → Structural mitigation: always `cat`/`Read` the actual log file and look for the error pattern or an explicit `EXIT=N` marker line before treating a background verification command as passed.
|
||||
- **ANTI-PATTERN: assuming multi-repo work is saved because one repo was pushed.** → Structural mitigation described in the BLOCKING CONSTRAINT above.
|
||||
- **ANTI-PATTERN (from earlier this session, already corrected): never run `archipelago --version` on a fleet node** — it starts the full daemon rather than printing a version string (deployed binaries predate the flag). Use source-reading instead of the binary for investigation.
|
||||
|
||||
## Infrastructure State
|
||||
- **archi-dev-box** (local node): `archipelago` daemon healthy, RPC on `127.0.0.1:5678` (session cookie in `/tmp/archy-dev-cookies.txt`, likely stale by the time this is read — re-login with `auth.login` / password `ThisIsWeb54321@`). `botfights` container healthy on `1.2.11`.
|
||||
- **x250-beta** (`archy-x250-beta.tail08d8f2.ts.net`, tailnet IP rotates — resolve by MagicDNS name): reachable via plain `ssh archipelago@archy-x250-beta.tail08d8f2.ts.net` this session (no password prompt hit — key-based or cached). RPC session cookie in `/tmp/archy-cookies.txt` **on that remote node**, likely stale — re-login same way. `botfights` container healthy on `1.2.11`.
|
||||
- Both nodes' local `/tmp` filled up mid-session (a 12G tmpfs, hit 0MB free once) — if you hit `ENOSPC` from the harness itself (not the actual command), check `df -h /tmp` and clean up stray large files (this session's culprit: two OTA release assets, ~260MB, downloaded to `/tmp` on the **local** machine as a relay step for an unrelated node update earlier in the session).
|
||||
- Canonical arena: `https://botfights.archipelago-foundation.org` — both demo nodes proxy to this via `ARENA_UPSTREAM_URL`, confirmed serving identical bot/fight data on both.
|
||||
|
||||
<context>
|
||||
The user is demoing BotFights live, same day, wants two real fighters paying/winning with Cashu ecash across two real node installs. All of that is now in place and verified. The security audit was NOT originally requested — it was triggered by investigating the user's question "can we confirm the fighter wins all the cashu sats into their node wallet automatically", which led to reading `payments.ts` end to end and discovering the payout claim flow had no frontend UI *and* the backend route serving it had no auth at all. That in turn led to checking every other route with a similar shape, which is how 5 more instances of the same bug were found. This is worth remembering: a seemingly simple product question ("where does the money go") uncovered a real, live, exploitable vulnerability in a publicly-deployed app — treat "let me just check how this actually works end to end" as time well spent, not scope creep.
|
||||
</context>
|
||||
|
||||
<next_action>
|
||||
Nothing is required to "resume" — this was a complete, self-contained session of off-plan work, fully committed, pushed, deployed, and verified. If the user opens a new session and says something like "continue" or "where were we", the right first move is to summarize the state above (both nodes on `1.2.11`, security fixes live, demo-ready), not to look for a GSD plan to execute. If the user wants to resume *GSD-tracked* work specifically, `STATE.md` says Phase 10 (Key-Material Hardening, KEY-01..KEY-04, sourced from `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`) is planned and ready to execute — but that is a separate, unrelated thread from tonight's BotFights work, and STATE.md is being actively updated by other concurrent agents working other phases (01, 02, 10) in this shared tree, so re-read it fresh rather than trusting anything cached.
|
||||
</next_action>
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"timestamp": "2026-08-02T10:34:47.198Z",
|
||||
"phase": "09",
|
||||
"phase_name": "BotFights Platform Upgrade",
|
||||
"phase_dir": ".planning/phases/09-botfights-platform-upgrade",
|
||||
"plan": null,
|
||||
"task": null,
|
||||
"total_tasks": null,
|
||||
"status": "paused",
|
||||
"context_type": "ad_hoc_reactive",
|
||||
"note": "This handoff does NOT track a GSD plan/task. Phase 09's plans 09-01..09-07 are all already complete (SUMMARY.md exists for each, most recent 09-07-SUMMARY.md dated 2026-07-31 05:08). Everything recorded here happened AFTER 09-06/09-07 were done, as live reactive demo-day work directed by the user in conversation, not from a PLAN.md task list. There is no in-progress GSD plan to resume — this is purely a work-state save so uncommitted/unpushed work and node state are not lost.",
|
||||
"completed_tasks": [
|
||||
{"id": "botfight-security-audit", "name": "Found + fixed 6 IDOR/missing-auth vulnerabilities in botfight repo", "status": "done", "commit": "f5f57e6 (auth.ts), c162d5e (payments.ts/queue.ts)"},
|
||||
{"id": "botfight-ai-config-existing-bots", "name": "AI-answer settings UI for existing bots (not just at creation)", "status": "done", "commit": "41f1b93"},
|
||||
{"id": "botfight-claim-winnings-ui", "name": "Claim-winnings UI (Cashu payouts were backend-only, no frontend consumer)", "status": "done", "commit": "41f1b93"},
|
||||
{"id": "botfight-tsc-fix", "name": "Fixed possibly-undefined route param tsc error caught by podman build", "status": "done", "commit": "10d4209"},
|
||||
{"id": "botfight-1.2.11-release", "name": "Built + pushed botfights:1.2.11 image to registry", "status": "done"},
|
||||
{"id": "archy-manifest-1.2.11", "name": "Bumped apps/botfights/manifest.yml + scripts/image-versions.sh to 1.2.11, regenerated+signed+published catalog", "status": "done", "commit": "aea17248 (manifest bump), b0a08345 (signed catalog)"},
|
||||
{"id": "deploy-archi-dev-box", "name": "Updated BotFights to 1.2.11 on archi-dev-box via package.update RPC", "status": "done"},
|
||||
{"id": "deploy-x250-beta", "name": "Updated BotFights to 1.2.11 on x250-beta via package.update RPC", "status": "done"},
|
||||
{"id": "botfight-push-to-origin", "name": "Pushed 4 local-only botfight commits to origin (were unpushed until this handoff step)", "status": "done", "commit": "d00e792..10d4209 -> origin/main"}
|
||||
],
|
||||
"remaining_tasks": [
|
||||
{"id": "framework-pt-access", "name": "Framework PT (100.65.115.109) SSH access still blocked — password was rotated 2026-07-26, current password unknown. User redirected focus to x250-beta instead, so this may no longer be needed for the demo.", "status": "blocked"}
|
||||
],
|
||||
"blockers": [
|
||||
{"description": "Framework PT SSH password unknown (rotated, not recorded)", "type": "human_action", "workaround": "User already redirected demo plan to use x250-beta instead of Framework PT — likely moot unless user asks for Framework PT again."}
|
||||
],
|
||||
"async_jobs": [],
|
||||
"human_actions_pending": [],
|
||||
"decisions": [
|
||||
{"decision": "Made Cashu the primary entry-fee AND payout UX for BotFights, Lightning/NWC secondary", "rationale": "Explicit user instruction: \"please make cashu the primary UX and lightning secondary\"", "phase": "09"},
|
||||
{"decision": "Fixed data_uid in apps/botfights/manifest.yml from 1001 to 999", "rationale": "Container's actual internal UID (confirmed via `podman exec botfights id`) is 999, not 1001 — first attempt copied fedimint-clientd/barkd's value without verifying against this specific image's Dockerfile (`useradd --system` with no explicit UID lands at 999)", "phase": "09"},
|
||||
{"decision": "Extended ai-config routes to accept EITHER nostr JWT (verifyBotOwner) OR the bot's own secret, rather than replacing bot-secret auth", "rationale": "Poll-mode AI-agent bots (no nostr identity) still need the original bot-secret path; nostr-logged-in browser owners needed a new path that didn't exist before", "phase": "09"}
|
||||
],
|
||||
"uncommitted_files": [],
|
||||
"unrelated_uncommitted_by_other_agent": [
|
||||
"core/archipelago/src/container/prod_orchestrator.rs (archy repo) — modified by a DIFFERENT concurrent agent, not touched by this session. Do NOT stage, commit, or stash this file."
|
||||
],
|
||||
"next_action": "No GSD action required to resume — Phase 09 is fully complete and this was off-plan reactive work, now fully committed and pushed in both repos (archy @ b0a08345, botfight @ 10d4209 on origin/main), deployed to both demo nodes (archi-dev-box + x250-beta, both verified healthy on botfights:1.2.11), and catalog signed+published. If resuming demo work: verify nodes are still healthy (`curl http://127.0.0.1:9100/api/health` on each) since time has passed. If resuming GSD-tracked work: STATE.md says Phase 10 (Key-Material Hardening) is planned and ready to execute — that is a SEPARATE, unrelated GSD phase from tonight's BotFights firefighting.",
|
||||
"context_notes": "This was a long reactive demo-prep session, not GSD-plan-driven. Started from GSD-executing 09-06-PLAN.md (bump BotFights manifest + sign catalog), which completed normally and STOPPED at the signing checkpoint as designed. Everything after that was live user-directed firefighting for a same-day demo: iframe embedding, native signer bridge, AI-answer feature, Cashu payment integration (both entry-fee and payout sides), a security audit that surfaced a systemic IDOR pattern (client-supplied pubkey trusted without verification) repeated across 6 routes — 2 of them critical (unauthenticated Cashu-token leak, unauthenticated wallet-hijack) — and a second-node deployment to x250-beta that surfaced a real manifest bug (data_uid). The single biggest risk caught in this handoff step itself: 4 botfight-repo commits (the entire security-fix work) were sitting LOCAL-ONLY, never pushed to origin, until this pause-work step explicitly checked ahead/behind counts and pushed them. Always verify `git status -sb` / ahead-behind against the actual remote before ending a session that touched a repo other than the one being actively `git push`ed in the visible workflow — pushing archy did not imply botfight got pushed too, they are separate repos."
|
||||
}
|
||||
+20
-20
@@ -15,21 +15,21 @@ declared exit criteria (multinode pass + workstreams B/C/F), `.planning/codebase
|
||||
- [ ] **FED-01**: Removing a federation node sticks — it disappears from every UI surface, tombstones propagate, it never reappears via later sync cycles, and a failed removal surfaces an error (never a silent no-op)
|
||||
- [ ] **FED-02**: Federation sync converges and is observable — after sync settles, fleet nodes agree on the node list with fresh status; stale entries, duplicates, and silent sync failures are eliminated and sync errors are operator-visible
|
||||
- [ ] **FED-03**: A structured code review of the federation/fleet area (`core/archipelago/src/federation`, node sync, FIPS/transport dial layer) and mesh area (`core/archipelago/src/mesh`, mesh RPC surface) is completed, with every finding fixed or explicitly deferred with a reason
|
||||
- [x] **FED-04**: Mesh messaging parity — attachment send (and the rest of the mesh chat surface) behaves identically on the demo and on real nodes: the demo backend implements the same RPC surface the UI calls, transport decisions mirror the real size-based tier logic, and no demo-only modals exist
|
||||
- [ ] **FED-04**: Mesh messaging parity — attachment send (and the rest of the mesh chat surface) behaves identically on the demo and on real nodes: the demo backend implements the same RPC surface the UI calls, transport decisions mirror the real size-based tier logic, and no demo-only modals exist
|
||||
- [ ] **FED-05**: Inter-node Lightning channel opening UX — the UI shows the node's shareable Lightning URI; lists trusted (federated) nodes by hostname for one-click channel opening; and lets the user browse/request channels with public nodes — using the existing design system and components, verified on the :8100 dev preview against archi-dev before deploy
|
||||
- [x] **FED-06**: On-brand payment success animation — the invoice "paid" tick's circle uses the screensaver-style ring with outer EQ-segment lines (reuse `ScreensaverRing.vue`'s compact size) in place of the current success burst, applied consistently everywhere the paid tick shows
|
||||
- [x] **FED-08**: Lightning invoices created by the wallet embed route hints (LND `private` flag) so nodes whose channels are unannounced can actually receive payments — diagnosed on archy-x250-mad2 2026-07-31, where every wallet-UI invoice had `route_hints: []` and was unroutable; the bug is unconditional and affects any node without a public channel
|
||||
- [x] **FED-09**: The container doctor does not restart Tor on every run — it recognises Tor's own setgid `2700` hidden-service directory mode as correct rather than "fixing" it to `700` and restarting, a loop that reset Tor every ~5 minutes, starved it of its consensus/HSDir cache (`No more HSDir available to query`), and broke the mesh's Tor fallback entirely; genuinely permissive modes are still corrected, and a restart backoff makes the failure class non-recurring
|
||||
- [x] **FED-07**: Fedimint gateway never installs with a pre-set password — gateway credentials are generated per-install via manifest-declared `generated_secrets` (or explicitly set by the user), never baked into the image/manifest; existing installs with the default password get a migration path (BLOCKER — default credentials are a security hole)
|
||||
- [ ] **FED-06**: On-brand payment success animation — the invoice "paid" tick's circle uses the screensaver-style ring with outer EQ-segment lines (reuse `ScreensaverRing.vue`'s compact size) in place of the current success burst, applied consistently everywhere the paid tick shows
|
||||
- [ ] **FED-08**: Lightning invoices created by the wallet embed route hints (LND `private` flag) so nodes whose channels are unannounced can actually receive payments — diagnosed on archy-x250-mad2 2026-07-31, where every wallet-UI invoice had `route_hints: []` and was unroutable; the bug is unconditional and affects any node without a public channel
|
||||
- [ ] **FED-09**: The container doctor does not restart Tor on every run — it recognises Tor's own setgid `2700` hidden-service directory mode as correct rather than "fixing" it to `700` and restarting, a loop that reset Tor every ~5 minutes, starved it of its consensus/HSDir cache (`No more HSDir available to query`), and broke the mesh's Tor fallback entirely; genuinely permissive modes are still corrected, and a restart backoff makes the failure class non-recurring
|
||||
- [ ] **FED-07**: Fedimint gateway never installs with a pre-set password — gateway credentials are generated per-install via manifest-declared `generated_secrets` (or explicitly set by the user), never baked into the image/manifest; existing installs with the default password get a migration path (BLOCKER — default credentials are a security hole)
|
||||
|
||||
### UI Fixes (UIFIX) — user-reported blockers, added 2026-07-30
|
||||
|
||||
- [ ] **UIFIX-01**: The FIPS/Tor pills on cloud files are kept (never removed by cleanups) and render at mobile widths — on mobile, users can see each file's security/transport state (BLOCKER)
|
||||
- [x] **UIFIX-02**: The connected-nodes list scrolls at row-matched height — its height tracks the taller right-hand sibling in the row and the inner list scrolls within it, never growing to fit all rows scroll-free (BLOCKER)
|
||||
- [x] **UIFIX-03**: On short viewports the onboarding confirmation tickbox is discoverably visible — an on-brand affordance (scroll cue, sticky footer, or equivalent) makes it obvious without altering tall-screen appearance (BLOCKER)
|
||||
- [x] **UIFIX-04**: Paid Files pictures open in the app's lightbox, not a browser tab — consistent with the rest of the app's media UX
|
||||
- [x] **UIFIX-05**: Picture-in-picture is robust — entering PiP closes the lightbox with a fluid on-brand animation, and an active PiP session survives main-tab changes and video buffering pauses (only an explicit user stop ends it)
|
||||
- [x] **UIFIX-06**: Surfaces with genuinely slow opens show house-style loader states — no dead-feeling clicks (cached revisits stay spinner-free per PERF-02)
|
||||
- [ ] **UIFIX-02**: The connected-nodes list scrolls at row-matched height — its height tracks the taller right-hand sibling in the row and the inner list scrolls within it, never growing to fit all rows scroll-free (BLOCKER)
|
||||
- [ ] **UIFIX-03**: On short viewports the onboarding confirmation tickbox is discoverably visible — an on-brand affordance (scroll cue, sticky footer, or equivalent) makes it obvious without altering tall-screen appearance (BLOCKER)
|
||||
- [ ] **UIFIX-04**: Paid Files pictures open in the app's lightbox, not a browser tab — consistent with the rest of the app's media UX
|
||||
- [ ] **UIFIX-05**: Picture-in-picture is robust — entering PiP closes the lightbox with a fluid on-brand animation, and an active PiP session survives main-tab changes and video buffering pauses (only an explicit user stop ends it)
|
||||
- [ ] **UIFIX-06**: Surfaces with genuinely slow opens show house-style loader states — no dead-feeling clicks (cached revisits stay spinner-free per PERF-02)
|
||||
|
||||
### UI Performance (PERF)
|
||||
|
||||
@@ -109,18 +109,18 @@ Which phases cover which requirements. Updated during roadmap creation.
|
||||
| FED-01 | Phase 1 | Pending |
|
||||
| FED-02 | Phase 1 | Pending |
|
||||
| FED-03 | Phase 1 | Pending |
|
||||
| FED-04 | Phase 1 | Complete |
|
||||
| FED-04 | Phase 1 | Pending |
|
||||
| FED-05 | Phase 1 | Pending |
|
||||
| FED-06 | Phase 1 | Complete |
|
||||
| FED-07 | Phase 1 | Complete — rotation + recreate verified on archi-dev-box 2026-08-02 |
|
||||
| FED-08 | Phase 1 | Code complete + unit-pinned; post-OTA check on the user device pending |
|
||||
| FED-09 | Phase 1 | Complete — 15h Tor uptime / 0 permission-fixes on archi-dev-box; onion-resolution check post-OTA |
|
||||
| FED-06 | Phase 1 | Pending |
|
||||
| FED-07 | Phase 1 | Pending |
|
||||
| FED-08 | Phase 1 | Pending |
|
||||
| FED-09 | Phase 1 | Pending |
|
||||
| UIFIX-01 | Phase 1 | Pending |
|
||||
| UIFIX-02 | Phase 1 | Complete |
|
||||
| UIFIX-03 | Phase 1 | Complete |
|
||||
| UIFIX-04 | Phase 1 | Complete |
|
||||
| UIFIX-05 | Phase 1 | Complete |
|
||||
| UIFIX-06 | Phase 1 | Complete |
|
||||
| UIFIX-02 | Phase 1 | Pending |
|
||||
| UIFIX-03 | Phase 1 | Pending |
|
||||
| UIFIX-04 | Phase 1 | Pending |
|
||||
| UIFIX-05 | Phase 1 | Pending |
|
||||
| UIFIX-06 | Phase 1 | Pending |
|
||||
| PERF-01 | Phase 2 | Complete |
|
||||
| PERF-02 | Phase 2 | Complete. 02-11 (`02-FINDINGS.md` § Client-Side Render Cost Root Cause + § Task 3) named and fixed the real cause of Web5/Server's revisit-ms regressions — three leaked background pollers (`useFleetData.ts`, `FipsNetworkCard.vue`, `Web5Monitoring.vue`) armed in `onMounted` and never disarmed once their owning views joined `KEEP_ALIVE_PATHS`, gated to activate/deactivate. Web5 now fixed (275ms, below both its 566ms pre-phase-2 baseline and the 300ms pass bar); Server's regression is closed (574ms, below its 738ms baseline) though not yet under the 300ms stretch target — residual named as real, un-eliminated per-resource reactivation cost, not a new defect |
|
||||
| PERF-03 | Phase 2 | Complete. 02-11 fixed Fleet's leaked `useFleetData.ts` poll (790ms, down from a 2631ms regression, substantially closing the gap to its 330ms baseline). AppDetails restored to at/near its own baseline (1231ms vs. 1204ms) — residual is the already-documented `useCachedResource` per-mount setup cost, not fixed further. Discover (1389ms) has a SECOND, distinct, evidenced cause found this session (CSS entrance-animation replay on KeepAlive reactivation, `card-stagger`/`showStagger` never removed from the DOM) — named with full profiling/diagnostic evidence but NOT fixed (blast radius spans 5+ files outside this plan's scope, needs its own real-device verification budget) — recommended as a dedicated follow-up. OpenWrtGateway: not measurable this pass (Chromium crash cascading from an unrelated surface); prior numbers stand, confirmed to reflect a real (not empty) disconnected-device UI render, not retracted |
|
||||
|
||||
+13
-99
@@ -28,8 +28,6 @@ signed/decentralized registry and a user installs it on their node.
|
||||
- [ ] **Phase 7: Developer Tooling CLI** - `archy app validate/render/local-install/lifecycle-test` + developer guide
|
||||
- [ ] **Phase 8: Decentralized Marketplace** - DID-signed publish to Nostr relays, trust-tier discovery, verified third-party install end-to-end
|
||||
- [ ] **Phase 9: BotFights Platform Upgrade** - Native nostr signer login, one self-contained AI bot-setup prompt, shared public VPS2 match endpoint so all nodes see all fighters, registry updated
|
||||
- [ ] **Phase 12: Bitcoin Node Settings & Core/Knots Parity** - Every bitcoind option reachable in the UI, Knots-only options gated to Knots, network mode a setting defaulting to Tor
|
||||
- [ ] **Phase 13: AIUI — Conversational Node Control & Content Surfaces** - Human-language node control and settings in AIUI chat, its designed content surfaces wired to real peer/music/movie data, all inside a user-granted capability sandbox that keeps keys and secrets away from the browser and the model
|
||||
|
||||
## Phase Details
|
||||
|
||||
@@ -55,15 +53,15 @@ signed/decentralized registry and a user installs it on their node.
|
||||
13. Genuinely slow opens show loader states (UIFIX-06, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-missing-loader-states-on-slow-opens.md`; 02-08's flagged timing regressions are the starting inventory)
|
||||
NOTE for criteria 7–13: all were added after phase 1's 10 plans were written — before phase 1 execution completes, create gap plan(s) covering FED-07 + UIFIX-01..06 (existing desktop visuals must remain untouched per the standing visual-invisibility rule; UIFIX items themselves are user-approved visual changes)
|
||||
|
||||
**Plans**: 11/20 plans executed
|
||||
**Plans**: 18 plans
|
||||
|
||||
Plans:
|
||||
|
||||
- [x] 01-20-PLAN.md — URGENT wave 1: doctor stops restarting Tor every 5min (mesh Tor fallback) (FED-09)
|
||||
- [x] 01-19-PLAN.md — URGENT wave 1: wallet invoices embed route hints so private-channel nodes can receive (FED-08)
|
||||
- [x] 01-01-PLAN.md — Serialize the federation node store and make removal stick (FED-01)
|
||||
- [x] 01-02-PLAN.md — Demo mesh/federation RPC parity + automated parity harness (FED-04)
|
||||
- [x] 01-03-PLAN.md — On-brand paid tick: ScreensaverRing badge variant on both success surfaces (FED-06)
|
||||
- [ ] 01-20-PLAN.md — URGENT wave 1: doctor stops restarting Tor every 5min (mesh Tor fallback) (FED-09)
|
||||
- [ ] 01-19-PLAN.md — URGENT wave 1: wallet invoices embed route hints so private-channel nodes can receive (FED-08)
|
||||
- [ ] 01-01-PLAN.md — Serialize the federation node store and make removal stick (FED-01)
|
||||
- [ ] 01-02-PLAN.md — Demo mesh/federation RPC parity + automated parity harness (FED-04)
|
||||
- [ ] 01-03-PLAN.md — On-brand paid tick: ScreensaverRing badge variant on both success surfaces (FED-06)
|
||||
- [ ] 01-04-PLAN.md — Lightning identity: own-node URI + meshed Lightning peer discovery (FED-05)
|
||||
- [ ] 01-05-PLAN.md — Federation sync convergence and operator-visible sync errors (FED-02)
|
||||
- [ ] 01-06-PLAN.md — Lightning URI on the federation sync payload, sharing default decided (FED-05)
|
||||
@@ -74,15 +72,15 @@ Plans:
|
||||
|
||||
**Wave 7** *(gap closure — criteria 7–13, added 2026-07-30 after the original 10 plans were written)*
|
||||
|
||||
- [x] 01-11-PLAN.md — No baked-in Fedimint gateway credential: per-install secret on every path (FED-07)
|
||||
- [x] 01-12-PLAN.md — Connected-nodes list scrolls at row-matched height instead of growing (UIFIX-02)
|
||||
- [x] 01-13-PLAN.md — On-brand scroll cue makes the onboarding tickbox findable on short screens (UIFIX-03)
|
||||
- [x] 01-14-PLAN.md — Paid Files open in the app lightbox, with a visible wait and a real error path (UIFIX-04/06)
|
||||
- [x] 01-15-PLAN.md — PiP hands off from the lightbox and survives tab changes and buffering (UIFIX-05)
|
||||
- [ ] 01-11-PLAN.md — No baked-in Fedimint gateway credential: per-install secret on every path (FED-07)
|
||||
- [ ] 01-12-PLAN.md — Connected-nodes list scrolls at row-matched height instead of growing (UIFIX-02)
|
||||
- [ ] 01-13-PLAN.md — On-brand scroll cue makes the onboarding tickbox findable on short screens (UIFIX-03)
|
||||
- [ ] 01-14-PLAN.md — Paid Files open in the app lightbox, with a visible wait and a real error path (UIFIX-04/06)
|
||||
- [ ] 01-15-PLAN.md — PiP hands off from the lightbox and survives tab changes and buffering (UIFIX-05)
|
||||
|
||||
**Wave 8** *(blocked on Wave 7 completion)*
|
||||
|
||||
- [x] 01-16-PLAN.md — Migrate existing installs off the default gateway credential, data preserved (FED-07)
|
||||
- [ ] 01-16-PLAN.md — Migrate existing installs off the default gateway credential, data preserved (FED-07)
|
||||
- [ ] 01-17-PLAN.md — FIPS/Tor pills pinned against removal and readable at phone widths (UIFIX-01)
|
||||
|
||||
**Wave 9** *(blocked on Wave 8 completion)*
|
||||
@@ -237,7 +235,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8
|
||||
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
|-------|----------------|--------|-----------|
|
||||
| 1. Federation & Mesh Hardening | 11/20 | In Progress| |
|
||||
| 1. Federation & Mesh Hardening | 0/10 | Planned | - |
|
||||
| 2. UI Performance | 11/12 | Complete | 2026-07-31 |
|
||||
| 3. Multinode Verification Pass | 0/TBD | Not started | - |
|
||||
| 4. Lifecycle Perfection & Quadlet Default | 0/TBD | Not started | - |
|
||||
@@ -246,8 +244,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8
|
||||
| 7. Developer Tooling CLI | 0/TBD | Not started | - |
|
||||
| 8. Decentralized Marketplace | 0/TBD | Not started | - |
|
||||
| 9. BotFights Platform Upgrade | 7/7 | Executed — awaiting human demo verification | 2026-07-31 |
|
||||
| 10. Key-Material Hardening | 0/5 | Planned — **priority override, see phase note** | - |
|
||||
| 11. Wallet Experience & LND UI Parity | 0/TBD | Not started — gated on 10-05's watch-only verdict | - |
|
||||
|
||||
### Phase 9: BotFights Platform Upgrade
|
||||
|
||||
@@ -265,85 +261,3 @@ Plans:
|
||||
- [x] 09-05-PLAN.md — Build+push botfights:1.2.0, roll the arena, prove cross-instance visibility (BOT-03/BOT-04)
|
||||
- [x] 09-06-PLAN.md — Manifest 1.2.0 with generated JWT secret + signed catalog republished (BOT-04)
|
||||
- [x] 09-07-PLAN.md — archi-dev-box deploy + demo rehearsal: real signer login, cloud bot from the prompt (BOT-01/02/03/04)
|
||||
|
||||
### Phase 10: Key-Material Hardening
|
||||
|
||||
**Goal:** Every path that creates, restores, or persists node key material proves the caller is authorized and the material is per-node — closing the three exploitable findings from `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`. A node that is already onboarded must refuse to have its identity replaced; a node flashed from the shared rootfs must never share another node's host keys; and the wallet spending key must not exist in cleartext outside the encrypted envelope.
|
||||
**Requirements**: KEY-01 (F-01, **Critical**) `seed.generate`/`seed.restore` are unauthenticated (`api/rpc/middleware.rs:25`) and `NodeIdentity::from_seed` (`identity.rs:79`) overwrites `node_key`/`nostr_secret`/FIPS key unconditionally — one unauthenticated POST with an attacker-chosen mnemonic hijacks a live node; gate on onboarding-incomplete (the unused `identity.rs:117` `key_exists` guard) + rate-limit; KEY-02 (F-03, **High**) first-boot per-device secret regeneration is fail-open and its completion marker is set even on failure (`image-recipe/_archived/build-auto-installer-iso.sh:1647,:1659,:1663`), over a fleet-shared cached rootfs that bakes SSH host keys + the TLS key — make it fail-closed and retried; KEY-03 (F-13, **High**) the BIP-84 account **private** key is imported into Bitcoin Core's wallet (`api/rpc/bitcoin.rs:203,:229-231`), duplicating the spending key outside the encrypted envelope — move to watch-only descriptors per `docs/security/PSBT-SIGNING-ARCHITECTURE.md`; KEY-04 on-node verification of C-3/C-4/C-6 from the audit's UNVERIFIED checklist (host-key uniqueness across two real nodes, rootfs tar contents on the build host, unauthenticated LAN reachability of the RPC endpoint); KEY-05 (F-10a, **Medium**, added 2026-08-02) **a defaulted RNG cannot be inherited anywhere in the crate**. The audit's F-10 recorded this as 2 call sites; it is **41 raw matches across 15 files** (`session.rs` 16 → 4 prod + 12 test, `pine_ha.rs` 6, `wallet/bdhke.rs` 4 → 2 prod — *Cashu proof secret + blinding factor, genuine key material*, `storage_crypto.rs` 1 — *AEAD nonce*, `mesh/x3dh.rs` 2 — *prekey identifiers, **not** key material, corrected 2026-08-02*, +10 more; full table in the audit's §F-10a. Per-site prod/test classification is KEY-05's Task 1, not an assumption). Nothing is broken today — `rand::random()`/`thread_rng()` are ChaCha12 seeded from `getrandom(2)` — but this is the exact T1 structural shape that produced the 2026-07-30 COLDCARD defect, now with key material in its blast radius. Five layers, all required: (a) **sealed allowlist trait** at key-generation seams (private supertrait, so no other module *or crate* can implement it; exactly one production impl, `OsRng`) — this also retires the `impl rand::CryptoRng for CountingRng` false promise at `seed.rs:656`; (b) **`clippy.toml` `disallowed-methods`** banning `rand::thread_rng`/`rand::random` crate-wide, so enforcement is a compile failure in CI rather than a review convention (no `clippy.toml` exists today; CI already runs clippy); (c) **`cargo-deny`** failing on duplicate `rand` majors — two coexist today, which is the mechanism by which a bump could silently rebind (absorbs R-05); (d) **degenerate-entropy runtime check** before key generation (rejects all-zero / counter-like draws — the one layer that would catch the Coldcard failure *on the device* rather than in review); (e) **persist the CSPRNG-readiness verdict** that `seed.rs:59` already computes and discards, so a node can answer after the fact "was the pool seeded when this key was born?" (absorbs R-09). Supersedes R-13
|
||||
**Depends on:** Nothing (independent security work; parallelizable with Phases 1–8). **Priority override: F-01 is Critical and live on every fleet node — this phase should be planned and executed ahead of its numeric position, which reflects append order in a shared roadmap, not sequencing.**
|
||||
**Plans:** 6 plans
|
||||
|
||||
> **EXECUTION GATE (user instruction, 2026-08-02):** do **not** begin executing this phase until
|
||||
> (a) the concurrent agent working Phase 1 has finished, and (b) their changes are synced and
|
||||
> accounted for. Rationale: Phase 10 edits `middleware.rs`, `identity.rs`, `seed_rpc.rs`,
|
||||
> `bitcoin.rs` and — under KEY-05 — ~15 further files across the same crate that agent is
|
||||
> actively committing to. Verify a clean tree and a fetched `gitea-ai/main` before starting.
|
||||
>
|
||||
> **KEY-05 is planned** as `10-06` (added 2026-08-02). The other 5 plans predate KEY-05 and
|
||||
> are unchanged by it. `10-06` is wave 2 because it shares `seed.rs` with `10-05` and
|
||||
> `api/rpc/auth.rs` with `10-01`; see its `<file_collision_analysis>`.
|
||||
|
||||
Plans:
|
||||
|
||||
**Wave 1** *(parallel — no shared files)*
|
||||
|
||||
- [ ] 10-01-PLAN.md — Identity-mutating unauthenticated RPCs hard-refuse on a provisioned node, with the byte-identity regression suite (KEY-01)
|
||||
- [ ] 10-03-PLAN.md — First-boot secret regeneration retries then fails closed, and the rootfs tar ships identity-free (KEY-02/KEY-04 C-4)
|
||||
- [ ] 10-05-PLAN.md — Delete the Bitcoin Core xprv-import path; make LND's PSBT round trip first-class, tested and honestly documented (KEY-03)
|
||||
|
||||
**Wave 2** *(each blocked on its wave-1 sibling)*
|
||||
|
||||
- [ ] 10-02-PLAN.md — On-node C-6 exposure measurement, live refusal proof, and the fresh-node onboarding non-regression (KEY-01/KEY-04) — depends on 10-01
|
||||
- [ ] 10-04-PLAN.md — Fleet detection of image-baked host secrets, guarded one-time rotation, and C-3 two-node verification (KEY-02/KEY-04) — depends on 10-03
|
||||
- [ ] 10-06-PLAN.md — A defaulted RNG cannot be inherited anywhere in the crate: sealed allowlist, clippy ban, cargo-deny, degenerate-entropy check, persisted CSPRNG verdict (KEY-05) — depends on 10-01 and 10-05
|
||||
|
||||
### Phase 11: Wallet Experience & LND UI Parity
|
||||
|
||||
**Goal:** The wallet is something a user chooses and understands, not something that just appears. A first-run wallet screen lets them pick a wallet type and route accordingly; seed handling reuses the SeedQR + seed-words patterns already shipped; and the day-to-day Lightning interface offers what umbrelOS's LND UI offers, so nothing is missing for someone arriving from Umbrel.
|
||||
**Requirements**: WALLET-01 first-run wallet-type chooser (an intro/initial screen presenting the available wallet types with plain-language trade-offs, routing into the matching setup flow) — the available types depend on Phase 10's `10-05` watch-only verdict, so this requirement is **gated on that evidence**, not on assumption; WALLET-02 seed handling in the wallet flow reuses the existing SeedQR + seed-words components rather than reimplementing them (`neode-ui/src/utils/seedqr.ts`, `OnboardingSeedGenerate.vue`, `SeedRevealPanel.vue`, `WalletScanModal.vue`) — including the standing constraint that the LND aezeed is text-only by design and has no SeedQR; WALLET-03 evidence-based umbrelOS LND UI parity — produce a feature-by-feature comparison matrix from the actual Umbrel interface (researched, not assumed), classify each row as already-shipped / gap / deliberately-not-wanted, and close the gaps worth closing; WALLET-04 the resulting interface is house-style (Teleport-to-body modals, existing design system) and verified on the :8100 dev preview against archi-dev before any deploy; WALLET-05 **the PSBT air-gap round trip is a real, usable flow** — the standard two-scan dance (node displays the unsigned PSBT as an animated QR → offline signer scans and signs → signer displays the signed PSBT → node scans it back with the camera → finalize + broadcast). Three sub-gaps, all verified 2026-08-01: (a) **no UI exists** — `lnd.create-psbt`/`lnd.finalize-psbt` and their `rpc-client.ts:417` wrappers are called by nothing but unit tests; (b) **no animated-QR encoder** — `qrcode`/`qrloop` are dependencies and `useAnimatedQRDecoder.ts` + `WalletScanModal.vue` already handle the *inbound* scan, but nothing encodes a PSBT for display; (c) **format interop is wrong for real signers** — the animated format in use is `qrloop` (Ledger's), while Passport/SeedSigner speak **BC-UR** (`ur:crypto-psbt`) and Coldcard Q speaks **BBQr**; BC-UR is the priority given the existing Passport-Prime-compatible SeedQR work. **WALLET-05 is meaningless until 10-05's watch-only verdict lands** — `lnd.create-psbt` funds from LND's own wallet whose keys LND holds, so until LND is watch-only against the external signer the offline device would produce a signature the node does not need
|
||||
**Depends on:** **Phase 10** — specifically `10-05`, which produces the evidence-backed verdict on whether LND can be provisioned watch-only against an external signer. WALLET-01's list of offerable wallet types is a direct consequence of that verdict; building the chooser first would mean guessing at what it can offer. `10-05` also deletes the dead Core wallet path, so this phase never has to represent it in the UI.
|
||||
**Plans:** 0 plans
|
||||
|
||||
**Already shipped — do not rebuild (verified 2026-08-01):** `LightningChannelsPanel.vue`, `SendBitcoinModal.vue`, `ReceiveBitcoinModal.vue`, `WalletScanModal.vue`, `WalletSettingsModal.vue`, `SeedRevealPanel.vue`, `LndSeedBackupPrompt.vue`, `utils/seedqr.ts`, and the channels All/Active/Pending/Closed tabs. The parity matrix (WALLET-03) must start from this inventory so the phase closes real gaps instead of re-implementing existing surfaces.
|
||||
|
||||
Plans:
|
||||
|
||||
- [ ] TBD (run /gsd-plan-phase 11 to break down)
|
||||
|
||||
### Phase 12: Bitcoin Node Settings & Core/Knots Parity
|
||||
|
||||
**Goal:** The Bitcoin node's configuration is something the operator chooses in the UI, not something baked into three shell scripts. Every option umbrelOS surfaces for its Bitcoin app is reachable, the options that exist **only** on Knots are surfaced separately from the ones Core shares, and the node's network mode is a first-class setting whose **default is Tor, not clearnet**.
|
||||
|
||||
**Requirements**: BTCSET-01 **a single source of truth for bitcoind arguments** — today they are hardcoded and duplicated across `scripts/first-boot-containers.sh:666`, `scripts/container-specs.sh:193-202` and `apps/bitcoin-knots/manifest.yml:43`, which is the exact triplication that produced the lnd-ui bridge/host defect (`HTTP 000`, found 2026-08-02); a persisted settings model must replace it, with those three call sites rendering FROM it rather than restating it; BTCSET-02 **network mode is a setting, defaulting to Tor** — Tor / clearnet / both, wired to the archy-net SOCKS listener shipped in `f0494193` via `-onion=<gw>:9050` (onion-only) or `-proxy=` (everything), with the operator's 2026-08-02 choice of onion-only as the shipped default for the "both" mode; **inbound onion is out of scope and must be stated as such in the UI** — it needs Tor's ControlPort, deliberately disabled for security, so the node can reach .onion peers but stays unlisted; BTCSET-03 **Core options surfaced** (prune, dbcache, txindex, maxconnections, maxmempool, mempoolexpiry, persistmempool, blocksonly, peerbloomfilters, blockfilterindex, and the rest of the umbrelOS set, researched from `getumbrel/umbrel-bitcoin` rather than assumed); BTCSET-04 **Knots-only options surfaced separately and gated to Knots** (`datacarrier`, `datacarriersize`, `permitbaremultisig`, `rejectparasites`, `maxscriptsize`, the spam-filter family) — offering a Knots-only flag on Core would produce a node that refuses to start, so the gate is a correctness requirement, not a cosmetic one; BTCSET-05 house-style UI verified on the `:8100` dev preview against archi-dev before any deploy, mobile included.
|
||||
|
||||
**The hazard this phase must not get wrong:** several of these options are **not freely reversible**. Turning `txindex` on forces a full reindex; turning `prune` on is destructive to block data and cannot be undone without a full resync; lowering `prune` below what is already pruned is meaningless. Any setting in that class must be labelled, confirmed, and — where it implies hours of resync on a node that is somebody's wallet backend — refused or gated rather than silently applied. Changing any option at all requires a bitcoind restart, which interrupts LND, electrs and the fedimint gateways that depend on it.
|
||||
|
||||
**Depends on:** `f0494193` (the archy-net SOCKS listener) for BTCSET-02's Tor path to exist at all. Independent of Phases 1–11 otherwise.
|
||||
|
||||
**Plans:** 0 plans
|
||||
|
||||
Plans:
|
||||
|
||||
- [ ] TBD (run /gsd-plan-phase 12 to break down)
|
||||
|
||||
### Phase 13: AIUI — Conversational Node Control & Content Surfaces
|
||||
|
||||
**Goal:** AIUI stops being a beautiful shell and becomes the node's conversational front door. Today it is embedded in `neode-ui/src/views/Chat.vue` as an iframe, its D-14 embed defaults are honoured, and its surfaces are designed — but the chat cannot *do* anything to the node, and the content views are not wired to real data. This phase makes it functional in three directions at once: (1) **ask the node in human language and have it act** — the capability Pine already demonstrates through voice becomes reachable from typed chat; (2) **talk to the system's settings** conversationally instead of hunting through screens; (3) **surface the node's content beautifully** — peer files, music, IndeeHub movies, owned/paid content — in the design AIUI already has but does not yet fill.
|
||||
|
||||
**Requirements**:
|
||||
- **AIUI-01 — human-language node control.** A typed request in AIUI chat ("restart bitcoin", "how much space is left", "who's connected") reaches a real node action and returns a real result. The Pine stack (`core/archipelago/src/api/rpc/pine_status.rs`, `.../package/pine_ha.rs`, the wyoming/Home-Assistant voice pipeline) already proves the intent→action path exists for voice; this requirement is about exposing that capability over a **permissioned tool-calling bridge** the browser can reach — not about handing the chat raw RPC. Whether a text entry point exists today or must be built is the first thing the phase research must settle.
|
||||
- **AIUI-02 — conversational settings.** The system settings surfaced across neode-ui become reachable by conversation, scoped to what the user has granted.
|
||||
- **AIUI-03 — content surfaces made real.** AIUI's designed-but-empty content views render live node data: **peer files** (the `/content`, `/content/<id>`, `/api/peer-content/<onion>/<id>` subsystem and the `content.*` RPCs), **music** (today only a MIME branch and a hardcoded `Music` folder — there is no library domain, so scope must be honest about what "music" means here), **IndeeHub movies**, and owned/paid content. Playback must respect the existing rules: audio belongs to the global bottom-bar player, never the lightbox; media streams via Range requests, never base64 blobs.
|
||||
- **AIUI-04 — sandboxed by construction, permissioned by the user.** *(see hazard below — this is the gating requirement, not a nice-to-have)*
|
||||
- **AIUI-05 — delivery and build.** AIUI is a `*-ui` app outside the signed catalog; it reaches nodes on the frontend rsync, which is how the `/assets` 404 happened (fixed in `fbec7006`). A functional AIUI needs a delivery path an operator can actually receive updates through, and the `VITE_BASE_PATH=/aiui/` build requirement pinned so a hand-built bundle cannot ship a black page.
|
||||
- **AIUI-06 — verified on device**, in the real embedded iframe on archi-dev-box, mobile included — not only in the local `dev:mock` loop.
|
||||
|
||||
**The hazard this phase must not get wrong — an LLM is now touching a node that holds keys.** AIUI runs in the browser and talks to a model. The node holds wallet keys, LND macaroons, Fedimint credentials, node identity and per-app secrets, and Phase 10 is currently hardening exactly that material. So: **secrets never reach the browser or the model context** — the existing pattern where credentials stay server-side and the client gets a scoped token (`app.filebrowser-token`) is the model to follow, not an exception to it. The chat gets an **explicit, user-granted capability scope** — it can reach only what the user has allowed, defaults closed, and the grant is visible and revocable. **Destructive and identity-touching operations are confirmed by the human**, never executed on model say-so alone; the Phase-10 hard-refuse gates and the loopback/auth boundaries must hold with AIUI on the other side of them, not be widened to accommodate it. Prompt injection is in the threat model: peer-supplied content (filenames, descriptions, chat) will enter the model's context, so tool authority must not be derivable from anything a peer controls. Note also the known leak to resolve rather than propagate: `filebrowser-client.ts` puts a JWT in the media URL query string.
|
||||
|
||||
**Depends on:** Independent of Phases 1–12 for its UI and content work. Its security model must not contradict Phase 10 (Key-Material Hardening) — coordinate rather than widen. AIUI's own source lives in a **separate repository** (`git.tx1138.com/lfg2025/AIUI`, branch `development`, cloned at `~/Projects/AIUI`), so this phase spans two repos and needs push access to both.
|
||||
|
||||
**Plans:** 0 plans
|
||||
|
||||
Plans:
|
||||
|
||||
- [ ] TBD (run /gsd-plan-phase 13 to break down)
|
||||
|
||||
+11
-70
@@ -5,16 +5,16 @@ milestone_name: milestone
|
||||
current_phase: 09
|
||||
current_phase_name: BotFights Platform Upgrade
|
||||
status: planning
|
||||
stopped_at: Phase 13 context gathered
|
||||
last_updated: "2026-08-03T12:57:51.075Z"
|
||||
stopped_at: Completed 02-11-PLAN.md — Phase 02 (ui-performance) final gap closure complete
|
||||
last_updated: "2026-07-31T15:58:19.908Z"
|
||||
last_activity: 2026-07-31
|
||||
last_activity_desc: Phase 02 complete, transitioned to Phase 09
|
||||
progress:
|
||||
total_phases: 13
|
||||
completed_phases: 2
|
||||
total_plans: 45
|
||||
completed_plans: 38
|
||||
percent: 15
|
||||
total_phases: 9
|
||||
completed_phases: 1
|
||||
total_plans: 39
|
||||
completed_plans: 18
|
||||
percent: 11
|
||||
---
|
||||
|
||||
# Project State
|
||||
@@ -33,7 +33,7 @@ Plan: Not started
|
||||
Status: Ready to plan
|
||||
Last activity: 2026-07-31 — Phase 02 complete, transitioned to Phase 09
|
||||
|
||||
Progress: [█████░░░░░] 54%
|
||||
Progress: [███████░░░] 67%
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
@@ -63,7 +63,6 @@ Progress: [█████░░░░░] 54%
|
||||
| Phase 02 P09 | 130min | 3 tasks | 3 files |
|
||||
| Phase 02 P10 | 55min | 2 tasks | 3 files |
|
||||
| Phase 02 P11 | ~150min | 3 tasks | 8 files |
|
||||
| Phase 01 P01 | n/a-continuation | 2 tasks | 1 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -74,8 +73,6 @@ Progress: [█████░░░░░] 54%
|
||||
- FED-05 added to Phase 1 (2026-07-29): inter-node Lightning channel-opening UX (share node URI, pick trusted/federated nodes by hostname, request channels with public nodes); UI tested on :8100 dev preview against archi-dev before deploy
|
||||
- FED-06 added to Phase 1 (2026-07-29): on-brand paid-tick animation — screensaver ring + EQ segments (reuse ScreensaverRing.vue compact) replacing the success burst in SendBitcoinModal.vue
|
||||
- Phase 9 added (2026-07-30): BotFights Platform Upgrade — native nostr signer login, unified AI bot-setup prompt replacing docs page, shared public match endpoint on VPS2 (all nodes see all fighters), registry/manifest update. Independent of Phases 1–8.
|
||||
- Phase 13 added (2026-08-03): AIUI — Conversational Node Control & Content Surfaces. User-directed: AIUI is embedded and styled but non-functional — chat cannot act on the node, content surfaces are unwired. Scope is (a) Pine's human-language intent→action capability reachable from typed chat, (b) conversational settings, (c) peer files / music / IndeeHub movies / node content rendered live, (d) **a user-granted capability sandbox** keeping keys, secrets and identity material away from the browser and the model — the user called this out explicitly as non-negotiable. Spans two repos: this one and `git.tx1138.com/lfg2025/AIUI` (branch `development`, clone at `~/Projects/AIUI`). Appended, not inserted — numeric position is append order, not priority.
|
||||
- Phase 10 added (2026-08-01): Key-Material Hardening — KEY-01/F-01 (Critical: unauthenticated `seed.generate`/`seed.restore` overwrite a live node's identity keys), KEY-02/F-03 (fail-open first-boot secret regeneration over a fleet-shared rootfs), KEY-03/F-13 (BIP-84 private key imported into Bitcoin Core), KEY-04 (on-node verification of the audit's UNVERIFIED checklist). Sourced from `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` (quick task 260731-upz). Appended rather than inserted to avoid renumbering a roadmap with concurrent uncommitted edits — **numeric position is append order, not priority; F-01 is Critical and live on the fleet.**
|
||||
|
||||
### Decisions
|
||||
|
||||
@@ -121,8 +118,6 @@ Decisions are logged in PROJECT.md (10 locked ADRs in the `<decisions>` block +
|
||||
- [Phase 2, gap closure 02-11]: Real cause of the six regressions was NOT compute-bound render cost (CPU profile: 86-99% idle/program, <10% JS self-time everywhere) — it was three background pollers (useFleetData.ts 60s, FipsNetworkCard.vue 15s, Web5Monitoring.vue 30s) armed in onMounted and never disarmed once their owning views joined KEEP_ALIVE_PATHS in 02-04, invisible to that audit because it grepped the top-level view files, not the child composables they delegate to. Gated to onActivated/onDeactivated, mirroring 02-04's own established pattern. Fixed: web5 275ms (was 566ms baseline/1329ms regressed), server 574ms (was 738/1239), fleet 790ms (was 330/2631)
|
||||
- [Phase 2, gap closure 02-11]: Discover (1389ms, worst remaining) has a SECOND, distinct cause: card-stagger/showStagger entrance-animation classes are baked into the DOM at first mount and never programmatically removed, so every KeepAlive detach/reattach cycle restarts the CSS animation on reactivation — replaying the full entrance cascade on every revisit. Confirmed via a diagnostic (DOM card count doubling transiently on every revisit) and an extended animation-event log. NOT fixed — blast radius spans 5+ files outside 02-11's scope (Apps.vue, Marketplace.vue, Home.vue, several Web5 sub-cards), needs its own real-device verification budget; recommended as a dedicated follow-up
|
||||
- [Phase 2, gap closure 02-11]: openwrt-gateway unmeasurable in the final re-measure (Chromium "Target crashed" cascading from an unrelated surface, cloud-folder, earlier in the same harness run) — recorded as not-measurable, not written in as data. Separately confirmed the prior baseline/after/remeasure numbers were measuring a real, substantive disconnected-state UI (OpenWrtGateway.vue's h1 is unconditional; a "No router configured" RPC error deterministically renders a real Connect-to-Router form, not a blank/error page) — the six-surface regression count is not retracted, but the numbers reflect one specific code branch (no OpenWrt device has ever been connected to archi-dev-box)
|
||||
- [Phase ?]: 01-01: record_peer_transport and update_node routed through FEDERATION_STORE_LOCK via *_inner; tombstone-write-failure test added; full-suite verify blocked by a concurrent agent's uncommitted install.rs edit (unrelated file, not fixed per scope boundary)
|
||||
- [Phase ?]: UIFIX-02: connected-nodes card height tracks row sibling via xl:flex-1 xl:basis-0 (zero-basis flex-grow) instead of flex-auto, with an xl:min-h-[40rem] floor for a short sibling (discovery disabled), tuned from an initial 20rem guess per Dorian's live feedback
|
||||
|
||||
### Pending Todos
|
||||
|
||||
@@ -164,62 +159,8 @@ Decisions are logged in PROJECT.md (10 locked ADRs in the `<decisions>` block +
|
||||
| Fleet | FLEET-02 per-app deep health assertions (~34 apps) | v2 | 2026-07-29 |
|
||||
| Fleet | FLEET-03 LUKS2 data-partition encryption | v2 | 2026-07-29 |
|
||||
|
||||
## Release SHIPPED — v1.7.120-alpha (2026-08-03)
|
||||
|
||||
**LIVE.** signature PRESENT (did:key:z6Mkkid…q7ur), both assets HTTP 200 at exactly their
|
||||
manifest byte counts, tag pushed. Two release-process traps hit and documented in memory:
|
||||
create-release.sh commits the manifest BEFORE signing (fleet refuses unsigned), and
|
||||
gitea-vps2 is the SAME server as gitea-ai (vps2 token is dead).
|
||||
|
||||
### Staging record (kept for the evidence trail)
|
||||
|
||||
Built from `4d67f56b` (release profile, 15m15s, exit 0), deployed to archi-dev-box,
|
||||
`.bak` rollback at /opt/archipelago/rollback/archipelago.bak.
|
||||
|
||||
Verified on the node: both security gates 401 unauthenticated from a non-loopback
|
||||
address; CORS origin-scoped; AIUI assets 200 AFTER the frontend rsync (the deploy that
|
||||
would have wiped a copied-file fix); mesh.lightning-peers/send-lightning-info answer
|
||||
correctly; system.stats host_secrets = per-node; served bundle sha256-matches the build
|
||||
on all three chunks; 31 containers up, none down, no restart loop.
|
||||
|
||||
NOT verified, deliberately: the new torrc SocksPort/SocksPolicy block. regenerate_torrc
|
||||
only fires on a Tor services change, so the live torrc still reads only `SocksPort 9050`.
|
||||
Gateway detection was proven in isolation (10.89.0.1 10.89.0.0/24; missing network exits
|
||||
non-zero -> stays loopback-only). The change is INERT this release since bitcoind has no
|
||||
-onion flag yet (Phase 12), so forcing a torrc regeneration would risk bouncing every
|
||||
onion service for zero benefit.
|
||||
|
||||
Frontend is a proven no-op this cycle — built chunks are byte-identical to those already
|
||||
served — so a fleet node only changes binary + the two app-UI images + nginx.
|
||||
|
||||
Remaining to ship: operator go/no-go, then `scripts/create-release.sh 1.7.120-alpha`
|
||||
(stops at the signing prompt — reads the master mnemonic interactively, operator-only),
|
||||
then publish-release-assets.sh to gitea-vps2, then push tags. CHANGELOG.md already
|
||||
carries curated v1.7.120-alpha notes (create-release.sh hard-fails without them).
|
||||
The 5x lifecycle gate was NOT run.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-08-03T12:57:50.980Z
|
||||
Stopped at: Phase 13 context gathered
|
||||
Resume file: .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-CONTEXT.md
|
||||
|
||||
Open on this thread (all recorded as broken windows, none blocking):
|
||||
|
||||
- Window 15 CLOSED 2026-08-02 20:02 — f6b5245b's reconcile path proven on archi-dev-box by
|
||||
a controlled test: stale conf installed + container restarted (probe 200, genuinely
|
||||
re-exposed), daemon started, reconcile repaired it unaided at 20:02:19 with the expected
|
||||
warn line, probe 401, conf byte-identical to the known-good. Both halves now proven on
|
||||
hardware.
|
||||
|
||||
- Windows 11/12: host-secret rotation on three fleet nodes sharing SSH host keys —
|
||||
detect-only so far; rotation is USER-GATED and deliberately not actioned.
|
||||
|
||||
- Credential rotation DECIDED AGAINST 2026-08-02 (operator): no LND macaroon rotation, no
|
||||
Bitcoin RPC password rotation — no evidence of exploitation and the vulnerability is
|
||||
being closed rather than lived with. rotate-lnd-macaroon.sh stays as a tool, exercised in
|
||||
detect mode only, never run against a node. Do not re-litigate; see
|
||||
docs/security/BITCOIN-RPC-PROXY-EXPOSURE.md.
|
||||
|
||||
- Dev-pair verification is archi-dev-box ONLY, by operator instruction 2026-08-02. Do not
|
||||
raise archy-x250-dev as a blocker again.
|
||||
Last session: 2026-07-31T13:45:00.000Z
|
||||
Stopped at: Completed 02-11-PLAN.md — Phase 02 (ui-performance) final gap closure complete
|
||||
Resume file: None
|
||||
|
||||
+4
-69
@@ -1,10 +1,10 @@
|
||||
---
|
||||
schema_version: 1
|
||||
open_count: 11
|
||||
open_count: 9
|
||||
waived_count: 0
|
||||
fixed_count: 4
|
||||
total_count: 15
|
||||
last_updated: 2026-08-03T00:06:03.112Z
|
||||
fixed_count: 1
|
||||
total_count: 10
|
||||
last_updated: 2026-07-31T10:56:26.933Z
|
||||
---
|
||||
|
||||
# Broken Windows Ledger
|
||||
@@ -25,11 +25,6 @@ last_updated: 2026-08-03T00:06:03.112Z
|
||||
| 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. | fixed | | 2026-08-02T19:07:40.522Z | 2026-08-02T23:00:30.894Z |
|
||||
| 14 | 10 | unrun-verify | core/archipelago/src/container/prod_orchestrator.rs | | LIVE EXPOSURE on archi-dev-box: archy-bitcoin-ui (systemd/Quadlet-owned, user-uninstalled marker set) still serves unauthenticated POST /bitcoin-rpc/ on 0.0.0.0:8334 with Access-Control-Allow-Origin *, reaching Bitcoin Core RPC through a credential-injecting proxy. Verified live 2026-08-02 (returned a real block height with no cookies). Code fix committed f6b5245b but NOT deployed: closing it needs the new binary on the node plus an archy-bitcoin-ui restart. archy-electrs-ui is in the same uninstalled-but-running state (static UI only, no credential proxy). Operator-gated; no node touched. | fixed | | 2026-08-02T22:44:15.215Z | 2026-08-02T23:16:04.071Z |
|
||||
| 15 | 10 | unrun-verify | core/archipelago/src/container/prod_orchestrator.rs | | The f6b5245b reconcile fix is DEPLOYED on archi-dev-box (binary installed 19:06, running) but NEVER EXERCISED on hardware: the state it repairs (uninstall marker + Quadlet-running + stale config) stopped existing here at 18:36, when a separate rebuild of bitcoin-ui rendered the fixed conf and restarted the container. So :8334 returning 401 proves a05956c4's template, NOT the reconcile path that is supposed to deliver it. archy-electrs-ui still carries the marker+running shape and could exercise it, but has no rendered config to rewrite. Needs a node that still has a stale bitcoin-ui conf, or a deliberately re-staled one. | fixed | | 2026-08-02T23:16:04.510Z | 2026-08-03T00:06:03.112Z |
|
||||
|
||||
````json
|
||||
[
|
||||
@@ -152,66 +147,6 @@ last_updated: 2026-08-03T00:06:03.112Z
|
||||
"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": "fixed",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-08-02T19:07:40.522Z",
|
||||
"resolved_at": "2026-08-02T23:00:30.894Z"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "10",
|
||||
"file": "core/archipelago/src/container/prod_orchestrator.rs",
|
||||
"line": null,
|
||||
"description": "LIVE EXPOSURE on archi-dev-box: archy-bitcoin-ui (systemd/Quadlet-owned, user-uninstalled marker set) still serves unauthenticated POST /bitcoin-rpc/ on 0.0.0.0:8334 with Access-Control-Allow-Origin *, reaching Bitcoin Core RPC through a credential-injecting proxy. Verified live 2026-08-02 (returned a real block height with no cookies). Code fix committed f6b5245b but NOT deployed: closing it needs the new binary on the node plus an archy-bitcoin-ui restart. archy-electrs-ui is in the same uninstalled-but-running state (static UI only, no credential proxy). Operator-gated; no node touched.",
|
||||
"status": "fixed",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-08-02T22:44:15.215Z",
|
||||
"resolved_at": "2026-08-02T23:16:04.071Z"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "10",
|
||||
"file": "core/archipelago/src/container/prod_orchestrator.rs",
|
||||
"line": null,
|
||||
"description": "The f6b5245b reconcile fix is DEPLOYED on archi-dev-box (binary installed 19:06, running) but NEVER EXERCISED on hardware: the state it repairs (uninstall marker + Quadlet-running + stale config) stopped existing here at 18:36, when a separate rebuild of bitcoin-ui rendered the fixed conf and restarted the container. So :8334 returning 401 proves a05956c4's template, NOT the reconcile path that is supposed to deliver it. archy-electrs-ui still carries the marker+running shape and could exercise it, but has no rendered config to rewrite. Needs a node that still has a stale bitcoin-ui conf, or a deliberately re-staled one.",
|
||||
"status": "fixed",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-08-02T23:16:04.510Z",
|
||||
"resolved_at": "2026-08-03T00:06:03.112Z"
|
||||
}
|
||||
]
|
||||
````
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 01
|
||||
subsystem: federation-storage
|
||||
tags: [federation, concurrency, tokio-mutex, atomic-write, FED-01]
|
||||
status: complete
|
||||
dependency-graph:
|
||||
requires: []
|
||||
provides:
|
||||
- FEDERATION_STORE_LOCK
|
||||
- atomic nodes.json write (temp+rename)
|
||||
affects:
|
||||
- core/archipelago/src/federation/sync.rs (caller, unchanged signatures)
|
||||
- core/archipelago/src/api/rpc/federation/handlers.rs (caller, unchanged signatures)
|
||||
tech-stack:
|
||||
added:
|
||||
- tokio::sync::Mutex (module-level static, const_new)
|
||||
patterns:
|
||||
- "Async static lock for a racy on-disk resource (mirrors update.rs's UPDATE_OP_LOCK, but .lock().await not try_lock, since federation writes must queue not reject)"
|
||||
- "Public fn = thin lock wrapper; private *_inner fn = lock-free body, so multi-step critical sections (tombstone + node-list save) don't self-deadlock on a non-reentrant Mutex"
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- core/archipelago/src/federation/storage.rs
|
||||
decisions:
|
||||
- "set_trust_level pulled forward into Task 1's commit (2f99db5e) rather than Task 2, because test_concurrent_writes_do_not_lose_updates (a Task 1 required-green test) races add_node against set_trust_level and needs it locked to pass"
|
||||
- "record_peer_transport and update_node route through *_inner under a single held guard rather than calling the public load_nodes/save_nodes, closing the same unlocked-two-call race the whole plan exists to fix"
|
||||
- "Tombstone-write failure test (test_remove_errors_when_tombstone_write_fails) forces failure by pre-creating removed-nodes.json as a directory, not by mocking I/O — no existing I/O mocking harness in this module, and this is the simplest deterministic failure induction available"
|
||||
metrics:
|
||||
duration: "resumed/completed in this session; Task 1 was previously committed 2026-07-30"
|
||||
completed: 2026-07-31
|
||||
---
|
||||
|
||||
# Phase 01 Plan 01: Serialize the federation node store and make removal stick (FED-01) Summary
|
||||
|
||||
Closed the concurrency race that let a removed federation node reappear: every load-mutate-save
|
||||
cycle in `federation/storage.rs` is now serialized behind one module-level `FEDERATION_STORE_LOCK`,
|
||||
the node-list write is atomic (temp file + same-directory rename), and a tombstone-write failure
|
||||
now surfaces as an `Err` instead of a silent no-op.
|
||||
|
||||
## What Was Built
|
||||
|
||||
- `FEDERATION_STORE_LOCK: tokio::sync::Mutex<()>` (module-level static, `const_new`), documented
|
||||
with the concrete failure it prevents (a `federation.remove-node` RPC racing the 90s auto-sync
|
||||
loop's stale pre-removal snapshot).
|
||||
- `load_nodes_inner` / `save_nodes_inner` / `tombstone_did_inner` / `untombstone_did_inner`:
|
||||
lock-free bodies so `remove_node` (tombstone + save) and `add_node` (untombstone + save) can
|
||||
each hold the guard across their whole multi-step critical section without self-deadlocking
|
||||
(`tokio::sync::Mutex` is not re-entrant).
|
||||
- Every public mutator now routes through the lock: `load_nodes`, `save_nodes`, `add_node`,
|
||||
`remove_node`, `tombstone_did`, `untombstone_did`, `set_trust_level`, `update_node`,
|
||||
`update_node_state`, `record_peer_transport` — 10 `.lock().await` call sites total.
|
||||
- `save_nodes_inner` writes atomically: serialize to `nodes.json.tmp` in the same directory,
|
||||
then `fs::rename` onto `nodes.json` — a crash mid-write can never leave a partial file for a
|
||||
concurrent reader.
|
||||
- Four new regression tests in `federation::storage::tests`:
|
||||
- `test_concurrent_writes_do_not_lose_updates` — races `add_node` against `set_trust_level`
|
||||
via real `tokio::spawn` tasks, 40 iterations; asserts both writes persist.
|
||||
- `test_remove_survives_concurrent_state_sync` — races `remove_node` against a 12-task burst
|
||||
of `update_node_state` calls, 30 iterations; asserts the removed DID stays removed and
|
||||
tombstoned.
|
||||
- `test_remove_last_node_leaves_empty_list` — removing the sole federated node returns/loads
|
||||
an empty `Vec`, not an error.
|
||||
- `test_remove_errors_when_tombstone_write_fails` — pre-creates `removed-nodes.json` as a
|
||||
directory so the tombstone write fails; asserts `remove_node` returns `Err` AND the node
|
||||
list is untouched (no half-applied removal).
|
||||
|
||||
## Task Execution Note (continuation)
|
||||
|
||||
Task 1 (the tracer: lock + atomic write + first three tests) was already committed in a prior
|
||||
session (`2f99db5e`, 2026-07-30) and pushed. This session picked up as a continuation: verified
|
||||
Task 1's commit and tests were real and green, then completed Task 2 — routing
|
||||
`record_peer_transport` and `update_node` through the lock (they still called the public,
|
||||
separately-locked `load_nodes`/`save_nodes` instead of the `*_inner` pair under one guard) and
|
||||
adding the tombstone-failure test. No SUMMARY/STATE/ROADMAP update had been done for this plan
|
||||
before this session; that gap is closed by this document.
|
||||
|
||||
## Pre-fix Failure Evidence (Task 1, historical)
|
||||
|
||||
Task 1's commit message (`2f99db5e`) records that both `test_concurrent_writes_do_not_lose_updates`
|
||||
and `test_remove_survives_concurrent_state_sync` were proven fail-first before the lock existed:
|
||||
using real `tokio::spawn` tasks (not just `tokio::join!`, since `remove_node`'s extra tombstone
|
||||
I/O hop structurally biases a simple 2-task race toward the safe ordering) reliably reproduced
|
||||
both the lost concurrent write and the removed-node-reappears bug pre-fix. This session did not
|
||||
re-run the pre-fix reproduction (the fix and lock already exist on disk); the historical evidence
|
||||
is carried forward from the Task 1 commit message since no separate SUMMARY captured it at the
|
||||
time.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd core && cargo test -p archipelago federation::storage` — **14/14 passed, 0 failed**
|
||||
(11 pre-existing + 3 new from Task 1's earlier commit + this session's
|
||||
`test_remove_errors_when_tombstone_write_fails`).
|
||||
- `cd core && cargo build -p archipelago` — succeeds, no new warnings in `federation::storage`
|
||||
(no dead-code warnings on any `*_inner` fn — confirms every mutator is wired through).
|
||||
- Acceptance-criteria greps: `FEDERATION_STORE_LOCK` count 16, `.lock().await` count 10 (≥9
|
||||
required), `tokio::sync::Mutex::const_new` count 1, `fs::rename` count 1, all four new test
|
||||
function names present, `flavor = "multi_thread"` present.
|
||||
- **`cd core && cargo test -p archipelago` (full suite) — NOT clean this session.** The
|
||||
workspace test binary fails to *compile*, but the failure is in
|
||||
`core/archipelago/src/api/rpc/package/install.rs:592` (a tuple-pattern-vs-`Result` mismatch on
|
||||
a `.await?` line marked "Not Committed Yet" by `git blame` at the time of this run) — a file
|
||||
this plan never touches, mid-edit by a different, concurrent agent session in this shared
|
||||
checkout (`git status` at commit time showed `install.rs`, `config.rs`, `dependencies.rs`,
|
||||
`secrets.rs`, and several `neode-ui` files dirty, none authored by this plan). This is the
|
||||
exact shared-tree hazard the task's hard constraints warn about, not a regression from this
|
||||
plan's change. `federation::storage`'s own test binary (scoped `cargo test -p archipelago
|
||||
federation::storage`) compiles and passes clean, and `cargo build -p archipelago` (non-test)
|
||||
also succeeds — the compile error is specific to the test-cfg path in `install.rs`, unrelated
|
||||
to `storage.rs`. Recorded honestly per the task's own instruction rather than declared green;
|
||||
re-run `cargo test -p archipelago` once the other in-flight session's `install.rs` edit lands
|
||||
or is reverted.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - blocking issue, scope-bounded] Full-suite verification blocked by a concurrent
|
||||
agent's uncommitted edit in an unrelated file**
|
||||
- **Found during:** final verification step (`cargo test -p archipelago`)
|
||||
- **Issue:** `install.rs:592` fails to compile (tuple destructure not wrapped in `Ok(...)`
|
||||
against a function that now returns `Result`), per `git blame` an uncommitted, in-progress
|
||||
edit by a different session.
|
||||
- **Fix:** None applied — out of this plan's scope per the SCOPE BOUNDARY rule (issue not caused
|
||||
by this plan's changes, and touching a file another agent is actively editing risks clobbering
|
||||
their work). Logged here and left for that session to resolve.
|
||||
- **Files modified:** none (no fix applied)
|
||||
|
||||
No other deviations — the rest of this plan (routing `record_peer_transport`/`update_node`
|
||||
through the lock, adding the failure test) executed exactly as written in Task 2's `<action>`.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `core/archipelago/src/federation/storage.rs` — FOUND (modified, contains `FEDERATION_STORE_LOCK`,
|
||||
`record_peer_transport`, `update_node`, `test_remove_errors_when_tombstone_write_fails`).
|
||||
- Commit `4b5367eb` — FOUND in `git log --oneline`.
|
||||
- Commit `2f99db5e` (Task 1, prior session) — FOUND in `git log --oneline`.
|
||||
- Push to `gitea-ai main` — confirmed (`bc9a210c..4b5367eb main -> main`).
|
||||
@@ -1,150 +0,0 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 02
|
||||
subsystem: demo
|
||||
tags: [mock-backend, rpc, parity, mesh, federation, harness]
|
||||
|
||||
requires:
|
||||
- phase: 01-federation-mesh-hardening
|
||||
provides: "mock-backend.js's per-session store (mesh.dynamic/blobs) and the attachment-parity handlers landed in c2ce71c6 (send-content-inline / send-content / fetch-content / transport-advice)"
|
||||
provides:
|
||||
- "Ten previously-missing mesh/federation RPC handlers, each mirroring and citing its daemon counterpart"
|
||||
- "Stateful chat mutations (reaction, reply, read-receipt, edit, delete, forward, channel) visible on the next mesh.messages read"
|
||||
- "scripts/mock-rpc-parity.mjs + npm run test:mock-parity — a single command that fails when the demo drifts from the UI's call surface"
|
||||
affects: [demo, mesh, federation]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Parity harnesses must match call syntax (`method: '<x>'`), not bare string literals. The same dotted names are used as resource-cache keys in the UI (`key: 'federation.nodes'`), so a literal scan reports phantom gaps that can never be closed — which would make the harness permanently red and therefore ignored."
|
||||
- "Mirror-don't-invent: every mock handler carries a comment naming the Rust file and function it mirrors, so a future reader can re-check parity instead of guessing what the demo is 'supposed' to do."
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- neode-ui/scripts/mock-rpc-parity.mjs
|
||||
modified:
|
||||
- neode-ui/mock-backend.js
|
||||
- neode-ui/package.json
|
||||
|
||||
key-decisions:
|
||||
- "The STATIC stage matches `method: '<x>'` rather than every `'mesh.*'`/`'federation.*'` string literal as the plan specified — see Deviations. This is what let KNOWN_GAPS actually reach zero."
|
||||
- "Edits and deletes are applied through a per-session `overrides` overlay keyed by sender_seq rather than by mutating a message array, because the demo's seed message list is rebuilt on every mesh.messages read. The overlay makes edit/delete observable on seeded messages too, not just ones sent this session."
|
||||
- "Delete tombstones in place (plaintext '🗑 message deleted', typed_payload { deleted: true }, message_type 'delete') because that is precisely what mesh/mod.rs apply_local_delete does — it does not remove the row. A comment says so, since 'simplifying' it to a splice would be a silent divergence."
|
||||
- "mesh.refresh and mesh.reboot-radio were deliberately LEFT as bare acknowledgements: the daemon's handlers have no message-store effect either, so giving them demo state would be divergence rather than parity. The comment records this so a later reader does not 'fix' them."
|
||||
- "mesh.peers and mesh.contacts-list now read from one shared DEMO_MESH_PEERS constant, so the two can never disagree about who is on the mesh — the daemon merges contacts over the same peer map for the same reason."
|
||||
- "An outbound/'sent' pending request was added to the demo seed. federation.cancel-request faithfully rejects anything that is not outbound-and-sent, so without such a request the demo's cancel button could only ever produce an error."
|
||||
|
||||
requirements-completed: [FED-04]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Every mesh.*/federation.* RPC the UI calls has a mock handler — the demo never answers a UI call with 'Method not found'"
|
||||
requirement: "FED-04"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "neode-ui/scripts/mock-rpc-parity.mjs STATIC stage — 57 UI-called methods, 0 unhandled"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Renaming a mesh peer on the demo persists across a contacts-list read"
|
||||
requirement: "FED-04"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "…LIVE stage — contacts-save then contacts-list round-trips the alias"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Reaction, reply, edit, delete and forward mutate demo state and are visible on the next mesh.messages read"
|
||||
requirement: "FED-04"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "…LIVE stage — six mutation assertions, each re-reading mesh.messages and checking the UI-expected shape"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "The parity check fails when a UI-called method has no mock handler"
|
||||
requirement: "FED-04"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Fail-first proof: disabling the mesh.clear-all case → exit 1, 'no mock handler for mesh.clear-all'; restored → exit 0 twice consecutively"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "The harness leaves no stray listener behind"
|
||||
requirement: "FED-04"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Two consecutive runs both exit 0 (child killed in a finally block; ephemeral port via MOCK_BACKEND_PORT)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 75min
|
||||
completed: 2026-08-01
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 1 Plan 2: Demo/Real Mesh RPC Parity (FED-04) Summary
|
||||
|
||||
**Closed the ten mesh/federation methods the demo answered with "Method not found", made the six ack-only chat mutations actually mutate demo state, and replaced "squint at the browser console" with a single command that fails when the demo drifts.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~75 min
|
||||
- **Completed:** 2026-08-01
|
||||
- **Tasks:** 3/3
|
||||
- **Files modified:** 3 (mock backend, new harness, package.json)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Ten new handlers, each citing the Rust it mirrors: `mesh.contacts-list`/`-save` (typed_messages.rs), `mesh.clear-all` (status.rs), `mesh.schedule-message`/`list-scheduled`/`cancel-scheduled` (assistant.rs + scheduler.rs), `mesh.assistant-status`/`-configure` (assistant.rs), `federation.cancel-request` and `federation.notify-did-change` (federation/handlers.rs).
|
||||
- The chat mutations are no longer bare acknowledgements. Reactions, replies and read-receipts push typed messages carrying the `{ sender_pubkey, sender_seq }` target key the UI's `reactionIndex`/`replyTargetPreview` read; edits rewrite the text and set `edited_at`; deletes tombstone in place; forwards copy to the destination peer; channel sends are channel-addressed.
|
||||
- `mesh.peers` and `mesh.contacts-list` share one `DEMO_MESH_PEERS` list, and the peer with no `pubkey_hex` is omitted from contacts exactly as the daemon's `if let Some(pk)` guard omits it.
|
||||
- `scripts/mock-rpc-parity.mjs` runs a static cross-reference then boots the mock on an ephemeral port and drives 17 live assertions. No `|| fallback` escapes anywhere in it — a failed spawn or fetch fails the run rather than producing a green run that measured nothing.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Tasks 1 + 2: contacts round-trip, harness, and the ten missing methods** — `b8979f36` (feat)
|
||||
2. **Task 3: make the message-mutation stubs mutate demo state** — committed with this SUMMARY
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### The STATIC scan matches call syntax, not every string literal — and the plan's gap list had two false positives
|
||||
|
||||
**Found during:** Task 1, building the static cross-reference
|
||||
**Issue:** The plan specifies scanning `src/**` for "every `'mesh.<verb>'` / `'federation.<verb>'` string literal". That over-reports badly: `Mesh.vue` and `Federation.vue` use the same dotted names as **resource-cache keys** (`key: 'mesh.self-did'`, `key: 'mesh.transport-status'`, `key: 'federation.nodes'`, `key: 'federation.dwn-status'`), and `stores/sync.ts` invalidates by the same strings. None are RPC methods. A literal scan reports 18 gaps where 10 exist.
|
||||
|
||||
Two of those phantoms — **`federation.nodes` and `federation.dwn-status`** — are named in the plan's own Task 2 gap list. They are not RPC methods and the daemon's dispatcher has no such routes; `Federation.vue` uses them purely as cache keys. Implementing them would have added demo-only endpoints the real node does not serve, which the plan's own transparency prohibition forbids.
|
||||
**Resolution:** The scan matches `method:\s*['"]…['"]`, i.e. an actual call site. The gap list becomes exactly the 10 real methods, `KNOWN_GAPS` is empty, and the harness is genuinely failable — proven by disabling a case and watching it exit 1.
|
||||
**Files modified:** `neode-ui/scripts/mock-rpc-parity.mjs`
|
||||
|
||||
### Added an outbound pending request to the demo seed
|
||||
|
||||
**Found during:** Task 2, implementing `federation.cancel-request`
|
||||
**Issue:** The daemon only permits cancelling an **outbound** request in **sent** state. The demo seed contained a single inbound/pending request, so a faithful handler could only ever return an error and the cancel path was unexercisable.
|
||||
**Resolution:** Added `preq-demo-out-1` (outbound, state `sent`) to `pendingPeerRequests`. The handler stays faithful — it still rejects non-outbound and non-sent requests with the daemon's own message.
|
||||
**Files modified:** `neode-ui/mock-backend.js`
|
||||
|
||||
### Edits/deletes use an overlay rather than in-place mutation
|
||||
|
||||
**Found during:** Task 3
|
||||
**Issue:** `mesh.messages` rebuilds its seeded message array on every read, so mutating "the message" in place would be lost immediately for any seeded message and only work for messages sent in this session.
|
||||
**Resolution:** A per-session `overrides` map keyed by `sender_seq`, applied over the merged list on read. This matches the daemon's matching rule (own-Sent message by `sender_seq`) and makes edit/delete observable for seeded messages too.
|
||||
**Files modified:** `neode-ui/mock-backend.js`
|
||||
|
||||
## Known Stubs
|
||||
|
||||
`mesh.assistant-status` reports `ollama_detected: false`, `claude_available: false` and an empty model list. That is the honest answer for a browser demo with no local model — the UI's "not detected" path is what a visitor should see, and fabricating a model list would be exactly the demo-only divergence this plan's prohibition forbids.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. This plan touches only the demo/mock backend and a dev-time harness; no production code path, endpoint or trust boundary is involved. The `T-01-SC` package-install threat does not apply — no dependencies were added (the harness uses only `node:` builtins and global `fetch`).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- CONFIRMED: `node --check mock-backend.js` exits 0
|
||||
- CONFIRMED: `node scripts/mock-rpc-parity.mjs` exits 0 — 17 live assertions, 0 unhandled methods
|
||||
- CONFIRMED: fail-first proof (disable a case → exit 1 naming it; restore → exit 0 twice in a row)
|
||||
- CONFIRMED: each of the ten methods appears exactly once as a `case`
|
||||
- CONFIRMED: `KNOWN_GAPS` is present and empty; `MOCK_BACKEND_PORT` honoured; `typed_messages.rs` cited twice
|
||||
- CONFIRMED: `npm run test:mock-parity` registered in package.json
|
||||
</content>
|
||||
@@ -1,170 +0,0 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 03
|
||||
subsystem: ui
|
||||
tags: [vue, css, wallet, branding, reduced-motion]
|
||||
|
||||
requires:
|
||||
- phase: 01-federation-mesh-hardening
|
||||
provides: "ScreensaverRing.vue's existing default/compact size variants and segment-pulse animation, plus the two paid-tick surfaces (SendBitcoinModal's ripple burst, WalletScanModal's plain circle)"
|
||||
provides:
|
||||
- "A third `badge` ring variant (160px / 192px, --viz-radius 80px / 96px) sized to sit inside a modal card"
|
||||
- "The site-wide prefers-reduced-motion guard ScreensaverRing was missing entirely, now covering every variant including the two pre-existing ones"
|
||||
- "Both paid-tick surfaces rendering the identical branded ring"
|
||||
affects: [wallet, web5, screensaver]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "New size variants on a shared visual component are added as their own class + mapping, never as a transform scale of an existing one — a scaled ring would also scale its segment stroke widths and blur, which is why 01-UI-SPEC.md ruled out the transform hack."
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- neode-ui/src/components/__tests__/ScreensaverRing.test.ts
|
||||
- neode-ui/src/components/__tests__/PaidTick.test.ts
|
||||
modified:
|
||||
- neode-ui/src/components/ScreensaverRing.vue
|
||||
- neode-ui/src/components/SendBitcoinModal.vue
|
||||
- neode-ui/src/components/WalletScanModal.vue
|
||||
|
||||
key-decisions:
|
||||
- "Composition is identical on both surfaces: a `position: relative` badge-sized container holding <ScreensaverRing size=\"badge\" /> with the checkmark core absolutely centred over it — so the two paid ticks cannot drift apart visually."
|
||||
- "SendBitcoinModal keeps its burst-pop/burst-draw check animation; only the three ripple `burst-ring` spans and the burst-ripple keyframes were removed, since the ring now carries the motion."
|
||||
- "WalletScanModal's core was kept at 6rem (it had no ripple to replace, just a w-24 circle) against SendBitcoinModal's 7rem, preserving each surface's existing checkmark proportion rather than homogenising them."
|
||||
- "The reduced-motion guard was added to ScreensaverRing itself rather than per call site, so the screensaver and SystemDangerZone variants gain it too — 01-UI-SPEC.md flagged its total absence as a real gap this phase should close."
|
||||
|
||||
requirements-completed: [FED-06]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "The payment-success tick in SendBitcoinModal renders the EQ-segment ring, not a CSS ripple burst"
|
||||
requirement: "FED-06"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "neode-ui/src/components/__tests__/PaidTick.test.ts#SendBitcoinModal: payment success shows exactly one badge ring, no ripple burst"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "The scan-modal paid tick renders the same ring, so the paid tick is identical on every surface"
|
||||
requirement: "FED-06"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "…#WalletScanModal: success pane shows the same badge ring and keeps its checkmark"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "A badge variant exists at 160px/192px with --viz-radius 80px/96px, alongside untouched default and compact variants"
|
||||
requirement: "FED-06"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "neode-ui/src/components/__tests__/ScreensaverRing.test.ts#maps each size variant to its own ring class; #keeps the existing variants off the badge class"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Success amount numerals and SENT / Done copy are unchanged — only the ring geometry behind the checkmark changed"
|
||||
requirement: "FED-06"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "…#SendBitcoinModal… asserts the 12,345 amount and SENT copy still render"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "SystemDangerZone and Screensaver continue to render compact and default unchanged"
|
||||
requirement: "FED-06"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git status shows Screensaver.vue and SystemDangerZone.vue untouched; viz-ring-compact occurrence count unchanged at 3"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Segment animation is disabled under prefers-reduced-motion for every size variant"
|
||||
requirement: "FED-06"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "@media (prefers-reduced-motion: reduce) { .viz-segment { animation: none; opacity: 0.55 } } — exactly one such block in ScreensaverRing.vue"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "The badge ring fits inside the modal card without clipping at either breakpoint"
|
||||
requirement: "FED-06"
|
||||
verification:
|
||||
- kind: manual_procedural
|
||||
ref: "NOT yet observed live — see Deviations. Geometric check only: 160px badge inside a max-w-2xl card is ~344px of usable width at a 390px viewport and 672px at desktop, so the ring cannot exceed the card box."
|
||||
status: deferred
|
||||
human_judgment: true
|
||||
|
||||
duration: 55min
|
||||
completed: 2026-08-01
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 1 Plan 3: On-Brand Paid Tick (FED-06) Summary
|
||||
|
||||
**Both paid-tick surfaces now render the screensaver's EQ-segment ring at a new modal-sized `badge` variant, and the shared ring component finally honours `prefers-reduced-motion` — a guard it had been missing for every variant, not just the new one.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~55 min
|
||||
- **Completed:** 2026-08-01
|
||||
- **Tasks:** 2/2
|
||||
- **Files modified:** 5 (3 components, 2 new test files)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `ScreensaverRing` gained a `badge` size (160px mobile / 192px from 768px up, with matching `--viz-radius` 80px / 96px) mapped through `sizeClass`, following the exact shape of the existing two variants. `default` and `compact` are untouched.
|
||||
- The reduced-motion guard was added to the component itself, so the screensaver and SystemDangerZone call sites gain it as a side benefit — `01-UI-SPEC.md` had flagged its complete absence.
|
||||
- `SendBitcoinModal`'s success pane swapped its three CSS ripple spans for the ring, keeping the emerald pop-in check and its draw animation; the dead `burst-ring*` rules and `burst-ripple` keyframes were removed and the reduced-motion rule updated to drop the clause referencing them.
|
||||
- `WalletScanModal`'s plain `w-24` circle became the same composition, so both paid ticks are now literally the same markup shape.
|
||||
- Two new suites (5 tests) pin the variant mapping, the segment count, and both call sites — including that the ripple elements are gone and the amount/SENT copy is unchanged.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: badge variant + SendBitcoinModal** and **Task 2: WalletScanModal** — committed together with this SUMMARY (both surfaces share the composition; splitting them would have committed a half-converted pair of paid ticks).
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `neode-ui/src/components/ScreensaverRing.vue` — `'badge'` added to the size union, `sizeClass` widened to an if-chain, `.viz-ring-badge` rule + 768px breakpoint, and the `prefers-reduced-motion` guard on `.viz-segment`.
|
||||
- `neode-ui/src/components/SendBitcoinModal.vue` — `ScreensaverRing` import; success pane restructured to `.send-success-badge` > ring + centred `.send-success-burst`/`.burst-core`; ripple markup, `.burst-ring*` rules and `burst-ripple` keyframes deleted.
|
||||
- `neode-ui/src/components/WalletScanModal.vue` — `ScreensaverRing` import; `.scan-success-badge` + `.scan-success-core` replacing the fixed circle and its `.success-ring` rule.
|
||||
- `neode-ui/src/components/__tests__/ScreensaverRing.test.ts` — 3 tests.
|
||||
- `neode-ui/src/components/__tests__/PaidTick.test.ts` — 2 tests, one per surface.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Test assertions had to target the document, not the wrapper
|
||||
|
||||
**Found during:** Task 1, first green run
|
||||
**Issue:** `wrapper.find('.burst-core')` returned nothing even though the markup rendered. `BaseModal` teleports its content to `document.body`, so the rendered nodes live outside the mounted wrapper's own root element. Component-tree queries (`findAllComponents`) still work, which is why the ring assertions passed while the DOM ones failed.
|
||||
**Resolution:** DOM assertions switched to `document.querySelector(...)`, with an `afterEach` clearing `document.body` so one modal's teleported nodes cannot answer the next test's queries.
|
||||
**Files modified:** `neode-ui/src/components/__tests__/PaidTick.test.ts`
|
||||
|
||||
### Auto-fixed: vue-tsc strict-null on indexed access
|
||||
|
||||
**Found during:** `npm run build`
|
||||
**Issue:** `rings[0].props('size')` failed `vue-tsc` under `noUncheckedIndexedAccess` (TS2532) — the same class of failure commit `4a8925f0` fixed in `usePaidItemViewer.test.ts`.
|
||||
**Resolution:** optional chaining (`rings[0]?.props('size')`), matching that commit's fix exactly.
|
||||
**Files modified:** `neode-ui/src/components/__tests__/PaidTick.test.ts`
|
||||
|
||||
### Outstanding: the live dev-preview observation Task 2 asks for was NOT made
|
||||
|
||||
**Found during:** Task 2 verification
|
||||
**Issue:** Task 2 requires confirming on a dev preview that neither ring is clipped by the modal card's scrolling container at a narrow and a desktop viewport. Reaching either success pane in a real browser requires an actually-settled payment; the built preview on `:4321` has no backend to settle one, and the running demo/dev servers in this tree belong to other sessions and are not to be disturbed.
|
||||
**Resolution:** NOT resolved. Recorded honestly as deferred rather than claimed. The automated evidence (5 tests, `npm run build` green, `viz-ring-badge` present in the built bundle) covers structure but not appearance. The plan already consolidates the blocking human sign-off for this into **plan 01-07**, which is where the visual check belongs; the geometric argument (a 160px badge inside a `max-w-2xl` card, ~344px usable at a 390px viewport) says clipping is implausible, but it is not an observation.
|
||||
**Files modified:** none
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None — presentational only. The plan's safety prohibition (the ring must never render success for an unsettled payment) is structurally satisfied: no success-gating condition was touched, only the markup inside an already-gated pane.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: `viz-ring-badge` in `ScreensaverRing.vue` (3 occurrences: mapping + rule + breakpoint)
|
||||
- FOUND: `prefers-reduced-motion` in `ScreensaverRing.vue` (exactly 1)
|
||||
- CONFIRMED: `burst-ring` count in `SendBitcoinModal.vue` == 0; `success-ring` in `WalletScanModal.vue` == 0
|
||||
- CONFIRMED: `npx vitest run` — 102 files, 822 tests, all pass
|
||||
- CONFIRMED: `npm run build` exits 0 and `grep -rq 'viz-ring-badge' web/dist/neode-ui/assets/` succeeds
|
||||
</content>
|
||||
@@ -1,154 +0,0 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 04
|
||||
subsystem: mesh
|
||||
tags: [lightning, mesh, FED-05, typed-envelope, channel-open]
|
||||
status: complete
|
||||
requires:
|
||||
- "01-CONTEXT.md's LOCKED FED-05 scope: the picker's public/other list is meshed peers that have Lightning installed"
|
||||
- "RESEARCH.md Pitfall 5 — neither datum existed; PATTERNS.md — peer capability advertisement has no analog"
|
||||
provides:
|
||||
- "lnd.getinfo carries identity_pubkey + uris (or an honest absence)"
|
||||
- "MeshMessageType::LightningInfo = 26 + LightningInfoPayload + is_valid_lightning_uri()"
|
||||
- "MeshPeer.lightning_uri, populated only by an explicit advertisement"
|
||||
- "mesh.lightning-peers (deduplicated, deterministically ordered) and mesh.send-lightning-info (target required)"
|
||||
affects:
|
||||
- "core/archipelago/src/api/rpc/lnd/info.rs"
|
||||
- "core/archipelago/src/mesh/message_types.rs"
|
||||
- "core/archipelago/src/mesh/types.rs"
|
||||
- "core/archipelago/src/mesh/listener/dispatch.rs"
|
||||
- "core/archipelago/src/mesh/listener/decode.rs (unplanned — see Deviations)"
|
||||
- "core/archipelago/src/mesh/listener/session.rs (unplanned — see Deviations)"
|
||||
- "core/archipelago/src/mesh/mod.rs (unplanned — see Deviations)"
|
||||
- "core/archipelago/src/api/rpc/mesh/typed_messages.rs"
|
||||
- "core/archipelago/src/api/rpc/dispatcher.rs"
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Extract a pure function at the seam so a contract is testable without a live service (map_identity, build_lightning_peer_list, parse_send_lightning_target) — the same shape Task 1's plan prescribed, reused for Task 3 where no handler test harness exists"
|
||||
- "Validate unauthenticated RF input BEFORE touching stored state, so a malformed message cannot destroy a good prior value"
|
||||
- "Mutation testing as evidence that tests are load-bearing, where pre-implementation failure output was not captured"
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- "core/archipelago/src/api/rpc/lnd/info.rs"
|
||||
- "core/archipelago/src/mesh/message_types.rs"
|
||||
- "core/archipelago/src/mesh/types.rs"
|
||||
- "core/archipelago/src/mesh/listener/dispatch.rs"
|
||||
- "core/archipelago/src/mesh/listener/decode.rs"
|
||||
- "core/archipelago/src/mesh/listener/session.rs"
|
||||
- "core/archipelago/src/mesh/mod.rs"
|
||||
- "core/archipelago/src/api/rpc/mesh/typed_messages.rs"
|
||||
- "core/archipelago/src/api/rpc/dispatcher.rs"
|
||||
decisions:
|
||||
- "is_valid_lightning_uri deliberately does NOT resolve or dial the host — that would turn a received advertisement into an outbound connection an attacker chose"
|
||||
- "Dedup keys on identity_pubkey_hex() (the authenticating key), lowercased, never the firmware routing key — T-01-11"
|
||||
- "last_heard compared as a parsed RFC3339 timestamp, not as a string, so a differing UTC offset cannot misorder 'newest wins'"
|
||||
- "Three unplanned files were touched: all three rebuild MeshPeer wholesale and would have silently wiped lightning_uri"
|
||||
requirements-completed: []
|
||||
metrics:
|
||||
duration: "~1h"
|
||||
completed: 2026-08-02
|
||||
tasks_completed: 3
|
||||
tasks_total: 3
|
||||
---
|
||||
|
||||
# 01-04 — the two Lightning facts the channel-open picker needs (FED-05)
|
||||
|
||||
## What shipped
|
||||
|
||||
| Task | Delivered |
|
||||
|---|---|
|
||||
| 1 | `lnd.getinfo` deserializes and returns `identity_pubkey` + `uris`; a pubkey that is not 66 hex chars maps to `None` rather than being forwarded |
|
||||
| 2 | `MeshMessageType::LightningInfo = 26`, `LightningInfoPayload { uri, alias? }`, `is_valid_lightning_uri()`, `MeshPeer.lightning_uri`, and a validating inbound dispatch arm |
|
||||
| 3 | `mesh.lightning-peers` (filtered, deduplicated, stable-ordered) and `mesh.send-lightning-info` (explicit target required), both registered in the dispatcher |
|
||||
|
||||
## The part that was not in the plan, and mattered most
|
||||
|
||||
`MeshPeer.lightning_uri` was specified as a field addition. It is, but **three separate code
|
||||
paths rebuild a `MeshPeer` wholesale**, and every one of them would have silently discarded the
|
||||
new field:
|
||||
|
||||
1. **`listener/decode.rs` — the identity-advert path.** A wholesale `peers.insert()` that
|
||||
hand-preserves only `advert_name` and `lat`/`lon`. Its own comment records why those two are
|
||||
there: Reticulum "re-emits identity adverts every announce tick", which had previously been
|
||||
renaming every federated contact once a minute. A stored Lightning URI would have been erased
|
||||
on the same schedule.
|
||||
2. **`listener/session.rs` — `refresh_contacts`.** Rebuilds the record from the radio snapshot,
|
||||
which carries no Lightning datum.
|
||||
3. **`mesh/mod.rs` — federation seeding.** Same shape.
|
||||
|
||||
All three now carry the previous value forward. Without this the feature would have appeared to
|
||||
work in tests and quietly emptied the picker on a live node — the failure mode is an absence,
|
||||
which is exactly the kind that does not announce itself.
|
||||
|
||||
## Security posture
|
||||
|
||||
- **T-01-12 (tampering):** the inbound arm validates the URI *before* the write and returns
|
||||
early on failure, so a malformed advertisement from anyone in range cannot blank out a real
|
||||
peer's entry. Asserted by test, not just by reading.
|
||||
- **T-01-13 (disclosure):** `mesh.send-lightning-info` requires an explicit `contact_id`. There
|
||||
is no broadcast form, and the test asserts that `{}`, `{"broadcast": true}` and an
|
||||
out-of-range id are all refused rather than treated as "send to everyone".
|
||||
- **T-01-11 (spoofing):** dedup keys on the authenticating key, never the firmware routing key.
|
||||
- **T-01-15 (EoP):** `server.rs` is untouched — `git diff HEAD` on it is empty, and
|
||||
`is_peer_allowed_path` still occurs 13 times. The peer allow-list was not widened.
|
||||
- Wire compatibility: discriminant 26 was unused, so a node predating this fails to decode the
|
||||
message rather than mis-decoding it as another type. The optional `alias` is
|
||||
`skip_serializing_if`, asserted to cost fewer bytes on air when absent — this rides LoRa.
|
||||
|
||||
## Evidence
|
||||
|
||||
- **`cargo test -p archipelago`: 1087 passed, 0 failed, 2 ignored.**
|
||||
- New tests: 5 (`lnd::info`), 5 (`mesh::message_types`), 6 (`lightning_peer_tests`).
|
||||
- `cargo clippy --all-targets`: **no warnings in any touched module** (two `useless_format`
|
||||
lints in the new test code were fixed, not waived).
|
||||
- Every acceptance-criteria grep met, including the negative one on `server.rs`.
|
||||
|
||||
## Deviation: TDD ordering on Task 1
|
||||
|
||||
The plan required the SUMMARY to record "the pre-implementation failing output of the fixture
|
||||
tests". Tests and implementation were written in the same pass, so **that output does not exist
|
||||
and is not reproduced here.**
|
||||
|
||||
Rather than drop the requirement's intent — *prove the tests are load-bearing* — a mutation test
|
||||
was run in its place. `is_valid_identity_pubkey` was replaced with `true`, and the suite re-run:
|
||||
|
||||
```
|
||||
3 failed:
|
||||
api::rpc::lnd::info::tests::malformed_pubkey_is_dropped_rather_than_propagated
|
||||
api::rpc::lnd::info::tests::a_malformed_pubkey_does_not_discard_the_advertised_uris
|
||||
api::rpc::lnd::info::tests::valid_pubkey_shape_matches_the_openchannel_rule
|
||||
```
|
||||
|
||||
The mutation was reverted and its absence verified. This is stronger evidence than a
|
||||
pre-implementation red run (which only shows the code is absent, not that the assertions bind),
|
||||
but it is a deviation from the ordering the plan asked for, and is recorded as one.
|
||||
|
||||
## On-node verification (archi-dev-box, deployed 23:16)
|
||||
|
||||
Deployed at commit `6b3693dc` off a clean tree and exercised over the real RPC surface:
|
||||
|
||||
| Call | Result |
|
||||
|---|---|
|
||||
| `lnd.getinfo` | `identity_pubkey: 024a5fd7de13623aeec81095…` — a real key, deserialized by the field that did not exist before this plan. `alias: "Archipelago Node"` |
|
||||
| `mesh.lightning-peers` | `{"peers":[]}` — **success with an empty array**, not an error. The FED-05 empty edge, proven on hardware rather than only in a unit test |
|
||||
| `mesh.send-lightning-info` `{}` | refused: *"Missing contact_id: … requires an explicit target and has no broadcast form"* — T-01-13's mitigation observed live |
|
||||
| `mesh.send-lightning-info` with a target | refused: *"This node has no advertised Lightning URI to share — LND may be down, or configured with no externally reachable address"* |
|
||||
|
||||
**A real finding from that last row:** this node's `lnd.getinfo` returns `uris: []`. Its LND has
|
||||
no externally reachable address configured, so it *cannot* advertise itself to a peer — the
|
||||
handler correctly refuses rather than sending an empty advertisement a peer would store as an
|
||||
undialable target. The receive and list halves work; the send half is inert on any node whose
|
||||
LND advertises no URI. **01-06 must not assume the local node can always share its own URI**,
|
||||
and the picker needs a sensible state for "you have nothing to share yet".
|
||||
|
||||
## Open / handed on
|
||||
|
||||
- **The mesh leg is still unproven end to end.** Two archy nodes with LND and a radio link are
|
||||
needed to watch a real advertisement traverse the air and land in a peer's `lightning_uri`.
|
||||
Everything above is either unit-level or single-node RPC.
|
||||
- `mesh.lightning-peers` is a data source with no consumer until **01-06** builds the picker UI
|
||||
(which `depends_on` this plan).
|
||||
- `MeshPeer.lightning_uri`'s doc mentions federation seeding as a future source; this plan does
|
||||
not implement it, and `mesh/mod.rs` only preserves an existing value.
|
||||
@@ -1,196 +0,0 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 11
|
||||
subsystem: security
|
||||
tags: [secrets, bcrypt, fedimint, credentials, shell, rust]
|
||||
|
||||
requires:
|
||||
- phase: 01-federation-mesh-hardening
|
||||
provides: "apps/fedimint-gateway/manifest.yml's existing `generated_secrets: fedimint-gateway-hash (kind: bcrypt)` block and container::secrets::ensure_one's Bcrypt arm, which already materialised a per-install credential at 0600 — this plan makes the five paths that bypassed it agree with the manifest"
|
||||
provides:
|
||||
- "One canonical per-install gateway credential accessor (container::secrets::gateway_bcrypt_hash) plus an idempotent generator (ensure_gateway_credential), used by the daemon and relied on by all four shell paths"
|
||||
- "A detection-only denylist (KNOWN_DEFAULT_GATEWAY_HASHES) that plan 01-16's migration consumes to find and rotate installs still carrying the shipped default"
|
||||
- "Fail-loud semantics on every configure path: no credential means no gateway container, never a shipped default"
|
||||
affects: [fedimint-gateway, container-secrets, deploy, first-boot, reconcile]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Credential-shaped secrets get one canonical accessor in container::secrets that returns Result, plus a denylist check inside that accessor — so refusing a known-compromised value is structurally impossible to bypass, rather than a rule each caller has to remember."
|
||||
- "Shell install paths stop generating credentials entirely and defer to the daemon's generator; where a script cannot obtain one it skips container creation with a printed reason instead of substituting anything."
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- core/archipelago/src/container/secrets.rs
|
||||
- core/archipelago/src/api/rpc/package/config.rs
|
||||
- core/archipelago/src/api/rpc/package/dependencies.rs
|
||||
- core/archipelago/src/api/rpc/package/install.rs
|
||||
- scripts/first-boot-containers.sh
|
||||
- scripts/reconcile-containers.sh
|
||||
- scripts/deploy-to-target.sh
|
||||
- scripts/deploy-tailscale.sh
|
||||
- scripts/container-specs.sh
|
||||
|
||||
key-decisions:
|
||||
- "Credential-less install failure mechanism: `get_app_config` was widened from a bare tuple to `Result<tuple>` and the fedimint-gateway arm propagates with `?`. Chosen over the plan's alternative (log + an argv the install path rejects) because it makes the failure unrepresentable rather than conventional — an install with no credential cannot reach `podman run` at all, and every other arm was mechanically wrapped in `Ok(...)` with no behaviour change. The one call site in install.rs became `.await?`."
|
||||
- "`configure_fedimint_lnd` now takes the resolved hash as a parameter instead of re-reading the secrets file with its own fallback, so there is exactly one read site and one failure point."
|
||||
- "`read_secret(name, default)` in config.rs was left intact — 6 other call sites still use its default parameter, so per the plan's explicit branch the gateway was simply routed off it rather than removing the helper."
|
||||
- "Shell paths generate nothing: first-boot, reconcile and both deploys defer bcrypt generation to the daemon. This removes the htpasswd host dependency entirely (the plan's preferred branch) and keeps bcrypt generation in exactly one implementation."
|
||||
- "deploy-tailscale.sh's gateway argv was switched from the plaintext `--password` flag to `--bcrypt-password-hash`, matching every other path; it reads the hash on the target rather than shipping the deploy host's copy of it."
|
||||
- "Legacy `fedimint-gateway-password` files are copied forward to the canonical `fedimint-gateway-hash.pw` name (0600) and never deleted or regenerated, so a node with a working unique credential keeps it. Plan 01-16 owns retirement of the legacy name."
|
||||
|
||||
requirements-completed: [FED-07]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "A fresh install derives a per-install gateway credential; two installs never share one"
|
||||
requirement: "FED-07"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "core/archipelago/src/container/secrets.rs#gateway_credential_fresh_generation_verifies_and_is_0600, #gateway_credential_is_per_install_not_per_build"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "No code path configures a gateway with a credential literal carried in this repository; a missing credential fails loudly instead of starting a defaulted gateway"
|
||||
requirement: "FED-07"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "core/archipelago/src/container/secrets.rs#gateway_credential_missing_is_a_named_error (error names the missing file)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "grep -rl 't9YjjxkiktrlYvjajB' --include='*.rs' --include='*.sh' --include='*.yml' --include='*.json' . -> exactly 1 hit, core/archipelago/src/container/secrets.rs (the denylist)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "The compromised hash exists in exactly one place, as a denylist never used to configure a container"
|
||||
requirement: "FED-07"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "core/archipelago/src/container/secrets.rs#gateway_credential_rejects_known_default — the accessor returns Err rather than handing the value back"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "One canonical secret filename across the Rust orchestrator, first-boot, reconcile and both deploy scripts"
|
||||
requirement: "FED-07"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "GATEWAY_HASH_SECRET_NAME const in secrets.rs matches manifest generated_secrets; all four scripts read/write fedimint-gateway-hash{,.pw}"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "A first boot on a host without htpasswd still yields a unique credential rather than a shipped one (the ISO path that put the default on real nodes)"
|
||||
requirement: "FED-07"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "scripts/first-boot-containers.sh — htpasswd removed entirely (grep -v '^\\s*#' | grep -c htpasswd == 0); gateway creation is skipped with a logged reason until the daemon generates the credential"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Generating the credential twice is idempotent — a reconcile tick never rotates a working gateway out from under itself"
|
||||
requirement: "FED-07"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "core/archipelago/src/container/secrets.rs#gateway_credential_is_idempotent"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D7
|
||||
description: "All five changed scripts remain syntactically valid"
|
||||
requirement: "FED-07"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "bash -n clean on first-boot-containers.sh, reconcile-containers.sh, deploy-to-target.sh, deploy-tailscale.sh, container-specs.sh"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 135min
|
||||
completed: 2026-07-31
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 1 Plan 11: Remove Every Shipped Fedimint Gateway Credential (FED-07) Summary
|
||||
|
||||
**Deleted the six sites that configured a Lightning gateway with a bcrypt hash (and, on one path, a plaintext password) committed to this repository, replaced them with one canonical per-install accessor that refuses to return the known-compromised value, and made every install path fail loudly — or skip the container with a printed reason — rather than fall back to anything shipped.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~135 min across two sessions (see Deviations — the first session's executor was killed mid-Task-2 by an SSH disconnect)
|
||||
- **Completed:** 2026-07-31
|
||||
- **Tasks:** 2/2
|
||||
- **Files modified:** 9 (4 Rust, 5 shell)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `container::secrets` gained the canonical trio: `GATEWAY_HASH_SECRET_NAME` (matching the manifest), `ensure_gateway_credential` (idempotent, delegates to `ensure_one`'s existing bcrypt arm so there is one generation implementation), and `gateway_bcrypt_hash` (returns `Err` naming the file when missing/empty/unreadable, and `Err` when the stored value is denylisted).
|
||||
- `KNOWN_DEFAULT_GATEWAY_HASHES` holds the compromised hash as detection-only data. It is now the single occurrence of that value in the entire tree, and the only function that reads it uses it to *refuse*.
|
||||
- Five fallback sites deleted: `config.rs`'s `read_secret(..., "$2y$10$t9Yjj…")`, `dependencies.rs`'s `unwrap_or_else` onto the same literal, and the generate-or-default blocks in `first-boot-containers.sh`, `reconcile-containers.sh` and `deploy-to-target.sh`. A sixth — `deploy-tailscale.sh`'s plaintext `|| echo 'archipelago'` — is gone too, along with the `--password` argv it fed.
|
||||
- The gateway is now unconfigurable without a per-install credential: in Rust the error propagates out of `get_app_config` via `?`; in shell each path skips container creation and prints why.
|
||||
- Naming unified on the manifest's `fedimint-gateway-hash` / `.pw`, with legacy `fedimint-gateway-password` values copied forward rather than regenerated, so no node with a working unique credential loses it.
|
||||
- `container-specs.sh` gained a `SPEC_SKIP_REASON` mechanism so a missing credential produces a skipped spec with a message instead of an empty `--bcrypt-password-hash` argument.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: End-to-end — a gateway spec that cannot be built without a per-install credential** — Rust accessor, denylist, 5 tests, and the three call-site changes.
|
||||
2. **Task 2: The shell install paths generate their own credential instead of shipping one** — all five scripts.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `core/archipelago/src/container/secrets.rs` — canonical name const, detection-only denylist, `ensure_gateway_credential`, `gateway_bcrypt_hash`, and 5 new tests (fresh generation + 0600 + plaintext verifies against hash, idempotence, named missing-secret error, denylist rejection, per-install uniqueness).
|
||||
- `core/archipelago/src/api/rpc/package/config.rs` — `get_app_config` now returns `Result<...>`; the `fedimint-gateway` arm calls `ensure_gateway_credential` then `gateway_bcrypt_hash` and propagates. No ports/volumes/network/health/other-argv changes.
|
||||
- `core/archipelago/src/api/rpc/package/dependencies.rs` — `configure_fedimint_lnd` takes `fedi_hash: &str`; its own read-with-fallback deleted.
|
||||
- `core/archipelago/src/api/rpc/package/install.rs` — `.await?` on `get_app_config`; resolves the hash once and passes it into `configure_fedimint_lnd`.
|
||||
- `scripts/first-boot-containers.sh` — htpasswd dependency and generation removed; legacy migration copy; gateway creation skipped with a logged reason when no credential exists.
|
||||
- `scripts/reconcile-containers.sh` — same pattern.
|
||||
- `scripts/deploy-to-target.sh` — remote generation block replaced with legacy-migration-only copy; the empty-`FEDI_HASH` literal substitution replaced with a printed NOTE; gateway creation wrapped in an `if [ -n '$FEDI_HASH' ]` guard with an else-branch explaining the skip. The dead `GW_COMMON` variable (its only definition, referenced nowhere) was removed with the literal it carried.
|
||||
- `scripts/deploy-tailscale.sh` — same generation/fallback removal; container-creation block now reads the target's `fedimint-gateway-hash`, skips with a reason when empty, and uses `--bcrypt-password-hash` in both the lnd and ldk branches.
|
||||
- `scripts/container-specs.sh` — `SPEC_SKIP_REASON` empty-value guard; secret name and `$`-escaping left untouched as instructed.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See `key-decisions` above. The load-bearing one is the `Result` widening of `get_app_config`: the plan left the mechanism open and required the choice be recorded. `Result` was chosen because the alternative (an argv the install path rejects) leaves a defaulted gateway one refactor away from being reachable again, whereas a `?` makes it a compile-time impossibility.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Process deviation: executor killed mid-task by an SSH disconnect; plan completed in a second session
|
||||
|
||||
**Found during:** Task 2, while editing `scripts/deploy-to-target.sh`
|
||||
**Issue:** The orchestrating session and all its background agents died when the operator's SSH connection dropped (broken pipe). The 01-11 executor's transcript ends on an unanswered `tool_use`. Task 1 was complete and correct; Task 2 was three scripts done, one left **syntactically broken**, and one never started.
|
||||
**Resolution:** A follow-on session verified Task 1 against the plan's acceptance criteria (all pass), repaired `deploy-to-target.sh`, and implemented `deploy-tailscale.sh` from scratch following the pattern the dead executor had established in its three finished scripts.
|
||||
**Files modified:** `scripts/deploy-to-target.sh`, `scripts/deploy-tailscale.sh`
|
||||
|
||||
### Auto-fixed Issue: apostrophe inside a single-quoted ssh heredoc broke deploy-to-target.sh
|
||||
|
||||
**Found during:** Task 2 verification (`bash -n` failed at line 1953, ~700 lines below the actual edit)
|
||||
**Issue:** The dead executor's new comment read `…is generated by the daemon's` — inside `ssh "$TARGET_HOST" '…'`, that apostrophe **terminates the single-quoted remote command string**, so the remainder of the block was reparsed as local shell and the error surfaced at an unrelated `fi` far below. This is a live hazard for anyone editing these deploy scripts: prose comments inside single-quoted ssh blocks must not contain apostrophes.
|
||||
**Resolution:** Reworded to `…is generated by the daemon / via container::secrets::ensure_gateway_credential`. `bash -n` clean. The same rule was applied to all new comments added to `deploy-tailscale.sh`.
|
||||
**Files modified:** `scripts/deploy-to-target.sh`
|
||||
|
||||
## Planner Assumptions — resolved
|
||||
|
||||
- **Whether `get_app_config` could return `Result` without a wide refactor:** yes. Every arm wrapped in a single `Ok(match …)`, one call site updated. No behaviour change to any other app.
|
||||
- **Whether the compromised hash's plaintext is publicly recoverable: UNRESOLVED — and it should not block 01-16.** No bcrypt implementation is available on this box outside the Rust crate (no `python3-bcrypt`, no `passlib`, no node `bcryptjs`, no `htpasswd`), so no candidate list was tested. **Strong lead for 01-16:** `deploy-tailscale.sh`'s plaintext fallback for the very same credential was the literal string `archipelago`, so that is the first candidate to check. Until someone verifies it, 01-16's operator message should assume the plaintext IS recoverable — the value shipped in a public repo either way, so the rotation is mandatory regardless.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
- **T-01-50 / T-01-51 (critical, elevation + spoofing) — mitigated.** No configure path can produce the shipped credential; the accessor refuses it even on a node that already carries it.
|
||||
- **T-01-52 (information disclosure) — mitigated.** Generation reuses `write_secret` (atomic, 0600); no script logs the value, only that generation was deferred or a container skipped.
|
||||
- **T-01-53 (DoS by removing the fallback) — mitigated.** No path hard-fails a whole install: the gateway container is skipped with a printed reason and created on a later reconcile once the daemon has generated the credential.
|
||||
- **Residual, owned by 01-16:** nodes already running a gateway configured with the shipped default keep running it. This plan makes them detectable and unre-configurable with that value; it does not rotate them.
|
||||
- **T-01-SC:** no packages added.
|
||||
|
||||
## Self-Check
|
||||
|
||||
- FOUND: `KNOWN_DEFAULT_GATEWAY_HASHES` in `core/archipelago/src/container/secrets.rs` (3 references: doc-linked definition + accessor use + test)
|
||||
- FOUND: `gateway_bcrypt_hash` used in `config.rs` and `install.rs`
|
||||
- CONFIRMED: `grep -rl 't9YjjxkiktrlYvjajB'` across `*.rs *.sh *.yml *.json` returns exactly one file — the denylist
|
||||
- CONFIRMED: `grep -c -- '--password ' scripts/deploy-tailscale.sh` == 0; `grep -c "|| echo 'archipelago'"` == 0
|
||||
- CONFIRMED: `bash -n` clean on all five scripts
|
||||
- CONFIRMED: `cargo build -p archipelago` exits 0 (3 pre-existing warnings, none from this plan)
|
||||
- CONFIRMED: `cargo test -p archipelago` — 999 passed, 2 failed. Both failures are `container::boot_reconciler::tests::{second_pass_fires_after_interval, shutdown_terminates_loop}`, wall-clock-timed loop tests (50ms tick, 5s timeout) that ran while the box was executing two cargo builds and the full vitest suite concurrently. Re-run in isolation: `cargo test -p archipelago boot_reconciler` → **4 passed, 0 failed in 0.29s**. `boot_reconciler.rs` contains no `gateway`/`secrets` references and is untouched by this plan's diff — load flakiness, not a regression.
|
||||
- CONFIRMED: `npx vitest run` (full frontend suite) exits 0
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -1,172 +0,0 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 12
|
||||
subsystem: ui
|
||||
tags: [vue, tailwind, flexbox, css-grid, web5, scroll]
|
||||
|
||||
requires:
|
||||
- phase: 01-federation-mesh-hardening
|
||||
provides: "Web5.vue's existing xl:grid-cols-2 row layout and Web5ConnectedNodes.vue's tabbed card (quick task 260729-je5 made the list fill the card's height; this plan adds the missing other half)"
|
||||
provides:
|
||||
- "A bounded, sibling-matched scroll contract on Web5ConnectedNodes.vue's three tab panes (trusted/observers/requests), pinned by a structural vitest suite"
|
||||
- "Settled verdict on the second 'connected nodes' surface flagged by the originating todo: AccountInfoSection.vue's hits are changelog prose describing the feature, not a second scrolling list — no fix needed there"
|
||||
affects: [web5, federation-mesh-hardening]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Sibling-matched equal-height row + inner-scroll: give the growing child `xl:flex-1 xl:basis-0` (zero flex-basis so it contributes no intrinsic height) instead of `flex-auto`/no-basis, so a CSS Grid row's default `align-items: stretch` sizes the card by its sibling, and the pane's own overflow-y-auto scrolls inside the leftover height. Card root needs `min-h-0` for the flex column to be allowed to shrink below content height, plus a row-breakpoint `xl:min-h-[Nrem]` floor for the case where the sibling itself is short."
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts
|
||||
modified:
|
||||
- neode-ui/src/views/web5/Web5ConnectedNodes.vue
|
||||
|
||||
key-decisions:
|
||||
- "Row-breakpoint height floor set to xl:min-h-[40rem] (not the planner's initial 20rem guess) — confirmed via Dorian's live-browser check: with node discovery disabled, Web5NodeVisibility (the row sibling) renders short, the floor takes over, and 20rem looked stunted; he asked for double, i.e. 40rem."
|
||||
- "Settings 'connected nodes' surface (AccountInfoSection.vue) is unrelated to this defect — its 10 'connected' hits are all changelog/what's-new prose describing the Connected Nodes feature by name, not a second scrolling list component. No code change made there."
|
||||
- "Live-browser verification for wide (>=1280px xl breakpoint, sibling-height match + forced-overflow scroll) and narrow (<1280px, stacked layout unchanged) viewports was performed by Dorian directly on his own already-running dev session, not by the executor — the executor does not touch :8100/:5173/:5175/:5959/:3141 (a hard constraint clarified mid-execution to mean 'never kill/restart/disturb', not 'never read from')."
|
||||
|
||||
requirements-completed: [UIFIX-02]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "The connected-nodes card's height at the xl (row) breakpoint tracks its Web5NodeVisibility sibling via CSS Grid stretch + a zero-basis flex child, instead of growing to fit every row"
|
||||
requirement: "UIFIX-02"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts#gives all three tab panes the bounded, sibling-matched scroll contract"
|
||||
status: pass
|
||||
- kind: manual_procedural
|
||||
ref: "Dorian's direct visual check on his running :8100 session, wide viewport — reported 'we are good' for height match, scroll, and stacked layout"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "The inner list scrolls within the matched height rather than growing scroll-free, for all three tabs (trusted/observers/requests)"
|
||||
requirement: "UIFIX-02"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts#gives all three tab panes the bounded, sibling-matched scroll contract"
|
||||
status: pass
|
||||
- kind: manual_procedural
|
||||
ref: "Dorian's direct visual check — scroll behaviour confirmed correct"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "A short sibling (discovery disabled) still leaves a usable list height via the xl:min-h-[40rem] floor, rather than collapsing to header+tabs"
|
||||
requirement: "UIFIX-02"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts#gives the card root a min-h-0 flex column with a row-breakpoint height floor"
|
||||
status: pass
|
||||
- kind: manual_procedural
|
||||
ref: "Dorian's direct feedback ('too short when discovery is disabled, should be twice as tall') drove the 20rem -> 40rem correction, applied and re-verified"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "The stacked (below-xl) single-column layout is byte-identical to before: same capped max-h-72 height, same scroll"
|
||||
requirement: "UIFIX-02"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts (max-h-72 asserted on all three panes; git diff confirms Web5.vue untouched and no script-block change)"
|
||||
status: pass
|
||||
- kind: manual_procedural
|
||||
ref: "Dorian's direct visual check, narrow viewport — stacked layout confirmed unchanged"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "The zero-node/empty-list edge case still renders the existing empty/loading row and the pane stays in the tree (does not collapse)"
|
||||
requirement: "UIFIX-02"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts#still renders the empty-state row for each pane when the node list is empty"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "The second 'connected nodes' surface flagged by the originating todo (settings AccountInfoSection.vue) is investigated and given an explicit verdict rather than silently skipped"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "grep -n -i connected neode-ui/src/views/settings/AccountInfoSection.vue — all 10 hits are changelog prose, not a scrolling list"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 105min
|
||||
completed: 2026-07-31
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 1 Plan 12: Connected-Nodes Row-Matched Scroll (UIFIX-02) Summary
|
||||
|
||||
**Gave the connected-nodes card's three tab panes a zero-basis flex-grow contract so the card's height at the row breakpoint comes from its `Web5NodeVisibility` sibling via Grid's default stretch, with the list scrolling inside that height instead of growing to fit every row — floor tuned to 40rem per Dorian's live-browser feedback.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~105 min (across two work sessions, separated by a live-browser verification checkpoint)
|
||||
- **Completed:** 2026-07-31
|
||||
- **Tasks:** 2/2 (Task 2's dev-preview portion completed by Dorian directly, not the executor — see Deviations)
|
||||
- **Files modified:** 2 (1 component, 1 new test file)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- All three tab panes (trusted/observers/requests) in `Web5ConnectedNodes.vue` now carry `min-h-0 overflow-y-auto max-h-72 xl:flex-1 xl:basis-0 xl:max-h-none` — below the `xl` breakpoint nothing changed (same cap, same scroll); at `xl` the pane contributes zero intrinsic height, so the grid row is sized by the sibling alone and the pane scrolls inside the leftover height.
|
||||
- Card root gained `min-h-0 xl:min-h-[40rem]` so a short sibling (e.g. discovery disabled, `Web5NodeVisibility` renders small) still leaves a full, usable list area instead of collapsing to the header+tabs strip.
|
||||
- New structural test `Web5ConnectedNodesScroll.test.ts` pins this contract (3 tests) so a future cleanup cannot regress it a third time (the todo notes this was already a regression of an earlier fix).
|
||||
- Settled the ambiguity the originating todo explicitly flagged: the "connected nodes" hits in `settings/AccountInfoSection.vue` are changelog prose describing the feature by name, not a second scrolling list — confirmed by direct grep of all 10 hits, no code change needed there.
|
||||
- Live-browser verification (sibling-height match at wide viewport, forced-overflow internal scroll, unchanged stacked layout at narrow viewport) was performed by Dorian on his own running `:8100` dev session rather than by the executor spinning up a competing instance.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically, across two rounds (the second correcting the height floor per live feedback):
|
||||
|
||||
1. **Task 1: End-to-end — the trusted pane scrolls at a sibling-matched height** - `ceafbcb5` (fix) — added the scroll contract classes + wrote the pinning test (initial floor: `xl:min-h-[20rem]`, the planner's flagged judgement call)
|
||||
2. **Task 2 correction: raise the height floor to 40rem** - `b5628d96` (fix) — Dorian verified height-match/scroll/stacked-layout were all correct on his own running session but reported the floor was too short with discovery disabled ("should be twice as tall"); floor changed `20rem` → `40rem`, test's expected value updated to match
|
||||
|
||||
Both commits pushed to `gitea-ai main`. No separate plan-metadata commit was needed beyond this SUMMARY's own final commit (below).
|
||||
|
||||
_Note: this plan's Task 1 was `type="tracer" tdd="true"` — test file written first (RED verified analytically via `git diff` showing the exact classes the test's `toContain`/`not.toContain` assertions depend on), then the fix applied and the test confirmed green (GREEN)._
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `neode-ui/src/views/web5/Web5ConnectedNodes.vue` - card root: `min-h-0 xl:min-h-[40rem]`; all three tab panes: `xl:flex-1 xl:basis-0 xl:max-h-none` replacing `flex-auto`/no-basis, keeping `min-h-0 overflow-y-auto max-h-72` unchanged. No script-block, header, tab-strip, row-markup, or `Web5.vue` changes (verified via `git diff --stat` showing zero change to `Web5.vue`, and zero `<script` diff lines).
|
||||
- `neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts` - new: 3 tests pinning (1) the bounded scroll contract classes on all three panes, (2) the card root's min-h-0/flex-col/`xl:min-h-[40rem]` floor, (3) the empty-state row still rendering for each pane.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Height floor: `xl:min-h-[40rem]` (final, confirmed value) — see key-decisions above for the full reasoning trail (planner's 20rem guess → Dorian's live feedback → 40rem).
|
||||
- Settings `AccountInfoSection.vue` "connected nodes" surface: confirmed unrelated (changelog prose), left untouched.
|
||||
- Live-browser verification delegated to Dorian's own already-running dev session rather than the executor starting a competing instance — see Deviations.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
None beyond the planned scope — the height/overflow class changes and test file are exactly what the plan specified (with the floor value corrected per live feedback, which the plan itself flagged as an open judgement call to be settled at this exact step).
|
||||
|
||||
### Process deviation: live-browser verification performed by the user, not the executor
|
||||
|
||||
**Found during:** Task 2, precondition step ("local dev preview can be started... on :8100")
|
||||
**Issue:** The session's hard constraints (as initially worded) listed `:8100`/`:5173`/`:5175`/`:5959`/`:3141` as ports the executor must never touch. All of `:8100`, `:5959`, `:5173`, `:5175` were confirmed (via `ss -ltnp`) to already be live processes belonging to running `vite`/mock-backend sessions in this same working tree, so starting a new instance risked colliding with a live session. The executor halted and returned a `checkpoint:human-verify` rather than starting anything or reading from those ports.
|
||||
**Resolution:** Dorian checked his own already-running `:8100` session directly (a live `vite` dev server on this exact `neode-ui` checkout, which had already hot-reloaded the template change via HMR) and reported: height match, internal scroll, and stacked layout all correct ("we are good"), with the floor value needing to double. The coordinator also clarified mid-execution that the constraint's intent was "never kill/restart/disturb," not "never read from" — read-only observation of an already-running dev server is fine going forward.
|
||||
**Files modified:** none as a result of this deviation itself (informational); the floor-value correction it produced is `neode-ui/src/views/web5/Web5ConnectedNodes.vue` (commit `b5628d96`).
|
||||
**Commit:** N/A (process note, not a code fix)
|
||||
|
||||
### Auth gates
|
||||
|
||||
None encountered.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — no hardcoded empty values, placeholder text, or unwired data introduced by this plan.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None — this plan is a template-class-only change plus a new test file; no new endpoints, auth paths, or trust-boundary surface introduced.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: neode-ui/src/views/web5/Web5ConnectedNodes.vue (modified, confirmed via git diff)
|
||||
- FOUND: neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts (created)
|
||||
- FOUND commit ceafbcb5 (git log --oneline --all | grep ceafbcb5)
|
||||
- FOUND commit b5628d96 (git log --oneline --all | grep b5628d96)
|
||||
- CONFIRMED: `neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts` byte-identical (git diff --stat shows no change) and green in every `npx vitest run` pass during this plan.
|
||||
@@ -1,159 +0,0 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 13
|
||||
subsystem: ui
|
||||
tags: [vue, onboarding, scroll, accessibility, reduced-motion]
|
||||
|
||||
requires:
|
||||
- phase: 01-federation-mesh-hardening
|
||||
provides: "OnboardingSeedGenerate.vue's existing flex-column step layout — a scrolling content region between a fixed header and a fixed footer whose Continue button stays pinned and disabled until `confirmed` is ticked"
|
||||
provides:
|
||||
- "An overflow-derived bottom scroll cue on the seed step that appears only when the confirmation tickbox is actually below the fold, scrolls it into view when activated, and disappears once it is visible"
|
||||
- "A vitest suite pinning the cue's show/hide contract, including the no-overflow no-render case"
|
||||
affects: [onboarding, web5]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Derive an affordance's visibility from a real measurement (scrollHeight vs clientHeight, plus getBoundingClientRect comparison of target vs container bottom) rather than a viewport-height guess — the cue is then structurally incapable of appearing when there is nothing below."
|
||||
- "Measure with viewport-relative rects, not offsetTop/offsetHeight: offsetTop is relative to the nearest *positioned* ancestor, which here is the outer card (it carries `relative` for z-index stacking), not the scroll container."
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts
|
||||
modified:
|
||||
- neode-ui/src/views/OnboardingSeedGenerate.vue
|
||||
|
||||
key-decisions:
|
||||
- "The cue is a sticky-bottom element inside the scroll region (gradient scrim + a glass pill reading 'One more step below' with a bobbing chevron), not a fixed overlay — so it rides the scroll container and cannot cover the fixed footer."
|
||||
- "Wayfinding only: `revealConfirm` calls scrollIntoView on the tickbox label and nothing else. It never sets `confirmed`, never focuses or enables Continue, and never auto-ticks on scroll — pinned by a dedicated test."
|
||||
- "Re-measure on scroll, window resize, and a ResizeObserver on the content wrapper, plus watches on `words` and `loading` — the word grid arrives asynchronously and changes height when the words/QR tabs switch, either of which can flip the region into overflow."
|
||||
- "onMounted was restructured so listener setup runs on both paths (restored-from-sessionStorage and freshly generated). The previous early `return` on the restore path would otherwise have skipped setup entirely for a user navigating back."
|
||||
|
||||
requirements-completed: [UIFIX-03]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "On a short viewport the cue appears, telling the user there is more below"
|
||||
requirement: "UIFIX-03"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts#renders the cue when there is overflow and the tickbox is below the fold"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Activating the cue brings the tickbox into view in one action, without touching confirmation state"
|
||||
requirement: "UIFIX-03"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "…#activating the cue scrolls the tickbox into view and never touches confirmed"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "The cue disappears once the tickbox is visible and does not reappear while it stays visible"
|
||||
requirement: "UIFIX-03"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "…#removes the cue once scrolling brings the tickbox into view"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "On a tall viewport the cue never renders — no element, no reserved space, no layout shift"
|
||||
requirement: "UIFIX-03"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "…#renders no cue when the scroll region reports no overflow"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "The cue is absent while the seed is generating (no tickbox to point at yet) and once the box is ticked"
|
||||
requirement: "UIFIX-03"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "…#never shows the cue while loading, regardless of overflow; …#removes the cue once the tickbox is ticked"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "The cue's motion is disabled under prefers-reduced-motion, per the site-wide convention"
|
||||
requirement: "UIFIX-03"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "@media (prefers-reduced-motion: reduce) { .onb-cue-chevron { animation: none; } } in OnboardingSeedGenerate.vue's scoped style"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 70min
|
||||
completed: 2026-08-01
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 1 Plan 13: On-Brand Scroll Cue for the Onboarding Tickbox (UIFIX-03) Summary
|
||||
|
||||
**Added a measurement-driven scroll cue to the seed step so the confirmation tickbox is never silently below the fold on a short screen — and made it structurally impossible for that cue to appear on a screen tall enough not to need it.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~70 min (across two sessions — see Deviations)
|
||||
- **Completed:** 2026-08-01
|
||||
- **Tasks:** 2/2
|
||||
- **Files modified:** 2 (1 view, 1 new test file)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- A sticky-bottom cue inside the seed step's scroll region: a gradient scrim with a glass pill ("One more step below") and a bobbing chevron, styled to the house dark/glass language rather than a bolted-on arrow.
|
||||
- Visibility is derived from real geometry — `scrollHeight > clientHeight` for overflow, then a `getBoundingClientRect()` comparison of the tickbox's bottom against the scroll container's bottom. On a tall viewport the element does not render at all, so tall screens are byte-identical to before.
|
||||
- Activating the cue smooth-scrolls the tickbox into view and does nothing else; a test asserts `confirmed` is untouched, so the affordance can never become a way to skip the confirmation.
|
||||
- The cue also stays hidden while the seed is generating and after the box is ticked, and its chevron animation is disabled under `prefers-reduced-motion`.
|
||||
- Listener/observer setup was moved onto both `onMounted` paths — previously the sessionStorage-restore path returned early, which would have left a user navigating back to this step with no cue at all.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `neode-ui/src/views/OnboardingSeedGenerate.vue` — refs on the scroll container, content wrapper and tickbox label; `updateScrollCue()` measurement; `revealConfirm()`; scroll/resize/ResizeObserver wiring with matching teardown in `onUnmounted`; the cue markup and its scoped CSS (fade transition, chevron bob, reduced-motion guard).
|
||||
- `neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts` — 6 tests pinning the full contract.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Process deviation: executor killed mid-verification by an SSH disconnect
|
||||
|
||||
**Found during:** post-implementation verification
|
||||
**Issue:** The orchestrating session and its agents died when the operator's SSH connection dropped. This plan's implementation and test file were complete and on disk but uncommitted, and no SUMMARY had been written.
|
||||
**Resolution:** A follow-on session re-ran the suite (6/6 green), confirmed the full frontend suite was green, wrote this SUMMARY, and committed.
|
||||
**Files modified:** none beyond the original work
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None — a presentational affordance with no new endpoint, no auth surface, and no state mutation. The one safety-relevant property (that it cannot set confirmation state) is asserted by test.
|
||||
|
||||
## Live-browser verification (added post-commit)
|
||||
|
||||
Run against the built bundle on the `:4321` preview via Playwright, at the three viewports the
|
||||
original executor's script targeted:
|
||||
|
||||
| Viewport | Cue shown | After activating it |
|
||||
|---|---|---|
|
||||
| 1280×620 (short) | yes | tickbox in view, `checked=false`, cue gone |
|
||||
| 1440×1000 (tall) | **no** — element never renders | n/a |
|
||||
| 390×740 (narrow) | yes | tickbox in view, `checked=false`, cue gone |
|
||||
|
||||
A geometry probe confirmed the mechanism rather than just the outcome: after activation the scroll
|
||||
container sits at its maximum offset (`scrollTop == scrollHeight - clientHeight`) with the tickbox's
|
||||
bottom above the container's (442 vs 454 at 1280×620; 622 vs 634 at 390×740), which is exactly the
|
||||
condition the cue's visibility is derived from.
|
||||
|
||||
One caveat worth recording: the *first* run reported the cue still visible after activation at
|
||||
1280×620. It did not reproduce on any subsequent run, and the geometry probe showed the cue absent at
|
||||
600 ms, 1500 ms and 3000 ms after the click at both viewports. The first run was the cold load
|
||||
immediately after a rebuild, so it is almost certainly smooth-scroll settling — but it is written
|
||||
down rather than discarded, because it is the one observation that contradicts the contract.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: `neode-ui/src/views/OnboardingSeedGenerate.vue` (modified)
|
||||
- FOUND: `neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts` (created, 6 tests)
|
||||
- CONFIRMED: `npx vitest run` full frontend suite green (102 files, 822 tests)
|
||||
- CONFIRMED: live browser, 3 viewports (table above)
|
||||
</content>
|
||||
@@ -1,188 +0,0 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 14
|
||||
subsystem: ui
|
||||
tags: [vue, composable, lightbox, loading-state, vitest]
|
||||
|
||||
requires:
|
||||
- phase: 02-ui-performance
|
||||
provides: "PERF-02 no-spinner-on-cached-revisit guarantee, useCachedResource/KeepAlive infrastructure, and the split-signal-cost root-cause findings for the eight named slow-open surfaces"
|
||||
provides:
|
||||
- "usePaidItemViewer() composable — fetch/decode/route/loading/error state for a purchased item, replacing the inline window.open() path"
|
||||
- "A second MediaLightbox instance in Cloud.vue, fed synthetic FileBrowserItems for purchased images/video"
|
||||
- "Row-level 'Opening…' loading affordance and non-interactive-while-loading guard on Paid Files rows"
|
||||
- "Evidence-backed per-surface verdict for every surface phase 2's findings named as a slow-open regression"
|
||||
affects: [cloud, media-viewer]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Fetch-before-show: the composable fetches+decodes the purchased blob BEFORE opening the lightbox, so the lightbox's own fetchBlobUrl/streamUrl props resolve instantly from an internal map — the row's own 'opening' spinner covers the real (possibly-slow) network wait, not the lightbox's own (now-instant) internal loading flash."
|
||||
- "One key, one owner: opening/inFlight/urlByPath are all keyed by the same paidItemKey(onion+content_id) formula the row's v-for :key already uses, so the row's spinner condition and the composable's dedup guard can never drift apart."
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- neode-ui/src/composables/usePaidItemViewer.ts
|
||||
- neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts
|
||||
modified:
|
||||
- neode-ui/src/views/Cloud.vue
|
||||
|
||||
key-decisions:
|
||||
- "Documents (and any other mime with no in-app viewer) keep the browser-tab fallback unchanged — this is a scope boundary from the plan, not an omission."
|
||||
- "All eight surfaces phase 2 flagged as slow-opening (Discover, Server, Web5, Fleet, AppDetails, OpenWrtGateway, MarketplaceAppDetails, Wallet-send) are CACHED-REVISIT render-cost regressions, not missing-first-load-affordance gaps — every one already shows a loading affordance on its genuinely slow path (first load or the specific in-flight async op), so no loader was added anywhere in Task 2. Adding one to any of them would put a spinner on a cached revisit, a direct PERF-02 regression."
|
||||
- "URL ownership: the lightbox revokes every URL it is handed, on unmount, exactly as it already does for My Files/Peer Files. The composable never schedules a competing revoke for a URL routed to the lightbox — only the browser-tab fallback path keeps its own revoke timer, unchanged from before."
|
||||
|
||||
patterns-established:
|
||||
- "Row-level loading + non-interactive guard for a fetch-then-route action, reusing PeerFiles.vue's existing 3x3 spinner + 'Opening...' treatment rather than inventing new visual language."
|
||||
|
||||
requirements-completed: [UIFIX-04, UIFIX-06]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "A purchased picture or video opens in the app's own MediaLightbox instead of a browser tab"
|
||||
requirement: UIFIX-04
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#routes an image mime to the lightbox, not window.open"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#routes a video mime to the lightbox, not window.open"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Audio still routes to the global bottom-bar player, never the lightbox; documents keep today's browser-tab fallback"
|
||||
requirement: UIFIX-04
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#routes an audio mime to the audio player, never the lightbox"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#falls back to the browser tab for a mime with no in-app viewer"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "The Paid Files row shows a visible loading affordance for the whole duration of a purchased-item fetch, and clears it (including on failure); a second click on the same item in flight issues one RPC"
|
||||
requirement: UIFIX-06
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#sets opening for the whole duration of the fetch and clears it on success"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#surfaces a rejected/timed-out fetch as an error, clears opening, and does not throw past the caller"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts#issues exactly one RPC when open() is called twice in quick succession for the same item"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Every surface phase 2's findings named as slow-opening has a recorded, evidence-backed verdict; no loader was added to a cached-revisit path"
|
||||
requirement: UIFIX-06
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "Per-surface verdict table below, cross-referenced against .planning/phases/02-ui-performance/02-FINDINGS.md's Per-surface verdict / Accepted deviations sections and direct grep of each surface's own loading-state code"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 30min
|
||||
completed: 2026-07-31
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 01 Plan 14: Paid Files lightbox + loading-state audit Summary
|
||||
|
||||
**Purchased pictures/videos now open in the app's own MediaLightbox with a visible "Opening…" row spinner and real error surfacing, replacing `window.open()` + a swallowed catch; the eight other surfaces phase 2 flagged as slow are confirmed cached-revisit render-cost regressions, not missing loaders, so none of them were touched.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~30 min
|
||||
- **Started:** 2026-07-31T22:00Z (approx, from first read)
|
||||
- **Completed:** 2026-07-31T22:30Z
|
||||
- **Tasks:** 2 (Task 1 tracer + Task 2 audit)
|
||||
- **Files modified:** 3
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- New `usePaidItemViewer()` composable: moves `Cloud.vue`'s inline `content.owned-get` fetch/decode chain into a reusable, unit-tested unit with per-mime routing (image/video → lightbox, audio → bottom-bar player, everything else → today's browser-tab fallback), a single-flight `open()` guard keyed on `onion+content_id`, and a `resolveBlobUrl` the lightbox calls to read the already-fetched URL.
|
||||
- `Cloud.vue`'s Paid Files row: `window.open()` is gone entirely (`grep -c 'window.open'` = 0); the row now shows a spinner + "Opening…" label (reusing `PeerFiles.vue`'s existing treatment verbatim) for the fetch's duration and becomes non-interactive so a second click can't queue a second fetch.
|
||||
- A second `MediaLightbox` instance added below the existing My Files one, fed the composable's `lightboxItems`/`lightboxIndex` and `resolveBlobUrl` for both `fetch-blob-url` and `stream-url`.
|
||||
- The composable's `catch` sets a real, user-facing `error` message that Cloud.vue surfaces through its existing `loadError`/`alert-error` block — no more silent failure.
|
||||
- Task 2 audit: every one of the eight surfaces phase 2's findings named (Discover, Server, Web5, Fleet, AppDetails, OpenWrtGateway, MarketplaceAppDetails, Wallet-send) confirmed as a cached-revisit client-side render-cost regression with an existing first-load/first-fetch affordance already in place — no new loader added anywhere.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: End-to-end — a purchased picture opens in the lightbox, with the wait visible** - `bc9a210c` (fix)
|
||||
- Follow-up type-check fix (vue-tsc caught two possibly-undefined array reads `npx vitest run` alone doesn't flag): `4a8925f0` (fix)
|
||||
2. **Task 2: Settle the slow-open inventory — verdict per surface, loader only where genuinely missing** - no code changes; audit-only, documented below (the pinning test required by this task's acceptance criteria was already written as part of Task 1's test file, see "opening is not derived from a background-refresh flag" test)
|
||||
|
||||
**Plan metadata:** (this commit)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `neode-ui/src/composables/usePaidItemViewer.ts` - fetch/decode/route/loading/error composable for purchased items
|
||||
- `neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts` - 9 tests: per-mime routing (image/video/audio/no-viewer), extension-carrying synthetic item, loading-state, the not-derived-from-refresh pin, error surfacing, double-click dedup
|
||||
- `neode-ui/src/views/Cloud.vue` - `viewPaidItem()` now delegates to the composable; row loading/error UI; second `MediaLightbox` instance
|
||||
|
||||
## Per-Surface Slow-Open Verdict (Task 2)
|
||||
|
||||
Audited against `.planning/phases/02-ui-performance/02-FINDINGS.md`'s "Per-surface verdict" and "Accepted deviations" sections (the authoritative measured evidence) plus a direct grep of each surface's own loading-state code, per the plan's own instruction to re-verify rather than assume a grep hit covers the first uncached open.
|
||||
|
||||
| Surface | File | Existing affordance (first/genuinely-slow open) | Genuinely slow first open? | Action taken |
|
||||
|---|---|---|---|---|
|
||||
| Discover | `neode-ui/src/views/Discover.vue` | `catalogResource.entry.loadState === 'loading'` drives a loading message (`loadingCommunity`, line 303) | No — 02-FINDINGS: 0 RPC confound-free reading, regression is Apps-tab-KeepAlive-transit render/reactivation cost on **revisit**, not the first uncached open | None — first-load already covered; a revisit regression is out of scope for a loader (would violate PERF-02) |
|
||||
| Server | `neode-ui/src/views/Server.vue` | Multiple `animate-spin` loading indicators per-section (e.g. line 91, 250) plus keyed `useCachedResource` per load group (02-06) | No — 02-FINDINGS/02-09: component instance confirmed to genuinely survive the round-trip (`vm.$.uid` proof); regression is 7 cached resources' reactivation cost on **revisit**, RPC count *improved* (8→2) | None — same reasoning; revisit-only cost |
|
||||
| Web5 | `neode-ui/src/views/web5/Web5.vue` | `loadingDidDoc` text indicator (line 44) plus each sub-card's own `useCachedResource` | No — 02-FINDINGS: strongest zero-overlap evidence of pure client-side reactivation cost on a confirmed-surviving instance, 0 RPC throughout, all three measurement runs | None — same reasoning; revisit-only cost |
|
||||
| Fleet | `neode-ui/src/views/Fleet.vue` | `animate-spin` loading state (lines 64, 93) | No — 02-FINDINGS: instance survives (confirmed), 0 RPC either time, the most severe of the split-signal regressions (330ms→2631ms) but purely a revisit/reactivation cost | None — same reasoning; revisit-only cost |
|
||||
| AppDetails | `neode-ui/src/views/AppDetails.vue` | `credentialsLoading` computed from `credentialsResource.loadState.value === 'loading'`, passed to its credentials section (line 44/206) | No — 02-FINDINGS: RPC count *improved* (2→1) via keyed `useCachedResource` (02-03); the keyed-lookup/re-render cost now exceeds what the eliminated fetch saved, on revisit | None — same reasoning; revisit-only cost |
|
||||
| OpenWrtGateway | `neode-ui/src/views/server/OpenWrtGateway.vue` | Explicit "Loading skeleton" block (line 522) with a documented first-load-only condition (line 102-104 comment) | No — 02-FINDINGS: same split-signal class as AppDetails; RPC evidence confounded by Server-tab transit but `revisitMs` alone shows the regression is on the cached path, and the skeleton is already gated to first-load-only | None — same reasoning; revisit-only cost, and the surface deliberately avoids showing a skeleton on revisit already (matches PERF-02) |
|
||||
| MarketplaceAppDetails | `neode-ui/src/views/MarketplaceAppDetails.vue` | `v-if="loading"` full-card skeleton with `animate-spin` (line 7-8) | No — 02-FINDINGS: RPC evidence confounded by Home-tab transit; only `package.versions` is a trustworthy call and it improved; regression (if any residual) reads as revisit render cost, and the raw `remounted: false` reading is flagged as likely the same selector-ambiguity artifact 02-09 proved, not new instance caching | None — first-load skeleton already present and correctly gated |
|
||||
| Wallet / send flow (`SendBitcoinModal.vue`) | `neode-ui/src/components/SendBitcoinModal.vue` | `feeEstimateLoading` ref renders `'…'` in place of the fee figure while the fee-estimate call is in flight (lines 388-419) | No — 02-FINDINGS: **cleared as environmental noise** in the re-measurement (median dropped below both prior runs); the separate, still-open anomaly (revisit consistently slower than first-visit, 0 RPC) is because `BaseModal`'s `v-if` always fully remounts the modal — not a missing loader, and out of this plan's `files_modified` (`SendBitcoinModal.vue` isn't in this plan's scope; carried forward in 02-FINDINGS' Outstanding section) | None — the modal's own async op (fee estimate) already has a first-load affordance; the remount-cost anomaly is a pre-existing, separately-tracked issue this plan doesn't own |
|
||||
|
||||
**No affordance was added to any cached-revisit path in this plan.** PERF-02's no-spinner-on-revisit guarantee is preserved everywhere — the only genuinely slow, uncached, first-open gap that existed (`Cloud.vue`'s `viewPaidItem()` / `content.owned-get`, 60s timeout, zero indicator) is the one fixed in Task 1; every other named surface's slow-open regression is client-side render/reactivation cost on an already-cached revisit, which a loader cannot fix and must not paper over.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Documents (no in-app viewer) deliberately keep the browser-tab fallback — a scope boundary named explicitly in the plan, not a gap.
|
||||
- No new loader was added anywhere in Task 2 — see the per-surface verdict table above; every named surface's regression is revisit-only render cost, confirmed by phase 2's own measured evidence (`02-FINDINGS.md`), not a missing first-load affordance.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Fixed two TS strict-null-check errors caught by `npm run build`'s `vue-tsc -b` pass**
|
||||
- **Found during:** Task 1, post-commit `npm run build` verification
|
||||
- **Issue:** `viewer.lightboxItems.value[0].name` — TS2532 "Object is possibly 'undefined'" on two array-index test assertions. `npx vitest run` alone doesn't type-check test files under this project's config, so this only surfaced during the build's own `vue-tsc -b` step.
|
||||
- **Fix:** Optional-chained both reads (`viewer.lightboxItems.value[0]?.name`).
|
||||
- **Files modified:** `neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts`
|
||||
- **Verification:** `npm run build` exits 0 after the fix; `npx vitest run` for the file still 9/9 green.
|
||||
- **Committed in:** `4a8925f0`
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (blocking, build type-check)
|
||||
**Impact on plan:** Test-only, no behavior change. No scope creep.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None beyond the above.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd neode-ui && npx vitest run src/composables/__tests__/usePaidItemViewer.test.ts` — 9/9 passed.
|
||||
- `cd neode-ui && npx vitest run` — 99 test files / 812 tests, all green (`keepAliveTabs.test.ts` confirmed byte-for-byte unmodified via `git status --short` and still passing within that run).
|
||||
- `cd neode-ui && npx vue-tsc --noEmit` — clean.
|
||||
- `cd neode-ui && npm run build` — succeeds; `grep -rq 'Opening…' ../web/dist/neode-ui/assets/Cloud-*.js` confirms the new string reached the bundle.
|
||||
- `grep -c 'window.open' neode-ui/src/views/Cloud.vue` = 0; `grep -c 'MediaLightbox' neode-ui/src/views/Cloud.vue` = 4 (import + 3 usages incl. the doc comment referencing it); `grep -c 'usePaidItemViewer' neode-ui/src/views/Cloud.vue` = 3.
|
||||
- `git diff -- neode-ui/src/views/Cloud.vue | grep -c "^-.*key: 'cloud\."` = 0 — no cached-resource key touched.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- UIFIX-04 and UIFIX-06 are both closed for their named gap (Paid Files). No blockers for future phase-1 plans.
|
||||
- Carried-forward, unrelated-to-this-plan items already tracked in `02-FINDINGS.md`'s Outstanding section (Discover/Server/Web5/Fleet/AppDetails/OpenWrtGateway split-signal render-cost regressions, Wallet-send's remount anomaly) remain open there — this plan's audit reconfirmed them but did not fix them, per the plan's own explicit instruction that a cached-revisit render-cost regression is not a UIFIX-06 loader gap.
|
||||
|
||||
---
|
||||
*Phase: 01-federation-mesh-hardening*
|
||||
*Completed: 2026-07-31*
|
||||
@@ -1,126 +0,0 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 15
|
||||
subsystem: ui
|
||||
tags: [vue, picture-in-picture, media, lifecycle, teleport]
|
||||
|
||||
requires:
|
||||
- phase: 01-federation-mesh-hardening
|
||||
provides: "MediaLightbox.vue's existing lightbox shell (Teleported backdrop, video element, pip button) and utils/pip's browser-support helper"
|
||||
provides:
|
||||
- "usePipSession(): a singleton PiP session that owns a body-level custodial host, so an adopted video survives the unmount of whatever view rendered it"
|
||||
- "A lightbox→PiP handoff that reads as the video moving into the PiP window rather than the lightbox being dismissed"
|
||||
affects: [cloud-files, media-viewer]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Custodial host pattern: to keep a media element alive across a view teardown, move it into an off-screen div under document.body before the teardown runs. Both Teleport and KeepAlive move their subtree on deactivation, which the picture-in-picture spec treats as removal — so the element must be re-parented above the view tree, not merely referenced."
|
||||
- "Order-of-operations as a documented invariant: adopt → animate → emit close. Adopting first is what makes the element survive the unmount that the emit triggers; the comment in onEnterPip says so explicitly so a future refactor cannot reorder it innocently."
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- neode-ui/src/composables/usePipSession.ts
|
||||
- neode-ui/src/components/__tests__/MediaLightboxPip.test.ts
|
||||
modified:
|
||||
- neode-ui/src/components/cloud/MediaLightbox.vue
|
||||
|
||||
key-decisions:
|
||||
- "PiP entry is detected via the element's own `enterpictureinpicture` event rather than inferred from the toolbar button, so PiP entered by any route — the browser's native control, a keyboard shortcut — takes exactly the same handoff path."
|
||||
- "The backdrop's close is driven by `transitionend` with a bounded 350ms fallback timer, covering browsers that skip the transition and the reduced-motion path where the duration is zero and the event never fires."
|
||||
- "`release()` is idempotent and is called both by the session's own `leavepictureinpicture` listener (the primary path, since the lightbox has usually unmounted by then) and by the component's handler for the rare still-mounted case."
|
||||
|
||||
requirements-completed: [UIFIX-05]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Entering PiP closes the lightbox once, having adopted the video first so it survives the unmount"
|
||||
requirement: "UIFIX-05"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "neode-ui/src/components/__tests__/MediaLightboxPip.test.ts#entering PiP emits close exactly once and adopts the video before doing so"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "The close reads as a handoff animation on the PiP path, and as an ordinary dismissal otherwise"
|
||||
requirement: "UIFIX-05"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "…#applies the handoff class on the PiP path; …#applies no handoff class on a button-driven close"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Leaving PiP releases the session and tears down playback"
|
||||
requirement: "UIFIX-05"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "…#releases the session when picture-in-picture is left"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "The component's public contract (props/emits) is unchanged by the rework"
|
||||
requirement: "UIFIX-05"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "…#does not change props or emits declared by the component"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 85min
|
||||
completed: 2026-08-01
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 1 Plan 15: PiP Handoff That Survives the Lightbox (UIFIX-05) Summary
|
||||
|
||||
**Made picture-in-picture a handoff rather than a dismissal: the video is re-parented to a body-level custodial host before the lightbox closes, so an active PiP session survives the unmount — and the close animates as the video moving out rather than the overlay disappearing.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~85 min (across two sessions — see Deviations)
|
||||
- **Completed:** 2026-08-01
|
||||
- **Tasks:** 2/2
|
||||
- **Files modified:** 3 (1 new composable, 1 component, 1 new test file)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `usePipSession()` owns a singleton off-screen host under `document.body`. `adopt(video)` moves the element there so it outlives the view that rendered it; `release()` pauses, detaches and tears down. A `leavepictureinpicture` listener attached at adopt time is the primary release path.
|
||||
- `MediaLightbox` listens for `enterpictureinpicture`/`leavepictureinpicture` on the video itself, so any route into PiP behaves identically.
|
||||
- The handoff ordering (adopt → animate → emit close) is enforced and documented in-place; a test asserts adoption happens before the single `close` emit.
|
||||
- The backdrop gains a `lightbox-pip-handoff` class on the PiP path only, with `transitionend`-driven close and a 350 ms fallback for browsers that skip the transition (including reduced-motion, where the duration is zero).
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: singleton PiP session with body-level custodial host** — `3288a02d` (feat)
|
||||
2. **Task 2: lightbox handoff wiring, handoff animation, and test suite** — committed with this SUMMARY
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `neode-ui/src/composables/usePipSession.ts` — the singleton session and custodial host (committed in `3288a02d`).
|
||||
- `neode-ui/src/components/cloud/MediaLightbox.vue` — PiP event handlers, handoff class + CSS, `transitionend`/timer close path, and the `pipSupported` → `isPipSupported()` call-site update.
|
||||
- `neode-ui/src/components/__tests__/MediaLightboxPip.test.ts` — 5 tests, with jsdom stubs for the picture-in-picture APIs it does not implement.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Process deviation: executor killed mid-verification by an SSH disconnect
|
||||
|
||||
**Found during:** post-implementation verification
|
||||
**Issue:** Task 1 had been committed (`3288a02d`); Task 2's component changes and test file were complete on disk but uncommitted when the orchestrating session and its agents died with the operator's SSH connection.
|
||||
**Resolution:** A follow-on session re-ran the suite (5/5 green), confirmed the full frontend suite was green, wrote this SUMMARY, and committed the remainder.
|
||||
**Files modified:** none beyond the original work
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None in product code. The test file stubs `pictureInPictureEnabled`, `requestPictureInPicture` and `exitPictureInPicture` because jsdom implements none of them; jsdom also logs "Not implemented: HTMLMediaElement.prototype.pause/load" to stderr during the release test — noise, not failure.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None — no new endpoint or trust boundary. The one lifecycle risk (an orphaned element left attached to `document.body` after PiP ends) is closed by `release()` being idempotent and wired to both the session's own listener and the component handler.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: `neode-ui/src/composables/usePipSession.ts` (created, commit `3288a02d`)
|
||||
- FOUND: `neode-ui/src/components/cloud/MediaLightbox.vue` (modified)
|
||||
- FOUND: `neode-ui/src/components/__tests__/MediaLightboxPip.test.ts` (created, 5 tests)
|
||||
- CONFIRMED: `npx vitest run` full frontend suite green
|
||||
</content>
|
||||
@@ -1,345 +0,0 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 16
|
||||
subsystem: security
|
||||
tags: [secrets, bcrypt, fedimint, migration, rotation, reconcile]
|
||||
|
||||
requires:
|
||||
- phase: 01-federation-mesh-hardening
|
||||
provides: "01-11's KNOWN_DEFAULT_GATEWAY_HASHES denylist, ensure_gateway_credential, gateway_bcrypt_hash and the atomic 0600 write_secret — rotation reuses all of it and adds no new generation or file-writing code"
|
||||
provides:
|
||||
- "rotate_compromised_gateway_credential(secrets_dir) -> Result<bool>: denylist-exact detection plus rotation of a shipped gateway credential"
|
||||
- "Self-healing on the existing reconcile tick, so an affected node rotates without operator action and without a hand-rolled container teardown"
|
||||
affects: [fedimint-gateway, container-secrets, reconcile]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Rotate by changing the secret, not by touching the container: writing the new credential changes the resolved secret env, which changes secret_env_hash, which the drift check reads as a container-label mismatch — so the platform's own recreate path rebuilds the container around unchanged data, ports, volumes and name."
|
||||
- "Denylist-exact detection: rotate only on an exact match against known-compromised values, never on 'unrecognised'. An operator's deliberately-set credential is unrecognised too."
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- core/archipelago/src/container/secrets.rs
|
||||
- core/archipelago/src/container/prod_orchestrator.rs
|
||||
|
||||
key-decisions:
|
||||
- "Bcrypt generation was factored out of ensure_one's Bcrypt arm into write_bcrypt_pair(dir, name), which both ensure_one and rotation call. 01-11 had left that arm inline, and rotation cannot reuse ensure_gateway_credential directly because ensure_one's idempotent fast path returns early when the file is present and non-empty — which is exactly the case rotation must act on."
|
||||
- "The rotation call is gated on `manifest.app.id == \"fedimint-gateway\"` rather than running for every app on every tick. It hangs off resolve_dynamic_env, immediately after ensure_generated_secrets, as the plan specified."
|
||||
- "Errors propagate (`?`) rather than being logged-and-continued: write_secret's atomic temp-file-plus-rename leaves the previous credential intact on failure, so surfacing the error is strictly safer than proceeding with a half-rotated gateway."
|
||||
- "No boot-specific wiring was added — see the boot-reconciler finding below."
|
||||
|
||||
requirements-completed: []
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "A node carrying the shipped default rotates itself onto a unique credential without operator action"
|
||||
requirement: "FED-07"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "core/archipelago/src/container/secrets.rs#rotates_a_denylisted_gateway_credential"
|
||||
status: pass
|
||||
- kind: manual_procedural
|
||||
ref: "Task 2 checkpoint, archi-dev-box 2026-08-01 — the credential FILE rotates correctly (~15s after restart, fresh unique value, 0600), but the RUNNING gateway keeps the pre-rotation credential: Quadlet rewrites the unit without restarting it, and the gateway is classified restart-sensitive so drift is detected and deliberately ignored on every tick"
|
||||
status: fail
|
||||
human_judgment: true
|
||||
- id: D2
|
||||
description: "A node already carrying a unique credential is left completely alone; detection never fires on merely-unrecognised values"
|
||||
requirement: "FED-07"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "…#leaves_a_unique_gateway_credential_alone, …#leaves_an_unrecognised_credential_alone"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Rotation runs at most once per affected node; later ticks detect nothing and change nothing"
|
||||
requirement: "FED-07"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "…#rotation_is_idempotent"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Rotation replaces one credential pair and nothing else — no other secret, and no app data, is touched"
|
||||
requirement: "FED-07"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "…#rotation_touches_no_other_secret (four bystander secrets asserted byte-identical)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "git diff of prod_orchestrator.rs contains zero added rm -f / remove_dir_all / podman rm / chown"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "The rotation is announced in the node's logs without ever printing the credential"
|
||||
requirement: "FED-07"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "The info! line interpolates self.secrets_dir and the secret NAME only; no value is in scope at the call site (rotate returns bool, not the credential)"
|
||||
status: pass
|
||||
- kind: manual_procedural
|
||||
ref: "Task 2 step 3, archi-dev-box 2026-08-01 — one info line fired, naming /var/lib/archipelago/secrets/fedimint-gateway-hash.pw; no credential value anywhere in the log"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
- id: D6
|
||||
description: "Generation where no credential exists stays ensure_gateway_credential's job"
|
||||
requirement: "FED-07"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "…#no_op_when_no_gateway_credential_exists"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: 140min
|
||||
completed: 2026-08-01
|
||||
status: task-1-complete-checkpoint-FAILED-recreate-does-not-fire
|
||||
---
|
||||
|
||||
# Phase 1 Plan 16: Rotate Existing Installs Off the Shipped Gateway Credential (FED-07) Summary
|
||||
|
||||
**Task 1 rotates the credential correctly and was proven to do so on a real node. But the checkpoint DISPROVED the assumption it rests on: the rotated credential never reaches the running container, because the Quadlet path does not restart units and the gateway is classified restart-sensitive. FED-07 remains open.**
|
||||
|
||||
## Status
|
||||
|
||||
**FED-07 is NOT closed, and this plan alone cannot close it.** The checkpoint ran on archi-dev-box on
|
||||
2026-08-01 and found that rotation does not propagate to the running gateway. A follow-up plan is
|
||||
required — see the checkpoint result below.
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `rotate_compromised_gateway_credential(secrets_dir) -> Result<bool>` in `container::secrets`:
|
||||
reads the canonical hash file, returns `Ok(false)` for absent/unreadable/unique/unrecognised, and
|
||||
only on an **exact** denylist match writes a fresh pair and returns `Ok(true)`.
|
||||
- `write_bcrypt_pair(dir, name)` factored out of `ensure_one`'s `Bcrypt` arm so there is exactly one
|
||||
bcrypt-generation implementation, called by both generation and rotation.
|
||||
- Wired into `resolve_dynamic_env` beside `ensure_generated_secrets`, gated on the gateway's app id,
|
||||
with an info-level announcement that names the *path* to the new plaintext and never the value.
|
||||
- Six new tests covering rotate-on-denylisted (including 0600 modes and that the `.pw` sibling
|
||||
verifies against the new hash), no-op-on-unique, no-op-on-unrecognised, no-op-on-absent,
|
||||
idempotence, and four bystander secrets left byte-identical.
|
||||
|
||||
## Findings the plan asked for
|
||||
|
||||
### Boot reconciler needs no separate call
|
||||
|
||||
`boot_reconciler` calls `reconcile_all()` → `reconcile_all_with_mode()` → per-manifest
|
||||
`ensure_running_with_mode()` (prod_orchestrator.rs:1714) → `resolve_dynamic_env()`
|
||||
(prod_orchestrator.rs:1914) → the rotation call. `install_fresh` reaches it by the same route.
|
||||
So boot and reconcile funnel through one chokepoint and **no boot-specific wiring was added**;
|
||||
`boot_reconciler.rs` is not in `files_modified`.
|
||||
|
||||
### ✅ RESOLVED 2026-08-02: the carve-out closes it, proven on the same node
|
||||
|
||||
The failure documented below was fixed and re-verified on archi-dev-box.
|
||||
|
||||
**The fix.** Rotation now records the app id in a `credential_rotated` set on the orchestrator, and
|
||||
the drift check consumes that flag to recreate the container **even when the app is
|
||||
restart-sensitive**, with a `WARN` naming the reason. It deliberately mirrors the published-port
|
||||
carve-out sitting a few lines above it in the same function, which already makes exactly this trade
|
||||
for exactly this reason: a container that is already non-functional (there) or already compromised
|
||||
(here) is not protected by leaving it running. No teardown was hand-rolled — the existing recreate
|
||||
path does the work.
|
||||
|
||||
**The design call.** Restart-sensitivity protects *working* services. A gateway answering to a
|
||||
credential published in this repository is not working, it is compromised, and an attacker with
|
||||
gateway admin can drain Lightning liquidity. Indefinite exposure loses to a few seconds of restart.
|
||||
The alternative — rotate but only raise an operator alert — was rejected because the monitoring
|
||||
system fires alerts from metric thresholds only (`check_alerts(&MetricSnapshot)`), so it would have
|
||||
needed new event-alert plumbing to deliver something strictly weaker than just fixing it.
|
||||
|
||||
**Before / after on the same box, same scenario:**
|
||||
|
||||
| | Before (15:51, first checkpoint) | After (06:39, re-run) |
|
||||
|---|---|---|
|
||||
| Credential file | rotated ✅ | rotated ✅ |
|
||||
| Container recreated | ❌ never — same PID 25 min later | ✅ 4s after rotation, PID 3923125 → 148426 |
|
||||
| Running credential | the compromised default | matches the file (`636d6031…`) |
|
||||
| Log | "leaving running restart-sensitive app untouched" ×4 and counting | "recreating restart-sensitive app: its admin credential was rotated off a publicly known default…" |
|
||||
|
||||
Post-recreate state: container healthy, **same name**, **same ports** (8176/9737), `gatewayd.db`
|
||||
intact at 18 files with `IDENTITY` present, credential `0600 archipelago:archipelago`, 32 containers
|
||||
untouched, and **zero repeat rotations** on subsequent ticks (the rotated value is not denylisted, so
|
||||
it is self-terminating — T-01-73 holds).
|
||||
|
||||
Covered by three new tests: `rotating_a_compromised_credential_flags_the_app_for_recreate`,
|
||||
`a_unique_credential_does_not_flag_the_app`, `a_second_pass_does_not_re_flag_the_app`.
|
||||
|
||||
---
|
||||
|
||||
### ⛔ ORIGINAL CHECKPOINT RESULT (2026-08-01) — retained: this is what the fix had to defeat
|
||||
|
||||
**Run on archi-dev-box, 2026-08-01.** The code-reading conclusion below was **wrong in practice**,
|
||||
which is exactly why the plan made this a blocking checkpoint. The rotation works; the propagation
|
||||
to the running container does not.
|
||||
|
||||
Observed: rotation fired ~15s after restart, wrote a fresh unique credential, and logged correctly.
|
||||
But **25 minutes later the running gatewayd process was still using the pre-rotation credential.**
|
||||
`/proc/<pid>/environ` for PID 1 held the old value while the file and the podman secret held the new
|
||||
one. The orchestrator says why, in its own log lines:
|
||||
|
||||
```
|
||||
Quadlet unit drift-synced — file rewritten, .service NOT restarted
|
||||
(operator restart picks up new config) app_id=fedimint-gateway
|
||||
container drift detected during boot reconcile;
|
||||
leaving running restart-sensitive app untouched app_id=fedimint-gateway
|
||||
```
|
||||
|
||||
Two independent guards, both deliberate:
|
||||
|
||||
1. **The Quadlet path rewrites the `.container` unit but never restarts the `.service`.** The unit
|
||||
file was rewritten at 15:51:36 (same second as the rotation) carrying the new
|
||||
`secret-env-hash=de9870c642a515f7` label — so the definition updated correctly. Systemd does not
|
||||
apply a changed unit to a running container without a restart.
|
||||
2. **`fedimint-gateway` is classified restart-sensitive**, so the drift check *detects* the change on
|
||||
every reconcile tick and then deliberately leaves the container alone. That line repeated at
|
||||
15:51, 15:53, 15:54, 15:56 — it will repeat forever.
|
||||
|
||||
**Consequence: on a real affected node, rotation makes the credential file unique but the gateway
|
||||
keeps answering to the compromised one indefinitely** — until an unrelated reboot or a manual
|
||||
restart. Worse, the operator reading `fedimint-gateway-hash.pw` gets a password the running gateway
|
||||
does not accept, which is the lockout risk T-01-77 inverted.
|
||||
|
||||
Confirmed the fix works when applied: `systemctl --user restart fedimint-gateway.service` produced a
|
||||
new PID whose `FEDI_HASH` is the rotated value, container healthy, same name, same ports, marker file
|
||||
and `gatewayd.db` intact.
|
||||
|
||||
**Deliberately NOT hand-rolled.** The plan's action says: "If that path does not fire for this app for
|
||||
some reason you discover, do not hand-roll a remove-and-run; stop and record what you found." So this
|
||||
is recorded, not patched. The fix belongs in a follow-up and has to answer a real design question:
|
||||
a compromised credential is arguably the one case that should override restart-sensitivity — or,
|
||||
failing that, the rotation must raise an operator-facing "restart required" alert rather than logging
|
||||
into the void.
|
||||
|
||||
### What the checkpoint DID prove
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| 1. State recorded | Node was CLEAN; affected state seeded deliberately (plan's Planner Assumption) |
|
||||
| 2. Deploy | `install -m0755` to `/usr/local/bin/archipelago` + `systemctl restart archipelago`; rollback kept at `archipelago.bak-pre-fed07` |
|
||||
| 3. Rotation announced | ✅ One info line, fired once, names the `.pw` path, **no credential value in the log** |
|
||||
| 4. Credential unique | ✅ Third distinct value (not the default, not the pre-test original), `0600 archipelago:archipelago` |
|
||||
| 5. Data preserved | ✅ Marker file and all 13 `gatewayd.db` files incl. `IDENTITY` intact; same container name; same ports 8176/9737 |
|
||||
| 5. Container recreated | ❌ **FAILED — see above** |
|
||||
| 6. Auth proof | ⚠️ Not obtainable: `gateway-cli` in this image returns the same "Invalid request" for a correct and an incorrect password, so it cannot distinguish them. Substituted PID-1 `environ` comparison, which is stronger evidence of *which* credential is in force. |
|
||||
| 7. Fresh install differs | ⏸ Not run |
|
||||
| 8. `run-gate.sh` | ⏸ Not run |
|
||||
|
||||
Also proved incidentally: **restarting `archipelago` does not kill containers on this box** — 29/29
|
||||
and later 31/31 survived, and the orchestrator logged "Adopted 31 existing container(s)". The
|
||||
CLAUDE.md "restart SIGKILLs containers" rule does not apply under `ARCHIPELAGO_USE_QUADLET_BACKENDS=true`
|
||||
with podman in the user slice (the service is `system.slice`/`KillMode=control-group`; the containers
|
||||
live in `user-1000.slice/…/libpod-*`, a different cgroup entirely).
|
||||
|
||||
### Original code-reading conclusion (retained — it is what the checkpoint disproved)
|
||||
|
||||
`resolve_dynamic_env` computes `secret_env_content_hash(&secret_bearing)` over the resolved
|
||||
secret-bearing env and stores it as `manifest.app.container.secret_env_hash`
|
||||
(prod_orchestrator.rs:3309). The drift check (prod_orchestrator.rs:3374) inspects the running
|
||||
container's `SECRET_ENV_HASH_LABEL` and returns "drifted" when it differs from the expected hash,
|
||||
which drives the existing recreate. The gateway's `FEDI_HASH` comes from the rotated file, so a
|
||||
rotation necessarily changes that hash and therefore the label comparison.
|
||||
|
||||
**This is a code-reading conclusion. It has not been observed firing on a node** — that is Task 2
|
||||
step 5, and it is the single most important thing the checkpoint proves.
|
||||
|
||||
### Operator recovery: the surface exists but does NOT cover this app — a real gap
|
||||
|
||||
- The UI path is live: `Apps.vue` calls `package.credentials` with an `app_id` before launching an
|
||||
app and renders a credentials modal from the response.
|
||||
- The backend, `handle_package_credentials` in
|
||||
`core/archipelago/src/api/rpc/package/install.rs:2093`, is a hardcoded per-app if-chain covering
|
||||
**only `filebrowser` and `photoprism`**. Every other app, including `fedimint-gateway`, falls
|
||||
through to `Ok(json!({ "credentials": [] }))`.
|
||||
- **Consequence:** after rotation the operator has no in-UI way to obtain the new gateway password.
|
||||
The recovery path is the file the log line names: `/var/lib/archipelago/secrets/fedimint-gateway-hash.pw`
|
||||
(0600, service user), readable over SSH.
|
||||
- **Gap owner:** `handle_package_credentials` in `core/archipelago/src/api/rpc/package/install.rs`.
|
||||
Adding a `fedimint-gateway` arm that reads the `.pw` sibling would close it; the UI needs no change.
|
||||
Deliberately not done here — this plan's `files_modified` is scoped to two files, and that handler
|
||||
belongs to the app-credentials surface, not to FED-07's rotation.
|
||||
|
||||
## Adjacent finding — NOT part of this plan, raised deliberately
|
||||
|
||||
`apps/photoprism/manifest.yml:35` sets `PHOTOPRISM_ADMIN_PASSWORD=archipelago`, and
|
||||
`handle_package_credentials` hands that same literal back to the UI. That is a shipped default
|
||||
credential in a manifest — the same class of defect as FED-07, on a different app. Every node running
|
||||
PhotoPrism answers to `admin` / `archipelago`.
|
||||
|
||||
It is out of scope here (this plan is the gateway migration) and was not touched. It wants its own
|
||||
requirement and plan, and probably the same treatment: a `generated_secrets` entry plus a denylist
|
||||
entry for the shipped value.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Bcrypt generation had to be factored out first
|
||||
|
||||
**Found during:** Task 1
|
||||
**Issue:** The plan says rotation should "generate a replacement pair through the same helper
|
||||
`ensure_gateway_credential` uses". 01-11 never actually created such a helper — it left the bcrypt
|
||||
arm inline in `ensure_one` and had `ensure_gateway_credential` call `ensure_one`. Rotation cannot
|
||||
call `ensure_gateway_credential`, because `ensure_one`'s idempotent fast path returns early when the
|
||||
target files are present and non-empty, which is precisely the state rotation acts on.
|
||||
**Resolution:** Extracted `write_bcrypt_pair(dir, name)` from the `Bcrypt` arm; `ensure_one` and
|
||||
rotation both call it. Still exactly one generation implementation, which is what the instruction was
|
||||
protecting.
|
||||
**Files modified:** `core/archipelago/src/container/secrets.rs`
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
- **T-01-72 (critical, EoP)** — mitigated in code, **not yet proven on a node**. Task 2 step 6 (old
|
||||
credential rejected, new one accepted) is the proof and has not been run.
|
||||
- **T-01-73 (DoS, rotation loop)** — mitigated and unit-tested: the rotated value is not on the
|
||||
denylist, so the next tick is a no-op (`rotation_is_idempotent`).
|
||||
- **T-01-74 (info disclosure)** — mitigated structurally: `rotate_compromised_gateway_credential`
|
||||
returns `bool`, so the credential is not even in scope at the logging call site.
|
||||
- **T-01-75 (tampering / data loss)** — mitigated: no teardown primitives added (grep-verified), the
|
||||
recreate goes through `secret_env_hash`. On-node data-survival check is Task 2 step 5, not run.
|
||||
- **T-01-76 (repudiation — signing off without exercising rotation)** — **live risk, unresolved.**
|
||||
Whether archi-dev-box is affected or already clean is still unknown; the plan requires declaring
|
||||
which case it is and deliberately seeding the old value if the node is clean.
|
||||
- **T-01-77 (operator lockout)** — partially mitigated: the plaintext exists at a named 0600 path and
|
||||
the log line points at it, but there is no UI retrieval path (see the gap above).
|
||||
- **T-01-SC** — no crates added.
|
||||
|
||||
## Self-Check
|
||||
|
||||
- CONFIRMED: `cargo test -p archipelago secrets` → **16 passed, 0 failed** (the `container::secrets`
|
||||
module holds 14 `#[test]` fns, all six new rotation cases among them:
|
||||
`rotates_a_denylisted_gateway_credential`, `leaves_a_unique_gateway_credential_alone`,
|
||||
`leaves_an_unrecognised_credential_alone`, `no_op_when_no_gateway_credential_exists`,
|
||||
`rotation_is_idempotent`, `rotation_touches_no_other_secret`)
|
||||
- FOUND: `rotate_compromised_gateway_credential` in `secrets.rs` (definition + 5 test uses)
|
||||
- FOUND: exactly 1 non-comment reference in `prod_orchestrator.rs`
|
||||
- CONFIRMED: 0 added teardown primitives (`rm -f` / `remove_dir_all` / `podman rm` / `chown`) in the
|
||||
`prod_orchestrator.rs` diff
|
||||
- CONFIRMED: `cargo fmt --check -p archipelago` clean. It was **not** clean before this plan —
|
||||
`install.rs` carried drift introduced by 01-11's commit (`42652547`), fixed here. That check has
|
||||
blocked the release gate before (`37d293be`), so it is worth keeping green rather than discovering
|
||||
at ship time.
|
||||
- CONFIRMED: `cargo test -p archipelago` (after `cargo clean -p archipelago`) — **1008 passed, 1
|
||||
failed**. The failure is `container::boot_reconciler::tests::second_pass_fires_after_interval`, the
|
||||
same wall-clock-timed test (50ms tick) that was flaky during 01-11; re-run in isolation it is
|
||||
**4 passed / 0 failed in 0.46s**. `boot_reconciler.rs` is untouched by this plan.
|
||||
- **NOT RUN:** Task 2's eight-step on-node checkpoint, and `tests/lifecycle/run-gate.sh`
|
||||
|
||||
### A false alarm worth recording, because it cost an hour
|
||||
|
||||
An intermediate full-suite run reported `credentials::operations::tests::test_list_credentials_filter_by_did`
|
||||
failing with "invalid utf-8 sequence of 1 bytes from index 2" — an identity-credentials test in a
|
||||
module this plan does not touch, which had passed in the 01-11 run two hours earlier.
|
||||
|
||||
Cause: **corrupted build artifacts, not a regression.** Two duplicate `cargo test` runs had been
|
||||
started against the same workspace lock and one was `SIGTERM`ed to free it. The next compile surfaced
|
||||
`rust-lld: error: undefined hidden symbol` — precisely the incremental-cache corruption CLAUDE.md
|
||||
documents. After `cargo clean -p archipelago` the credentials test passes and the only failure is the
|
||||
known timing flake above.
|
||||
|
||||
Lesson for the next executor on this box: do not kill an in-flight `cargo` to free the build lock —
|
||||
let it finish. A corrupted target dir produces failures in modules you never touched, which reads
|
||||
exactly like a real regression and is not one.
|
||||
</content>
|
||||
@@ -1,243 +0,0 @@
|
||||
---
|
||||
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` 311–331 (pre-fix) | **Yes** — 90×24 px pill, in viewport at both 390 and 320 | **Conditionally** — fine with real trust labels (36–69 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` 199–218 | n/a — **no transport pill exists at any width** | n/a | **No change** — decision recorded below |
|
||||
| S3 | Paid Files rows | `Cloud.vue` 150–178 | 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` 8–38 | **Yes** — the `md:hidden` copy renders at 390 and 320 (41×20 px, 183–253 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` 152–232 | 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 54–56 character peer names and 65–70
|
||||
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 54–56 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
|
||||
@@ -1,331 +0,0 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 18
|
||||
subsystem: ui
|
||||
tags: [verification, deploy, dev-pair, on-device, checkpoint, gap-closure]
|
||||
status: blocked-on-checkpoint
|
||||
|
||||
requires:
|
||||
- phase: 01-federation-mesh-hardening
|
||||
plan: 12
|
||||
provides: "UIFIX-02 connected-nodes sibling-matched scroll contract"
|
||||
- phase: 01-federation-mesh-hardening
|
||||
plan: 13
|
||||
provides: "UIFIX-03 onboarding scroll cue"
|
||||
- phase: 01-federation-mesh-hardening
|
||||
plan: 14
|
||||
provides: "UIFIX-04/UIFIX-06 paid-item lightbox + Opening… loader"
|
||||
- phase: 01-federation-mesh-hardening
|
||||
plan: 15
|
||||
provides: "UIFIX-05 PiP custodial-host handoff"
|
||||
- phase: 01-federation-mesh-hardening
|
||||
plan: 17
|
||||
provides: "UIFIX-01 transport-pill wrap fix + pin test"
|
||||
provides:
|
||||
- "All six UIFIX fixes delivered to archi-dev-box and proven present in the bundle the node actually serves, fetched over HTTP and matched against the live service-worker chunk manifest"
|
||||
- "An on-device boot check of the deployed bundle at 1440x900 and 390x740 — zero console errors, zero page errors, zero failed requests"
|
||||
- "Two of the planner's flagged open questions settled from the node's own data: it HAS one purchased image, and it has NO purchased video and NO audio/video content at all"
|
||||
- "A per-check VERIFIED / NOT VERIFIED table with the exact human action each unverified check still needs"
|
||||
affects: [cloud, web5, onboarding, media-viewer]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Served-bundle verification must resolve the live chunk set first: /opt/archipelago/web-ui/assets accumulates every prior deploy's hashed chunks, so a naive `grep -rl` across the directory returns hits from dead chunks. The service-worker precache manifest (sw.js) is the authoritative list of the chunks the current build actually loads — grep only those, and fetch them over HTTP rather than off disk."
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- .planning/phases/01-federation-mesh-hardening/01-18-SUMMARY.md
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "No source file was modified and no fix was made. This is a verification plan (files_modified: []) and three other executors were live in the same working tree on plans 10-02/10-04/10-06 for its whole duration; anything found here is recorded as a finding, not patched inline."
|
||||
- "Task 2's six numbered checks are recorded NOT VERIFIED. Every one of them requires an authenticated session on archi-dev-box, and the node's UI password was not available to this executor (02-08 records it being supplied out-of-band by the coordinator and passed only via ARCHY_PASSWORD). No credential was guessed against a live node holding real federation trust and real funds."
|
||||
- "STATE.md/ROADMAP.md/REQUIREMENTS.md were deliberately NOT updated and NOT committed. .planning/STATE.md was already dirty with another agent's in-flight edit, and the execution brief restricted this plan's commit to the SUMMARY alone. UIFIX-01..06 must NOT be marked complete until the Task 2 checkpoint is answered."
|
||||
|
||||
requirements-completed: []
|
||||
|
||||
metrics:
|
||||
duration: ~20min
|
||||
tasks: 1 of 2 (Task 2 is a blocking human checkpoint, unanswered)
|
||||
files-changed: 0 source files
|
||||
completed: 2026-08-02
|
||||
---
|
||||
|
||||
# Phase 1 Plan 18: Six-Fix Sign-Off on archi-dev-box — Summary
|
||||
|
||||
**Task 1 is complete with full evidence: all six UI fixes are live in the bundle archi-dev-box actually serves, and the deployed bundle boots clean on the node. Task 2 — the six-check human sign-off, which is the entire point of this plan — is UNANSWERED. No UIFIX requirement is closed by this summary.**
|
||||
|
||||
## Verdict
|
||||
|
||||
**Not signed off.** The operator's response is this plan's verification (`<verification>`), and there was no operator response. Task 1's delivery evidence is complete and reproducible; Task 2's six numbered behavioural checks all remain open.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Deploy to archi-dev-box — COMPLETE
|
||||
|
||||
### Exact command and host list
|
||||
|
||||
```
|
||||
ARCHIPELAGO_TARGET=archipelago@archi-dev-box scripts/deploy-to-target.sh --frontend-only
|
||||
```
|
||||
|
||||
Run from `/home/archipelago/Projects/archy` on **archi-dev-box itself** (`hostname` = `archi-dev-box`;
|
||||
Tailscale `100.69.68.39`). Exit code **0**, 346 s, `2026-08-02T18:20:08Z → 18:25:54Z`.
|
||||
|
||||
**Host list targeted: `archipelago@archi-dev-box` and nothing else.** Confirmed three ways:
|
||||
|
||||
1. The script's own deploy manifest written on the node:
|
||||
`/opt/archipelago/deploy-manifest.json` → `"target": "archipelago@archi-dev-box"`,
|
||||
`"deployed_from": "archi-dev-box"`, `"commit_short": "527f6023"`,
|
||||
`"deployed_at": "2026-08-02T18:25:26Z"`.
|
||||
2. `--tailscale`, `--tailscale-node=`, `--both` and `--fleet` were **not** passed, so the
|
||||
`SEC_TARGET` fan-out branch (lines 355–385, which contains the only `192.168.1.228` reference on
|
||||
any AIUI path) was never entered. The AIUI step that did run is the `LIVE` branch at lines
|
||||
725–736, which talks only to `$TARGET_HOST`.
|
||||
3. No release was cut, no OTA manifest touched, no catalog signed. Acceptance check:
|
||||
`git status --short -- release-manifest.json releases/ app-catalog/` → **empty**.
|
||||
|
||||
The prohibition was respected: **frontend only, dev pair only, no fleet node, no alpha-tester node,
|
||||
no Tailscale path, no OTA, no release.**
|
||||
|
||||
### Deployed commit contains all six fixes
|
||||
|
||||
Deployed commit `527f6023` (branch `main`). Every contributing commit is an ancestor of it:
|
||||
|
||||
| Plan | Commit | In `527f6023`? |
|
||||
|---|---|---|
|
||||
| 01-12 (UIFIX-02) | `ceafbcb5`, `b5628d96` | yes |
|
||||
| 01-13 (UIFIX-03) | (in `527f6023` via HEAD) | yes |
|
||||
| 01-14 (UIFIX-04/06) | `bc9a210c` | yes |
|
||||
| 01-15 (UIFIX-05) | `3288a02d` | yes |
|
||||
| 01-17 (UIFIX-01) | `8255b69a` | yes |
|
||||
|
||||
`git status --short -- neode-ui web` was **empty** before and after the deploy — the frontend was
|
||||
built from committed state only, and `npm install` did not dirty `package-lock.json`. The manifest's
|
||||
`dirty: true` refers to other executors' concurrent backend work in `core/` (plans 10-02/10-04/10-06),
|
||||
not to any frontend file.
|
||||
|
||||
### Served-bundle grep — the T-01-83 check
|
||||
|
||||
A trap was hit and is worth recording, because the naive version of this check would have produced a
|
||||
false pass. `/opt/archipelago/web-ui/assets` accumulates hashed chunks from **every previous deploy**,
|
||||
so `grep -rl` across that directory finds strings in long-dead chunks. Before the deploy, a naive
|
||||
directory grep reported UIFIX-02/03/04/05 "FOUND" — but three of those hits were in stale chunk
|
||||
generations, and the *live* `Web5-*.js` at that moment did not contain the 40rem floor at all.
|
||||
|
||||
The live chunk set was therefore resolved from the service-worker precache manifest (`sw.js`, 110
|
||||
entries, containing the entry chunk `index--lyLAgu1.js` referenced by the served `index.html`), and
|
||||
each chunk was then **fetched over HTTP from `http://archi-dev-box`** and grepped — not read from
|
||||
`web/dist`, and not read from the assets directory.
|
||||
|
||||
| Fix | String probed | Live chunk fetched over HTTP | Result |
|
||||
|---|---|---|---|
|
||||
| UIFIX-01 | `flex flex-wrap items-center gap-2 text-xs` | `/assets/Cloud-yWcQ9GU-.js` | **PRESENT** |
|
||||
| UIFIX-01 | `inline-flex shrink-0 items-center gap-1.5` | `/assets/Cloud-yWcQ9GU-.js` | **PRESENT** |
|
||||
| UIFIX-02 | `xl:basis-0` | `/assets/Web5-BvURT8TJ.js` | **PRESENT** |
|
||||
| UIFIX-02 | `min-h-[40rem]` | `/assets/Web5-BvURT8TJ.js` | **PRESENT** |
|
||||
| UIFIX-03 | `One more step below` | `/assets/OnboardingSeedGenerate-C7UXVA3p.js` | **PRESENT** |
|
||||
| UIFIX-04 | `resolveBlobUrl` | `/assets/Cloud-yWcQ9GU-.js` | **PRESENT** |
|
||||
| UIFIX-05 | `lightbox-pip-handoff` | `/assets/MediaLightbox-DKht-qI-.js` + `MediaLightbox-DAsuRYOD.css` | **PRESENT** |
|
||||
| UIFIX-06 | `Opening…` | `/assets/Cloud-yWcQ9GU-.js` | **PRESENT** |
|
||||
|
||||
URL base fetched: **`http://archi-dev-box`** (`index.html`, `sw.js`, and each chunk under `/assets/`).
|
||||
All chunk hashes changed from the pre-deploy build, so this is provably a new build and not a no-op.
|
||||
|
||||
### On-device boot check (real Chromium, on the node)
|
||||
|
||||
Beyond the grep, the deployed bundle was loaded in a real Chromium (Playwright 1.58.2, chromium-1208)
|
||||
against `http://archi-dev-box` at **1440×900** and **390×740**:
|
||||
|
||||
| Viewport | App mounted | Entry script | Console errors | Page errors | Failed requests |
|
||||
|---|---|---|---|---|---|
|
||||
| 1440×900 | yes | `/assets/index--lyLAgu1.js` | 0 | 0 | 0 |
|
||||
| 390×740 | yes | `/assets/index--lyLAgu1.js` | 0 | 0 | 0 |
|
||||
|
||||
Both land on `http://archi-dev-box/login` with a password field. Post-deploy health check reported
|
||||
`Health: OK (200) after 5s`; backend `active`. The entry script matches the served `index.html` and
|
||||
the `sw.js` manifest, so the boot check exercised the same build the grep table describes.
|
||||
|
||||
**This proves the deploy is live and healthy. It proves nothing about any of the six numbered checks.**
|
||||
|
||||
### archy-x250-dev — the second dev-pair node
|
||||
|
||||
**OFFLINE. Recorded as an explicit gap, per the plan's instruction not to wait for it or pretend the
|
||||
pair was covered.**
|
||||
|
||||
```
|
||||
100.113.100.55 archy-x250-dev.tail08d8f2.ts.net ssmithx@ linux
|
||||
active; relay "mad"; offline, last seen 2d ago, tx 5573880 rx 0
|
||||
```
|
||||
|
||||
MagicDNS has no record (`lookup archy-x250-dev ... no such host`), so it cannot even be addressed by
|
||||
name. This continues unbroken from phase 2, where 02-08 checked three times and never found it up.
|
||||
**archy-x250-dev has received neither phase 2's nor this plan set's frontend and still needs the same
|
||||
`--frontend-only` deploy once it is reachable.**
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Six-fix sign-off — NOT DONE (blocking human checkpoint)
|
||||
|
||||
### Per-check status
|
||||
|
||||
| # | Check | Requirement | Status | Why |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Connected-nodes card sibling-matched height + inner scroll, tab-switch stability, single-column unchanged | UIFIX-02 | **NOT VERIFIED** | Needs an authenticated session on the node; no UI password available to this executor |
|
||||
| 2 | Onboarding cue at ~1280×620, click-to-reveal, Continue stays disabled, no cue at full height | UIFIX-03 | **NOT VERIFIED** | Needs an authenticated session; and see the safety note below — this executor will not drive a seed-generation flow on a provisioned node holding real funds |
|
||||
| 3a | Purchased **picture** → row spinner + "Opening…" → app lightbox, no new tab | UIFIX-04 / UIFIX-06 | **NOT VERIFIED** | Needs an authenticated session. The content exists (see below), so this one is genuinely runnable by a human today |
|
||||
| 3b | Purchased **video** → lightbox with player controls | UIFIX-04 | **NOT VERIFIED — and NOT CURRENTLY TESTABLE** | The node owns no purchased video (see below) |
|
||||
| 3c | Purchased **music** → bottom-bar player, not the lightbox | UIFIX-04 | **NOT VERIFIED — and NOT CURRENTLY TESTABLE** | The node owns no purchased audio (see below) |
|
||||
| 3d | Double-click issues one load, not two | UIFIX-06 | **NOT VERIFIED** | Needs an authenticated session |
|
||||
| 4 | PiP handoff animation, survives tab changes, survives a buffering pause, clean explicit close, unchanged normal close/Escape | UIFIX-05 | **NOT VERIFIED — and NOT CURRENTLY TESTABLE** | Requires a video in the lightbox; the node has **no video content at all** (see below) |
|
||||
| 5 | FIPS/Tor pills fully readable at phone width on peer cards and peer files, long names included; desktop unchanged | UIFIX-01 | **NOT VERIFIED** | Needs an authenticated session and a real phone or stated device emulation |
|
||||
| 6 | Nothing else moved — page margins, tab slide transitions, existing animations | (standing visual-invisibility rule) | **NOT VERIFIED** | Needs an authenticated session and human perceptual judgment |
|
||||
|
||||
**Zero of the six numbered checks passed. None of them failed either — none were run.** The plan's
|
||||
transparency prohibition forbids signing any node-named check off on local-preview evidence, and that
|
||||
is all the prior evidence is: 01-12/13/15/17 each verified themselves against a local dev preview or
|
||||
`:4321` build preview, and 01-14 against unit tests only.
|
||||
|
||||
### The two blockers, precisely
|
||||
|
||||
**Blocker A — no authenticated session.** `http://archi-dev-box` redirects to `/login` and requires a
|
||||
password; `POST /rpc/v1 {system.status}` returns `401 Unauthorized`. 02-08 records the node's UI
|
||||
password being supplied out-of-band by the coordinator and passed only via the `ARCHY_PASSWORD`
|
||||
environment variable. It was not supplied for this run. **No credential was guessed** — this node
|
||||
holds real federation trust, real purchases and real funds, and probing passwords against it is not a
|
||||
verification step.
|
||||
|
||||
**Blocker B — missing media on the node.** Settled from the node's own data (read-only, counts and
|
||||
mime types only; no filenames, no content, no seed material read):
|
||||
|
||||
- `/var/lib/archipelago/purchased-content/owned.json` — **1 owned item, mime `image/jpeg`**.
|
||||
So the planner's flagged question is answered: **archi-dev-box does have purchased content, but
|
||||
exactly one item and it is a picture.** Check 3a is runnable on real purchased content today.
|
||||
- **No purchased video and no purchased audio exist**, so checks 3b and 3c cannot be exercised on
|
||||
purchased content on this node at all.
|
||||
- A scan of `content/`, `blobs/`, `filebrowser-data/`, `content/files/`, `~/Files`, `~/Videos`,
|
||||
`~/Music`, `~/Documents` found **zero video files and zero audio files anywhere**. Check 4 (PiP)
|
||||
needs a video in the lightbox and therefore **cannot be run on this node as it stands.**
|
||||
|
||||
Per the plan's own instruction, this is said plainly rather than passed on the demo: **a demo-only or
|
||||
preview-only pass for the paid-content and PiP paths would be exactly the divergence class this phase
|
||||
exists to remove.**
|
||||
|
||||
### What still needs a human at a device
|
||||
|
||||
1. **Supply the archi-dev-box UI password out-of-band** (as in 02-08), or drive the checks by hand in
|
||||
a logged-in browser on the node. Without this, checks 1, 2, 3a, 3d, 5 and 6 cannot start.
|
||||
2. **Put one video file on the node** — upload an `.mp4` through My Files is enough — so check 4 (PiP)
|
||||
and the video half of check 3 have something to open. Without it check 4 is untestable, not failing.
|
||||
3. **Purchase (or seed) one audio item and one video item** if checks 3b/3c are to be run against the
|
||||
genuine Paid Files path rather than My Files. If that is not worth doing, record 3b/3c as
|
||||
permanently deferred with that reason rather than as passed.
|
||||
4. **Use a real phone, or state that device emulation was used**, for checks 1 (narrow), 2 and 5. The
|
||||
plan asks explicitly which was used; this summary cannot answer it.
|
||||
5. **Check 4's buffering-pause sub-step needs devtools network throttling or a long seek** — a human
|
||||
action, on the node's own browser.
|
||||
6. **Check 6 is irreducibly perceptual** ("look exactly as they did before"). Phase 2 broke margins
|
||||
and slide transitions this way once; no script substitutes for the eye here.
|
||||
7. **Re-deploy to `archy-x250-dev` once it comes back** — it is 2 days offline and has neither this
|
||||
plan set's nor phase 2's frontend.
|
||||
|
||||
### Safety note on check 2
|
||||
|
||||
Check 2 exercises the onboarding **seed step**. Threat `T-01-84` in this plan's register covers exactly
|
||||
this: the operator is asked to judge the step's *layout* only. Do not screenshot, photograph,
|
||||
transcribe or paste the recovery words, and do not attach an image of that screen to any summary,
|
||||
issue or chat. This executor did not open that flow at all — beyond the credential blocker, generating
|
||||
or restoring a seed on a provisioned node holding real funds is not a layout check.
|
||||
|
||||
---
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Precondition, satisfied in substance but not in letter — recorded
|
||||
|
||||
Task 1's `<precondition>` names `scripts/deploy-config.sh`. **That file does not exist on this
|
||||
machine.** It was not treated as an unmet precondition after checking what it actually supplies:
|
||||
`scripts/deploy-config.example` shows its only required content is `ARCHIPELAGO_PASSWORD`, and the
|
||||
deploy authenticated fine without it — `ssh -i ~/.ssh/archipelago-deploy archipelago@archi-dev-box`
|
||||
succeeded under `BatchMode=yes` (key auth, symlinked to `id_ed25519`) and `sudo -n true` returned
|
||||
`SUDO_NOPASSWD_OK` on the target. The precondition's stated purpose ("so the deploy script can
|
||||
authenticate") was therefore met by other means, and the deploy's exit code 0 confirms it. Recorded
|
||||
rather than glossed: **if a future run of this plan needs the password path — a different target, or
|
||||
`sudo` tightened on this one — `deploy-config.sh` will have to be created first.**
|
||||
|
||||
### Task 2 not executed; no checkpoint round-trip
|
||||
|
||||
This plan is `autonomous: false` and Task 2 is `checkpoint:human-verify gate="blocking"`. Its
|
||||
verification is a human verdict, which was not obtainable in this run. Rather than stop with nothing
|
||||
recorded, Task 1 was completed in full and the checkpoint's blockers were investigated so that whoever
|
||||
picks it up starts with the environment already proven ready (bundle confirmed live) and the two
|
||||
practical obstacles already identified and quantified. **The plan is not complete and UIFIX-01..06 are
|
||||
not closed.**
|
||||
|
||||
### Plan-state files deliberately not updated
|
||||
|
||||
`STATE.md`, `ROADMAP.md` and `REQUIREMENTS.md` were **not** modified and **not** committed:
|
||||
`.planning/STATE.md` was already dirty with a concurrent executor's edit, the execution brief limited
|
||||
this plan's commit to this SUMMARY alone, and — most importantly — **marking UIFIX-01..06 complete
|
||||
would be false** while the sign-off that closes them is unanswered.
|
||||
|
||||
### No source file modified, no fix applied
|
||||
|
||||
`files_modified: []` was honoured. Nothing was found that needed fixing, but the standing rule for
|
||||
this run was that a genuine bug would be recorded as a finding, not patched inline, because three
|
||||
other executors held the same working tree throughout.
|
||||
|
||||
## Findings
|
||||
|
||||
1. **The assets directory on the node is never pruned.** `/opt/archipelago/web-ui/assets` holds many
|
||||
generations of hashed chunks (the deploy's `rm -rf` excludes `aiui`/`claude-login.html` but the
|
||||
directory still showed several `Cloud-*.js`, `Web5-*.js` and `MediaLightbox-*.js` hashes
|
||||
simultaneously). Harmless at runtime — the entry chunk and `sw.js` pin the live set — but it makes
|
||||
any disk-side "is the fix deployed?" grep unreliable, and it silently inflates the directory.
|
||||
Not fixed here (out of scope). Anyone verifying a deploy by grep must resolve the live set from
|
||||
`sw.js` first, as this plan did.
|
||||
2. **The container doctor applied 2 fixes during the deploy** (`rootless-ports` restart of
|
||||
`netbird-server` for a missing 3478 listener, and an NPM public-hosts sync), 12 checks passed. This
|
||||
is the deploy script's normal behaviour, noted only because it means the deploy was not purely a
|
||||
frontend file copy.
|
||||
3. **`⚠️ No ANTHROPIC_API_KEY found`** during the Claude API proxy step — pre-existing on this node,
|
||||
unrelated to this plan, not acted on.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — this plan wrote no code.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. No new endpoint, auth path, file-access pattern or schema change.
|
||||
|
||||
Threat register status:
|
||||
- **T-01-82** (verification deploy reaching fleet/alpha-tester nodes) — **mitigated**: frontend-only
|
||||
dev-pair path, no Tailscale/alpha-tester/`--both`/`--fleet` flag, no release or OTA path; exact
|
||||
command and single-host list recorded above and corroborated by the node's own deploy manifest.
|
||||
- **T-01-83** (signing off against a stale bundle) — **mitigated, and the threat was real**: the naive
|
||||
disk-side grep would have produced false hits from dead chunks. Verified instead against the live
|
||||
`sw.js` chunk set fetched over HTTP.
|
||||
- **T-01-84** (a seed exposed by check 2's verification) — **mitigated**: the onboarding seed flow was
|
||||
not opened, nothing was captured, and the warning is restated for whoever runs check 2.
|
||||
- **T-01-85** (a demo/preview-only pass recorded as a node pass) — **mitigated**: no check is marked
|
||||
passed. The node's purchased-content inventory (1 image, 0 video, 0 audio) is stated explicitly so
|
||||
the paid-content path cannot be quietly signed off on the demo.
|
||||
- **T-01-SC** (package installs) — nothing installed. `npm install --silent` inside the deploy ran
|
||||
against the existing committed lockfile and left `neode-ui` clean; no dependency added.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `.planning/phases/01-federation-mesh-hardening/01-18-SUMMARY.md` — FOUND (this file)
|
||||
- Deployed commit `527f6023` — FOUND on the node in `/opt/archipelago/deploy-manifest.json`
|
||||
- `8255b69a`, `ceafbcb5`, `b5628d96`, `bc9a210c`, `3288a02d` — all confirmed ancestors of `527f6023`
|
||||
- Served chunks `Cloud-yWcQ9GU-.js`, `Web5-BvURT8TJ.js`, `MediaLightbox-DKht-qI-.js`,
|
||||
`OnboardingSeedGenerate-C7UXVA3p.js` — all fetched over HTTP from `http://archi-dev-box` and grepped
|
||||
- `git status --short -- release-manifest.json releases/ app-catalog/` — empty
|
||||
- `git status --short -- neode-ui web` — empty
|
||||
- No source file modified by this plan
|
||||
|
||||
---
|
||||
*Phase: 01-federation-mesh-hardening*
|
||||
*Task 1 complete 2026-08-02. Task 2 open — awaiting a human at the device.*
|
||||
@@ -1,103 +0,0 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 19
|
||||
subsystem: lightning
|
||||
tags: [lnd, invoices, route-hints, private-channels, wallet]
|
||||
|
||||
requires:
|
||||
- phase: 01-federation-mesh-hardening
|
||||
provides: "The two existing LND invoice-creation call sites in core/archipelago/src/api/rpc/lnd/wallet.rs — the seller-side/peer-file flow and the wallet UI's Receive flow"
|
||||
provides:
|
||||
- "build_invoice_request_body(): one place where an invoice body is minted, with `private: true` unconditional so LND embeds route hints for unannounced channels"
|
||||
affects: [wallet, lightning, paid-content]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Two call sites that must agree get one shared constructor plus one test on the constructor, rather than two near-identical literals and a hope. The duplicated json! literal is exactly how one site got fixed and the other didn't."
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- core/archipelago/src/api/rpc/lnd/wallet.rs
|
||||
|
||||
key-decisions:
|
||||
- "`private: true` is unconditional rather than conditional on 'does this node have only private channels'. It is harmless when public channels exist — LND still routes directly over a public channel when it can, and the hint is an unused alternate path — so the conditional would add a failure mode (mis-detecting channel state) to buy nothing."
|
||||
- "Both call sites route through one constructor so a single test pins the field for both, and neither can silently drift back to `false`."
|
||||
|
||||
requirements-completed: [FED-08]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "An invoice minted by the wallet UI's Receive flow embeds route hints for private/unannounced channels"
|
||||
requirement: "FED-08"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "core/archipelago/src/api/rpc/lnd/wallet.rs#invoice_request_body_always_sets_private_true"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "The seller-side/peer-file invoice path embeds them too — the twin site does not drift"
|
||||
requirement: "FED-08"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "Both call sites (wallet.rs:330 and wallet.rs:554) call build_invoice_request_body; the json! literal exists in exactly one place"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "A payment actually arrives over a private channel on an affected node"
|
||||
requirement: "FED-08"
|
||||
verification:
|
||||
- kind: manual_procedural
|
||||
ref: "Post-OTA checkpoint on the affected node (archy-x250-mad2) — NOT RUN; the plan forbids deploying directly to a user device, so this is verified after the release lands"
|
||||
status: deferred
|
||||
human_judgment: true
|
||||
|
||||
duration: 20min
|
||||
completed: 2026-08-02
|
||||
status: complete-pending-post-ota-checkpoint
|
||||
---
|
||||
|
||||
# Phase 1 Plan 19: Invoices Embed Route Hints for Private Channels (FED-08) Summary
|
||||
|
||||
**A node whose inbound liquidity sits on unannounced channels could not be paid: its invoices carried no route hints, so a payer had no way to discover a path in. Both invoice-creation paths now mint their body through one constructor that sets `private: true` unconditionally.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~20 min (code committed 2026-07-31 as `e5c38866`; this SUMMARY 2026-08-02)
|
||||
- **Tasks:** 1/2 (Task 2 is a post-OTA checkpoint on a user device — see below)
|
||||
- **Files modified:** 1
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `build_invoice_request_body(amount_sats, memo)` is now the single place an LND `/v1/invoices` body is constructed, with `private: true` set unconditionally and a doc comment explaining why it must stay that way.
|
||||
- Both call sites use it: the seller-side/peer-file flow (`create_invoice`) and the wallet UI's Receive flow (`handle_lnd_createinvoice`, the `lnd.createinvoice` RPC). The duplicated `json!` literal that let one site diverge from the other is gone.
|
||||
- `invoice_request_body_always_sets_private_true` pins the field for both sites at once.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None.
|
||||
|
||||
**Process note:** as with 01-20, the code landed on 2026-07-31 (`e5c38866`) but no SUMMARY was written and the roadmap entry was never ticked, so the plan read as unstarted. This file closes that gap.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. `private: true` does not weaken anything: it adds a routing hint to an invoice the payer already holds. It does reveal the existence of an unannounced channel to whoever holds that specific invoice — which is inherent to being payable over that channel at all, and is the explicit intent of the requirement.
|
||||
|
||||
## Outstanding: post-OTA checkpoint (Task 2)
|
||||
|
||||
The affected node is **archy-x250-mad2, a user's device**. Commit `516c3bfa` records the standing
|
||||
constraint: never deploy directly to a user device — verify post-OTA instead. So the remaining
|
||||
verification is:
|
||||
|
||||
1. Ship the release
|
||||
2. After it lands on the affected node, mint an invoice from the wallet's Receive flow
|
||||
3. Confirm the BOLT11 decodes with a route hint (`r` field) for the private channel
|
||||
4. Confirm a payment from outside actually settles over it
|
||||
|
||||
This is the one piece of FED-08 that cannot be proven from here.
|
||||
</content>
|
||||
@@ -1,131 +0,0 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 20
|
||||
subsystem: infra
|
||||
tags: [tor, doctor, shell, mesh, permissions]
|
||||
|
||||
requires:
|
||||
- phase: 01-federation-mesh-hardening
|
||||
provides: "scripts/container-doctor.sh's fix_* convention and the archipelago-doctor.timer (OnUnitActiveSec=5min) that drives it"
|
||||
provides:
|
||||
- "A permission predicate that tests the property that matters (group/other denied) instead of exact-matching one octal string"
|
||||
- "A 30-minute Tor restart backoff, so a restart storm is structurally impossible even if some future condition makes the fix fire repeatedly"
|
||||
affects: [tor, mesh, doctor]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Assert the property, not the representation: `2700` and `700` both deny group and other, which is the security property. Exact-matching the string `700` turned Tor's own setgid bit into a permanent false positive."
|
||||
- "Pair a predicate fix with a rate limit: the predicate stops today's loop, the backoff stops the whole class of loop. One is a fix, both is a guarantee."
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- scripts/container-doctor.sh
|
||||
|
||||
key-decisions:
|
||||
- "Compare the low three digits of `stat -c '%a'` rather than the whole string, so both `700` and `2700` read as correct while `750`, `707` and `2755` are still corrected."
|
||||
- "The 'already correct' path logs at debug level only. The original defect was invisible precisely because 'Fixed permissions on … (2700 -> 700)' looked like the doctor working — a healthy node should stay quiet."
|
||||
- "Backoff state lives in `/var/lib/archipelago/doctor-tor-last-restart` with a 1800s window, and non-numeric/missing content reads as 0 so a corrupt state file fails open into 'restart allowed' rather than wedging the fix permanently."
|
||||
|
||||
requirements-completed: [FED-09]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "The doctor no longer restarts Tor on every run — Tor uptime grows past the doctor's 5-minute interval"
|
||||
requirement: "FED-09"
|
||||
verification:
|
||||
- kind: manual_procedural
|
||||
ref: "archi-dev-box 2026-08-02: tor@default active since 2026-08-01 15:25:45 — 15+ hours continuous, spanning ~180 doctor intervals"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "A hidden-service directory at Tor's own 2700 is recognised as correct — no chmod, no restart"
|
||||
requirement: "FED-09"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "Predicate exercised directly on tmpdirs: 2700 -> low3=700 -> ACCEPTED; 700 -> ACCEPTED"
|
||||
status: pass
|
||||
- kind: manual_procedural
|
||||
ref: "archi-dev-box: zero 'Fixed permissions … hidden_service' lines across 542 doctor log entries in 6 hours"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "A genuinely insecure directory (group/other readable) is still corrected"
|
||||
requirement: "FED-09"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "Predicate exercised on 750, 707 and 2755 — all three fall through to the corrective chmod"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "Even on a real fix, Tor cannot be restarted more than once per backoff window"
|
||||
requirement: "FED-09"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "TOR_RESTART_BACKOFF_SECONDS=1800 gates the restart against TOR_RESTART_STATE_FILE; non-numeric state reads as 0"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Tor retains its consensus/HSDir cache long enough to resolve .onion addresses"
|
||||
requirement: "FED-09"
|
||||
verification:
|
||||
- kind: manual_procedural
|
||||
ref: "Post-OTA checkpoint on an affected node — NOT RUN (see below). 15h of unbroken Tor uptime on archi-dev-box is the necessary precondition and is met."
|
||||
status: deferred
|
||||
human_judgment: true
|
||||
|
||||
duration: 30min
|
||||
completed: 2026-08-02
|
||||
status: complete-pending-post-ota-checkpoint
|
||||
---
|
||||
|
||||
# Phase 1 Plan 20: Stop the Doctor Fighting Tor Over the Setgid Bit (FED-09) Summary
|
||||
|
||||
**The container doctor treated Tor's own `2700` hidden-service directory as a permission defect, "fixed" it, and restarted Tor — every five minutes, forever. Tor never lived long enough to build a usable HSDir cache, so onion lookups failed and the mesh's Tor fallback died with it.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~30 min (code committed 2026-07-31 as `4435f95e`; verification and this SUMMARY 2026-08-02)
|
||||
- **Tasks:** 1/2 (Task 2 is a post-OTA checkpoint — see below)
|
||||
- **Files modified:** 1
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- The predicate now compares the low three digits of `stat -c '%a'`, so Tor's setgid `2700` and a plain `700` both read as correct, while `750`, `707` and `2755` are still corrected. Verified directly against all five modes.
|
||||
- A 30-minute restart backoff (`/var/lib/archipelago/doctor-tor-last-restart`) makes a restart storm impossible even if some future condition makes the fix fire repeatedly. The predicate fixes today's bug; the backoff retires the class.
|
||||
- The "already correct" branch logs at debug level so a healthy node stays quiet — the original defect hid inside a log line that read like success.
|
||||
|
||||
## Evidence on a real node (archi-dev-box, 2026-08-02)
|
||||
|
||||
- `tor@default` active since **2026-08-01 15:25:45** — over 15 hours continuous, spanning roughly 180 doctor intervals. Before the fix, observed restarts were 13:07:56 → 13:13:14 → 13:18:39 → 13:23:57, each within a second of a timer firing.
|
||||
- **Zero** `Fixed permissions … hidden_service` lines across **542** doctor log entries in the preceding 6 hours.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None. The implementation matches the plan's action exactly: predicate corrected at the property level, backoff added, logging made quiet-when-healthy.
|
||||
|
||||
**Process note:** the code landed on 2026-07-31 as `4435f95e` but no SUMMARY was written and the roadmap entry was never ticked, so the plan looked unstarted for two days. That is the bookkeeping gap this file closes.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
- The plan's prohibition — never loosen hidden-service permissions — holds: group and other access is still denied on every path. `2700` is accepted precisely *because* it denies them; the setgid bit is orthogonal to that property.
|
||||
- **Scope boundary respected:** the FIPS direct-transport `connect_fail` (problem 1 in the diagnosis) was deliberately NOT touched here. It belongs with FED-03's structured review of the transport/dial layer.
|
||||
|
||||
## Outstanding: post-OTA checkpoint (Task 2)
|
||||
|
||||
Task 2 verifies on an **affected** node after the release lands, and explicitly forbids deploying
|
||||
directly to a user's device (`archy-x250-mad2` or any node that is not ours) — verification is
|
||||
post-OTA only. Remaining there:
|
||||
|
||||
1. Tor uptime keeps growing across three doctor intervals (met on archi-dev-box; needs repeating on an affected node)
|
||||
2. No recurring "Fixed permissions" lines (met on archi-dev-box)
|
||||
3. Onion resolution works — a mesh send to a Tor-only peer succeeds and `No more HSDir available to query` is gone
|
||||
4. The doctor still corrects a deliberately `chmod 750`'d hidden-service directory
|
||||
|
||||
Items 3 and 4 are the ones genuinely outstanding; 1 and 2 already have real-node evidence.
|
||||
</content>
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,400 +0,0 @@
|
||||
---
|
||||
phase: 10-key-material-hardening
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- core/archipelago/src/api/rpc/onboarding_gate.rs
|
||||
- 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
|
||||
autonomous: true
|
||||
requirements: [KEY-01]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "An already-provisioned node refuses seed.restore with attacker-supplied words, and its identity/node_key and identity/nostr_secret are byte-identical afterwards (D-01)"
|
||||
- "An already-provisioned node refuses seed.generate, seed.save-encrypted and backup.restore-identity with the same refusal (D-04)"
|
||||
- "A fresh, never-onboarded node still completes seed.generate -> seed.verify -> auth.setup -> auth.onboardingComplete with no refusal (trap 1)"
|
||||
- "A retried seed.generate during onboarding still returns the SAME words and is not rate-limited into a user-visible error (trap 2)"
|
||||
- "auth.onboardingComplete cannot be used by an unauthenticated caller to mark a fresh node onboarded and thereby lock it out of onboarding"
|
||||
- "The refusal names the authenticated recovery path (system.factory-reset) rather than leaving the caller with a dead end (D-02)"
|
||||
artifacts:
|
||||
- path: "core/archipelago/src/api/rpc/onboarding_gate.rs"
|
||||
provides: "The shared onboarding-posture gate and its regression suite"
|
||||
contains: "ensure_onboarding_open"
|
||||
min_lines: 120
|
||||
- path: "core/archipelago/src/api/rpc/seed_rpc.rs"
|
||||
provides: "Gated seed.generate / seed.restore / seed.save-encrypted handlers"
|
||||
contains: "ensure_onboarding_open"
|
||||
- path: "core/archipelago/src/api/rpc/backup_rpc.rs"
|
||||
provides: "Gated backup.restore-identity handler"
|
||||
contains: "ensure_onboarding_open"
|
||||
- path: "core/archipelago/src/rate_limit.rs"
|
||||
provides: "Per-method limits for the identity-mutating onboarding endpoints"
|
||||
contains: "seed.restore"
|
||||
key_links:
|
||||
- from: "core/archipelago/src/api/rpc/seed_rpc.rs"
|
||||
to: "core/archipelago/src/api/rpc/onboarding_gate.rs"
|
||||
via: "every identity-mutating handler calls the gate as its first statement"
|
||||
pattern: "ensure_onboarding_open"
|
||||
- from: "core/archipelago/src/api/rpc/onboarding_gate.rs"
|
||||
to: "core/archipelago/src/auth.rs"
|
||||
via: "reads is_setup() and is_onboarding_complete() as the two authoritative provisioning signals"
|
||||
pattern: "is_onboarding_complete"
|
||||
- from: "core/archipelago/src/api/rpc/onboarding_gate.rs"
|
||||
to: "core/archipelago/src/seed.rs"
|
||||
via: "reads seed_exists() as the on-disk provisioning signal"
|
||||
pattern: "seed_exists"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close F-01 (Critical): make every unauthenticated RPC that can mutate node identity or
|
||||
credentials **hard refuse** once the node is provisioned (D-01), behind one shared gate and one
|
||||
shared regression suite (D-04), while leaving first-boot onboarding on a fresh node completely
|
||||
intact.
|
||||
|
||||
Purpose: today a single unauthenticated JSON-RPC POST from anywhere on the LAN — or from any
|
||||
FIPS mesh peer — replaces a live node's Ed25519 identity, Nostr node key and FIPS transport key
|
||||
(`identity.rs:79-114`, `seed_rpc.rs:226-306`). There is no session check, no CSRF, no rate
|
||||
limit. This is live on every fleet node right now.
|
||||
|
||||
Output: a new `onboarding_gate` module, four gated handlers, a guarded
|
||||
`auth.onboardingComplete`, per-method rate limits sized against the real client retry budget,
|
||||
and a regression suite that fails the moment the gate is removed.
|
||||
|
||||
**This plan is deliberately self-contained (D-11).** It has no `depends_on`, touches no file
|
||||
another plan in this phase touches, and can be cut on its own if the OTA schedule (D-10) moves.
|
||||
Do not refactor anything shared with 10-04 or 10-06 — duplicate a little rather than couple.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/10-key-material-hardening/10-CONTEXT.md
|
||||
@docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md
|
||||
@CLAUDE.md
|
||||
</context>
|
||||
|
||||
<scoping_correction>
|
||||
**Read this before writing a line of code. It overrides the literal wording of D-03.**
|
||||
|
||||
D-03 names `NodeIdentity::key_exists` (`identity.rs:117`) as the on-disk "node is onboarded"
|
||||
signal, and CONTEXT.md calls it "already written and already correct". That belief is factually
|
||||
wrong for this codebase, and implementing D-03 literally **bricks first boot on every new node**:
|
||||
|
||||
`Server::new` (`core/archipelago/src/server.rs:63-71`) calls `NodeIdentity::load_or_create`
|
||||
unconditionally on every start, and `load_or_create` (`identity.rs:25-58`) **generates and
|
||||
writes a random `node_key` when none exists** — its own comment says "Fresh install — create a
|
||||
temporary identity. Onboarding will overwrite this with seed-derived keys." So
|
||||
`key_exists(identity_dir)` is `true` on every booted node, fresh or provisioned. A gate keyed on
|
||||
it refuses `seed.generate` on a node that has never been onboarded. The audit's own suggested
|
||||
remediation ("bail when `key_exists` is true *and* the in-memory onboarding mnemonic is absent")
|
||||
has the same defect: on a genuinely fresh node the pending mnemonic is also absent.
|
||||
|
||||
**D-03's intent is preserved exactly** — two independent signals, OR-ed, failing safe when they
|
||||
disagree. Only the choice of which on-disk artefact carries the meaning changes, and it changes
|
||||
because of evidence discovered during planning. The signal set this plan implements is:
|
||||
|
||||
| Signal | Source | Fresh node | Mid-onboarding | Provisioned |
|
||||
|---|---|---|---|---|
|
||||
| `AuthManager::is_setup()` (`auth.rs:116-119`, `user.json` exists) | disk | false | false | true |
|
||||
| `AuthManager::is_onboarding_complete()` (`auth.rs:182-219`, incl. its auto-heal drift logic) | disk + flag | false | false | true |
|
||||
| `crate::seed::seed_exists()` (`seed.rs:384-386`, `identity/master_seed.enc`) | disk | false | false | true (legacy nodes: false — covered by the other two) |
|
||||
|
||||
`NodeIdentity::key_exists` and `identity::fips_key_exists` were both evaluated and rejected as
|
||||
refusal signals; record that verdict with the `file:line` evidence in Task 2. This is the same
|
||||
class of correction the audit itself made about `image-recipe/_archived/` being live — surface
|
||||
it, do not bury it.
|
||||
</scoping_correction>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end refusal for seed.restore on a provisioned node — one path, proven</name>
|
||||
<reversibility rating="costly">The refusal becomes an observable API contract the onboarding UI, the companion app and any restore tooling are written against; loosening it later is safe, tightening a looser rule after release is not (D-01).</reversibility>
|
||||
<files>core/archipelago/src/api/rpc/onboarding_gate.rs, core/archipelago/src/api/rpc/mod.rs, core/archipelago/src/api/rpc/seed_rpc.rs</files>
|
||||
<read_first>
|
||||
- core/archipelago/src/api/rpc/seed_rpc.rs (lines 1-60 and 226-310 — handler under change, plus the ONBOARDING_MNEMONIC state and its Drop/zeroize contract)
|
||||
- core/archipelago/src/auth.rs (lines 116-219 — is_setup, complete_onboarding, is_onboarding_complete and its auto-heal drift logic)
|
||||
- core/archipelago/src/seed.rs (lines 355-390 — save_seed_encrypted / seed_exists)
|
||||
- core/archipelago/src/identity.rs (lines 25-119 — load_or_create vs from_seed vs key_exists; this is the evidence for the scoping correction above)
|
||||
- core/archipelago/src/server.rs (lines 50-75 — proves load_or_create runs on every boot)
|
||||
- core/archipelago/src/api/rpc/middleware.rs (lines 43-75 — sanitize_error_message, which decides whether the refusal text survives to the caller)
|
||||
- core/archipelago/src/api/rpc/mod.rs (lines 1-72 — module declarations, to add the new submodule)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `core/archipelago/src/api/rpc/onboarding_gate.rs` and declare it in
|
||||
`core/archipelago/src/api/rpc/mod.rs`. It exports exactly two items:
|
||||
|
||||
1. `pub(in crate::api::rpc) const IDENTITY_MUTATING_ONBOARDING_METHODS: &[&str]` — the D-04
|
||||
sweep set, as method-name strings: `seed.generate`, `seed.restore`, `seed.save-encrypted`,
|
||||
`backup.restore-identity`, `auth.setup`, `auth.onboardingComplete`. This constant is the
|
||||
anti-drift anchor Task 2's source-guard test reads; it does not itself dispatch anything.
|
||||
2. `pub(in crate::api::rpc) async fn ensure_onboarding_open(data_dir: &std::path::Path, auth: &crate::auth::AuthManager) -> anyhow::Result<()>`.
|
||||
|
||||
`ensure_onboarding_open` returns `Ok(())` only when ALL THREE signals from the scoping
|
||||
correction above say "not yet provisioned": `auth.is_setup()` is false, AND
|
||||
`auth.is_onboarding_complete()` is false, AND `crate::seed::seed_exists(data_dir)` is false.
|
||||
If ANY signal says provisioned it returns `Err` whose message begins with the literal prefix
|
||||
`Not supported:` — that exact prefix is required, because `sanitize_error_message`
|
||||
(`middleware.rs:47-71`) only passes an error through to the caller when it starts with a known
|
||||
prefix, and `Not supported` is already on that list. The message body must name the authenticated
|
||||
recovery path from D-02 verbatim in spirit: this node is already provisioned; re-keying requires
|
||||
the authenticated `system.factory-reset`. Do not include which of the three signals fired — a
|
||||
one-bit "provisioned" answer discloses nothing beyond what the already-unauthenticated
|
||||
`auth.isOnboardingComplete` discloses, per CONTEXT.md's discretion note; a per-signal breakdown
|
||||
would disclose more.
|
||||
|
||||
Treat an I/O error from any signal as provisioned (fail safe), not as open. Document that
|
||||
choice in a doc comment on the function, along with the three-signal table and the reason
|
||||
`NodeIdentity::key_exists` is NOT one of them (cite `server.rs:63-71`).
|
||||
|
||||
Then wire the first path end-to-end. In `seed_rpc.rs`, extract the entire body of
|
||||
`handle_seed_restore` into a free async function
|
||||
`pub(in crate::api::rpc) async fn restore_node_identity_from_words(data_dir: &std::path::Path, auth: &crate::auth::AuthManager, words: &[String]) -> anyhow::Result<serde_json::Value>`,
|
||||
whose FIRST statement is `ensure_onboarding_open(data_dir, auth).await?`. `handle_seed_restore`
|
||||
becomes a thin wrapper that parses `params.words` and delegates. The extraction exists so the
|
||||
regression test can drive the real production path against a temp data dir without constructing
|
||||
an `RpcHandler` (which needs an orchestrator, port allocator, session store and metrics store).
|
||||
Preserve every existing behaviour verbatim: the ONBOARDING_MNEMONIC stash, the 0600 permissions
|
||||
on `nostr_secret`, `save_identity_index`, the IdentityManager default-identity creation, and
|
||||
`spawn_post_onboarding_fips_activate`.
|
||||
|
||||
Add a `#[cfg(test)] mod tests` in `onboarding_gate.rs` with, at minimum:
|
||||
`refuses_when_user_json_exists`, `refuses_when_onboarding_flag_set`,
|
||||
`refuses_when_encrypted_seed_on_disk`, `allows_on_fresh_temp_dir_even_though_node_key_exists`
|
||||
(this last one writes a `node_key` file first, to pin the scoping correction as a test rather
|
||||
than a comment), and the headline
|
||||
`provisioned_node_refuses_restore_and_identity_bytes_are_unchanged`: derive an identity from
|
||||
seed A via `NodeIdentity::from_seed` plus a `nostr_secret` write, mark the node provisioned via
|
||||
`AuthManager::complete_onboarding()`, snapshot the bytes of `identity/node_key` and
|
||||
`identity/nostr_secret`, call `restore_node_identity_from_words` with a valid but
|
||||
attacker-chosen 24-word mnemonic, assert the call returned `Err`, and assert both files are
|
||||
byte-identical to the snapshot.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate:: -- --nocapture</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate::` passes with at least 5 tests.
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo build -p archipelago` succeeds and `cargo clippy -p archipelago -- -D warnings` is clean for the touched files.
|
||||
- Executor MUST record, in the plan SUMMARY, the observed failure output from a scratch run with the `ensure_onboarding_open(...)` call commented out of `restore_node_identity_from_words`: `provisioned_node_refuses_restore_and_identity_bytes_are_unchanged` must FAIL in that state and PASS with the call restored. Revert the scratch edit before committing.
|
||||
- `grep -n 'Not supported:' core/archipelago/src/api/rpc/onboarding_gate.rs` returns at least one line.
|
||||
- `grep -c 'ensure_onboarding_open' core/archipelago/src/api/rpc/seed_rpc.rs` is at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>An already-provisioned node rejects `seed.restore` with attacker-supplied words and its `node_key` and `nostr_secret` are provably byte-identical afterwards; a fresh temp dir with a `node_key` on it still passes the gate.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Sweep the remaining identity- and credential-mutating unauthenticated methods (D-04)</name>
|
||||
<files>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/api/rpc/onboarding_gate.rs</files>
|
||||
<read_first>
|
||||
- core/archipelago/src/api/rpc/onboarding_gate.rs (Task 1 output — the gate and the method constant)
|
||||
- core/archipelago/src/api/rpc/seed_rpc.rs (lines 90-160 for the generate lock + TTL fast path; 308-340 for save-encrypted; 160-224 for verify)
|
||||
- core/archipelago/src/api/rpc/backup_rpc.rs (lines 405-434 — handle_backup_restore_identity)
|
||||
- core/archipelago/src/backup/identity.rs (lines 72-120 — restore_encrypted_backup, which writes identity/node_key unconditionally; this is the evidence that backup.restore-identity reaches the same primitive)
|
||||
- core/archipelago/src/api/rpc/auth.rs (lines 195-270 — handle_auth_setup and handle_auth_onboarding_complete)
|
||||
- core/archipelago/src/api/rpc/middleware.rs (lines 5-38 — the full UNAUTHENTICATED_METHODS list being swept)
|
||||
</read_first>
|
||||
<action>
|
||||
Apply `ensure_onboarding_open` to the rest of the D-04 set, each with the ordering its own
|
||||
handler requires:
|
||||
|
||||
`handle_seed_generate` (`seed_rpc.rs:93`): call the gate BEFORE acquiring the ONBOARDING_MNEMONIC
|
||||
lock's fast path. Ordering matters in both directions and both are load-bearing. Gate-first is
|
||||
required because the idempotent fast path returns the 24 words to an unauthenticated caller, so
|
||||
on a provisioned node whose in-memory mnemonic survived (the `auth.setup` encrypted-save is
|
||||
best-effort and can fail) the fast path is itself a disclosure. Gate-first is also SAFE for
|
||||
onboarding because all three signals are false throughout the seed steps — `auth.setup` runs
|
||||
after them in the real flow (router order: `onboarding/seed` then `onboarding/seed-verify` then
|
||||
`onboarding/verify`, with the password screen at `views/Login.vue:405-425` after that). Leave the
|
||||
lock, the TTL fast path and its comment block completely untouched below the gate: it is
|
||||
retry-storm protection, not authorization.
|
||||
|
||||
`handle_seed_save_encrypted` (`seed_rpc.rs:309`): gate as first statement. Note in a comment that
|
||||
this method has no UI caller today (`neode-ui/src/api/rpc-client.ts:334` exposes it, no view
|
||||
calls it) and that the real encrypted save happens inside `auth.setup` via
|
||||
`save_pending_seed_encrypted` — which is called from INSIDE the handler and therefore is not
|
||||
itself gated.
|
||||
|
||||
`handle_backup_restore_identity` (`backup_rpc.rs:409`): gate as first statement. Record the
|
||||
evidence verdict in a doc comment: this reaches `backup::identity::restore_encrypted_backup`,
|
||||
which writes `identity/node_key` unconditionally at `backup/identity.rs:112-117` — the same
|
||||
overwrite primitive F-01 names, behind a different door.
|
||||
|
||||
`handle_auth_setup` (`auth.rs:199`): keep the existing `is_setup()` rejection and ADD the gate.
|
||||
Record the evidence verdict D-04 asks for: `auth.setup` already refuses when `user.json` exists,
|
||||
but on a provisioned node whose `user.json` is missing or was deleted it would still run — and
|
||||
it does more than create the account, it also rewrites the OS login password via
|
||||
`crate::auth::change_ssh_password` (`auth.rs:230`). The gate closes that drift case.
|
||||
|
||||
`handle_auth_onboarding_complete` (`auth.rs:250`): this one needs the OPPOSITE guard, and it is
|
||||
the single most important addition in this task. It is unauthenticated and sets the very flag the
|
||||
gate reads, so without a guard an attacker can call it once against a fresh node and permanently
|
||||
lock that node out of onboarding — a denial of service created BY this plan. Refuse when
|
||||
`auth.is_setup()` is false, with a message beginning `Not supported:` explaining that onboarding
|
||||
cannot be completed before a user account exists. Verify against the real flow before committing:
|
||||
`OnboardingVerify.vue:157` calls it, and the password step at `Login.vue:405-425` must precede
|
||||
it; if the executor finds the UI calls it before `auth.setup`, STOP and raise a checkpoint rather
|
||||
than shipping a guard that breaks the wizard.
|
||||
|
||||
`seed.verify` (`seed_rpc.rs:163`): do NOT gate. Record the verdict instead — it only compares
|
||||
submitted words against the in-memory copy and re-derives a DID and npub for display; it writes
|
||||
no file and mutates no identity. Leaving it open costs nothing and gating it would break a
|
||||
legitimate retry.
|
||||
|
||||
Finally add a source-guard test in `onboarding_gate.rs` that `include_str!`s `seed_rpc.rs`,
|
||||
`backup_rpc.rs` and `auth.rs` and asserts each of the five gated handler function names is
|
||||
followed, within its own body, by a `ensure_onboarding_open` call — so a future edit that adds a
|
||||
sixth door or deletes a gate call fails a test instead of shipping.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago onboarding_gate:: seed_rpc:: -- --nocapture</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The source-guard test passes and fails if any single `ensure_onboarding_open` call is deleted (executor records one such scratch run in the SUMMARY).
|
||||
- `grep -c 'ensure_onboarding_open' core/archipelago/src/api/rpc/auth.rs` is at least 1, and the same for `backup_rpc.rs`.
|
||||
- A test named for the `auth.onboardingComplete` guard asserts it returns `Err` when `user.json` is absent and `Ok` when present.
|
||||
- The SUMMARY records, with `file:line`, the four D-04 verdicts: `auth.setup` (gated, plus the `change_ssh_password` drift rationale), `seed.verify` (not gated, non-mutating), `NodeIdentity::key_exists` (rejected as a signal, `server.rs:63-71`), `identity::fips_key_exists` (rejected as a refusal signal — true from the first seed step, which would break a generate-then-restore switchback inside the wizard).
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo build -p archipelago` succeeds.
|
||||
</acceptance_criteria>
|
||||
<done>Every method in `IDENTITY_MUTATING_ONBOARDING_METHODS` either calls the gate or carries a written, evidence-backed verdict for why it does not; `auth.onboardingComplete` can no longer be used to lock a fresh node out of onboarding.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Rate-limit the onboarding mutators without reintroducing the DID-screen failure</name>
|
||||
<files>core/archipelago/src/rate_limit.rs</files>
|
||||
<read_first>
|
||||
- core/archipelago/src/rate_limit.rs (lines 48-140 — EndpointRateLimiter::new, the limits table, and check())
|
||||
- core/archipelago/src/api/rpc/mod.rs (lines 381-400 and 506-520 — where the limiter is consulted and what a 429 looks like on the wire)
|
||||
- neode-ui/src/views/OnboardingSeedGenerate.vue (lines 240-278 — the 4s silent retry loop and its transient-error regex)
|
||||
- neode-ui/src/api/rpc-client.ts (lines 170-225 — the internal per-call retry budget)
|
||||
- neode-ui/src/views/OnboardingSeedRestore.vue (the restore submit path)
|
||||
</read_first>
|
||||
<action>
|
||||
Add per-method entries to `EndpointRateLimiter::new()` in `core/archipelago/src/rate_limit.rs`,
|
||||
each with a comment stating the budget it was derived from:
|
||||
|
||||
- `seed.generate` — 20 per 300s. Derivation: the 4s silent retry loop in
|
||||
`OnboardingSeedGenerate.vue:265-268` only fires on transient/network errors, which means the
|
||||
daemon is not answering and the limiter never sees those requests; the requests that DO reach
|
||||
the limiter are the 30s-timeout aborts plus `rpc-client.ts`'s internal retries, roughly one
|
||||
user-visible attempt per 30s. 20/300s is ~6x that budget.
|
||||
- `seed.restore` — 10 per 300s. The audit suggests matching `auth.changePassword` at 3/300s;
|
||||
that is REJECTED with cause: `rpc-client.ts:196-215` retries a single call up to 3 times, so
|
||||
3/300s would burn a user's whole budget on one submit. Record the rejection in the comment.
|
||||
- `seed.save-encrypted` — 10 per 300s.
|
||||
- `backup.restore-identity` — 10 per 300s.
|
||||
|
||||
Do not touch any existing entry. Note in the comment block why the numbers are generous rather
|
||||
than minimal: a 429 is returned as `{"error":{"code":429,...}}` with the message
|
||||
`Rate limit exceeded. Try again later.` (`mod.rs:507-519`), and the onboarding view's
|
||||
transient-error regex (`OnboardingSeedGenerate.vue:243`) does not match it — so a too-tight limit
|
||||
surfaces to the user as a hard failure at the DID-creation screen, exactly the failure the
|
||||
in-memory lock was written to prevent.
|
||||
|
||||
Add tests in `rate_limit.rs`'s test module: a burst test asserting 20 consecutive
|
||||
`check("seed.generate", ip)` calls all return true and the 21st returns false; and a test
|
||||
asserting `seed.restore` allows at least 4 consecutive calls (one user submit plus its internal
|
||||
retries) from one IP.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago rate_limit:: -- --nocapture</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago rate_limit::` passes, including the 20-then-429 burst test and the 4-consecutive-restore test.
|
||||
- `grep -c 'seed.generate' core/archipelago/src/rate_limit.rs` is at least 1 and the same for `seed.restore`, `seed.save-encrypted` and `backup.restore-identity`.
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago` completes with no new failures relative to the pre-plan baseline; the executor records the before/after pass counts in the SUMMARY.
|
||||
- Commit stages only this plan's own paths: `git add core/archipelago/src/api/rpc/onboarding_gate.rs 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`. Never `git add -A`, `git add .` or `git commit -a` — another agent shares this tree.
|
||||
</acceptance_criteria>
|
||||
<done>The four onboarding mutators are rate-limited at thresholds proven not to trip the real client retry budget, with the budget derivation written down.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| LAN HTTP -> RPC dispatcher | `nginx-archipelago.conf:11,:15` binds `:80` as `default_server` and `:165-195` proxies `/rpc/v1` to `127.0.0.1:5678`. Untrusted, unauthenticated, plaintext. |
|
||||
| FIPS mesh peer listener -> RPC dispatcher | `server.rs:1375` applies `is_peer_allowed_path`, which allows `/rpc/v1` (`server.rs:1270-1296`, asserted at `:2080`). Untrusted peers. |
|
||||
| Tor onion -> RPC dispatcher | Same dispatcher, no session on the allowlisted methods. |
|
||||
| Browser onboarding wizard -> RPC dispatcher | Legitimately pre-auth: no user account exists until `auth.setup`. This is why the methods cannot simply be removed from `UNAUTHENTICATED_METHODS`. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-10-01 | Spoofing | `seed.restore` -> `NodeIdentity::from_seed` (`identity.rs:79-114`) | critical | mitigate | Task 1: `ensure_onboarding_open` as the first statement of `restore_node_identity_from_words`; proven by the byte-identity regression test |
|
||||
| T-10-02 | Tampering | `seed.generate` -> unconditional identity overwrite (`seed_rpc.rs:118-140`) | critical | mitigate | Task 2: gate placed before the lock/TTL fast path |
|
||||
| T-10-03 | Tampering | `backup.restore-identity` -> `restore_encrypted_backup` writes `identity/node_key` (`backup/identity.rs:112-117`) | high | mitigate | Task 2: gate as first statement; same primitive, different door |
|
||||
| T-10-04 | Denial of service | `auth.onboardingComplete` is unauthenticated and sets the flag the gate reads — one call locks a fresh node out of onboarding | high | mitigate | Task 2: refuse `complete_onboarding` when `is_setup()` is false |
|
||||
| T-10-05 | Denial of service | A gate keyed on `NodeIdentity::key_exists` refuses `seed.generate` on every fresh node, because `server.rs:63-71` writes a temporary key at boot | critical | mitigate | Scoping correction: three-signal set excludes `key_exists`; pinned by `allows_on_fresh_temp_dir_even_though_node_key_exists` |
|
||||
| T-10-06 | Denial of service | A rate limit tighter than the client retry budget turns a slow first boot into a 429 the UI shows as a hard error | high | mitigate | Task 3: limits derived from the measured retry budget, pinned by a burst test |
|
||||
| T-10-07 | Information disclosure | `seed.generate`'s idempotent fast path returns the 24 words to any unauthenticated caller while a pending mnemonic is in memory | medium | mitigate | Task 2: gate precedes the fast path, so a provisioned node refuses before reading the mnemonic |
|
||||
| T-10-08 | Elevation of privilege | `auth.setup` is unauthenticated and rewrites the OS login password (`auth.rs:230`); its `is_setup()` guard fails open if `user.json` is missing on a provisioned node | medium | mitigate | Task 2: gate added alongside the existing `is_setup()` check |
|
||||
| T-10-09 | Spoofing | A FIPS mesh peer reaches `/rpc/v1` and calls `seed.restore` | low | accept | After the gate, only an un-onboarded node is affected — and an un-onboarded node has no `fips_key` (written only by `identity.rs:108`), so it is not on the mesh. Narrowing `is_peer_allowed_path` by method is recorded as out of scope for this plan and left to a follow-up. |
|
||||
| T-10-10 | Information disclosure | The refusal itself reveals that the node is provisioned | low | accept | `auth.isOnboardingComplete` is already in `UNAUTHENTICATED_METHODS` (`middleware.rs:9`), so the bit is not new. Per-signal detail is withheld. |
|
||||
| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | This plan adds no dependency to `Cargo.toml` and runs no package-manager install; the Package Legitimacy Gate is not triggered. Executor MUST halt and raise a checkpoint if implementation appears to need a new crate. |
|
||||
</threat_model>
|
||||
|
||||
<artifacts_this_phase_produces>
|
||||
## Artifacts this plan produces
|
||||
|
||||
**New file:** `core/archipelago/src/api/rpc/onboarding_gate.rs`
|
||||
|
||||
| Symbol | Kind | Signature / value |
|
||||
|---|---|---|
|
||||
| `ensure_onboarding_open` | async fn | `pub(in crate::api::rpc) async fn ensure_onboarding_open(data_dir: &std::path::Path, auth: &crate::auth::AuthManager) -> anyhow::Result<()>` |
|
||||
| `IDENTITY_MUTATING_ONBOARDING_METHODS` | const | `pub(in crate::api::rpc) const IDENTITY_MUTATING_ONBOARDING_METHODS: &[&str]` — `seed.generate`, `seed.restore`, `seed.save-encrypted`, `backup.restore-identity`, `auth.setup`, `auth.onboardingComplete` |
|
||||
|
||||
**New symbol in `core/archipelago/src/api/rpc/seed_rpc.rs`:**
|
||||
|
||||
| Symbol | Kind | Signature |
|
||||
|---|---|---|
|
||||
| `restore_node_identity_from_words` | async fn | `pub(in crate::api::rpc) async fn restore_node_identity_from_words(data_dir: &std::path::Path, auth: &crate::auth::AuthManager, words: &[String]) -> anyhow::Result<serde_json::Value>` |
|
||||
|
||||
**New rate-limit table keys** (`core/archipelago/src/rate_limit.rs`): `seed.generate` (20, 300),
|
||||
`seed.restore` (10, 300), `seed.save-encrypted` (10, 300), `backup.restore-identity` (10, 300).
|
||||
|
||||
**New error contract:** any refusal from the gate is an `anyhow::Error` whose message begins
|
||||
`Not supported:` so it survives `sanitize_error_message` (`middleware.rs:47-71`) and reaches the
|
||||
caller as a JSON-RPC error rather than a masked internal error.
|
||||
</artifacts_this_phase_produces>
|
||||
|
||||
<verification>
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago` — no new failures vs. the recorded baseline.
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo clippy -p archipelago -- -D warnings` — clean for the six touched files.
|
||||
- On `rust-lld: undefined hidden symbol`, rebuild with `CARGO_INCREMENTAL=0` (CLAUDE.md).
|
||||
- On-node verification of this plan (fresh-node onboarding survives the gate; a live node refuses
|
||||
a LAN `seed.restore`) is 10-02's job and is a precondition of the OTA (D-10), not of this commit.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- An already-provisioned node returns a `Not supported:` error for `seed.generate`,
|
||||
`seed.restore`, `seed.save-encrypted`, `backup.restore-identity` and `auth.setup`, and its
|
||||
`identity/node_key` and `identity/nostr_secret` are byte-identical after the attempt.
|
||||
- A fresh temp data dir — including one that already carries a boot-time `node_key` — passes the
|
||||
gate, so first-boot onboarding is untouched.
|
||||
- `auth.onboardingComplete` refuses before a user account exists.
|
||||
- Every gated handler is pinned by the source-guard test; deleting any single gate call fails a test.
|
||||
- All six touched files are committed in one commit staged explicitly by path.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/10-key-material-hardening/10-01-SUMMARY.md` when done. It MUST carry:
|
||||
the four D-04 verdicts with `file:line`; the scratch-run evidence that the byte-identity test and
|
||||
the source-guard test fail when the gate is removed; the rate-limit budget derivations; and the
|
||||
before/after `cargo test` pass counts.
|
||||
</output>
|
||||
@@ -1,256 +0,0 @@
|
||||
---
|
||||
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
|
||||
30–45 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.
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
---
|
||||
phase: 10-key-material-hardening
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["10-01"]
|
||||
files_modified:
|
||||
- scripts/security/rpc-exposure-probe.sh
|
||||
- docs/security/KEY-01-ON-NODE-VERIFICATION.md
|
||||
autonomous: false
|
||||
requirements: [KEY-01, KEY-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "The unauthenticated RPC surface's real reachability from the LAN, from Tor and from the FIPS mesh ULA is measured, not assumed (KEY-04 / audit C-6)"
|
||||
- "On a real node running 10-01's gate, an unauthenticated seed.restore with attacker-supplied words is refused and the node's node_key and nostr_secret are byte-identical afterwards"
|
||||
- "A fresh, never-onboarded node still completes the whole onboarding wizard with 10-01's gate in place — the anti-brick proof for D-03's signal set"
|
||||
- "The probe is safe by construction: it can never call an identity-mutating method unless the operator passes an explicit destructive flag"
|
||||
artifacts:
|
||||
- path: "scripts/security/rpc-exposure-probe.sh"
|
||||
provides: "Repeatable, read-only-by-default RPC exposure probe for C-6 and the KEY-01 refusal check"
|
||||
contains: "DESTRUCTIVE"
|
||||
min_lines: 60
|
||||
- path: "docs/security/KEY-01-ON-NODE-VERIFICATION.md"
|
||||
provides: "The recorded on-node evidence that turns the audit's UNVERIFIED C-6 into a verified result"
|
||||
contains: "C-6"
|
||||
key_links:
|
||||
- from: "scripts/security/rpc-exposure-probe.sh"
|
||||
to: "docs/security/KEY-01-ON-NODE-VERIFICATION.md"
|
||||
via: "probe output is pasted verbatim into the evidence record, per node and per transport"
|
||||
pattern: "rpc-exposure-probe"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Turn the audit's UNVERIFIED C-6 into recorded evidence, and prove on real hardware that 10-01's
|
||||
gate does what it claims without bricking a fresh node.
|
||||
|
||||
Purpose: 10-01 is verified by unit tests against temp directories. Two things unit tests cannot
|
||||
answer, and both are release-gating for D-10's OTA: (1) is the unauthenticated RPC surface
|
||||
actually reachable from another machine on the LAN, over Tor, and over the FIPS mesh ULA — the
|
||||
exposure surface F-01 depends on; (2) does a genuinely fresh node still get through onboarding
|
||||
with the gate in place. Correctness trap 1 says a naive fix bricks first boot on every new node;
|
||||
this plan is where that claim gets tested rather than argued.
|
||||
|
||||
Output: a reusable, read-only-by-default probe script and a written evidence record.
|
||||
|
||||
Sequencing (per the phase brief): C-6 is runnable the moment 10-01 lands — it does not wait for
|
||||
KEY-02 or KEY-03. This plan depends on 10-01 only because its refusal check needs 10-01's gate
|
||||
running on the target.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/10-key-material-hardening/10-CONTEXT.md
|
||||
@.planning/phases/10-key-material-hardening/10-01-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
</context>
|
||||
|
||||
<probe_method_correction>
|
||||
**The audit's C-6 command does not measure what it claims. Fix it here rather than copying it.**
|
||||
|
||||
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md:814-824` probes with `seed.status`. But
|
||||
`seed.status` is **not** in `UNAUTHENTICATED_METHODS` (`core/archipelago/src/api/rpc/middleware.rs:5-38`
|
||||
lists `seed.generate`, `seed.verify`, `seed.restore`, `seed.save-encrypted` — not `seed.status`).
|
||||
An unauthenticated `seed.status` therefore returns **401 by design**, so the audit's "Fail: 200"
|
||||
criterion can never fire and the probe would report the surface as closed while F-01's actual
|
||||
door stands open.
|
||||
|
||||
The probe this plan builds measures both facts separately:
|
||||
|
||||
- **Exposure:** `auth.isOnboardingComplete` — genuinely unauthenticated (`middleware.rs:9`),
|
||||
read-only, no side effects. A `200` proves the unauthenticated RPC surface is reachable from
|
||||
that vantage point. This is the honest C-6 signal.
|
||||
- **Session enforcement:** `seed.status` — a `401` proves the session check is working for
|
||||
non-allowlisted methods. A `200` here would be a far worse finding than C-6 and must stop the
|
||||
plan.
|
||||
|
||||
Record this correction in the evidence document so the next reader does not re-derive it.
|
||||
</probe_method_correction>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Build the read-only-by-default RPC exposure probe</name>
|
||||
<files>scripts/security/rpc-exposure-probe.sh</files>
|
||||
<read_first>
|
||||
- core/archipelago/src/api/rpc/middleware.rs (lines 5-38 — the authoritative unauthenticated method list the probe is written against)
|
||||
- core/archipelago/src/api/rpc/mod.rs (lines 245-300 and 505-520 — what a 401 and a 429 look like on the wire)
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (section 6, checklist items C-6 and C-5 — C-5's "handle the output as key material" warning applies to this script too)
|
||||
- image-recipe/configs/nginx-archipelago.conf (the :80 default_server and the /rpc/v1 and /rpc/ proxy blocks — the exact paths the probe must try)
|
||||
- scripts/iso-smoke-test.sh (house style for a repo probe script: arg parsing, coloured pass/fail, exit codes)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `scripts/security/rpc-exposure-probe.sh`, executable, `set -euo pipefail`.
|
||||
|
||||
Usage: `rpc-exposure-probe.sh --target <host-or-onion-or-ULA> [--scheme http|https] [--port N]
|
||||
[--label <name>] [--destructive]`.
|
||||
|
||||
Default (read-only) mode issues exactly three POSTs to `/rpc/v1` and one to `/rpc/`, each with a
|
||||
bounded `--max-time 15`, and prints a one-line PASS/FAIL verdict per check plus the raw HTTP
|
||||
status:
|
||||
|
||||
1. `health` — liveness of the endpoint from this vantage point.
|
||||
2. `auth.isOnboardingComplete` — the exposure signal. `200` means the unauthenticated RPC
|
||||
surface is reachable from here. Report it as `EXPOSED` rather than `FAIL`, because on the LAN
|
||||
this is currently expected and the point of the probe is to record the surface, not to assert
|
||||
it is closed.
|
||||
3. `seed.status` — the session-enforcement control. Anything other than `401` is reported as
|
||||
`CRITICAL` and makes the script exit non-zero.
|
||||
|
||||
The read-only mode must be structurally incapable of mutating identity: build the request method
|
||||
from a fixed `READONLY_METHODS` array and never from an argument, and put every mutating request
|
||||
inside a single `if [ "$DESTRUCTIVE" = "1" ]` branch.
|
||||
|
||||
`--destructive` mode adds the KEY-01 refusal check and prints a red banner stating that it must
|
||||
only be run against a disposable node. It POSTs `seed.restore` with a fixed, well-formed,
|
||||
publicly-known 24-word BIP-39 test mnemonic (use the BIP-39 all-`abandon` + `art` 24-word vector,
|
||||
which is published test data, so no real key material is ever handled — this is the deliberate
|
||||
difference from the audit's C-5, which mints real mnemonics). It asserts the JSON response
|
||||
carries an error whose message begins with the refusal prefix `Not supported:` emitted by
|
||||
10-01's gate, and it exits non-zero on a `200`-with-result.
|
||||
|
||||
The before/after byte-identity check is NOT done by this script (it has no node-local file
|
||||
access); the script prints the two `sha256sum` commands the operator must run on the node
|
||||
itself, so they land in the transcript alongside the probe output.
|
||||
|
||||
Add a `--help` that prints the usage and the safety rules. Do not embed any credential, node
|
||||
address or password in the script.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -n scripts/security/rpc-exposure-probe.sh && bash scripts/security/rpc-exposure-probe.sh --help && test -x scripts/security/rpc-exposure-probe.sh</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bash -n scripts/security/rpc-exposure-probe.sh` exits 0 and the file is mode 755.
|
||||
- `bash scripts/security/rpc-exposure-probe.sh --help` prints usage and exits 0.
|
||||
- `grep -c 'READONLY_METHODS' scripts/security/rpc-exposure-probe.sh` is at least 1 and `grep -c 'DESTRUCTIVE' scripts/security/rpc-exposure-probe.sh` is at least 2 (the guard and the flag parse).
|
||||
- If `shellcheck` is available on the host, `shellcheck -S error scripts/security/rpc-exposure-probe.sh` is clean; if it is not available, the SUMMARY records that it was unavailable rather than silently skipping.
|
||||
- The script contains no host address, onion address, username or password.
|
||||
</acceptance_criteria>
|
||||
<done>A repeatable probe exists that measures exposure and session enforcement separately, cannot mutate identity without an explicit flag, and handles no real key material.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 2: Measure C-6 on real nodes and prove the refusal on a disposable one</name>
|
||||
<precondition>A node running a build that contains 10-01's gate is reachable, and a second machine on the same LAN is available to probe from (probing from the node itself measures loopback, not exposure).</precondition>
|
||||
<files>docs/security/KEY-01-ON-NODE-VERIFICATION.md</files>
|
||||
<read_first>
|
||||
- scripts/security/rpc-exposure-probe.sh (Task 1 output)
|
||||
- .planning/phases/10-key-material-hardening/10-01-SUMMARY.md (the exact refusal message text 10-01 shipped)
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (section 6, C-6 — and the probe_method_correction section of this plan, which supersedes its command)
|
||||
- CLAUDE.md (dev-pair policy: archi-dev-box + x250-dev are deployed and verified before any OTA)
|
||||
</read_first>
|
||||
<action>
|
||||
Claude builds the deployable artefact and hands the operator an exact, copy-pasteable sequence;
|
||||
Claude does not ask the operator to do anything a CLI can do from here. Deploying 10-01's binary
|
||||
to the dev pair is the operator's existing `scripts/deploy-to-target.sh` flow and is NOT planned
|
||||
here (the phase brief excludes deployment). Claude's job in this task is to prepare the command
|
||||
sequence, then record the operator's output verbatim into
|
||||
`docs/security/KEY-01-ON-NODE-VERIFICATION.md` under a `## C-6 — unauthenticated RPC
|
||||
reachability` heading with one subsection per node and per transport.
|
||||
</action>
|
||||
<what-built>A read-only-by-default probe that separates "is the unauthenticated surface reachable from here" from "is session enforcement working", plus a destructive refusal check that uses only published BIP-39 test vectors.</what-built>
|
||||
<how-to-verify>
|
||||
1. From a **second machine on the same LAN** (not the node), run:
|
||||
`bash scripts/security/rpc-exposure-probe.sh --target <node-lan-ip> --scheme http --port 80 --label lan`
|
||||
Record the three verdict lines. `auth.isOnboardingComplete` returning `200` is the C-6
|
||||
exposure result; `seed.status` returning anything but `401` is a stop-the-plan finding.
|
||||
2. Repeat over Tor against the node's onion address:
|
||||
`torsocks bash scripts/security/rpc-exposure-probe.sh --target <onion> --scheme http --port 80 --label tor`
|
||||
3. Repeat over the FIPS mesh ULA from a peer node:
|
||||
`bash scripts/security/rpc-exposure-probe.sh --target <fips-ula> --scheme http --port 80 --label mesh`
|
||||
(the peer listener allows `/rpc/v1` — `core/archipelago/src/server.rs:1270-1296`, asserted
|
||||
at `:2080` — so a `200` here confirms the mesh half of F-01's reachability claim).
|
||||
4. On a **disposable** node only — never one in real use — first capture the baseline on the
|
||||
node: `sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret`
|
||||
5. From the second machine: `bash scripts/security/rpc-exposure-probe.sh --target <disposable-node-ip> --destructive --label refusal`
|
||||
6. On the disposable node, re-run the same `sha256sum` command. The two digests must be identical.
|
||||
7. Paste every command and its full output into the checkpoint response.
|
||||
</how-to-verify>
|
||||
<acceptance_criteria>
|
||||
- Three transports (LAN, Tor, mesh) each have a recorded `auth.isOnboardingComplete` status code in the evidence document. An unreachable transport is recorded as `UNREACHABLE` with the error, never omitted.
|
||||
- `seed.status` returned `401` on every transport tested. Any other code halts the phase and is raised as a blocker.
|
||||
- The destructive run returned an error whose message begins with the refusal prefix from 10-01, and the two `sha256sum` outputs from steps 4 and 6 match character for character.
|
||||
- `docs/security/KEY-01-ON-NODE-VERIFICATION.md` contains the probe-method correction (why `auth.isOnboardingComplete` replaced the audit's `seed.status` as the C-6 signal) and marks audit item C-6 as VERIFIED with a date and the node labels used.
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Paste the probe output for each transport plus the two sha256sum lines, then type "approved" — or describe what failed.</resume-signal>
|
||||
<done>Audit item C-6 is no longer UNVERIFIED: the exposure surface is measured per transport and the KEY-01 refusal is proven on real hardware with unchanged identity bytes.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: Prove a fresh node still onboards end-to-end with the gate in place</name>
|
||||
<precondition>An un-onboarded Archipelago instance is available — either a freshly flashed node, or a second daemon instance started with a clean ARCHIPELAGO_DATA_DIR (shape A of `.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md`).</precondition>
|
||||
<files>docs/security/KEY-01-ON-NODE-VERIFICATION.md</files>
|
||||
<read_first>
|
||||
- .planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md (shape A is the intended harness — a second instance boots un-onboarded, which is exactly the state the gate must let through)
|
||||
- neode-ui/src/router/index.ts (lines 20-80 — the onboarding route order the walkthrough follows)
|
||||
- core/archipelago/src/api/rpc/onboarding_gate.rs (10-01 output — the three signals and when each flips)
|
||||
- docs/security/KEY-01-ON-NODE-VERIFICATION.md (Task 2 output — append to it)
|
||||
</read_first>
|
||||
<action>
|
||||
This is the anti-brick proof for correctness traps 1 and 2, and it is the single most important
|
||||
non-regression check in the phase: if the gate's signal set is wrong, every future flashed node
|
||||
is unusable, and no unit test against a temp directory can catch a wrong signal choice in the
|
||||
live boot sequence (the whole point of the scoping correction is that `Server::new` writes a
|
||||
`node_key` before the user ever sees the wizard).
|
||||
|
||||
Claude prepares the walkthrough and, after the operator responds, records the result into
|
||||
`docs/security/KEY-01-ON-NODE-VERIFICATION.md` under `## Fresh-node onboarding non-regression`,
|
||||
including the daemon log lines around each seed RPC and the wall-clock time from first
|
||||
`seed.generate` to `auth.onboardingComplete`.
|
||||
</action>
|
||||
<what-built>10-01's gate, running on a real un-onboarded instance, on the exact code path a newly flashed node takes.</what-built>
|
||||
<how-to-verify>
|
||||
1. Start (or flash) an un-onboarded instance and confirm it is genuinely fresh:
|
||||
`ls -l <data-dir>/user.json <data-dir>/onboarding.json <data-dir>/identity/master_seed.enc`
|
||||
— all three must be absent. Note that `<data-dir>/identity/node_key` WILL exist after boot;
|
||||
that is expected and is precisely the condition the gate must tolerate.
|
||||
2. Open the UI and walk the wizard end to end: intro → options → path → seed →
|
||||
seed-verify → did → identity → backup → verify → done, then set the password on the login
|
||||
screen. Do not skip the seed step.
|
||||
3. While on the seed screen, force a retry: reload the page once and confirm the SAME 24
|
||||
words are shown (the idempotent fast path must still work below the gate).
|
||||
4. Confirm no `Not supported:` error and no `Rate limit exceeded` error appears at any point.
|
||||
5. After completion, confirm the node is provisioned: `ls -l <data-dir>/user.json` exists.
|
||||
6. Now confirm the door closed behind you — from a second machine, run
|
||||
`bash scripts/security/rpc-exposure-probe.sh --target <instance> --destructive --label post-onboarding`
|
||||
and confirm the refusal.
|
||||
7. Paste the wizard outcome, the step-3 result, and the step-6 output.
|
||||
</how-to-verify>
|
||||
<acceptance_criteria>
|
||||
- The wizard completed without any `Not supported:` or `Rate limit exceeded` error, and the operator confirms the same 24 words survived the reload in step 3.
|
||||
- Step 5 shows `user.json` present, and step 6's refusal confirms the same instance now refuses `seed.restore`.
|
||||
- `docs/security/KEY-01-ON-NODE-VERIFICATION.md` records both halves — onboarding succeeded, then the door closed — with the instance label and date.
|
||||
- If ANY step fails, the executor must NOT patch the gate ad hoc: raise a blocker naming which of the three signals fired early, with the `file:line` and the on-disk state that triggered it.
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Type "approved" with the wizard result and the step-6 probe output, or describe exactly which step failed and what error appeared.</resume-signal>
|
||||
<done>A genuinely fresh instance onboards with the gate in place, and the same instance refuses identity replacement immediately afterwards.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Operator workstation -> node RPC | The probe crosses the same untrusted LAN an attacker would use; it is the attacker's-eye view by construction. |
|
||||
| Probe script -> node identity | `--destructive` mode issues a real `seed.restore`. If run against a node in real use with a build that lacks 10-01's gate, it destroys that node's identity. |
|
||||
| Evidence document -> repository | The probe output could carry node addresses, onion addresses and mesh ULAs into a repo that is being prepared for open-source publication. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-10-11 | Tampering | `--destructive` mode run against a production node | high | mitigate | Mutating requests live inside a single explicit-flag branch; the flag prints a red banner; every checkpoint step says "disposable node only"; Task 2 requires a before/after digest so an accident is at least detected |
|
||||
| T-10-12 | Information disclosure | The probe mints or handles real mnemonics (the audit's C-5 does exactly this) | high | mitigate | The refusal check uses only the published BIP-39 all-`abandon`/`art` test vector; the script never generates a mnemonic and never prints one |
|
||||
| T-10-13 | Information disclosure | Node addresses, onion addresses and ULAs committed into `docs/security/` ahead of open-sourcing | medium | mitigate | Evidence records node **labels** (`lan`, `tor`, `mesh`, `dev-box`) and status codes, never raw addresses; Task 1 forbids embedding any address in the script |
|
||||
| T-10-14 | Repudiation | A checkpoint is rubber-stamped without the commands actually being run | medium | mitigate | Each acceptance criterion requires pasted verbatim output including status codes and two matching digests, not a yes/no |
|
||||
| T-10-15 | Denial of service | The probe trips 10-01's new rate limits and reports a false negative | low | accept | The probe issues four requests per run, far under the 10/300s floor; a `429` is reported as its own verdict line rather than being conflated with a refusal |
|
||||
| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | No package-manager install occurs in this plan; `curl`, `sha256sum` and optionally `torsocks`/`shellcheck` are pre-existing host tools. Executor MUST halt and raise a checkpoint if a new dependency appears necessary. |
|
||||
</threat_model>
|
||||
|
||||
<artifacts_this_phase_produces>
|
||||
## Artifacts this plan produces
|
||||
|
||||
**New file:** `scripts/security/rpc-exposure-probe.sh` (mode 755)
|
||||
|
||||
| Symbol | Kind | Contract |
|
||||
|---|---|---|
|
||||
| `--target <host>` | CLI flag | required; host, onion or ULA to probe |
|
||||
| `--scheme http\|https` | CLI flag | default `http` |
|
||||
| `--port <n>` | CLI flag | default `80` |
|
||||
| `--label <name>` | CLI flag | vantage-point label written into the verdict lines |
|
||||
| `--destructive` | CLI flag | enables the single mutating branch (the `seed.restore` refusal check) |
|
||||
| `READONLY_METHODS` | shell array | `health`, `auth.isOnboardingComplete`, `seed.status` — the only methods the default path may call |
|
||||
| exit `0` | contract | all controls behaved as expected |
|
||||
| exit non-zero | contract | `seed.status` returned other than 401, or `--destructive` was not refused |
|
||||
|
||||
**New file:** `docs/security/KEY-01-ON-NODE-VERIFICATION.md` — headings
|
||||
`## C-6 — unauthenticated RPC reachability`, `## Probe-method correction`,
|
||||
`## Fresh-node onboarding non-regression`.
|
||||
</artifacts_this_phase_produces>
|
||||
|
||||
<verification>
|
||||
- `bash -n scripts/security/rpc-exposure-probe.sh` and `--help` both succeed locally.
|
||||
- Both checkpoints resolved with pasted, verbatim command output.
|
||||
- Commit stages only `scripts/security/rpc-exposure-probe.sh` and
|
||||
`docs/security/KEY-01-ON-NODE-VERIFICATION.md` by explicit path — never `git add -A`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Audit item C-6 is recorded as VERIFIED with per-transport status codes and the method
|
||||
correction that makes the measurement meaningful.
|
||||
- `seed.status` returns 401 on every transport tested; any other result is escalated, not filed.
|
||||
- The KEY-01 refusal is proven on real hardware with byte-identical identity files before and after.
|
||||
- A fresh instance completes onboarding with the gate in place, then refuses identity replacement.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/10-key-material-hardening/10-02-SUMMARY.md` when done, carrying the
|
||||
per-transport status codes, both `sha256sum` outputs, the fresh-node walkthrough result, and an
|
||||
explicit statement of which audit checklist items moved from UNVERIFIED to VERIFIED.
|
||||
</output>
|
||||
@@ -1,252 +0,0 @@
|
||||
---
|
||||
phase: 10-key-material-hardening
|
||||
plan: 02
|
||||
subsystem: security-verification
|
||||
tags: [security, KEY-01, KEY-04, F-01, C-6, probe, unverified]
|
||||
requires:
|
||||
- "10-01 — api::rpc::onboarding_gate (the gate whose refusal this plan must prove on hardware)"
|
||||
provides:
|
||||
- "scripts/security/rpc-exposure-probe.sh — repeatable, read-only-by-default RPC exposure probe"
|
||||
- "docs/security/KEY-01-ON-NODE-VERIFICATION.md — the evidence record for C-6 and the F-01 refusal"
|
||||
- "The probe-method correction: auth.isOnboardingComplete, not seed.status, is the C-6 exposure signal"
|
||||
affects:
|
||||
- "audit item C-6 (still UNVERIFIED — narrowed, not closed)"
|
||||
- "D-10 OTA gating: the refusal check is now provably blocked on deployment, not on repo work"
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Read-only by construction: probe methods come from a fixed array, never from an argument"
|
||||
- "Mutating requests confined to a single explicit --destructive branch"
|
||||
- "Published BIP-39 test vector instead of minting real mnemonics (the C-5 anti-pattern)"
|
||||
key-files:
|
||||
created:
|
||||
- scripts/security/rpc-exposure-probe.sh
|
||||
- docs/security/KEY-01-ON-NODE-VERIFICATION.md
|
||||
modified: []
|
||||
decisions:
|
||||
- "C-6 recorded as NOT VERIFIED rather than closed on loopback evidence — probing from the node measures the local stack, not the LAN"
|
||||
- "The --destructive refusal check was NOT run: no node in the fleet carries 10-01's gate yet, so it would have replaced the dev-box's identity instead of being refused"
|
||||
- "The /rpc/ nginx block returns 404 — the unauthenticated surface is reachable through /rpc/v1 only"
|
||||
metrics:
|
||||
duration: "~35m"
|
||||
completed: 2026-08-02
|
||||
status: blocked
|
||||
---
|
||||
|
||||
# Phase 10 Plan 02: On-Node Verification of KEY-01 / C-6 Summary
|
||||
|
||||
The probe exists, is safe by construction, and has been run — but **audit item C-6 is still
|
||||
UNVERIFIED and the KEY-01 refusal is still unproven on hardware**, because no node in the fleet
|
||||
is running 10-01's gate and no second machine was available to probe from.
|
||||
|
||||
## Status: Task 1 complete · Tasks 2 and 3 BLOCKED on unmet preconditions
|
||||
|
||||
| Task | Type | Outcome |
|
||||
|---|---|---|
|
||||
| 1. Build the read-only-by-default probe | auto | **Done**, committed `527f6023` |
|
||||
| 2. Measure C-6 on real nodes, prove the refusal | checkpoint (blocking) | **BLOCKED** — precondition unmet |
|
||||
| 3. Fresh-node onboarding non-regression | checkpoint (blocking) | **BLOCKED** — precondition unmet |
|
||||
|
||||
Per the plan's `autonomous: false` posture, neither checkpoint was auto-approved and no result
|
||||
was recorded that was not observed.
|
||||
|
||||
## What Was Built
|
||||
|
||||
**`scripts/security/rpc-exposure-probe.sh`** (new, 296 lines, mode 755)
|
||||
|
||||
| Flag / symbol | Contract |
|
||||
|---|---|
|
||||
| `--target <host>` | required; host, onion or ULA. Bare IPv6 is bracketed automatically so the mesh ULA can be probed |
|
||||
| `--scheme http\|https` · `--port N` · `--label <name>` | defaults `http` / `80` / `unlabelled` |
|
||||
| `--insecure` | **added beyond the plan** — accept a self-signed cert on `https`; without it every https vantage point is a false `UNREACHABLE` |
|
||||
| `--destructive` | the single mutating branch: the KEY-01 refusal check |
|
||||
| `READONLY_METHODS` | `health`, `auth.isOnboardingComplete`, `seed.status` — the only methods the default path can call |
|
||||
| exit `0` / non-zero | all controls as expected / `seed.status` was not 401, or `--destructive` was not refused |
|
||||
|
||||
Four requests per read-only run (well under 10-01's 10-per-300s floor, so T-10-15 does not fire):
|
||||
the three methods on `/rpc/v1`, plus the exposure signal repeated on nginx's `/rpc/` block.
|
||||
|
||||
Safety properties, as required by the threat model:
|
||||
|
||||
- **T-10-11:** the method string is built from the fixed array, never from an argument; every
|
||||
mutating request is inside one `if [ "$DESTRUCTIVE" = "1" ]` branch behind a red
|
||||
disposable-nodes-only banner.
|
||||
- **T-10-12:** the refusal check uses the published BIP-39 all-`abandon` + `art` vector (32 zero
|
||||
bytes). The script never generates and never prints a mnemonic — the deliberate difference from
|
||||
audit item C-5, which mints real ones.
|
||||
- **T-10-13:** no node address, onion address, username or password is embedded
|
||||
(`grep -nE '([0-9]{1,3}\.){3}[0-9]{1,3}|\.onion|password'` matches only the safety comment
|
||||
that forbids them).
|
||||
|
||||
The before/after byte-identity check is not attempted by the script (it has no node-local file
|
||||
access); it prints the two `sha256sum` commands so they land in the operator's transcript.
|
||||
|
||||
## The Probe-Method Correction (the substantive finding)
|
||||
|
||||
The audit's C-6 command (`ENTROPY-SEED-AUDIT-2026-07-31.md:890-901`) probes with `seed.status`
|
||||
and calls `200` a failure. `seed.status` is **not** in `UNAUTHENTICATED_METHODS`
|
||||
(`middleware.rs:5-38`), so it is rejected at `api/rpc/mod.rs:293` with a **401 by design** — the
|
||||
audit's failure criterion can never fire, and the probe reports the surface CLOSED while F-01's
|
||||
door stands open. The probe therefore measures exposure with `auth.isOnboardingComplete`
|
||||
(genuinely allowlisted at `middleware.rs:9`, read-only) and keeps `seed.status` as the
|
||||
session-enforcement control. Recorded in the evidence document so it is not re-derived a third
|
||||
time.
|
||||
|
||||
## What Was Actually Measured
|
||||
|
||||
Both runs originated **on the node under test**, so neither is a C-6 result — they are recorded
|
||||
as `loopback` and `self-lan-ip`, not `lan`.
|
||||
|
||||
| Vantage | `health` | `auth.isOnboardingComplete` | `seed.status` | `/rpc/` |
|
||||
|---|---|---|---|---|
|
||||
| `loopback` | 200 | **200 — EXPOSED** | **401 — PASS** | 404 |
|
||||
| `self-lan-ip` | 200 | **200 — EXPOSED** | **401 — PASS** | 404 |
|
||||
|
||||
- **`seed.status` returned 401 on every vantage tested** — no stop-the-plan finding.
|
||||
- **Incidental finding:** `/rpc/` returns **404**. nginx's second proxy block
|
||||
(`nginx-archipelago.conf:192`) forwards the full URI and the backend routes only `/rpc/v1`, so
|
||||
the unauthenticated surface has exactly one path. This narrows F-01's exposure surface.
|
||||
- **Corroborating, not measurement:** nginx binds `0.0.0.0:80` and `[::]:80`, the daemon is
|
||||
loopback-only on `:5678`, and the host filter has no rule matching tcp/80 (`-P INPUT ACCEPT`,
|
||||
nft ruleset is Tailscale chains only). A LAN `EXPOSED` result is very likely — but likely is
|
||||
not measured, and C-6 stays open.
|
||||
|
||||
## Why Tasks 2 and 3 Are Blocked (verified, not assumed)
|
||||
|
||||
**No node in the fleet is running 10-01's gate.** Checked on the dev-box rather than inferred:
|
||||
|
||||
```
|
||||
$ ls -l /usr/local/bin/archipelago
|
||||
-rwxr-xr-x 1 root root 53437536 Aug 2 06:37 /usr/local/bin/archipelago
|
||||
$ git log -1 --format='%ci' 879de59e
|
||||
2026-08-02 13:05:35 -0400
|
||||
$ grep -qa "Not supported: this node is already provisioned" /usr/local/bin/archipelago && echo PRESENT || echo ABSENT
|
||||
ABSENT
|
||||
```
|
||||
|
||||
The installed binary was built ~6.5h before 10-01 landed, and the gate's refusal string is absent
|
||||
from it. A `--destructive` run against this node would therefore **not** be refused — it would
|
||||
replace `node_key`, `nostr_secret` and `fips_key` on a live dev-pair deploy target that is gated
|
||||
before every OTA. It was not run. The plan's own threat model (T-10-11) and the phase brief
|
||||
(which excludes deployment) make this a hard block, not a judgement call.
|
||||
|
||||
Task 3's harness is shape (A) of
|
||||
`.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md` — a second
|
||||
daemon under its own `ARCHIPELAGO_DATA_DIR`. That todo is still **pending**: the harness does not
|
||||
exist, and it would additionally need a binary built from `879de59e` or later.
|
||||
|
||||
Task 2 also requires a second machine on the LAN. This session ran on the node itself, and
|
||||
probing fleet nodes uninvited is out of bounds (`.228` is in real use).
|
||||
|
||||
## Pre-OTA Fleet Check Carried Over from 10-01
|
||||
|
||||
10-01 flagged a state its gate makes unrecoverable — `onboarding.json` complete-true with no
|
||||
`user.json` — and asked 10-02 to sweep the fleet before the OTA (D-10).
|
||||
|
||||
| Node | `user.json` | `onboarding.json` | Verdict |
|
||||
|---|---|---|---|
|
||||
| dev-box | PRESENT | `{"complete": true}` | **safe** — provisioned normally |
|
||||
| rest of fleet | — | — | **NOT CHECKED** |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
**1. [Rule 1 — Bug] `curl` failure fallback produced a `000000` status code**
|
||||
|
||||
- **Found during:** Task 1 verification against a dead port.
|
||||
- **Issue:** `HTTP_CODE=$(curl … -w '%{http_code}' … || echo "000")` — curl already emits `000`
|
||||
when no response arrives, so the fallback *appended* a second `000`. Every verdict then fell
|
||||
through to the wildcard branch and an unreachable host was reported as
|
||||
`seed.status … CRITICAL — session enforcement is NOT working`, exit 1. A false critical on an
|
||||
unreachable transport is exactly the misreading this plan exists to prevent.
|
||||
- **Fix:** the fallback now replaces rather than appends (`if ! HTTP_CODE=$(curl …); then
|
||||
HTTP_CODE="000"; fi`) plus a three-digit normalisation. Retested: an unreachable target now
|
||||
reports `UNREACHABLE` on all four lines and exits 0, since an unreachable vantage point is a
|
||||
result, not a control failure.
|
||||
- **Commit:** `527f6023` (fixed before the commit).
|
||||
|
||||
**2. [Rule 2 — Missing critical functionality] `--insecure` flag added**
|
||||
|
||||
- **Issue:** the plan's CLI contract has no way to accept a self-signed certificate, but
|
||||
Archipelago nodes serve https with one. Every `--scheme https` probe would have reported a
|
||||
false `UNREACHABLE`, silently under-measuring the exposure surface.
|
||||
- **Fix:** opt-in `--insecure`, off by default, documented in `--help`. It does not alter the
|
||||
plan's flag contract.
|
||||
|
||||
**3. Tasks 2 and 3 not executed** — see the blocked section above. No result was recorded that
|
||||
was not observed; nothing was marked verified.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bash -n scripts/security/rpc-exposure-probe.sh` → exits 0.
|
||||
- `bash scripts/security/rpc-exposure-probe.sh --help` → prints usage, exits 0.
|
||||
- `test -x` → mode `755` (`-rwxr-xr-x`).
|
||||
- `grep -c READONLY_METHODS` → **22** (≥1 required); `grep -c DESTRUCTIVE` → **5** (≥2 required).
|
||||
- Missing `--target` → exit 2; unknown argument → exit 2; unreachable target → all `UNREACHABLE`,
|
||||
exit 0; live daemon → `401` on the enforcement control, exit 0.
|
||||
- **`shellcheck` is NOT installed on this host** (`command -v shellcheck` → empty). The
|
||||
acceptance criterion's shellcheck run was therefore not performed, recorded here rather than
|
||||
silently skipped.
|
||||
- Both commits stage exactly one file each by explicit path; `git diff --diff-filter=D` over both
|
||||
is empty. Three other agents are working in this tree (plans 10-04, 10-06, 01-18) and none of
|
||||
their files were staged, reverted or modified.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None in code. The **evidence document is deliberately incomplete** and says so in its first
|
||||
line — five of its rows are `NOT MEASURED` / `NOT PERFORMED` with the exact command needed to
|
||||
close each.
|
||||
|
||||
## Audit Checklist Movement
|
||||
|
||||
| Item | Before | After |
|
||||
|---|---|---|
|
||||
| C-6 | UNVERIFIED | **still UNVERIFIED** — method corrected, tooling built, 2 non-qualifying vantage points recorded, 3 transports outstanding |
|
||||
| KEY-01 refusal on hardware | unproven | **still unproven** — blocked on deploying 10-01 |
|
||||
| Fresh-node onboarding non-regression | unproven | **still unproven** — blocked on the shape-A harness |
|
||||
|
||||
**Nothing moved from UNVERIFIED to VERIFIED in this plan.** What changed is that the remaining
|
||||
work is now mechanised (one command per transport), correctly specified (the method correction),
|
||||
and provably blocked on deployment rather than on anything doable in the repository.
|
||||
|
||||
## What Is Still Required
|
||||
|
||||
1. LAN: `rpc-exposure-probe.sh --target <node-lan-ip> --scheme http --port 80 --label lan` **from a second machine**.
|
||||
2. Tor: `torsocks rpc-exposure-probe.sh --target <onion> --label tor`.
|
||||
3. Mesh: `rpc-exposure-probe.sh --target <fips-ula> --label mesh` from a peer node.
|
||||
4. Deploy `879de59e`+ to a **disposable** node, capture
|
||||
`sudo sha256sum /var/lib/archipelago/identity/{node_key,nostr_secret}`, run
|
||||
`--destructive --label refusal` from a second machine, re-capture the digests. Response must
|
||||
carry `Not supported:` and the digests must match character for character.
|
||||
5. Build shape (A), walk the wizard end to end on a 10-01 binary (reloading once on the seed
|
||||
screen to confirm the same 24 words return, and seeing no `Not supported:` /
|
||||
`Rate limit exceeded`), then re-run step 4 against it.
|
||||
6. Sweep the remaining fleet for `onboarding.json` complete-true without `user.json`.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. No new endpoint, no dependency (T-10-SC did not fire — `curl`, `grep` and `mktemp` are
|
||||
pre-existing host tools), no schema change. The one new surface is the `--destructive` branch,
|
||||
which is the plan's own T-10-11 and is mitigated as specified.
|
||||
|
||||
## Commits
|
||||
|
||||
- `527f6023` — `feat(10-02): add read-only-by-default RPC exposure probe (C-6 / KEY-01)` —
|
||||
`scripts/security/rpc-exposure-probe.sh` only.
|
||||
- `f2f89b5f` — `docs(10-02): record C-6 evidence so far — probe-method correction, 3 transports still open` —
|
||||
`docs/security/KEY-01-ON-NODE-VERIFICATION.md` only.
|
||||
|
||||
Not pushed — the orchestrator pushes.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Both files exist on disk (`scripts/security/rpc-exposure-probe.sh` mode 755, 296 lines;
|
||||
`docs/security/KEY-01-ON-NODE-VERIFICATION.md` 224 lines, contains `C-6` and
|
||||
`rpc-exposure-probe`); both commits `527f6023` and `f2f89b5f` are present in `git log`; the probe
|
||||
contains `DESTRUCTIVE` (5) and `READONLY_METHODS` (22); neither file contains a node address,
|
||||
onion address or credential.
|
||||
|
||||
**Not done by this agent, deliberately:** `STATE.md` / `ROADMAP.md` / `REQUIREMENTS.md` updates.
|
||||
Three other agents are executing concurrently in this shared tree and `.planning/STATE.md`
|
||||
already carries an uncommitted edit that is not mine; mutating shared planning state here would
|
||||
entangle their work. Requirements KEY-01 and KEY-04 must **not** be marked complete — this plan
|
||||
did not close them.
|
||||
@@ -1,333 +0,0 @@
|
||||
---
|
||||
phase: 10-key-material-hardening
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- image-recipe/_archived/build-auto-installer-iso.sh
|
||||
- tests/first-boot-secrets/run-tests.sh
|
||||
- docs/security/KEY-02-ROOTFS-EVIDENCE.md
|
||||
autonomous: false
|
||||
requirements: [KEY-02, KEY-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A first-boot secret regeneration that fails does NOT set the completion marker, so the oneshot retries on the next boot (D-05)"
|
||||
- "Each generator is retried with backoff within a single boot before the boot is declared failed (D-05)"
|
||||
- "A terminal failure is loud: it reaches the console and a durable on-disk failure record, not only a log file nobody reads (D-05)"
|
||||
- "The shipped rootfs tar contains no SSH host keys, no TLS private key and no populated machine-id, so a regeneration failure degrades to 'no key, service refuses to start' rather than 'fleet-shared key, silently'"
|
||||
- "The regeneration script is exercised by an automated test that fails when the marker is set on a failed run"
|
||||
artifacts:
|
||||
- path: "image-recipe/_archived/build-auto-installer-iso.sh"
|
||||
provides: "Fail-closed, retried first-boot secret regeneration and an identity-free rootfs tar"
|
||||
contains: "FIRST_BOOT_SECRETS_ROOT"
|
||||
- path: "tests/first-boot-secrets/run-tests.sh"
|
||||
provides: "Automated harness that extracts the generated script and drives it with stubbed generators"
|
||||
min_lines: 60
|
||||
- path: "docs/security/KEY-02-ROOTFS-EVIDENCE.md"
|
||||
provides: "Recorded build-host evidence for audit checklist item C-4"
|
||||
contains: "C-4"
|
||||
key_links:
|
||||
- from: "tests/first-boot-secrets/run-tests.sh"
|
||||
to: "image-recipe/_archived/build-auto-installer-iso.sh"
|
||||
via: "extracts the first-boot-secrets.sh heredoc body from the builder and executes it against a temp root"
|
||||
pattern: "SECRETSSCRIPT"
|
||||
- from: "image-recipe/_archived/build-auto-installer-iso.sh"
|
||||
to: "docs/security/KEY-02-ROOTFS-EVIDENCE.md"
|
||||
via: "the Dockerfile strip step is what the C-4 tar listing proves"
|
||||
pattern: "ssh_host"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close F-03 (High) on the build side: make first-boot per-device secret regeneration **retry with
|
||||
backoff and then fail closed** (D-05), and remove the fleet-shared identity material from the
|
||||
rootfs tar so a failure degrades to "no key" instead of "everyone's key".
|
||||
|
||||
Purpose: today both regeneration branches log a warning and continue, and
|
||||
`touch "$MARKER"` runs unconditionally outside both `if` blocks
|
||||
(`image-recipe/_archived/build-auto-installer-iso.sh:1647`, `:1659`, `:1663`). Combined with
|
||||
`ConditionPathExists=!/var/lib/archipelago/.secrets-regenerated` (`:1605`) and the script's own
|
||||
`[ -f "$MARKER" ] && exit 0` (`:1625`), one transient failure leaves that node on the
|
||||
**image-wide shared** SSH host key and TLS private key permanently and silently — and the ISO is
|
||||
a published artefact, so anyone who downloads it holds those keys.
|
||||
|
||||
Output: a fail-closed regeneration script with a real automated test, an identity-free rootfs,
|
||||
and recorded C-4 build-host evidence.
|
||||
|
||||
**`image-recipe/_archived/` is LIVE.** `image-recipe/build-debian-iso.sh:19-40` copies it to a
|
||||
temp path, rewrites its relative paths and `exec`s it. Do not relocate, rename or tidy it — the
|
||||
audit records that treating it as dead would have hidden F-03 entirely.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/10-key-material-hardening/10-CONTEXT.md
|
||||
@docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md
|
||||
@CLAUDE.md
|
||||
</context>
|
||||
|
||||
<build_cache_note>
|
||||
`RECIPE_HASH` (`build-auto-installer-iso.sh:265`) hashes only the region between
|
||||
`# STEP 1: Build complete root filesystem` (line 252) and `# STEP 2: Build minimal installer`
|
||||
(line 732), and the rootfs tar is rebuilt only when that hash changes. Consequences the executor
|
||||
must plan around:
|
||||
|
||||
- Task 1 edits the first-boot script heredoc at ~1590-1670, which is in STEP 3 — **outside** the
|
||||
hashed region. It does not and should not force a rootfs rebuild; it is installer-side content.
|
||||
- Task 2 edits the Dockerfile inside STEP 1, so the hash changes and the next build rebuilds the
|
||||
rootfs automatically. That is required for Task 3's C-4 evidence to mean anything: a cached
|
||||
tar would still contain the baked keys and the check would fail for the wrong reason.
|
||||
</build_cache_note>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Fail-closed, retried first-boot regeneration — proven end to end by a real test</name>
|
||||
<files>image-recipe/_archived/build-auto-installer-iso.sh, tests/first-boot-secrets/run-tests.sh</files>
|
||||
<read_first>
|
||||
- image-recipe/_archived/build-auto-installer-iso.sh (lines 1590-1675 — the unit definition and the whole first-boot-secrets.sh heredoc being rewritten)
|
||||
- image-recipe/_archived/build-auto-installer-iso.sh (lines 2580-2600 and 3330-3345 — where the script and unit are installed and enabled, so the executor can confirm nothing else needs changing)
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (finding F-03 and remediation R-02)
|
||||
- tests/lifecycle/TESTING.md (house conventions for a repo test harness — exit codes, output shape)
|
||||
- scripts/first-boot-containers.sh (house style for a first-boot script on this project)
|
||||
</read_first>
|
||||
<action>
|
||||
Rewrite the `first-boot-secrets.sh` heredoc body inside
|
||||
`image-recipe/_archived/build-auto-installer-iso.sh` (currently lines ~1616-1665) so it is
|
||||
retried-then-fail-closed, and add one testability seam.
|
||||
|
||||
**Testability seam (required, and the reason the rest of this task is verifiable at all):**
|
||||
introduce `ROOT="${FIRST_BOOT_SECRETS_ROOT:-}"` at the top and prefix every absolute path with
|
||||
`$ROOT` — `$ROOT/etc/archipelago/ssl`, `$ROOT/etc/ssh`, `$ROOT/var/lib/archipelago`,
|
||||
`$ROOT/var/log`. With the variable unset the expansion is empty and production behaviour is
|
||||
byte-identical to today. This is the same move the audit made for the RNG: create a seam so the
|
||||
property can be tested, rather than asserting it in a comment.
|
||||
|
||||
**Retry with backoff (D-05):** wrap each generator in a loop of 3 attempts with sleeps of 2, 8
|
||||
and 20 seconds between them. Track `TLS_OK` and `SSH_OK` as `0`/`1`. Keep the existing
|
||||
staging-then-swap structure for both — generate to `.new` / a `mktemp -d` staging tree and only
|
||||
swap on success — because that is what guarantees the node is never left mid-swap.
|
||||
|
||||
**Fail closed (D-05):** move `touch "$MARKER"` inside a branch that requires
|
||||
`TLS_OK = 1 && SSH_OK = 1`. On any other outcome: do not create the marker (so
|
||||
`ConditionPathExists=!` lets the oneshot run again on the next boot), write a durable failure
|
||||
record to `$ROOT/var/lib/archipelago/first-boot-secrets.failed` containing the timestamp and
|
||||
which generator failed, emit the failure to the console with `tee -a /dev/console` (guarded so a
|
||||
missing `/dev/console` in a test root cannot itself fail the script) and to the journal via
|
||||
`logger -t archipelago-first-boot-secrets`, and `exit 1` so the unit lands in `failed` rather
|
||||
than `active`. Delete the `first-boot-secrets.failed` record on a successful run so a node that
|
||||
recovers on its second boot does not carry a stale alarm.
|
||||
|
||||
**Unit ordering:** add `After=systemd-random-seed.service` to
|
||||
`archipelago-first-boot-secrets.service` (line ~1603) alongside the existing
|
||||
`After=local-fs.target`. It is a no-op today — no seed file is baked, which the audit verified —
|
||||
and correct if one is ever introduced. Leave `DefaultDependencies=no`,
|
||||
`Before=ssh.service nginx.service archipelago.service` and the `ConditionPathExists` line as they
|
||||
are.
|
||||
|
||||
State the operational consequence in a comment at the top of the script, in plain words: after
|
||||
Task 2 strips the baked material, a terminal failure means the node has no SSH host key and no
|
||||
TLS key, so `sshd` and the nginx TLS listener will not start and recovery requires the physical
|
||||
console. That is the deliberate trade D-05 chose over running on fleet-shared keys, and the next
|
||||
person to read this script deserves to see it stated rather than discover it.
|
||||
|
||||
Then create `tests/first-boot-secrets/run-tests.sh` (executable, `set -euo pipefail`). It
|
||||
extracts the heredoc body from the builder with `awk` between the `SECRETSSCRIPT` delimiters,
|
||||
writes it to a temp file, and runs it three times against a fresh temp root with a stub `PATH`
|
||||
that shadows `openssl`, `ssh-keygen`, `systemctl` and `logger`:
|
||||
|
||||
- **both succeed** — assert exit 0, marker file present, no `first-boot-secrets.failed`, and the
|
||||
swapped TLS key and host keys present at their final paths.
|
||||
- **openssl fails every attempt** — assert exit non-zero, marker file ABSENT,
|
||||
`first-boot-secrets.failed` present and naming TLS, and no `.new` leftovers.
|
||||
- **ssh-keygen fails twice then succeeds** — assert exit 0 and marker present, proving the
|
||||
backoff retry actually recovers rather than just delaying a failure. Have the stub use a
|
||||
counter file so the third invocation succeeds, and shorten the waits for the test by driving
|
||||
the sleeps through a `FIRST_BOOT_SECRETS_BACKOFF` variable defaulting to `2 8 20`.
|
||||
|
||||
Print a `PASS`/`FAIL` line per case and exit non-zero if any case fails.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash tests/first-boot-secrets/run-tests.sh</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bash tests/first-boot-secrets/run-tests.sh` exits 0 and prints three `PASS` lines.
|
||||
- The failure case asserts the marker is absent; the executor records a scratch run with `touch "$MARKER"` moved back outside the success branch, which MUST make that case fail — pasted into the SUMMARY, then reverted.
|
||||
- `bash -n` is clean on the builder: `bash -n image-recipe/_archived/build-auto-installer-iso.sh`.
|
||||
- Extracting the heredoc and running `bash -n` on the extracted body is clean (the harness does this as its first step).
|
||||
- `grep -c 'FIRST_BOOT_SECRETS_ROOT' image-recipe/_archived/build-auto-installer-iso.sh` is at least 1, and `grep -c 'After=systemd-random-seed.service' image-recipe/_archived/build-auto-installer-iso.sh` is exactly 1.
|
||||
- `image-recipe/_archived/` is not moved, renamed, or referenced from a new location: `git status --porcelain image-recipe/` shows only a modification to `build-auto-installer-iso.sh`.
|
||||
</acceptance_criteria>
|
||||
<done>A failed regeneration leaves no marker, writes a durable failure record, reaches the console, and exits non-zero — and a transient failure recovers via backoff within the same boot, all proven by an automated harness.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Strip fleet-shared identity material from the rootfs tar at build time</name>
|
||||
<files>image-recipe/_archived/build-auto-installer-iso.sh</files>
|
||||
<read_first>
|
||||
- image-recipe/_archived/build-auto-installer-iso.sh (lines 252-272 for the RECIPE_HASH cache condition; 330-355 for the package list that installs openssh-server; 455-470 for the baked TLS keypair; 710-726 for the container export that becomes the shipped tar)
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (finding F-03 and remediation R-03; and the ARCHY-3 table row on machine-id, which is the remaining UNVERIFIED item this task also closes on the build side)
|
||||
- image-recipe/configs/nginx-archipelago.conf (confirms nginx's TLS server block depends on /etc/archipelago/ssl/archipelago.key, i.e. what "fail closed" actually costs)
|
||||
</read_first>
|
||||
<action>
|
||||
Add a final `RUN` layer to the rootfs `Dockerfile.rootfs` heredoc inside STEP 1 (after the TLS
|
||||
generation at ~line 463-469 and after every package install, so nothing regenerates them
|
||||
afterwards) that removes the identity material Debian's `openssh-server` postinst and the
|
||||
`openssl req` step bake into the shared image:
|
||||
|
||||
- delete every `/etc/ssh/ssh_host_*` file (private keys and `.pub` alike),
|
||||
- delete `/etc/archipelago/ssl/archipelago.key` and `/etc/archipelago/ssl/archipelago.crt`,
|
||||
keeping the `/etc/archipelago/ssl` directory itself so the first-boot script's `mkdir -p` and
|
||||
the later swap have somewhere to land,
|
||||
- truncate `/etc/machine-id` to zero length (`: > /etc/machine-id`), which is systemd's
|
||||
documented "generate on next boot" state and is what makes two nodes flashed from one ISO have
|
||||
different machine-ids.
|
||||
|
||||
Keep the `openssl req` step where it is rather than deleting it — leaving it means the build
|
||||
still proves `openssl` is present and the SAN template still lives next to the code that uses it;
|
||||
the strip layer is what makes the output non-shared. Add a comment on the strip layer naming
|
||||
F-03 and stating that its purpose is to make a first-boot regeneration failure degrade to
|
||||
"no key, service refuses to start" instead of "fleet-shared key, silently".
|
||||
|
||||
Also write a build-time provenance line: have the strip layer create
|
||||
`/opt/archipelago/rootfs-identity-stripped` containing the strings it removed, so a node can
|
||||
answer after the fact whether its rootfs came from a stripped build. Do not put a build
|
||||
timestamp in it — that would defeat the reproducibility the RECIPE_HASH cache depends on.
|
||||
|
||||
Note in the SUMMARY that this edit is inside the hashed region and therefore forces the next
|
||||
build to rebuild the rootfs tar, which Task 3 depends on.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -n image-recipe/_archived/build-auto-installer-iso.sh && sed -n '/^# STEP 1: Build complete root filesystem/,/^# STEP 2: Build minimal installer/p' image-recipe/_archived/build-auto-installer-iso.sh | grep -c 'rootfs-identity-stripped'</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bash -n image-recipe/_archived/build-auto-installer-iso.sh` exits 0.
|
||||
- The strip layer is inside the hashed region: the `sed` range extraction above finds `rootfs-identity-stripped` at least once, so the next build invalidates the cached tar.
|
||||
- The strip layer removes all four artefact classes; `grep -c 'ssh_host' image-recipe/_archived/build-auto-installer-iso.sh` increases by at least 1 relative to the pre-plan count, which the executor records in the SUMMARY.
|
||||
- `bash tests/first-boot-secrets/run-tests.sh` still exits 0 (Task 1's harness must not regress).
|
||||
- The `openssl req` block at ~line 463 is still present and unmodified.
|
||||
</acceptance_criteria>
|
||||
<done>The rootfs tar the installer extracts onto every disk carries no SSH host keys, no TLS private key and no populated machine-id, and the next build is forced to rebuild it.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: C-4 — prove the shipped rootfs tar is identity-free on the build host</name>
|
||||
<precondition>An ISO build host with the image-recipe prerequisites (podman or docker, and enough disk for a full rootfs rebuild) is available; the repo checkout on it contains Task 1 and Task 2's commits.</precondition>
|
||||
<files>docs/security/KEY-02-ROOTFS-EVIDENCE.md</files>
|
||||
<read_first>
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (section 6, checklist item C-4 — the exact tar listing and its expected result, which THIS plan deliberately inverts)
|
||||
- image-recipe/_archived/build-auto-installer-iso.sh (Task 2 output — the strip layer whose effect is being measured)
|
||||
- image-recipe/build-debian-iso.sh (lines 15-40 — the wrapper that execs the archived builder, and the `UNBUNDLED=1` convention from CLAUDE.md/project memory)
|
||||
</read_first>
|
||||
<action>
|
||||
Claude prepares the exact command sequence and, after the operator responds, records the raw tar
|
||||
listing and verdict into `docs/security/KEY-02-ROOTFS-EVIDENCE.md` under a `## C-4 — rootfs tar
|
||||
contents` heading, together with the build-host label, the builder commit sha and the RECIPE_HASH
|
||||
observed.
|
||||
|
||||
The expectation is deliberately the INVERSE of the audit's. The audit expected SSH host keys and
|
||||
the TLS key **present** (they were baked) and recorded that "anything else changes F-03's
|
||||
severity". After Task 2 they must be **absent** — so the audit's stated expectation is now the
|
||||
failure condition. Say that explicitly in the evidence document so a future reader comparing the
|
||||
two does not conclude the check regressed.
|
||||
</action>
|
||||
<what-built>A rootfs Dockerfile that strips baked SSH host keys, the TLS keypair and machine-id from the shared image, plus a fail-closed regeneration script that recreates them per node.</what-built>
|
||||
<how-to-verify>
|
||||
1. On the build host, from the repo root, force a full rebuild so the cached tar cannot mask the change:
|
||||
`UNBUNDLED=1 bash image-recipe/build-debian-iso.sh --rebuild`
|
||||
2. Locate the produced tar (the builder prints its path; it is the `$ROOTFS_TAR` it exported) and list the identity artefacts:
|
||||
`tar -tvf <path>/archipelago-rootfs.tar | grep -E 'etc/ssh/ssh_host|etc/machine-id|var/lib/systemd/random-seed|archipelago/ssl/archipelago'`
|
||||
3. Expected after this plan: no `etc/ssh/ssh_host_*` entries at all; no `archipelago/ssl/archipelago.key` or `.crt`; no `var/lib/systemd/random-seed`; `etc/machine-id` present with size 0.
|
||||
4. Confirm the provenance file rode along: `tar -tvf <path>/archipelago-rootfs.tar | grep rootfs-identity-stripped`
|
||||
5. Confirm the first-boot pieces are still shipped onto the installer media:
|
||||
`ls -l <build-dir>/installer-iso/archipelago/scripts/first-boot-secrets.sh <build-dir>/installer-iso/archipelago/scripts/archipelago-first-boot-secrets.service`
|
||||
6. Paste the full output of steps 2, 3, 4 and 5.
|
||||
</how-to-verify>
|
||||
<acceptance_criteria>
|
||||
- The step-2 listing shows zero `etc/ssh/ssh_host` entries and zero `archipelago/ssl/archipelago.key` entries.
|
||||
- `etc/machine-id` appears with size `0`, or is recorded as absent with that stated explicitly — either satisfies "not shared", and the evidence document must say which was observed rather than generalising.
|
||||
- `var/lib/systemd/random-seed` is absent, re-confirming the audit's negative finding against the rebuilt tar rather than inheriting it.
|
||||
- Steps 4 and 5 both succeed, proving the strip layer ran and the regeneration script is still installed — a stripped rootfs with no regeneration script would be a brick, and this criterion is what catches that.
|
||||
- `docs/security/KEY-02-ROOTFS-EVIDENCE.md` marks audit item C-4 as VERIFIED with the date, build-host label and builder commit sha, and states the inverted expectation explicitly.
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Paste the tar listings from steps 2-5, then type "approved" — or describe what was still present.</resume-signal>
|
||||
<done>Audit item C-4 is no longer UNVERIFIED, and the recorded evidence shows the shipped rootfs is identity-free while the regeneration path is still installed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Published ISO -> any downloader | The ISO is a public artefact. Anything identity-shaped inside it is known to every attacker who fetches it. This is the boundary F-03 crosses. |
|
||||
| Shared rootfs tar -> every flashed node | `tar -xf "$ROOTFS_TAR" -C /mnt/target` (`:2303`) puts a byte-identical filesystem on every disk. |
|
||||
| First boot -> network-facing services | `archipelago-first-boot-secrets.service` runs `Before=ssh.service nginx.service archipelago.service`; whatever it leaves behind is what those services present to the network. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-10-21 | Spoofing | Fleet-shared SSH host key from the published ISO enables undetectable host impersonation | high | mitigate | Task 2 strips the baked host keys; Task 1 makes regeneration fail closed so a failure cannot silently restore the shared state |
|
||||
| T-10-22 | Information disclosure | Fleet-shared TLS private key from the published ISO enables transparent MITM of the web UI | high | mitigate | Task 2 strips the baked TLS keypair; Task 1's staging-then-swap keeps the swap atomic |
|
||||
| T-10-23 | Tampering | The completion marker is set on a failed run, so the failure is permanent and unretried (`:1663`) | high | mitigate | Task 1 moves `touch "$MARKER"` inside a both-succeeded branch and writes a durable failure record instead |
|
||||
| T-10-24 | Denial of service | Fail-closed leaves a node with no SSH and no TLS after a terminal failure, unrecoverable remotely | high | mitigate | Three attempts with 2/8/20s backoff within the boot, then retry on every subsequent boot because the marker is absent; the trade is stated in the script header and is D-05's explicit choice; physical console recovery exists on these nodes |
|
||||
| T-10-25 | Repudiation | The only record of a failure is a log file that surfaces nowhere | medium | mitigate | Task 1 adds `/var/lib/archipelago/first-boot-secrets.failed`, a console write and a `logger` line; surfacing it in the daemon's status output is 10-04's job |
|
||||
| T-10-26 | Spoofing | Correlated `machine-id` across nodes flashed from one ISO | medium | mitigate | Task 2 truncates `/etc/machine-id` so systemd regenerates per node; the observed result is recorded in Task 3 rather than assumed |
|
||||
| T-10-27 | Tampering | A cached rootfs tar masks the strip layer, so C-4 passes against stale output | medium | mitigate | The strip layer is inside the RECIPE_HASH region and Task 3 additionally passes `--rebuild`; the acceptance criterion checks the extraction range, not just the file |
|
||||
| T-10-28 | Denial of service | A stripped rootfs ships without the regeneration script, bricking every flashed node | high | mitigate | Task 3 step 5 explicitly checks that `first-boot-secrets.sh` and its unit are present on the installer media, and that check is an acceptance criterion |
|
||||
| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | This plan installs no packages; it edits a shell builder and adds a bash test harness. The Debian package list in the rootfs Dockerfile is not modified. Executor MUST halt and raise a checkpoint if a package addition appears necessary. |
|
||||
</threat_model>
|
||||
|
||||
<artifacts_this_phase_produces>
|
||||
## Artifacts this plan produces
|
||||
|
||||
**Modified:** `image-recipe/_archived/build-auto-installer-iso.sh`
|
||||
|
||||
| Symbol | Kind | Contract |
|
||||
|---|---|---|
|
||||
| `FIRST_BOOT_SECRETS_ROOT` | env var read by the generated `first-boot-secrets.sh` | path prefix for every absolute path; unset in production, set by the test harness |
|
||||
| `FIRST_BOOT_SECRETS_BACKOFF` | env var read by the generated script | space-separated backoff seconds; default `2 8 20` |
|
||||
| `/var/lib/archipelago/first-boot-secrets.failed` | new on-disk file | durable failure record: timestamp plus which generator failed; deleted on a later successful run |
|
||||
| `/opt/archipelago/rootfs-identity-stripped` | new on-disk file | build-time provenance: the artefact classes removed from the rootfs |
|
||||
| `/var/lib/archipelago/.secrets-regenerated` | existing marker, contract changed | now written ONLY when both TLS and SSH regeneration succeeded |
|
||||
| `After=systemd-random-seed.service` | unit ordering | added to `archipelago-first-boot-secrets.service` |
|
||||
|
||||
**New file:** `tests/first-boot-secrets/run-tests.sh` (mode 755) — three cases: both-succeed,
|
||||
openssl-always-fails, ssh-keygen-fails-twice-then-succeeds. Exit 0 only if all three pass.
|
||||
|
||||
**New file:** `docs/security/KEY-02-ROOTFS-EVIDENCE.md` — heading `## C-4 — rootfs tar contents`.
|
||||
</artifacts_this_phase_produces>
|
||||
|
||||
<verification>
|
||||
- `bash tests/first-boot-secrets/run-tests.sh` exits 0 with three `PASS` lines.
|
||||
- `bash -n image-recipe/_archived/build-auto-installer-iso.sh` is clean.
|
||||
- Task 3's checkpoint resolved with a pasted tar listing from a `--rebuild` build.
|
||||
- Commit stages only `image-recipe/_archived/build-auto-installer-iso.sh`,
|
||||
`tests/first-boot-secrets/run-tests.sh` and `docs/security/KEY-02-ROOTFS-EVIDENCE.md` by
|
||||
explicit path — never `git add -A`; another agent shares this tree.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- A failed regeneration leaves no completion marker, writes a durable failure record, reaches the
|
||||
console and exits non-zero — pinned by an automated test that fails if the marker moves back out.
|
||||
- A transient failure recovers via backoff inside the same boot.
|
||||
- The shipped rootfs tar contains no SSH host keys, no TLS private key and no populated
|
||||
machine-id, verified against a forced rebuild on a real build host.
|
||||
- The regeneration script and its unit are still installed onto the installer media.
|
||||
- Audit item C-4 is recorded as VERIFIED with the inverted expectation stated explicitly.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/10-key-material-hardening/10-03-SUMMARY.md` when done, carrying the
|
||||
three harness results, the scratch-run evidence that moving `touch "$MARKER"` back out fails the
|
||||
test, the C-4 tar listing, and an explicit note that the RECIPE_HASH changed.
|
||||
</output>
|
||||
@@ -1,551 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,382 +0,0 @@
|
||||
---
|
||||
phase: 10-key-material-hardening
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["10-03"]
|
||||
files_modified:
|
||||
- scripts/security/host-secrets-audit.sh
|
||||
- image-recipe/configs/archipelago-host-secrets-audit.service
|
||||
- core/archipelago/src/bootstrap.rs
|
||||
- core/archipelago/src/api/rpc/system/handlers.rs
|
||||
- tests/first-boot-secrets/rotation-tests.sh
|
||||
- docs/security/KEY-02-FLEET-ROTATION.md
|
||||
autonomous: false
|
||||
requirements: [KEY-02, KEY-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A deployed node can determine, from its own disk alone, whether its SSH host keys and TLS key are image-baked (fleet-shared) or per-node (D-06)"
|
||||
- "The verdict is surfaced beyond a log file — it appears in system.stats so it is visible without shell access"
|
||||
- "Rotation never loses remote access mid-flight: staging then atomic swap, reload rather than restart, and the new fingerprints recorded where an operator can read them"
|
||||
- "Rotation does not happen by accident: the script is detect-only unless an explicit apply flag is passed, and the auto-versus-manual choice is a resolved human decision (D-06)"
|
||||
- "Two real nodes flashed from the same ISO are proven to have distinct SSH host-key and TLS fingerprints (audit C-3)"
|
||||
artifacts:
|
||||
- path: "scripts/security/host-secrets-audit.sh"
|
||||
provides: "On-node detection of image-baked host secrets, and the guarded one-time rotation"
|
||||
contains: "HOST_SECRETS_ROOT"
|
||||
min_lines: 100
|
||||
- path: "image-recipe/configs/archipelago-host-secrets-audit.service"
|
||||
provides: "Boot-time detection unit, installed onto fleet nodes by the OTA runtime-asset promotion"
|
||||
- path: "core/archipelago/src/api/rpc/system/handlers.rs"
|
||||
provides: "system.stats host_secrets field carrying the verdict and any rotation record"
|
||||
contains: "host_secrets"
|
||||
- path: "tests/first-boot-secrets/rotation-tests.sh"
|
||||
provides: "Automated harness for the detection verdicts and the guarded rotation"
|
||||
min_lines: 60
|
||||
key_links:
|
||||
- from: "core/archipelago/src/bootstrap.rs"
|
||||
to: "image-recipe/configs/archipelago-host-secrets-audit.service"
|
||||
via: "run_runtime_assets installs the unit from the OTA runtime payload, the same path archipelago-doctor.service uses"
|
||||
pattern: "archipelago-host-secrets-audit"
|
||||
- from: "scripts/security/host-secrets-audit.sh"
|
||||
to: "core/archipelago/src/api/rpc/system/handlers.rs"
|
||||
via: "script writes /var/lib/archipelago/host-secrets-audit.json, handler reads it into system.stats"
|
||||
pattern: "host-secrets-audit.json"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close the deployed half of F-03 (D-06): every node already in the field can determine whether it
|
||||
is running the fleet-shared SSH host key and TLS private key baked into its ISO, surface that
|
||||
verdict where an operator can see it, and rotate once — without losing remote access in the
|
||||
middle of the rotation.
|
||||
|
||||
Purpose: 10-03 stops the exposure growing. It does nothing for the nodes that are already live,
|
||||
which is exactly where the exposure sits. D-06 rejected builder-only for that reason.
|
||||
|
||||
Output: an on-node audit script delivered by the existing OTA runtime-asset promotion, a boot
|
||||
unit, a `system.stats` field, an automated harness, and recorded C-3 evidence from two real nodes.
|
||||
|
||||
**D-06 is rated one-way and has its own decision checkpoint (Task 1) before the task that
|
||||
implements rotation.** Rotating a host key invalidates every `known_hosts` entry for that node
|
||||
fleet-wide, including the Tailscale-reached nodes this project depends on for access. There is no
|
||||
going back to the old key.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/10-key-material-hardening/10-CONTEXT.md
|
||||
@.planning/phases/10-key-material-hardening/10-03-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
</context>
|
||||
|
||||
<detection_method>
|
||||
**How a node decides, from its own disk alone, whether its host secrets are fleet-shared.**
|
||||
|
||||
No external data is available — the baked fingerprints live only in the ISO the node was flashed
|
||||
from, and older ISOs are not enumerable. Four on-disk signals, in priority order:
|
||||
|
||||
1. **mtime against the first-boot anchor (primary, independent of any log).**
|
||||
`/var/lib/archipelago/.secrets-regenerated` is touched at first boot on every node that ran
|
||||
the regeneration unit — including the fail-open path — so its mtime is a reliable
|
||||
"this node's first boot" anchor. Host keys regenerated at first boot carry an mtime within
|
||||
seconds of it; keys baked into the image carry the image build time, typically days or weeks
|
||||
earlier. A host key whose mtime is more than 300 seconds OLDER than the anchor came from the
|
||||
image and is therefore shared. Fall back to `/root/.luks-archipelago.key` (written by the
|
||||
installer with `dd if=/dev/urandom`, `install-to-disk.sh`) and then `/etc/machine-id` when the
|
||||
marker is absent; report `unknown` when no anchor exists rather than guessing.
|
||||
2. **The fail-open fingerprint (corroborating, and the audit's own C-3 criterion).**
|
||||
`.secrets-regenerated` present AND `/var/log/archipelago-first-boot-secrets.log` containing a
|
||||
`WARNING:` line is precisely the combination the fail-open path at `:1647`/`:1659`/`:1663`
|
||||
produces.
|
||||
3. **10-03's durable failure record.** `/var/lib/archipelago/first-boot-secrets.failed` present
|
||||
means a post-10-03 node failed regeneration and did not silently continue.
|
||||
4. **Rootfs provenance.** Absence of `/opt/archipelago/rootfs-identity-stripped` means the node
|
||||
was flashed from a pre-10-03 ISO whose rootfs did carry baked material, so signal 1 is
|
||||
meaningful for it. Its presence means the rootfs shipped identity-free, so a missing host key
|
||||
is a fail-closed state rather than a shared one — a materially different verdict.
|
||||
|
||||
The verdict is one of `per-node`, `shared`, `fail-closed-missing` or `unknown`, and the evidence
|
||||
for it is always recorded alongside it. Never report `per-node` on the strength of an absent
|
||||
signal.
|
||||
</detection_method>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 1: Decide how host-key rotation reaches the fleet (D-06, one-way)</name>
|
||||
<files>docs/security/KEY-02-FLEET-ROTATION.md</files>
|
||||
<read_first>
|
||||
- .planning/phases/10-key-material-hardening/10-CONTEXT.md (decision D-06 and its one-way rating)
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (finding F-03, and checklist item C-3 which is how the outcome gets verified)
|
||||
- core/archipelago/src/bootstrap.rs (lines 307-430 — run_runtime_assets, the OTA delivery path both options use)
|
||||
- CLAUDE.md (dev-pair-before-OTA policy, and the invariant that migrations never destroy data)
|
||||
</read_first>
|
||||
<action>
|
||||
Present the choice and stop. Do not implement either option before this resolves — Task 2's
|
||||
`<reversibility>` rating exists because walking through this door unattended is the failure mode
|
||||
the gate is for.
|
||||
</action>
|
||||
<decision>When a fleet node detects that its SSH host keys and TLS key are the image-baked, fleet-shared ones, does it rotate them automatically, or detect and report and wait for an operator?</decision>
|
||||
<context>
|
||||
Rotating an SSH host key is one-way: every existing `known_hosts` entry for that node breaks, on
|
||||
every machine that has ever connected to it, and there is no path back to the old key. The fleet
|
||||
is reached over Tailscale for day-to-day work, and several nodes are remote (`.228` is at a
|
||||
remote site and is in real use). A rotation that fires during an OTA on many nodes at once
|
||||
produces simultaneous host-key mismatches across the fleet with no warning. Against that: every
|
||||
boot a shared key stays in place is a boot on a key that anyone holding a copy of the published
|
||||
ISO also holds. D-06 already chose "remediate deployed nodes"; this decides only the trigger.
|
||||
</context>
|
||||
<options>
|
||||
<option id="auto-on-boot">
|
||||
<name>Auto-rotate on the first boot after the OTA</name>
|
||||
<pros>Closes the exposure on every node without operator effort; no node is left behind because someone forgot; the exposure window is bounded by the OTA rollout rather than by operator attention.</pros>
|
||||
<cons>Simultaneous fleet-wide `known_hosts` breakage with no advance notice; a node whose only access path is SSH-over-Tailscale becomes unreachable to any tooling that pins the host key until an operator clears the entry; if the rotation itself fails partway on a node, that node may be left needing physical console access.</cons>
|
||||
</option>
|
||||
<option id="detect-report-then-apply">
|
||||
<name>Detect and report on boot; rotate only when an operator runs the script with an explicit apply flag</name>
|
||||
<pros>Access is never lost unexpectedly; the operator rotates one node at a time with the new fingerprint in hand; the verdict is still visible fleet-wide immediately via `system.stats`, so the exposure is measured rather than assumed; matches this project's standing "verify on the dev pair first" policy.</pros>
|
||||
<cons>The exposure persists on any node whose operator does not act; requires a follow-up operational task per affected node; a node that is never revisited stays exposed indefinitely.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<acceptance_criteria>
|
||||
- The chosen option id is recorded verbatim in `docs/security/KEY-02-FLEET-ROTATION.md` under `## D-06 rotation trigger`, with the date and the reason given.
|
||||
- If `auto-on-boot` is chosen, Task 2 must additionally implement a pre-rotation reachability guard and a staged rollout knob, and the SUMMARY must record how a node is recovered if rotation fails mid-flight.
|
||||
- If `detect-report-then-apply` is chosen, Task 2's unit ships in detect-only mode and Task 3's checkpoint covers an operator-driven rotation on one node.
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Select: auto-on-boot, or detect-report-then-apply.</resume-signal>
|
||||
<done>The rotation trigger is a recorded human decision, not an implementation default.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: On-node detection, guarded rotation, OTA delivery and status surfacing</name>
|
||||
<reversibility rating="one-way">Rotating a node's SSH host key invalidates every existing `known_hosts` entry for it fleet-wide and cannot be undone — the old private key is destroyed by the swap (D-06).</reversibility>
|
||||
<precondition>Task 1's decision is recorded in `docs/security/KEY-02-FLEET-ROTATION.md`; the script's default mode follows it.</precondition>
|
||||
<files>scripts/security/host-secrets-audit.sh, image-recipe/configs/archipelago-host-secrets-audit.service, core/archipelago/src/bootstrap.rs, core/archipelago/src/api/rpc/system/handlers.rs, tests/first-boot-secrets/rotation-tests.sh</files>
|
||||
<read_first>
|
||||
- core/archipelago/src/bootstrap.rs (lines 307-430 — run_runtime_assets: the `scripts` -> /opt/archipelago/scripts promotion, the chmod 755 sweep, and the `for unit in [...]` loop that installs units from image-recipe/configs)
|
||||
- image-recipe/configs/archipelago-doctor.service (the house pattern for a unit delivered this way)
|
||||
- image-recipe/_archived/build-auto-installer-iso.sh (10-03 output: the fail-closed script, its `FIRST_BOOT_SECRETS_ROOT` seam, the `first-boot-secrets.failed` record and the `rootfs-identity-stripped` provenance file this script keys off)
|
||||
- core/archipelago/src/api/rpc/system/handlers.rs (lines 179-206 — handle_system_stats, the object being extended)
|
||||
- core/archipelago/src/api/rpc/middleware.rs (line 41 — system.stats is in CACHEABLE_METHODS, so the new field must be cheap to compute)
|
||||
- tests/first-boot-secrets/run-tests.sh (10-03 output: the stub-PATH harness pattern to mirror)
|
||||
</read_first>
|
||||
<action>
|
||||
**a. `scripts/security/host-secrets-audit.sh`** (executable, `set -euo pipefail`). Mirrors 10-03's
|
||||
testability seam: `ROOT="${HOST_SECRETS_ROOT:-}"` prefixes every absolute path so the harness can
|
||||
drive it against a temp tree, and production behaviour with the variable unset is unchanged.
|
||||
|
||||
Modes: `--detect` (default, read-only) and `--apply` (rotates). `--apply` without `--yes` prints
|
||||
what it would do and exits 0 without touching anything, so a mistyped invocation is inert.
|
||||
|
||||
`--detect` evaluates the four signals from the detection_method section above, writes
|
||||
`$ROOT/var/lib/archipelago/host-secrets-audit.json` with fields `verdict`
|
||||
(`per-node`|`shared`|`fail-closed-missing`|`unknown`), `evidence` (an array of the signal strings
|
||||
that fired, each naming the file it read), `checked_at` (ISO-8601), `ssh_host_key_fingerprints`
|
||||
(the `ssh-keygen -lf` output for each public key — public data, safe to record) and
|
||||
`tls_cert_sha256` (from `openssl x509 -noout -fingerprint -sha256`). Write it 0644 so the daemon
|
||||
can read it without privilege. Print a one-line human verdict to stdout. Exit 0 on any verdict —
|
||||
detection is informational and must never fail a boot.
|
||||
|
||||
`--apply --yes` rotates only the classes the detect pass flagged as `shared`, in this order,
|
||||
which is the access-preserving sequence and is the reason ordering is specified rather than left
|
||||
to the implementer:
|
||||
1. Generate the replacement TLS keypair and the full SSH host-key set into staging directories.
|
||||
If any generation fails, abort before touching anything live and exit non-zero — a partial
|
||||
rotation is the failure mode that loses access.
|
||||
2. Record the OLD fingerprints into `$ROOT/var/lib/archipelago/host-key-rotation.json`
|
||||
(0644: `rotated_at`, `old_ssh_fingerprints`, `old_tls_sha256`) BEFORE the swap, so an
|
||||
operator who loses access can still identify what changed.
|
||||
3. Swap the TLS pair, then `systemctl reload nginx`.
|
||||
4. Swap the SSH host keys, then `systemctl reload ssh` — reload, never restart. A reload
|
||||
re-execs the listener while already-forked session children keep running, so the operator's
|
||||
current SSH session survives its own rotation. Note that in the script comment; it is the
|
||||
single most important line in the file.
|
||||
5. Append the NEW fingerprints to `host-key-rotation.json`, print them to stdout and to
|
||||
`/dev/console` (guarded so a missing console cannot fail the run), and re-run the detect
|
||||
pass so `host-secrets-audit.json` reflects the post-rotation state.
|
||||
Never delete a key without a successfully staged replacement in hand.
|
||||
|
||||
**b. `image-recipe/configs/archipelago-host-secrets-audit.service`** — `Type=oneshot`,
|
||||
`After=archipelago-first-boot-secrets.service network.target`, `ExecStart` pointing at
|
||||
`/opt/archipelago/scripts/security/host-secrets-audit.sh` with the mode Task 1's decision chose,
|
||||
`WantedBy=multi-user.target`. Follow `image-recipe/configs/archipelago-doctor.service`'s shape.
|
||||
|
||||
**c. `core/archipelago/src/bootstrap.rs`** — add `"archipelago-host-secrets-audit.service"` to the
|
||||
`for unit in [...]` array at line ~361 so the OTA runtime-asset promotion installs it, exactly as
|
||||
`archipelago-doctor.service` is installed today. The `scripts` directory promotion at line ~320
|
||||
already carries `scripts/security/` along with it; confirm that by reading
|
||||
`replace_dir_from_runtime` rather than assuming, and record the confirmation in the SUMMARY.
|
||||
Enable the unit after install (`systemctl enable --now` via the existing `host_sudo` helper) in
|
||||
the same `if changed` block that already runs `daemon-reload`.
|
||||
|
||||
**d. `core/archipelago/src/api/rpc/system/handlers.rs`** — extend `handle_system_stats`'s JSON with
|
||||
a `host_secrets` object read from `/var/lib/archipelago/host-secrets-audit.json`: `verdict`,
|
||||
`checked_at`, `evidence`, and `rotated_at` from `host-key-rotation.json` when present. A missing
|
||||
or unparseable file yields `{"verdict":"unknown"}` — never an error, because `system.stats` is
|
||||
cacheable and polled by the dashboard. Do not include the raw fingerprints in `system.stats`;
|
||||
expose them only in the on-disk record, so the polled dashboard payload stays small.
|
||||
|
||||
**e. `tests/first-boot-secrets/rotation-tests.sh`** (executable) — same stub-PATH pattern as
|
||||
10-03's harness, driving `host-secrets-audit.sh` against temp roots:
|
||||
- host keys newer than the anchor -> verdict `per-node`, JSON written, no files changed;
|
||||
- host keys 30 days older than the anchor -> verdict `shared`;
|
||||
- marker present plus a `WARNING:` line in the log -> verdict `shared` with both signals in `evidence`;
|
||||
- `rootfs-identity-stripped` present and host keys absent -> verdict `fail-closed-missing`, not `shared`;
|
||||
- no anchor at all -> verdict `unknown`;
|
||||
- `--apply` without `--yes` -> no file in the tree changes (compare a `find … -newer` snapshot);
|
||||
- `--apply --yes` on a `shared` tree -> old fingerprints recorded before the swap, new keys present, `host-key-rotation.json` contains both, and at no point in the run is the tree left with zero SSH host keys (assert by having the `ssh-keygen` stub fail and checking the live keys are untouched).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash tests/first-boot-secrets/rotation-tests.sh && cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago system:: -- --nocapture</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bash tests/first-boot-secrets/rotation-tests.sh` exits 0 with a `PASS` line for each of the seven cases.
|
||||
- The abort-before-swap case is proven: with the `ssh-keygen` stub failing, the pre-existing host keys in the temp tree are byte-identical after the run (harness asserts with `sha256sum`).
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo build -p archipelago` succeeds and `cargo clippy -p archipelago -- -D warnings` is clean for the two touched Rust files.
|
||||
- A Rust test asserts `handle_system_stats` yields `host_secrets.verdict == "unknown"` when the JSON file is absent, and the recorded verdict when it is present.
|
||||
- `grep -c 'archipelago-host-secrets-audit' core/archipelago/src/bootstrap.rs` is at least 1.
|
||||
- `grep -n 'systemctl reload ssh' scripts/security/host-secrets-audit.sh` matches and there is no `systemctl restart ssh` in the file.
|
||||
- `bash -n scripts/security/host-secrets-audit.sh` exits 0; if `shellcheck` is available, `shellcheck -S error` is clean, otherwise its absence is recorded.
|
||||
</acceptance_criteria>
|
||||
<done>A deployed node writes a verdict with its evidence to disk and to `system.stats`, and a guarded rotation exists that stages everything before touching anything live and reloads rather than restarts sshd.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: C-3 — two real nodes, distinct host keys, access intact</name>
|
||||
<precondition>Two nodes flashed from the same ISO are reachable, and both are running a build that carries this plan's runtime payload.</precondition>
|
||||
<files>docs/security/KEY-02-FLEET-ROTATION.md</files>
|
||||
<read_first>
|
||||
- scripts/security/host-secrets-audit.sh (Task 2 output)
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (section 6, checklist item C-3 — the highest-value check in the audit's list)
|
||||
- docs/security/KEY-02-FLEET-ROTATION.md (Task 1's recorded decision, which determines whether step 4 below is expected to be a no-op)
|
||||
- CLAUDE.md (node access policy; `.228` is remote and in real use — do not rotate it uninvited)
|
||||
</read_first>
|
||||
<action>
|
||||
Claude prepares the sequence; the operator runs it on two nodes and pastes the output. Claude then
|
||||
records the result into `docs/security/KEY-02-FLEET-ROTATION.md` under `## C-3 — per-node host
|
||||
key and TLS uniqueness`, with node labels rather than addresses, and marks audit item C-3 as
|
||||
VERIFIED or FAILED with the fingerprints compared as opaque digests.
|
||||
|
||||
Pick the two nodes deliberately: use the dev pair (archi-dev-box + x250-dev) or another
|
||||
disposable pair. Do not run `--apply` against `.228` or any node in real use as part of this
|
||||
checkpoint; if the audit verdict on such a node comes back `shared`, record it as a finding and
|
||||
raise it rather than rotating it inside a verification task.
|
||||
</action>
|
||||
<what-built>An on-node audit that reports whether this node's SSH host keys and TLS key are image-baked, and a guarded rotation that preserves the operator's own session.</what-built>
|
||||
<how-to-verify>
|
||||
1. On EACH node: `sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --detect`
|
||||
then `cat /var/lib/archipelago/host-secrets-audit.json`
|
||||
2. On EACH node, capture the fingerprints directly, independently of the script:
|
||||
`for f in /etc/ssh/ssh_host_*_key.pub; do ssh-keygen -lf "$f"; done`
|
||||
and `openssl x509 -in /etc/archipelago/ssl/archipelago.crt -noout -fingerprint -sha256`
|
||||
3. Compare the two nodes' outputs from step 2. ANY fingerprint appearing on both nodes is a
|
||||
confirmed F-03 instance — record it as the C-3 FAIL result, which is a finding, not an error.
|
||||
4. If Task 1 chose `detect-report-then-apply` AND either node's verdict is `shared`: on ONE
|
||||
disposable node, from a session you are willing to lose, run
|
||||
`sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --apply --yes`
|
||||
Then, WITHOUT closing that session, confirm it is still alive (`echo still-here`), open a
|
||||
SECOND connection and confirm the expected host-key-mismatch warning, accept the new key,
|
||||
and paste the new fingerprints from `/var/lib/archipelago/host-key-rotation.json`.
|
||||
5. Confirm the web UI still loads over HTTPS on the rotated node (new self-signed cert, so a
|
||||
fresh browser trust prompt is expected and is the correct outcome).
|
||||
6. Confirm the verdict propagated: call `system.stats` on the rotated node and paste the
|
||||
`host_secrets` object.
|
||||
</how-to-verify>
|
||||
<acceptance_criteria>
|
||||
- Step 3's comparison is recorded for both nodes: either "no fingerprint appears on both nodes" (C-3 PASS) or the exact shared fingerprint classes (C-3 FAIL, recorded as a live F-03 instance with the node labels).
|
||||
- If step 4 ran: the operator confirms the original session survived the rotation, the second connection showed the expected mismatch, and `host-key-rotation.json` contains both old and new fingerprints.
|
||||
- Step 6's `host_secrets.verdict` is `per-node` after a rotation, proving the detect pass re-ran and the surfacing works end to end.
|
||||
- `docs/security/KEY-02-FLEET-ROTATION.md` marks audit item C-3 VERIFIED (or FAILED-with-finding) with the date and node labels, and records every node whose verdict came back `shared` but which was deliberately NOT rotated, so none is quietly forgotten.
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Paste the per-node JSON, the fingerprint listings from both nodes, and (if run) the rotation output, then type "approved" — or describe what failed.</resume-signal>
|
||||
<done>Audit item C-3 is no longer UNVERIFIED: two real nodes are compared, any shared material is named, and a rotation has been demonstrated to preserve the operator's own session.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| OTA runtime payload -> node filesystem | `run_runtime_assets` (`bootstrap.rs:307-430`) promotes `scripts/` into `/opt/archipelago/scripts` and installs units into `/etc/systemd/system` using `host_sudo`. Anything shipped here runs as root at boot on every fleet node. |
|
||||
| Rotation script -> live remote access | The script rewrites the credentials the operator's own SSH session and the web UI's TLS depend on. |
|
||||
| `system.stats` -> dashboard | An authenticated, cacheable, frequently-polled read. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-10-31 | Denial of service | Rotation loses remote access mid-flight, on a remote node, with no console | high | mitigate | Task 2 stages every replacement before touching anything live and aborts on any generation failure; swaps TLS then SSH; reloads rather than restarts sshd so forked session children survive; Task 3 step 4 proves it on a live session before it is trusted |
|
||||
| T-10-32 | Denial of service | Fleet-wide simultaneous `known_hosts` breakage during an OTA | high | mitigate | Task 1's blocking decision, with `detect-report-then-apply` available specifically to bound this; old and new fingerprints recorded before and after so operators can update `known_hosts` deliberately |
|
||||
| T-10-33 | Spoofing | A node keeps running a fleet-shared SSH host key that anyone with the published ISO holds | high | mitigate | Detection runs at boot and the verdict reaches `system.stats`, so an exposed node is visible without shell access; rotation closes it |
|
||||
| T-10-34 | Tampering | The audit script runs as root at boot from a directory replaced wholesale by the OTA payload | medium | mitigate | Delivery reuses the existing, already-trusted `run_runtime_assets` path and adds no new trust source; the script performs no network I/O and takes no input from the network; `--apply` requires `--yes` |
|
||||
| T-10-35 | Information disclosure | Host-key fingerprints and TLS digests written to disk and into a polled RPC payload | low | accept | Fingerprints of PUBLIC keys are public data; the private keys are never read by the script beyond regeneration. `system.stats` deliberately carries only the verdict, not the fingerprints |
|
||||
| T-10-36 | Repudiation | A rotation happens with no record of what the key used to be | medium | mitigate | Old fingerprints are written to `host-key-rotation.json` BEFORE the swap; new ones appended after; both echoed to console |
|
||||
| T-10-37 | Spoofing | A false `per-node` verdict from a missing signal leaves an exposed node looking clean | high | mitigate | The detection method reports `unknown` when no anchor exists and never infers `per-node` from an absent signal; every verdict carries the evidence strings that produced it |
|
||||
| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | No package-manager install occurs; the script uses `openssl`, `ssh-keygen`, `stat` and `systemctl`, all already present on fleet nodes, and the Rust change adds no crate. Executor MUST halt and raise a checkpoint if a new dependency appears necessary. |
|
||||
</threat_model>
|
||||
|
||||
<artifacts_this_phase_produces>
|
||||
## Artifacts this plan produces
|
||||
|
||||
**New file:** `scripts/security/host-secrets-audit.sh` (mode 755, promoted to
|
||||
`/opt/archipelago/scripts/security/host-secrets-audit.sh` by the OTA runtime payload)
|
||||
|
||||
| Symbol | Kind | Contract |
|
||||
|---|---|---|
|
||||
| `--detect` | CLI flag | default; read-only; writes the verdict JSON; always exits 0 |
|
||||
| `--apply` | CLI flag | inert without `--yes` |
|
||||
| `--yes` | CLI flag | required confirmation for a real rotation |
|
||||
| `HOST_SECRETS_ROOT` | env var | path prefix for the whole script; unset in production |
|
||||
| `/var/lib/archipelago/host-secrets-audit.json` | new on-disk file (0644) | `verdict`, `evidence[]`, `checked_at`, `ssh_host_key_fingerprints[]`, `tls_cert_sha256` |
|
||||
| `/var/lib/archipelago/host-key-rotation.json` | new on-disk file (0644) | `rotated_at`, `old_ssh_fingerprints[]`, `old_tls_sha256`, `new_ssh_fingerprints[]`, `new_tls_sha256` |
|
||||
|
||||
**New file:** `image-recipe/configs/archipelago-host-secrets-audit.service` — `Type=oneshot`,
|
||||
`After=archipelago-first-boot-secrets.service network.target`, `WantedBy=multi-user.target`.
|
||||
|
||||
**Modified:** `core/archipelago/src/bootstrap.rs` — `archipelago-host-secrets-audit.service` added
|
||||
to the runtime-asset unit install list.
|
||||
|
||||
**Modified:** `core/archipelago/src/api/rpc/system/handlers.rs` — `system.stats` gains a
|
||||
`host_secrets` object: `{ verdict, checked_at, evidence, rotated_at }`. Absent file yields
|
||||
`{"verdict":"unknown"}`.
|
||||
|
||||
**New file:** `tests/first-boot-secrets/rotation-tests.sh` (mode 755) — seven cases.
|
||||
|
||||
**New file:** `docs/security/KEY-02-FLEET-ROTATION.md` — headings
|
||||
`## D-06 rotation trigger`, `## C-3 — per-node host key and TLS uniqueness`.
|
||||
</artifacts_this_phase_produces>
|
||||
|
||||
<verification>
|
||||
- `bash tests/first-boot-secrets/rotation-tests.sh` exits 0.
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago` shows no new failures.
|
||||
- Both checkpoints resolved with pasted output.
|
||||
- Commit stages only this plan's six paths explicitly — never `git add -A`. Note that
|
||||
`core/archipelago/src/api/rpc/system/handlers.rs` and `core/archipelago/src/bootstrap.rs` are
|
||||
shared-tree files: run `git status --porcelain` first and, if another agent has uncommitted work
|
||||
in either, stop and raise it rather than committing around them.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Every deployed node writes a verdict with its evidence and exposes it via `system.stats`.
|
||||
- The rotation path stages everything before touching anything live, reloads rather than restarts
|
||||
sshd, and records old and new fingerprints on both sides of the swap.
|
||||
- Rotation cannot happen by accident: detect-only default, `--apply` inert without `--yes`, and
|
||||
the auto-versus-manual trigger is a recorded human decision.
|
||||
- Audit item C-3 is recorded as VERIFIED or FAILED-with-finding against two real nodes.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/10-key-material-hardening/10-04-SUMMARY.md` when done, carrying Task 1's
|
||||
chosen option, the seven harness results, the C-3 fingerprint comparison, the confirmation that
|
||||
`replace_dir_from_runtime` carries `scripts/security/`, and a list of any node whose verdict was
|
||||
`shared` but which was deliberately not rotated.
|
||||
</output>
|
||||
@@ -1,546 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,360 +0,0 @@
|
||||
---
|
||||
phase: 10-key-material-hardening
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- core/archipelago/src/api/rpc/bitcoin.rs
|
||||
- core/archipelago/src/api/rpc/dispatcher.rs
|
||||
- core/archipelago/src/seed.rs
|
||||
- core/archipelago/src/api/rpc/lnd/wallet.rs
|
||||
- docs/security/KEY-03-SIGNING-POSTURE.md
|
||||
- docs/security/PSBT-SIGNING-ARCHITECTURE.md
|
||||
autonomous: false
|
||||
requirements: [KEY-03]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "No code path in the daemon writes the BIP-84 account extended private key into Bitcoin Core (F-13 closed by deletion, per D-07b)"
|
||||
- "The PSBT produced by lnd.create-psbt is inspected for the BIP-32 key-origin data an external signer needs, and the result is reported to the caller instead of assumed"
|
||||
- "The repository states honestly which parts of the fund -> sign-offline -> finalize -> broadcast round trip are covered by tests today and which are not"
|
||||
- "No document this plan touches implies Lightning channel, revocation or HTLC keys can be air-gapped"
|
||||
- "If any fleet node turns out to hold a descriptor wallet this handler created, that is surfaced and stopped on, not silently migrated (D-07b)"
|
||||
artifacts:
|
||||
- path: "core/archipelago/src/api/rpc/lnd/wallet.rs"
|
||||
provides: "psbt_key_origin_report plus its tests, and the key-origin field on the lnd.create-psbt response"
|
||||
contains: "psbt_key_origin_report"
|
||||
- path: "docs/security/KEY-03-SIGNING-POSTURE.md"
|
||||
provides: "The evidence-backed record of the Core deletion, the LND PSBT coverage map, and the air-gap honesty statement"
|
||||
contains: "D-07b"
|
||||
min_lines: 60
|
||||
- path: "docs/security/PSBT-SIGNING-ARCHITECTURE.md"
|
||||
provides: "Status banner recording that its Phase 1 was superseded by deletion rather than conversion"
|
||||
contains: "D-07b"
|
||||
key_links:
|
||||
- from: "core/archipelago/src/api/rpc/lnd/wallet.rs"
|
||||
to: "docs/security/KEY-03-SIGNING-POSTURE.md"
|
||||
via: "the key-origin report is the mechanical form of the doc's external-signer claim"
|
||||
pattern: "psbt_key_origin_report"
|
||||
- from: "core/archipelago/src/api/rpc/dispatcher.rs"
|
||||
to: "core/archipelago/src/api/rpc/bitcoin.rs"
|
||||
via: "the bitcoin.init-wallet-from-seed dispatch arm and its handler are removed together"
|
||||
pattern: "init-wallet-from-seed"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close F-13 (High) by deleting the code that duplicates the spending key, and make the signing
|
||||
path that actually matters — LND's PSBT round trip — a first-class, tested, honestly documented
|
||||
one (D-07b).
|
||||
|
||||
Purpose: `handle_bitcoin_init_wallet_from_seed` derives the BIP-84 account **xprv**, stringifies
|
||||
it, and imports `wpkh(xprv/0/*)` and `wpkh(xprv/1/*)` into Bitcoin Core's `wallet.dat`
|
||||
(`core/archipelago/src/api/rpc/bitcoin.rs:188-189`, `:203`, `:229-231`, `:278-281`). It has no
|
||||
caller anywhere in the repo, LND is the wallet the UI actually drives, and archi-dev-box shows
|
||||
the wallet it creates has never existed there. Its entire function is to put a second copy of the
|
||||
spending key somewhere with weaker protection than the Argon2 envelope. D-07b: delete it.
|
||||
|
||||
Output: the Core path gone, `lnd.create-psbt` reporting whether its PSBT carries the BIP-32
|
||||
derivation data a hardware signer needs, and a signing-posture document that states plainly what
|
||||
is and is not air-gappable.
|
||||
|
||||
**D-07's parity-proof migration and its one-way checkpoint are withdrawn (D-07b).** There is no
|
||||
wallet to migrate. Do not plan or build migration machinery. If Task 3's census unexpectedly
|
||||
finds a wallet this handler created, that is a finding to stop on — it would mean the endpoint
|
||||
was invoked by hand and that node's spending key is duplicated in Core, which deserves a human
|
||||
decision, not an automated rewrite.
|
||||
|
||||
**What deletion does to D-08 and D-09.** D-08 asked that the spending key exist in exactly one
|
||||
place, with an opt-in air-gapped path; deleting the Core import achieves the first half outright
|
||||
(the only remaining on-node copy of the BIP-84 key is the Argon2 envelope), and the opt-in path
|
||||
is LND's existing PSBT round trip rather than a Core watch-only wallet. D-09 required a
|
||||
`[fingerprint/derivation]` key origin on emitted descriptors so a hardware signer can locate its
|
||||
key; with Core's descriptors deleted there are no Archipelago-emitted descriptors left to
|
||||
annotate, so D-09's actual protection moves to the PSBT itself — Task 2 inspects and reports
|
||||
whether the PSBT `lnd.create-psbt` returns carries the BIP-32 key-origin data a signer needs.
|
||||
Neither decision is dropped; both are satisfied by a different mechanism, and the plan says so
|
||||
rather than letting them lapse.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/10-key-material-hardening/10-CONTEXT.md
|
||||
@docs/security/PSBT-SIGNING-ARCHITECTURE.md
|
||||
@CLAUDE.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Delete the Core wallet path end to end, and record why (D-07b)</name>
|
||||
<reversibility rating="reversible">Deleting an uncalled, password-gated endpoint is recoverable from git in one revert; nothing consumes its output and no on-disk state depends on it (D-07b).</reversibility>
|
||||
<files>core/archipelago/src/api/rpc/bitcoin.rs, core/archipelago/src/api/rpc/dispatcher.rs, core/archipelago/src/seed.rs, docs/security/KEY-03-SIGNING-POSTURE.md</files>
|
||||
<read_first>
|
||||
- core/archipelago/src/api/rpc/bitcoin.rs (lines 155-300 — the whole handler being deleted, including the zeroize calls at :222 and :284 whose careful in-memory handling is worth naming in the record)
|
||||
- core/archipelago/src/api/rpc/dispatcher.rs (lines 105-130 — the bitcoin.* dispatch arms, one of which is being removed)
|
||||
- core/archipelago/src/seed.rs (lines 225-250 — derive_bitcoin_xprv, which loses its only non-test caller)
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (finding F-13 and remediation R-04 — the record this task closes)
|
||||
- .planning/phases/10-key-material-hardening/10-CONTEXT.md (D-07a and D-07b — the evidence chain, and D-07c which must stay visible)
|
||||
</read_first>
|
||||
<action>
|
||||
Before deleting anything, re-establish the evidence yourself rather than inheriting it, and paste
|
||||
the raw command output into the SUMMARY. Run a repo-wide search for the method name
|
||||
`bitcoin.init-wallet-from-seed` and for the handler symbol
|
||||
`handle_bitcoin_init_wallet_from_seed` across `core/`, `neode-ui/src`, `scripts/`, `web/`, `apps/`
|
||||
and `tests/`, excluding `core/target`, `node_modules` and `.git`. The expected result is exactly
|
||||
two occurrences of the method name (the dispatcher arm and, if present, a docs mention) and two
|
||||
of the symbol (its definition and the dispatcher call). If the search finds a third caller, STOP
|
||||
and raise a checkpoint — the deletion's premise is that nothing calls it.
|
||||
|
||||
Then:
|
||||
|
||||
1. Delete `handle_bitcoin_init_wallet_from_seed` from `core/archipelago/src/api/rpc/bitcoin.rs`
|
||||
(the whole function including its doc comment) and remove the `"bitcoin.init-wallet-from-seed"`
|
||||
arm from `core/archipelago/src/api/rpc/dispatcher.rs`. Remove any import that becomes unused
|
||||
as a result — `zeroize::Zeroize` is the likely one; let the compiler tell you rather than
|
||||
guessing.
|
||||
2. `crate::seed::derive_bitcoin_xprv` loses its only non-test caller. Do NOT delete it: it is
|
||||
covered by existing tests (`seed.rs:601-602`, `:856`) and it is the derivation D-07c's deferred
|
||||
BDK cold vault will need. Add `#[allow(dead_code)]` with a doc line naming D-07c as the reason
|
||||
it is retained, so the next reader does not delete it as cruft. `archipelago` is a binary crate
|
||||
with no `lib.rs`, so an uncalled `pub fn` does warn and `clippy -D warnings` would fail without
|
||||
this.
|
||||
3. Create `docs/security/KEY-03-SIGNING-POSTURE.md` with a first section
|
||||
`## Bitcoin Core wallet path — deleted (D-07b)` recording: the four evidence points from D-07a
|
||||
with their `file:line`; the search output from this task; that the endpoint was authenticated
|
||||
AND password-gated (`bitcoin.rs:176-180`) so F-13 was never remotely reachable — key-at-rest
|
||||
duplication, not an exposed endpoint; that the in-memory handling of the xprv string was
|
||||
careful (zeroized on both paths) and the defect was which key went into the wallet, not how it
|
||||
was held; and that F-13 is closed by removal rather than by conversion to watch-only.
|
||||
Reference D-07c explicitly so the deferred cold-vault option stays visible rather than being
|
||||
quietly lost with the code.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && CARGO_INCREMENTAL=0 cargo build -p archipelago && CARGO_INCREMENTAL=0 cargo clippy -p archipelago -- -D warnings</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo build -p archipelago` succeeds and `cargo clippy -p archipelago -- -D warnings` is clean.
|
||||
- `grep -rn 'handle_bitcoin_init_wallet_from_seed' core/ --include=*.rs` returns no matches.
|
||||
- `grep -rn 'init-wallet-from-seed' core/archipelago/src/api/rpc/dispatcher.rs` returns only the `lnd.init-wallet-from-seed` arm, which is a different endpoint and stays.
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago seed::` still passes, proving `derive_bitcoin_xprv`'s coverage survived the deletion.
|
||||
- `docs/security/KEY-03-SIGNING-POSTURE.md` exists, cites D-07b and D-07c by name, and contains the pasted no-caller search output.
|
||||
</acceptance_criteria>
|
||||
<done>Nothing in the daemon can write the BIP-84 account private key into Bitcoin Core, the derivation function survives with its tests and a stated reason, and the deletion is documented with its evidence.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Make the LND PSBT path first-class — report the key-origin data an external signer needs</name>
|
||||
<files>core/archipelago/src/api/rpc/lnd/wallet.rs, docs/security/KEY-03-SIGNING-POSTURE.md, docs/security/PSBT-SIGNING-ARCHITECTURE.md</files>
|
||||
<read_first>
|
||||
- core/archipelago/src/api/rpc/lnd/wallet.rs (lines 600-710 for handle_lnd_create_psbt; 705-790 for handle_lnd_finalize_psbt including the broadcast at the end; 790-905 for handle_lnd_create_raw_tx, the auto-signing sibling that must NOT be changed; 1128-1160 for the existing test module and the build_invoice_request_body free-function pattern to mirror)
|
||||
- core/archipelago/src/rate_limit.rs (lines 68-69 — lnd.create-psbt and lnd.finalize-psbt are already limited at 5/300s)
|
||||
- core/archipelago/Cargo.toml (lines 70-95 — `bitcoin = "=0.32.5"` and `base64 = "0.21"` are already present; no new dependency is permitted in this task)
|
||||
- docs/security/PSBT-SIGNING-ARCHITECTURE.md (sections 1.2, 3.1 and 5 — the RPC loop, why key origin is mandatory rather than cosmetic, and the honest LND limits)
|
||||
- neode-ui/src/api/rpc-client.ts (lines 410-435 — createPsbt/finalizePsbt, the client contract the new response field extends)
|
||||
</read_first>
|
||||
<action>
|
||||
Add a pure, testable inspection function to `core/archipelago/src/api/rpc/lnd/wallet.rs`,
|
||||
alongside the existing free function `build_invoice_request_body`:
|
||||
|
||||
`fn psbt_key_origin_report(psbt_base64: &str) -> anyhow::Result<PsbtKeyOriginReport>` returning a
|
||||
struct with `input_count: usize`, `inputs_with_key_origin: usize` and
|
||||
`all_inputs_have_key_origin: bool`. Decode with `base64` and parse with
|
||||
`bitcoin::psbt::Psbt::deserialize`; count an input as carrying key origin when either its
|
||||
`bip32_derivation` map or its `tap_key_origins` map is non-empty. Use only the already-present
|
||||
`bitcoin` and `base64` crates — adding a dependency is out of scope for this plan and the
|
||||
executor must halt rather than add one.
|
||||
|
||||
Wire it into `handle_lnd_create_psbt` after `funded_psbt` is extracted: compute the report
|
||||
best-effort (a decode failure must degrade to `null`, never to an error — a user's send must not
|
||||
fail because an inspection helper could not parse something), add a `key_origin` object to the
|
||||
JSON response carrying the three fields, and `tracing::warn!` with the counts when
|
||||
`all_inputs_have_key_origin` is false, because that is the exact condition under which a hardware
|
||||
signer will refuse the PSBT. Do not change `handle_lnd_finalize_psbt` and do not touch
|
||||
`handle_lnd_create_raw_tx` — the latter deliberately auto-signs with LND's hot keys and is a
|
||||
different flow.
|
||||
|
||||
Add tests in the existing `mod tests`, building the fixtures programmatically with the `bitcoin`
|
||||
crate rather than pasting opaque base64 so the tests explain themselves:
|
||||
`psbt_without_derivations_reports_no_key_origin` (a PSBT built from an unsigned transaction with
|
||||
one input, no `bip32_derivation`) and `psbt_with_derivations_reports_key_origin` (the same PSBT
|
||||
with a `bip32_derivation` entry inserted on input 0). Also
|
||||
`malformed_psbt_is_an_error_not_a_panic` for a non-base64 and a truncated input.
|
||||
|
||||
Then answer, with evidence, the question the report exists to raise, and put the answer in
|
||||
`docs/security/KEY-03-SIGNING-POSTURE.md` under `## LND PSBT round trip — what is covered`:
|
||||
|
||||
- Which steps of fund -> export -> sign offline -> import -> finalize -> broadcast exist in this
|
||||
repo today, with `file:line` for each (`lnd.create-psbt` at `lnd/wallet.rs:605`,
|
||||
`lnd.finalize-psbt` at `:711` including its broadcast to `/v2/wallet/tx`, the client bindings at
|
||||
`neode-ui/src/api/rpc-client.ts:410-435`, the 5/300s limits at `rate_limit.rs:68-69`).
|
||||
- Which of those steps has automated test coverage after this task and which does not. State the
|
||||
uncovered ones plainly; do not describe an untested path as verified.
|
||||
- **The question that decides whether this is a real air gap:** on a default node, LND holds the
|
||||
keys for the inputs `lnd.create-psbt` selects. Determine and record whether an externally-held
|
||||
signer can sign such a PSBT at all without LND first being provisioned watch-only against that
|
||||
signer (`remotesigner.*` / `createwatchonly`, PSBT-SIGNING-ARCHITECTURE §5.1-5.2), and whether
|
||||
any fleet node is so provisioned today. Record the verdict either way with its evidence. The
|
||||
PSBT transport being present is not the same claim as custody being air-gapped, and this
|
||||
document must not let the two blur.
|
||||
- The standing honesty statement, in its own subsection: Lightning channel, revocation and HTLC
|
||||
keys are **not** air-gappable at all — they must sign in real time to answer counterparty
|
||||
commitments; remote signing relocates them to a hardened host, it does not cool them. No
|
||||
wording anywhere in the document may imply otherwise.
|
||||
|
||||
Finally, add a short status banner at the top of `docs/security/PSBT-SIGNING-ARCHITECTURE.md`
|
||||
recording that its Phase 1 ("Descriptor watch-only read path", §8) was **superseded by D-07b**:
|
||||
the Core wallet path was deleted rather than converted, so §0's "single highest-value change" and
|
||||
§2.1's invariant now read against a code path that no longer exists. Point the reader at
|
||||
`docs/security/KEY-03-SIGNING-POSTURE.md` for the current state. Change nothing else in that
|
||||
document — §5.4's honesty table is correct and stays exactly as written.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago psbt -- --nocapture</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago psbt` passes with at least three new tests, including the with-derivations and without-derivations pair.
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo clippy -p archipelago -- -D warnings` is clean.
|
||||
- `git diff core/archipelago/Cargo.toml` is empty — no dependency was added.
|
||||
- `grep -c 'key_origin' core/archipelago/src/api/rpc/lnd/wallet.rs` is at least 4 (struct, function, response field, warn line).
|
||||
- `handle_lnd_create_raw_tx` is unchanged: `git diff` shows no hunk inside it.
|
||||
- `docs/security/KEY-03-SIGNING-POSTURE.md` contains a per-step coverage table with `file:line` and an explicit tested/untested column, the recorded watch-only verdict, and the Lightning-keys-are-not-air-gappable subsection.
|
||||
- `docs/security/PSBT-SIGNING-ARCHITECTURE.md`'s diff is confined to the added status banner; section 5.4 is byte-identical.
|
||||
</acceptance_criteria>
|
||||
<done>`lnd.create-psbt` reports whether its PSBT carries the BIP-32 key-origin data an external signer needs, backed by tests, and the repository states honestly what the round trip does and does not deliver today.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: Fleet census — does any node hold a descriptor wallet this handler created? (D-07b, stop on finding)</name>
|
||||
<precondition>At least one fleet node is reachable with a running Bitcoin Core or Knots container whose `bitcoin-cli` can be invoked (for example via `podman exec`).</precondition>
|
||||
<files>docs/security/KEY-03-SIGNING-POSTURE.md</files>
|
||||
<read_first>
|
||||
- .planning/phases/10-key-material-hardening/10-CONTEXT.md (D-07a point 3 — archi-dev-box is the only known-negative node; the rest of the fleet is UNVERIFIED — and D-07b's stop-on-finding rule)
|
||||
- docs/security/KEY-03-SIGNING-POSTURE.md (Task 1 output — append the census to it)
|
||||
- core/archipelago/src/api/rpc/bitcoin.rs (as it stood before Task 1 — the default wallet_name was `archipelago`, which is what the census looks for)
|
||||
- CLAUDE.md (node access policy; `.228` is remote and in real use)
|
||||
</read_first>
|
||||
<action>
|
||||
Claude prepares the command set; the operator runs it per node and pastes the output; Claude
|
||||
records it into `docs/security/KEY-03-SIGNING-POSTURE.md` under `## Fleet census — Core descriptor
|
||||
wallets`, one row per node, using node labels rather than addresses.
|
||||
|
||||
**Hard constraint on every command in this task: never run `listdescriptors true`.** The `true`
|
||||
argument makes Core return the descriptors including private keys, which would print an xprv to a
|
||||
terminal and into a transcript. `listwallets`, `getwalletinfo` and `listdescriptors` without
|
||||
arguments answer the question completely. If any output unexpectedly contains a string starting
|
||||
with `xprv`, stop immediately, do not paste it, and report only that it occurred.
|
||||
|
||||
If a wallet with `private_keys_enabled: true` is found on any node, that is a **finding**: stop,
|
||||
record it, and raise it as a blocker. It would mean the endpoint was invoked manually before this
|
||||
plan deleted it, and that node's spending key is duplicated outside the Argon2 envelope. Do not
|
||||
migrate, unload or modify it — D-07b withdrew the migration deliberately, and rewriting a wallet
|
||||
that might hold funds is exactly the kind of decision that belongs to a human.
|
||||
</action>
|
||||
<what-built>Deletion of the code path that would create such a wallet, plus the signing-posture record this census completes.</what-built>
|
||||
<how-to-verify>
|
||||
On each reachable fleet node, for the Bitcoin Core (or Knots) container:
|
||||
1. `ls -la /var/lib/archipelago/bitcoin/wallets/ 2>&1` — an absent directory is itself a complete answer for that node; paste it as-is.
|
||||
2. `bitcoin-cli listwallets` (via `podman exec` into the container, matching however that node runs it).
|
||||
3. For each wallet name returned: `bitcoin-cli -rpcwallet=<name> getwalletinfo` and record `private_keys_enabled`, `descriptors`, `walletname` and `balance`.
|
||||
4. For any wallet with `private_keys_enabled: true`: `bitcoin-cli -rpcwallet=<name> listdescriptors` — with NO second argument. Record only the descriptor prefixes (`wpkh(...`), never a full key string.
|
||||
5. Record the node label, the Bitcoin app in use (Core or Knots) and its version from `bitcoin-cli getnetworkinfo | head`.
|
||||
6. Paste the output for every node checked, and list explicitly any fleet node that was NOT checked and why.
|
||||
</how-to-verify>
|
||||
<acceptance_criteria>
|
||||
- Every reachable fleet node has a row in the census table with `wallets_dir_present`, `listwallets` output and, per wallet, `private_keys_enabled`. Unreachable nodes are listed as UNCHECKED with the reason, never omitted.
|
||||
- No command run in this task included `listdescriptors true`, and no output containing a key string was pasted. The operator confirms this explicitly.
|
||||
- If any wallet reported `private_keys_enabled: true`, the phase raises a blocker naming the node label and the wallet name, and the plan does NOT proceed to close KEY-03 until that is decided by a human.
|
||||
- If no such wallet is found, `docs/security/KEY-03-SIGNING-POSTURE.md` records F-13 as closed by deletion with no migration required, and names the nodes that evidence it.
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Paste the per-node census output, then type "approved" — or name the node and wallet if a private-key-bearing wallet was found.</resume-signal>
|
||||
<done>The fleet's Core wallet state is recorded rather than assumed, and any private-key-bearing wallet is surfaced as a blocker instead of being silently migrated.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Daemon encrypted envelope -> Bitcoin Core `wallet.dat` | The boundary F-13 crosses. `wallet.dat` lives in the Bitcoin container's data volume with no Argon2 passphrase (the wallet was created with an empty one, `bitcoin.rs:205`). Task 1 removes the only code that crosses it. |
|
||||
| Daemon -> LND REST (`/v2/wallet/psbt/*`) | Macaroon-authenticated, loopback. The PSBT that crosses it is public data; the keys that sign it are LND's. |
|
||||
| Node -> external signer (offline) | The air-gap boundary. Whether it can be crossed at all depends on which party holds the input keys — the question Task 2 must answer with evidence. |
|
||||
| Operator terminal -> census output | `listdescriptors true` would print an xprv into a transcript; the census must not create the exposure it is measuring. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-10-41 | Information disclosure | BIP-84 account xprv persisted in Core's `wallet.dat` outside the Argon2 envelope (F-13) | high | mitigate | Task 1 deletes the only code path that writes it; Task 3 confirms no node already holds such a wallet |
|
||||
| T-10-42 | Information disclosure | A census command prints a private key into a terminal and a transcript | high | mitigate | `listdescriptors true` is banned by name in the task; the acceptance criterion requires the operator to confirm it was not run; a key-looking string aborts the paste |
|
||||
| T-10-43 | Tampering | An automated migration rewrites a wallet that may hold real funds | high | mitigate | D-07b withdrew the migration; Task 3 stops on a finding and escalates to a human instead |
|
||||
| T-10-44 | Spoofing | An external signer refuses a PSBT because it carries no key origin, and the failure surfaces as an opaque error | medium | mitigate | Task 2 inspects the PSBT and reports `all_inputs_have_key_origin` on the response plus a warn log, so the condition is named before the user reaches the signer |
|
||||
| T-10-45 | Repudiation | Documentation claims air-gapped custody the implementation does not deliver | high | mitigate | Task 2 requires a recorded, evidence-backed verdict on whether an external signer can sign a default node's PSBT at all, plus the Lightning-keys honesty subsection; the PSBT-SIGNING-ARCHITECTURE banner records that its Phase 1 was superseded rather than delivered |
|
||||
| T-10-46 | Denial of service | The new PSBT inspection breaks a user's on-chain send | medium | mitigate | The report is computed best-effort and degrades to `null`; `handle_lnd_finalize_psbt` and `handle_lnd_create_raw_tx` are untouched, asserted by a diff-scoped acceptance criterion |
|
||||
| T-10-47 | Elevation of privilege | Deleting a dispatcher arm changes the reachable RPC surface unexpectedly | low | accept | The removed method was authenticated and additionally password-gated and had no caller; the no-caller search is re-run as an acceptance criterion rather than inherited |
|
||||
| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | No dependency is added; an empty `git diff core/archipelago/Cargo.toml` is an acceptance criterion. Executor MUST halt and raise a checkpoint rather than adding a crate to satisfy the PSBT parsing. |
|
||||
</threat_model>
|
||||
|
||||
<artifacts_this_phase_produces>
|
||||
## Artifacts this plan produces
|
||||
|
||||
**Removed:**
|
||||
|
||||
| Symbol | Kind | Location |
|
||||
|---|---|---|
|
||||
| `handle_bitcoin_init_wallet_from_seed` | async fn | `core/archipelago/src/api/rpc/bitcoin.rs:161-295` |
|
||||
| `"bitcoin.init-wallet-from-seed"` | dispatch arm | `core/archipelago/src/api/rpc/dispatcher.rs:122-124` |
|
||||
|
||||
**New in `core/archipelago/src/api/rpc/lnd/wallet.rs`:**
|
||||
|
||||
| Symbol | Kind | Signature |
|
||||
|---|---|---|
|
||||
| `PsbtKeyOriginReport` | struct | `{ input_count: usize, inputs_with_key_origin: usize, all_inputs_have_key_origin: bool }` |
|
||||
| `psbt_key_origin_report` | fn | `fn psbt_key_origin_report(psbt_base64: &str) -> anyhow::Result<PsbtKeyOriginReport>` |
|
||||
|
||||
**Changed RPC response contract:** `lnd.create-psbt` gains
|
||||
`key_origin: { input_count, inputs_with_key_origin, all_inputs_have_key_origin } | null`.
|
||||
Existing fields (`psbt_base64`, `change_output_index`, `total_amount_sats`,
|
||||
`fee_rate_sat_per_vbyte`) are unchanged; the field is additive.
|
||||
|
||||
**Changed attribute:** `crate::seed::derive_bitcoin_xprv` gains `#[allow(dead_code)]` with a
|
||||
doc line naming D-07c as the reason it is retained.
|
||||
|
||||
**New file:** `docs/security/KEY-03-SIGNING-POSTURE.md` — headings
|
||||
`## Bitcoin Core wallet path — deleted (D-07b)`, `## LND PSBT round trip — what is covered`,
|
||||
`## Fleet census — Core descriptor wallets`.
|
||||
|
||||
**Modified:** `docs/security/PSBT-SIGNING-ARCHITECTURE.md` — status banner only.
|
||||
</artifacts_this_phase_produces>
|
||||
|
||||
<verification>
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago` — no new failures vs. the recorded baseline.
|
||||
- `cd core && CARGO_INCREMENTAL=0 cargo clippy -p archipelago -- -D warnings` — clean.
|
||||
- `git diff core/archipelago/Cargo.toml` is empty.
|
||||
- Task 3's checkpoint resolved with per-node census output.
|
||||
- Commit stages only this plan's six paths explicitly — never `git add -A`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- No code path in the daemon writes the BIP-84 account private key into Bitcoin Core.
|
||||
- `derive_bitcoin_xprv` survives with its tests and a written reason (D-07c).
|
||||
- `lnd.create-psbt` reports key-origin presence, backed by programmatically-built test fixtures.
|
||||
- The signing-posture document states, with `file:line`, which round-trip steps exist, which are
|
||||
tested, whether an external signer can sign a default node's PSBT at all, and that Lightning
|
||||
channel/revocation/HTLC keys are not air-gappable.
|
||||
- The fleet census is recorded per node, with any private-key-bearing wallet raised as a blocker.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/10-key-material-hardening/10-05-SUMMARY.md` when done, carrying the
|
||||
no-caller search output, the new test names and results, the round-trip coverage table, the
|
||||
watch-only verdict, and the per-node census.
|
||||
</output>
|
||||
@@ -1,370 +0,0 @@
|
||||
---
|
||||
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).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,111 +0,0 @@
|
||||
---
|
||||
phase: 10-key-material-hardening
|
||||
plan: 06
|
||||
subsystem: entropy
|
||||
tags: [security, key-material, entropy, rng, KEY-05, F-10a, F-07, R-05, R-09, R-13]
|
||||
status: complete
|
||||
requires:
|
||||
- "docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md F-10a (the deliberately-unclassified raw match table), F-07, F-02, F-09"
|
||||
- "10-01 (F-01 onboarding gate) and 10-05 (F-13 Core wallet deletion) committed first"
|
||||
provides:
|
||||
- "crate::entropy — sealed KeyGenRng allowlist, degenerate-entropy predicate, CSPRNG-readiness ledger"
|
||||
- "core/clippy.toml — crate-wide compile-time ban on rand::random / rand::thread_rng"
|
||||
- "core/deny.toml — change-detecting rand duplicate-major rule, grandfathered 2026-08-02"
|
||||
- "docs/security/KEY-05-ENTROPY-ENFORCEMENT.md — per-site classification + observed gate evidence"
|
||||
affects:
|
||||
- "core/archipelago/src/entropy.rs (new)"
|
||||
- "core/archipelago/src/seed.rs"
|
||||
- "core/archipelago/src/session.rs"
|
||||
- "core/archipelago/src/storage_crypto.rs"
|
||||
- "core/archipelago/src/credentials/store.rs"
|
||||
- "core/archipelago/src/wallet/bdhke.rs"
|
||||
- "core/archipelago/src/mesh/x3dh.rs"
|
||||
- "core/archipelago/src/container/secrets.rs"
|
||||
- "core/archipelago/src/totp.rs"
|
||||
- "17 further call-site files (see the commit)"
|
||||
tech-stack:
|
||||
added:
|
||||
- "cargo-deny 0.20.2 (pinned; installed from crates.io in CI)"
|
||||
patterns:
|
||||
- "Sealed trait as an allowlist: supertrait in a private module, so no other module can add a member"
|
||||
- "Hardcoded pre-migration ciphertext vectors from an INDEPENDENT implementation, because a same-process round trip proves self-consistency rather than compatibility"
|
||||
- "Degenerate-entropy predicate restricted to shapes with closed-form false-positive bounds — no heuristics, no entropy estimators"
|
||||
- "Gates proven by observation (inject -> observe failure -> revert -> observe pass), never by assumption"
|
||||
key-files:
|
||||
created:
|
||||
- "core/archipelago/src/entropy.rs"
|
||||
- "core/clippy.toml"
|
||||
- "core/deny.toml"
|
||||
modified:
|
||||
- "docs/security/KEY-05-ENTROPY-ENFORCEMENT.md"
|
||||
- ".github/workflows/ci.yml"
|
||||
- "23 source files under core/archipelago/src (see commit 09a1f762)"
|
||||
decisions:
|
||||
- "cargo-deny wired as bans-only; advisories deliberately NOT enabled (human checkpoint, Task 5) — F-07's advisory half stays OPEN"
|
||||
- "cargo-deny installed from crates.io at a pinned 0.20.2 rather than via EmbarkStudios/cargo-deny-action, because that action exposes no version-pinning input and an unpinned supply-chain checker reproduces the very failure shape this plan removes"
|
||||
- "generate_mnemonic_with switched from generate_in_with to from_entropy so the draw is inspectable at the seam; equivalence held by the pre-existing known-answer test"
|
||||
- "The blinding factor in bdhke.rs is deliberately NOT routed through the guard — intercepting it would mean reimplementing secp256k1 rejection sampling, a larger correctness risk than the guard buys"
|
||||
- "totp.rs migrated SOURCE only; the % charset.len() reduction and 32-char charset untouched (R-12 stays deferred, bias is presently zero)"
|
||||
- "session token minting aborts rather than returns on a degenerate draw, because the callers live in files this plan does not own and widening them to Result is an API change out of scope"
|
||||
metrics:
|
||||
duration: "resumed session; migration pre-existing uncommitted, gates + evidence completed 2026-08-02"
|
||||
completed: 2026-08-02
|
||||
tasks_completed: 6
|
||||
tasks_total: 6
|
||||
---
|
||||
|
||||
# 10-06 — KEY-05 entropy enforcement
|
||||
|
||||
## What shipped
|
||||
|
||||
Five layers, all landed:
|
||||
|
||||
| Layer | What | Where |
|
||||
|---|---|---|
|
||||
| (a) | Every production key/nonce/token draw names `rand::rngs::OsRng` at its own call site; the mnemonic seam is bound to a **sealed** `KeyGenRng` allowlist | `entropy.rs`, 23 source files |
|
||||
| (b) | Crate-wide compile-time ban on `rand::random` / `rand::thread_rng` | `core/clippy.toml` |
|
||||
| (c) | `rand` duplicate-major rule that is change-detecting, grandfathered | `core/deny.toml`, CI step |
|
||||
| (d) | Degenerate-entropy predicate refusing all-zero / all-identical / ±1-counter draws | `entropy::draw_key_bytes` |
|
||||
| (e) | Durable kernel-CSPRNG readiness record at master-seed generation (R-09) | `entropy::record_csprng_readiness` |
|
||||
|
||||
`impl rand::CryptoRng` count in the crate is now **zero** — the false marker promise at the old
|
||||
`seed.rs:656` is retired, as the roadmap required.
|
||||
|
||||
## Why this was worth doing when nothing was broken
|
||||
|
||||
Nothing in F-10a's table is broken today: on the pinned `rand 0.8.5` both banned entry points
|
||||
resolve to a ChaCha12 CSPRNG seeded from `getrandom(2)`. What they lacked was a *stated* backend —
|
||||
fixed by dependency and build configuration rather than by the calling code, with no compile error
|
||||
if it changed. That is the structural shape ("T1") behind the 2026-07-30 COLDCARD entropy defect,
|
||||
and here the blast radius included Cashu blinded-key-exchange values, X3DH prekey material, session
|
||||
bearer tokens and a ChaCha20-Poly1305 nonce.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `cargo test -p archipelago`: **1068 passed, 2 failed**. Both failures are
|
||||
`container::boot_reconciler` timing tests in a file this plan never touches; **re-run in
|
||||
isolation they pass 4/4 in 0.84s**, so they are full-suite parallel-load flakes, not regressions.
|
||||
- Format compatibility proven with **hardcoded pre-migration ciphertext vectors** produced by an
|
||||
independent RFC 8439 implementation — a same-process seal/open round trip would have passed even
|
||||
if the envelope had changed.
|
||||
- Both gates observed working (inject → fail → revert → pass for clippy; remove grandfather →
|
||||
exit 2 → restore → exit 0 for cargo-deny). Full transcripts in the evidence doc.
|
||||
|
||||
## Open, and deliberately so
|
||||
|
||||
1. **⚠️ Layer (b)'s gate is live but not yet EFFECTIVE.** The tree carries **42 pre-existing clippy
|
||||
warnings** unrelated to KEY-05 (unused imports, dead code, ~39 style lints). Under the CI step's
|
||||
`-D warnings` every one is already an error, so that step cannot pass today for reasons that
|
||||
predate this plan. The ban is correctly configured and proven to fire, but until a dedicated
|
||||
lint-clearing pass lands, a new banned RNG call is one error among many rather than the
|
||||
distinctive build-stopper the design intends. **Recommended next follow-up.**
|
||||
2. **F-07's advisory half stays OPEN** (bans-only policy).
|
||||
3. **`core/models` is outside the enforcement graph** — not a workspace member, so no
|
||||
`disallowed-methods` entry can reach its two matches. Stated limitation, not an omission.
|
||||
4. **F-09/R-12 and F-11/R-14 remain deferred.**
|
||||
5. **Sealing does not prevent an edit to `entropy.rs` itself** — it raises the act from an invisible
|
||||
default to a reviewable change to the one file whose purpose is this guarantee. That is the
|
||||
honest claim; "impossible" would not be.
|
||||
|
||||
Nothing already generated is suspect: the previous source was, and remains, `getrandom(2)`-backed.
|
||||
This plan removes a *future* failure mode and implies no re-generation of existing key material.
|
||||
@@ -1,325 +0,0 @@
|
||||
# Phase 10: Key-Material Hardening - Context
|
||||
|
||||
**Gathered:** 2026-08-01
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Every path that creates, restores, or persists node key material proves the caller is
|
||||
authorized and the material is per-node. Closes the three exploitable findings from
|
||||
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`:
|
||||
|
||||
- An already-onboarded node must refuse to have its identity replaced (F-01, Critical).
|
||||
- A node flashed from the fleet-shared rootfs must never share another node's host keys (F-03, High).
|
||||
- The wallet spending key must not exist in cleartext outside the encrypted envelope (F-13, High).
|
||||
|
||||
**Not in scope:** the remaining audit findings F-04..F-12 (tracked as R-05..R-14 in
|
||||
`docs/UNIFIED-TASK-TRACKER.md`), the PSBT air-gap *implementation*, and any change to
|
||||
derivation paths, word counts, or the at-rest encryption envelope.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### KEY-01 — Re-key policy (F-01, Critical)
|
||||
|
||||
- **D-01:** An already-onboarded node **hard refuses** `seed.restore` and `seed.generate`.
|
||||
These endpoints are permanently closed once the node holds identity keys. No
|
||||
authenticated-session variant, no physical-presence window — the pre-auth path carries no
|
||||
authorization decision at all, which is what keeps its attack surface at zero.
|
||||
— **Reversibility:** costly — the refusal becomes an observable API contract that the
|
||||
onboarding UI, the companion app, and any restore tooling will be written against; loosening
|
||||
it later is safe, but tightening a looser rule after release would break callers.
|
||||
|
||||
- **D-02:** The legitimate re-key path is the **existing authenticated `system.factory-reset`**
|
||||
(`api/rpc/dispatcher.rs:469`, `system/handlers.rs:575`), after which the node is un-onboarded
|
||||
and the normal onboarding restore flow works. Verified during discussion that both
|
||||
`system.factory-reset` and `auth.resetOnboarding` are already authenticated — they are absent
|
||||
from `UNAUTHENTICATED_METHODS` — so this path does not need to be built, and it is not itself
|
||||
a bypass.
|
||||
|
||||
- **D-03:** The gate **refuses if *either* signal says onboarded** — never one signal trusted
|
||||
alone. Fails safe when signals disagree, which is a real state: `auth.rs:196-207` already
|
||||
carries auto-heal logic for exactly that drift.
|
||||
|
||||
- **D-03a (mechanism correction, 2026-08-01 — intent of D-03 unchanged):** D-03 originally named
|
||||
`NodeIdentity::key_exists` (`identity.rs:117`) as one of the two signals. **That signal is
|
||||
unusable and would have bricked onboarding on every fresh node.** `Server::new`
|
||||
(`server.rs:63-72`) calls `NodeIdentity::load_or_create` on *both* branches of its
|
||||
fresh-vs-existing check, and `load_or_create` (`identity.rs:48-51`) generates and writes a
|
||||
random temporary node key when none exists. So `key_exists` is `true` on every node that has
|
||||
booted even once, onboarded or not — a gate keyed on it refuses `seed.generate` on a node that
|
||||
has never been onboarded.
|
||||
|
||||
This flaw was inherited from the audit's own suggested remediation
|
||||
(`ENTROPY-SEED-AUDIT-2026-07-31.md:214-221`) and repeated in the planning brief; the planner
|
||||
caught it against the code. The corrected signal set is `is_setup()` /
|
||||
`is_onboarding_complete()` / `seed_exists()` — all false on a fresh node and during the seed
|
||||
steps, all true afterwards. `key_exists` and `fips_key_exists` are rejected with recorded
|
||||
verdicts, and the correction is pinned by a test
|
||||
(`allows_on_fresh_temp_dir_even_though_node_key_exists`), not a comment.
|
||||
|
||||
- **D-04:** 10-01 covers **every method in `UNAUTHENTICATED_METHODS` that can mutate identity
|
||||
or credentials**, behind the same gate and one shared test suite — not just the two endpoints
|
||||
F-01 names. Explicitly in the audit sweep: `seed.generate`, `seed.restore`,
|
||||
`seed.save-encrypted`, `backup.restore-identity`, `auth.setup`, and `auth.onboardingComplete`.
|
||||
Fixing `seed.restore` while `backup.restore-identity` reaches the same identity-overwrite
|
||||
primitive would move the door, not close it. For endpoints that turn out not to mutate
|
||||
(e.g. possibly `seed.verify`), record an explicit evidence-backed verdict rather than
|
||||
changing behaviour.
|
||||
|
||||
### KEY-02 — First-boot secret regeneration (F-03, High)
|
||||
|
||||
- **D-05:** On failure, **retry with backoff, then fail closed** — refuse to bring the service
|
||||
up and surface a loud console/screen error. Chosen over fail-immediately (a transient
|
||||
first-boot condition would brick a new node with no self-recovery) and over boot-locked-with-
|
||||
warning (a dismissable warning means running on shared keys). The current behaviour is the
|
||||
opposite of all three: fail-open with the completion marker set even on failure
|
||||
(`build-auto-installer-iso.sh:1647`, `:1659`, `:1663`).
|
||||
|
||||
- **D-06:** Scope is **fix the ISO builder AND remediate already-deployed nodes** — boot-time
|
||||
detection plus one-time regeneration, reaching the fleet via OTA. Builder-only would stop the
|
||||
exposure growing without ending it, on exactly the nodes that are already live.
|
||||
— **Reversibility:** one-way — rotating SSH host keys on live nodes invalidates existing
|
||||
`known_hosts` entries fleet-wide and changes host identity for any tooling pinned to it;
|
||||
once rotated there is no going back to the old key. The plan must sequence this so remote
|
||||
access is not lost mid-rotation, and this decision earns a checkpoint before the task that
|
||||
implements it.
|
||||
|
||||
### KEY-03 — Wallet spending key (F-13, High)
|
||||
|
||||
- **D-07:** **Migrate existing wallets** to watch-only (`disable_private_keys=true`, xpub
|
||||
imported with a `[fingerprint/derivation]` key origin), with **balance and UTXO-set parity
|
||||
verified before and after**, keeping the old `wallet.dat` as a rollback. Matches the audit's
|
||||
R-04 and CLAUDE.md's "migrations never destroy data" invariant. New-wallets-only was rejected
|
||||
because it leaves the exposure precisely on nodes holding real funds.
|
||||
— **Reversibility:** one-way — this rewrites a wallet that may hold user funds. Rollback
|
||||
depends entirely on the retained `wallet.dat` and the parity proof; a migration that loses
|
||||
UTXO visibility is a funds-visibility incident. Earns a checkpoint before execution.
|
||||
|
||||
- **D-07a (scoping correction, 2026-08-01, after D-07 was recorded):** the user states Bitcoin
|
||||
Core's wallet is no longer used by anything and is very old. Verified:
|
||||
1. `bitcoin.init-wallet-from-seed` has **no caller anywhere** — the only occurrence outside
|
||||
the handler is its dispatcher registration (`dispatcher.rs:122`). Nothing in `neode-ui/src`,
|
||||
`core/`, or scripts.
|
||||
2. **LND is the wallet.** `Web5Wallet.vue` and `SendBitcoinModal.vue` call `lnd.sendcoins`,
|
||||
`lnd.estimatefee`, `lnd.getinfo`. Across all of `neode-ui/src` the only `bitcoin.*` calls
|
||||
are `bitcoin.getinfo`, `bitcoin.prune-status`, `bitcoin.onion` — no wallet operations.
|
||||
3. archi-dev-box has **no `/var/lib/archipelago/bitcoin/wallets/` directory**, so the named
|
||||
descriptor wallet this handler creates (default `wallet_name` = `"archipelago"`,
|
||||
`bitcoin.rs:172-174`) has never been created there. The `wallet.dat` at the datadir root is
|
||||
Core's legacy default-wallet location, not this handler's output.
|
||||
4. The endpoint is authenticated **and** requires the user's password (`verify_password`,
|
||||
`bitcoin.rs:177-180`); it is absent from `UNAUTHENTICATED_METHODS`. F-13 was never remotely
|
||||
reachable — it is key-at-rest duplication, not an exposed endpoint.
|
||||
|
||||
**Therefore F-13 is latent, not live**, and D-07's premise (funded wallets out there carrying
|
||||
the xprv) is unproven. KEY-03 is re-scoped **discovery-first**: a fleet-wide check for any
|
||||
wallet this handler created, and whether it holds balance/UTXO history, runs *before* any
|
||||
migration. The migration and its checkpoint stay in the plan but become **conditional on
|
||||
discovery finding a real wallet**. If discovery is empty fleet-wide, the correct fix is the
|
||||
cheap one — make the handler watch-only by construction (xpub + key origin per D-09), or
|
||||
delete the endpoint as dead code, recording the evidence either way. Deleting is to be
|
||||
presented as a decision, not taken unilaterally. Only archi-dev-box is known-negative; the
|
||||
rest of the fleet is UNVERIFIED. Discovery commands must never use `listdescriptors true`
|
||||
(it returns private keys); `listwallets` / `getwalletinfo` / `listdescriptors` suffice.
|
||||
|
||||
*Consequence for D-08:* if Core's wallet is genuinely dead, LND is the only wallet that
|
||||
matters, and `PSBT-SIGNING-ARCHITECTURE.md`'s honest LND limits (channel, revocation and HTLC
|
||||
keys cannot be air-gapped) become the governing constraint on the phase's signing story. The
|
||||
plan must say so plainly rather than implying a watch-only Core wallet delivers air-gapped
|
||||
custody.
|
||||
|
||||
- **D-07b (final KEY-03 scope, supersedes D-07 and D-07a's conditional migration):** Core's
|
||||
wallet is out entirely — it is outdated and used by nothing. **Delete
|
||||
`bitcoin.init-wallet-from-seed`** (handler `bitcoin.rs:161-294` + its `dispatcher.rs:122`
|
||||
registration): an uncalled, authenticated, password-gated endpoint whose only job is to derive
|
||||
and stringify the master BIP-84 xprv. Pure liability, zero benefit. Deleted outright, not
|
||||
deprecated — nothing in the repo or frontend calls it, no unattended caller can reach it
|
||||
(password-gated), and archi-dev-box shows it never ran.
|
||||
|
||||
**No migration is planned.** D-07's parity-proof migration and its `one-way` checkpoint are
|
||||
withdrawn — there is no wallet to migrate. If the KEY-03 discovery sweep unexpectedly finds a
|
||||
descriptor wallet this handler created on some fleet node, that is a **finding to surface and
|
||||
stop on**, not a trigger to auto-migrate: it would mean the endpoint was invoked manually and
|
||||
the node's spending key is duplicated in Core, which deserves a human decision.
|
||||
|
||||
**PSBT is already solved by LND, not Core.** Verified in-repo:
|
||||
- `lnd.create-psbt` (`api/rpc/lnd/wallet.rs:605`) → LND WalletKit `/v2/wallet/psbt/fund`;
|
||||
its own doc comment says "Create an unsigned PSBT for hardware wallet signing".
|
||||
- `lnd.finalize-psbt` (`:711`) takes `signed_psbt_base64` → `/v2/wallet/psbt/finalize` →
|
||||
broadcasts via `/v2/wallet/tx`.
|
||||
- Both already rate-limited (`rate_limit.rs:68-69`, 5/300s). LND is pinned to **v0.18.4-beta**.
|
||||
|
||||
KEY-03 therefore becomes: **delete the Core path, and make the existing LND PSBT flow a
|
||||
first-class, tested, documented path** — including an acceptance criterion that the PSBT
|
||||
produced by `lnd.create-psbt` carries the BIP-32 derivation / key-origin data a hardware
|
||||
signer needs to locate its key (this is what D-09 was really protecting; it no longer applies
|
||||
to Core descriptors, which are being deleted).
|
||||
— **Reversibility:** reversible — deleting an uncalled endpoint is recoverable from git, and
|
||||
the LND flow already exists. The `one-way` rating from D-07 no longer applies.
|
||||
|
||||
- **D-07c (deferred, not chosen now):** a true cold vault independent of both Core and LND —
|
||||
BDK descriptor wallet in the daemon with the node's own **ElectrumX** app as chain source
|
||||
(already shipped: `apps/electrumx`, `electrs_status.rs`). Considered and deliberately deferred
|
||||
out of Phase 10; it needs its own phase (new dependency, new UI surface). Recorded so the
|
||||
option is not lost. The alternative shape — LND watch-only via `importaccount` + remote
|
||||
signing — was also considered and rejected for coupling cold storage to LND's upgrade path.
|
||||
|
||||
**Standing honesty constraint for any signing docs this phase touches:** on-chain funds held
|
||||
by LND *are* air-gappable today via the create→sign-offline→finalize flow. Lightning channel,
|
||||
revocation and HTLC keys are **not air-gappable at all** — they must sign in real time to
|
||||
answer counterparty commitments. LND remote signing relocates those keys; it does not cool
|
||||
them. No document produced by this phase may imply otherwise.
|
||||
|
||||
- **D-08:** Default signing stays **daemon-side PSBT signing** using the seed already held in
|
||||
the encrypted envelope, with the air-gapped/external-signer path from
|
||||
`docs/security/PSBT-SIGNING-ARCHITECTURE.md` available as **opt-in**. Send UX is unchanged;
|
||||
the win is that the spending key exists in exactly one place instead of two. Requiring an
|
||||
external signer was rejected as a UX change needing hardware users may not have.
|
||||
|
||||
- **D-09:** The missing key-origin annotation is in scope, not a follow-up. Today's descriptors
|
||||
(`bitcoin.rs:230-231`) carry none, which is why the current wallet could not be converted to
|
||||
an external-signer setup even if the private key were removed — fixing the key without the
|
||||
origin would leave D-08's opt-in path unreachable.
|
||||
|
||||
### Rollout
|
||||
|
||||
- **D-10:** The KEY-01 fix **rides the next scheduled OTA** rather than an emergency point
|
||||
release. *Recorded consequence:* F-01 is remotely reachable on every live fleet node until
|
||||
that OTA ships, so the exposure window is set by the OTA cadence, not by when 10-01 is
|
||||
verified. Per CLAUDE.md the dev pair (archi-dev-box + x250-dev) is deployed and verified
|
||||
before any OTA regardless.
|
||||
|
||||
- **D-11:** 10-01 (KEY-01) is still planned as **wave 1, empty `depends_on`, independently
|
||||
shippable** — so the release decision stays a scheduling choice rather than a technical
|
||||
constraint. If the OTA slips, 10-01 must remain cuttable on its own.
|
||||
|
||||
### Claude's Discretion
|
||||
|
||||
- Exact error code / JSON-RPC response shape for a refused call (must not leak whether the node
|
||||
is onboarded to an unauthenticated caller beyond what `auth.isOnboardingComplete` already
|
||||
discloses — that method is itself unauthenticated, so the information is not new).
|
||||
- Rate-limit shape and thresholds, subject to the constraint in the traps below.
|
||||
- Test organisation and file placement.
|
||||
- Whether the shared gate is a middleware-layer check, a helper called by each handler, or both.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### The findings themselves (primary source — read first, in full)
|
||||
- `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — the audit this phase exists to close.
|
||||
F-01 at §166, F-03 at §267, F-13 at §528. Remediation register R-01..R-15 near §890.
|
||||
On-node UNVERIFIED checklist C-3 §779, C-4 §792, C-6 §814. 103 file:line references.
|
||||
- `docs/security/PSBT-SIGNING-ARCHITECTURE.md` — target architecture for KEY-03: watch-only
|
||||
descriptors, `wsh(sortedmulti)`, air-gap transport, and the honest LND limits (channel,
|
||||
revocation and HTLC keys cannot be air-gapped).
|
||||
|
||||
### Project invariants
|
||||
- `CLAUDE.md` — rootless Podman only; secrets are manifest-declared; **migrations never
|
||||
destroy data**; verify on a real node before any tag; commit+push every unit of work.
|
||||
- `docs/UNIFIED-TASK-TRACKER.md` — carries R-01..R-15; the 9 items added by quick task
|
||||
260731-upz are the out-of-scope remainder of this audit.
|
||||
|
||||
### Code that is the subject of the phase
|
||||
- `core/archipelago/src/api/rpc/middleware.rs:5-40` — `UNAUTHENTICATED_METHODS`, the list D-04
|
||||
sweeps.
|
||||
- `core/archipelago/src/identity.rs:79-114` (`from_seed`, the unconditional overwrite) and
|
||||
`:117` (`key_exists`, the guard that exists and is never called on this path).
|
||||
- `core/archipelago/src/auth.rs:182-210` — `is_onboarding_complete` and its auto-heal drift logic.
|
||||
- `core/archipelago/src/api/rpc/seed_rpc.rs:93-120` (generate, with the lock + TTL fast-path)
|
||||
and `:226-265` (restore).
|
||||
- `core/archipelago/src/api/rpc/bitcoin.rs:161-294` — `handle_bitcoin_init_wallet_from_seed`.
|
||||
- `image-recipe/_archived/build-auto-installer-iso.sh:1647`,`:1659`,`:1663` — the fail-open
|
||||
regeneration and its marker.
|
||||
- `image-recipe/build-debian-iso.sh:40` — **proves `_archived/` is live**, not dead code.
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `NodeIdentity::key_exists` (`identity.rs:117`) — the guard D-03 needs, already written and
|
||||
already correct; it is simply never called on the seed path.
|
||||
- `system.factory-reset` (`system/handlers.rs:575`) and `auth.resetOnboarding` (`auth.rs:272`)
|
||||
— both already authenticated; D-02's recovery path is existing behaviour, not new code.
|
||||
- The wallet is already a **descriptor** wallet (`bitcoin.rs:207` passes `descriptors=true`),
|
||||
which is the correct foundation for D-07 — the defect is which key goes into it, not the
|
||||
wallet type.
|
||||
- The xprv string is already zeroized on both the error path (`bitcoin.rs:222`) and the success
|
||||
path (`:284`) — in-memory handling is careful and should be preserved by the migration.
|
||||
|
||||
### Established Patterns
|
||||
- Pre-auth onboarding endpoints are an intentional design, not an oversight — the node has no
|
||||
user account until `auth.setup` runs. Any fix must preserve first-boot onboarding on a fresh
|
||||
node; this is the single biggest way to get KEY-01 wrong.
|
||||
- `handle_seed_generate`'s `ONBOARDING_MNEMONIC` lock + `MNEMONIC_TTL` idempotent fast-path
|
||||
(`seed_rpc.rs:93-120`) is **retry-storm protection, not authorization** — written because the
|
||||
web client retries every 4s on slow first-boot hardware and aborts at 15s. A new rate limit
|
||||
must not reintroduce the "error at the DID-creation screen" failure it was added to prevent.
|
||||
|
||||
### Integration Points
|
||||
- The gate sits between `middleware.rs`'s dispatch decision and the `seed_rpc.rs` /
|
||||
`backup` / `auth` handlers.
|
||||
- KEY-02 spans the ISO builder (build host) and node boot (systemd), not the Rust daemon —
|
||||
a different verification surface from KEY-01/KEY-03.
|
||||
- KEY-03 touches the Bitcoin Core container's wallet, so it interacts with the app lifecycle,
|
||||
not just the daemon.
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- **The regression test is the deliverable, not the patch.** For KEY-01 there must be a test
|
||||
that fails against today's code: an already-onboarded node rejects `seed.restore` with
|
||||
attacker-supplied words, and its `node_key` and `nostr_secret` are byte-identical afterwards.
|
||||
This mirrors the standard the entropy fix in `8b51b7e2` was held to (a known-answer test that
|
||||
could not exist before the change).
|
||||
- `image-recipe/_archived/` must **not** be "tidied up" or relocated as part of KEY-02. It is
|
||||
live — `build-debian-iso.sh:40` execs it — and the audit notes that treating it as dead would
|
||||
have hidden F-03 entirely.
|
||||
- Concurrent agents share this git tree and push to `main`: stage explicitly by path, never
|
||||
`git add -A` / `git commit -a`.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- **F-04 (Medium)** — mnemonic crosses the RPC boundary and is held in memory 10 minutes over
|
||||
plaintext-capable HTTP. Tracked as R-07, itself marked PHASE-sized by the audit. Its natural
|
||||
home is a follow-up phase alongside the loopback/TLS confinement question, not here.
|
||||
- **F-05 (Medium)** — `Argon2::default()` (19 MiB / t=2) contradicts ADR-005's stated 64 MB / 3
|
||||
iterations. Needs either a versioned envelope migration or an ADR amendment (R-06).
|
||||
- **F-06 (Medium)** — release master mnemonic passed via env var / stdout in the signing
|
||||
ceremony (R-08). Deliberately scheduled separately: it *is* the signing ceremony.
|
||||
- **F-07 (Medium)** — no `cargo audit` / `cargo deny` in CI; two `rand` majors coexist (R-05).
|
||||
- **F-09 / F-10 / F-11 (Low / Informational)** — TOTP modulo bias (R-12), container
|
||||
`generated_secrets` using `thread_rng()` (R-13, blocked on another agent's uncommitted work in
|
||||
`container/secrets.rs`), and the `Math.random()` comment (R-14).
|
||||
- **The archi-dev-box test node** (shapes A and B) —
|
||||
`.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md`.
|
||||
Sequenced after this phase; shape A is the natural harness for KEY-04, since a second
|
||||
instance boots un-onboarded, which is exactly the state D-03's gate must distinguish.
|
||||
|
||||
### Reviewed Todos (not folded)
|
||||
- *Fedimint gateway must not install with a pre-set password* — matched on `area: security`,
|
||||
but already fixed by a concurrent agent in commit `42652547` (FED-07, Phase 1). Not folded.
|
||||
- *Connected-nodes list scroll height*, *FIPS/Tor pills on cloud files* — matched only on
|
||||
incidental keywords (`must`, `2026`, `fips`); both are UI work belonging to Phase 1's UIFIX
|
||||
series. Not folded.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 10-key-material-hardening*
|
||||
*Context gathered: 2026-08-01*
|
||||
@@ -1,130 +0,0 @@
|
||||
# Phase 10: Key-Material Hardening - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-08-01
|
||||
**Phase:** 10-key-material-hardening
|
||||
**Areas discussed:** Re-key policy for a live node, Gate signal, First-boot fail-closed behavior, Fleet scope, Wallet migration, Signing path, Rollout, 10-01 scope
|
||||
|
||||
---
|
||||
|
||||
## Re-key policy for a live node (KEY-01)
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Hard refuse; reset first | Endpoints permanently closed once the node holds identity keys; re-key via the authenticated `system.factory-reset`, then normal onboarding restore. No authorization logic on the pre-auth path at all. | ✓ |
|
||||
| Allow with an authenticated session | Keep endpoints usable post-onboarding behind a session + password re-entry. Convenient for in-place recovery, but a session-fixation or CSRF bug becomes a node takeover. | |
|
||||
| Allow only with physical presence | Local console / button-press window. Strongest guarantee, needs new plumbing, awkward for headless nodes reached over Tailscale. | |
|
||||
|
||||
**User's choice:** Hard refuse; reset first
|
||||
**Notes:** Verified mid-discussion that `system.factory-reset` (`dispatcher.rs:469`) and `auth.resetOnboarding` (`auth.rs:272`) are both absent from `UNAUTHENTICATED_METHODS`, i.e. already authenticated. This made "reset first" a real existing path rather than something the phase would have to build, and confirmed it is not itself a gate bypass.
|
||||
|
||||
---
|
||||
|
||||
## Gate signal (KEY-01)
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Refuse if either says onboarded | Check both `key_exists` (on-disk key file) and `is_onboarding_complete` (JSON flag); refuse if either indicates onboarded. Fails safe when they disagree. | ✓ |
|
||||
| Key file on disk only | Trust only `key_exists` — the artefact actually being protected, unflippable without filesystem access. | |
|
||||
| Onboarding flag only | Trust only `is_onboarding_complete` — matches how the rest of the app reasons, but the flag is writable by `auth.resetOnboarding` and can drift. | |
|
||||
|
||||
**User's choice:** Refuse if either says onboarded
|
||||
**Notes:** The drift case is real, not hypothetical — `auth.rs:196-207` already contains auto-heal logic that infers completion from `setup_complete` + `password_hash` precisely because the flag can be wrong.
|
||||
|
||||
---
|
||||
|
||||
## First-boot fail-closed behavior (KEY-02)
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Retry, then fail closed | Retry with backoff; on continued failure refuse to start and show a loud error. Survives transient causes without ever silently shipping fleet-shared keys. | ✓ |
|
||||
| Fail closed immediately | First failure refuses to start. Absolutely safe; a transient condition bricks a brand-new node with no self-recovery. | |
|
||||
| Boot locked, with a visible alert | Starts but refuses onboarding/network exposure until fixed. Most forgiving; risks a dismissed warning and a node running on shared keys. | |
|
||||
|
||||
**User's choice:** Retry, then fail closed
|
||||
**Notes:** Current behaviour is the inverse of all three options — fail-open, with the completion marker written even when regeneration fails (`build-auto-installer-iso.sh:1647`, `:1659`, `:1663`).
|
||||
|
||||
---
|
||||
|
||||
## Fleet scope (KEY-02)
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Fix builder + remediate existing | Fix the ISO builder for future flashes and add boot-time detection + one-time regeneration reaching existing nodes via OTA. | ✓ |
|
||||
| Fix the ISO builder only | Future flashes get unique keys; deployed nodes keep what they have. | |
|
||||
| Builder now, remediation as its own phase | Ship the builder fix here; scope live-node rotation separately since it can break `known_hosts` and Tailscale-based access. | |
|
||||
|
||||
**User's choice:** Fix builder + remediate existing
|
||||
**Notes:** Chosen as the only option that ends the exposure rather than stopping it from growing. The `known_hosts` / remote-access risk raised in option 3 does not disappear with this choice — it was carried into CONTEXT.md as D-06's `one-way` reversibility rating with a required checkpoint, rather than being deferred.
|
||||
|
||||
---
|
||||
|
||||
## Wallet migration (KEY-03)
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Migrate with parity proof | Recreate watch-only (`disable_private_keys=true`, xpub + key origin), verify balance and UTXO-set parity before/after, retain old `wallet.dat` for rollback. | ✓ |
|
||||
| New wallets only | Future initialisations are watch-only; existing nodes keep the private key until the user re-creates their wallet. | |
|
||||
| Migrate, but user-confirmed per node | Same migration behind an explicit UI opt-in. | |
|
||||
|
||||
**User's choice:** Migrate with parity proof
|
||||
**Notes:** Matches the audit's R-04 and CLAUDE.md's "migrations never destroy data". New-wallets-only was rejected because it leaves the exposure on exactly the nodes holding real funds.
|
||||
|
||||
---
|
||||
|
||||
## Signing path (KEY-03)
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Daemon signs PSBTs, air-gap opt-in | Daemon signs with the seed it already holds in the encrypted envelope; send UX unchanged; the spending key exists in one place instead of two. External-signer path stays opt-in. | ✓ |
|
||||
| Daemon signs, nothing else | Remove the duplicate copy, one hot signing path, defer all external-signer work. | |
|
||||
| External signer required | Core goes watch-only, signing happens off-node. Strongest posture, changes how every user spends, needs hardware. | |
|
||||
|
||||
**User's choice:** Daemon signs PSBTs, air-gap opt-in
|
||||
**Notes:** Because the opt-in path must actually be reachable, the missing `[fingerprint/derivation]` key-origin annotation on the current descriptors (`bitcoin.rs:230-231`) was pulled into scope as D-09 rather than left as a follow-up — without it no hardware signer can locate its key in a PSBT.
|
||||
|
||||
---
|
||||
|
||||
## Rollout
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Emergency point release | Cut a release carrying 10-01 alone once verified on the dev pair. | |
|
||||
| Dev pair now, decide after | Deploy and verify, then choose emergency vs scheduled after seeing the real diff. | |
|
||||
| Ride the next scheduled OTA | Fold into the next planned release with the rest of Phase 10 and pending 1.7.119 work. | ✓ |
|
||||
|
||||
**User's choice:** Ride the next scheduled OTA
|
||||
**Notes:** Claude recommended the emergency release; the user chose the scheduled OTA. Recorded in CONTEXT.md as D-10 with its consequence stated plainly — the exposure window for a Critical, remotely-reachable finding is now set by OTA cadence rather than by when the fix is verified. D-11 keeps 10-01 independently shippable so this stays a scheduling choice, not a technical constraint, if the timing changes.
|
||||
|
||||
---
|
||||
|
||||
## 10-01 scope
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| One gate over all identity-mutating endpoints | Sweep every `UNAUTHENTICATED_METHODS` entry that mutates identity or credentials behind the same gate with one shared test suite. | ✓ |
|
||||
| Only seed.generate + seed.restore | Keep 10-01 minimal — exactly the endpoints F-01 names — others get their own plan. | |
|
||||
| Gate the mutating ones, document the rest | Gate writers; record an evidence-backed verdict for read-only-ish endpoints. | |
|
||||
|
||||
**User's choice:** One gate over all identity-mutating endpoints
|
||||
**Notes:** The "document the rest" behaviour from option 3 was folded into the chosen option for endpoints that turn out not to mutate — a verdict with evidence rather than a behaviour change.
|
||||
|
||||
---
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- Error code / JSON-RPC response shape for a refused call.
|
||||
- Rate-limit shape and thresholds (constrained by the retry-storm trap).
|
||||
- Test organisation and file placement.
|
||||
- Whether the shared gate is middleware-layer, a per-handler helper, or both.
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- F-04 mnemonic-over-RPC exposure (R-07) — PHASE-sized in its own right.
|
||||
- F-05 Argon2 params vs ADR-005 (R-06) — needs a versioned envelope migration or an ADR amendment.
|
||||
- F-06 ceremony mnemonic via env/stdout (R-08) — deliberately scheduled separately.
|
||||
- F-07 no `cargo audit`/`cargo deny` in CI (R-05).
|
||||
- F-09 / F-10 / F-11 low+informational items (R-12, R-13 — blocked on another agent's uncommitted work — and R-14).
|
||||
- archi-dev-box as a fresh test node, shapes A and B — sequenced after this phase; shape A is the natural harness for KEY-04.
|
||||
@@ -1,31 +0,0 @@
|
||||
# Deferred items — Phase 10
|
||||
|
||||
Out-of-scope discoveries found while executing this phase. Logged, not fixed.
|
||||
|
||||
## From 10-01 (KEY-01 / F-01)
|
||||
|
||||
**Flaky test: `credentials::operations::tests::test_list_credentials_no_filter`**
|
||||
|
||||
- Discovered: 2026-08-02, during the post-plan full-suite run (`cargo test -p archipelago`).
|
||||
- Symptom: `called Result::unwrap() on an Err value: UTF-8 credentials / invalid utf-8 sequence
|
||||
of 1 bytes from index 3`.
|
||||
- Root cause (read, not fixed): `credentials/store.rs:29` sniffs the FIRST BYTE of the stored
|
||||
blob for `[` or `{` to distinguish a plaintext-JSON legacy store from the encrypted binary
|
||||
one. When the encrypted ciphertext happens to begin with `0x5B` or `0x7B` — about a 1-in-128
|
||||
chance per run — the encrypted store is misread as plaintext and `String::from_utf8` fails.
|
||||
This is a real bug in the migration sniffing, not just a test problem: a real node whose
|
||||
credential ciphertext starts with one of those bytes cannot load its credentials.
|
||||
- Why deferred: unrelated to KEY-01, different subsystem, untouched by this plan
|
||||
(`git status` shows `credentials/` unmodified). Fixing it means adding a format marker or
|
||||
version header to the store, which is an envelope change.
|
||||
- Suggested fix: prepend an explicit magic/version byte on write and branch on that, keeping the
|
||||
first-byte sniff only as the legacy fallback.
|
||||
- **RESOLVED 2026-08-02** — fixed along the suggested lines, with one improvement. Writes are now
|
||||
prefixed with a fixed `ARCHYCRED1` marker, which cannot collide with a random nonce. Legacy
|
||||
unmarked files are detected by *successful AEAD decryption* rather than by a byte sniff: a
|
||||
Poly1305 tag that verifies under the node key is a cryptographic discriminator (~2^-128 false
|
||||
positive), strictly stronger than the structural sniff the fallback would have kept. Plaintext
|
||||
JSON stays the last resort, and an undecodable file now errors instead of silently becoming an
|
||||
empty store that the next save would overwrite. Legacy files upgrade on write, never on read.
|
||||
Regression tests drive the collision deterministically via an explicit nonce (`0x5B`/`0x7B`)
|
||||
instead of waiting on the 1-in-128 draw.
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
-1
@@ -1 +0,0 @@
|
||||
|
||||
-1326
File diff suppressed because it is too large
Load Diff
-319
@@ -1,319 +0,0 @@
|
||||
# Phase 13: AIUI — Conversational Node Control & Content Surfaces - Context
|
||||
|
||||
**Gathered:** 2026-08-03
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Make the embedded AIUI functional in three directions: (1) **human-language node control** —
|
||||
a typed request in AIUI chat reaches a real node action and returns a real result;
|
||||
(2) **conversational settings** — system settings reachable by conversation, scoped to what
|
||||
the user granted; (3) **content surfaces made real** — peer files, music, IndeeHub movies and
|
||||
owned/paid content rendered live in the design AIUI already has. All of it inside a
|
||||
**user-granted capability sandbox** that keeps keys, secrets and identity material away from
|
||||
both the browser and the model.
|
||||
|
||||
**Not in scope:** cross-node content distribution with payments (the "archipelago content
|
||||
source"); wallet spends, seed/key operations, federation trust changes and factory reset as
|
||||
chat-reachable actions; Nostr integration polish; reviving the dead `ContentPanel.vue`
|
||||
architecture.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Where the agent loop lives
|
||||
|
||||
- **D-01:** The agent loop (model call → tool call → result → model) runs **node-side in
|
||||
Rust**. The `archipelago` binary owns the loop, the tool registry, and the model key. AIUI
|
||||
becomes a thin chat client. Rationale: the key never reaches the browser; tool authorization
|
||||
sits where session auth already lives; Pine/voice can reuse the same tools later.
|
||||
— **Reversibility:** costly — the RPC surface becomes a contract AIUI, and later the voice
|
||||
pipeline, are written against; moving the loop browser-side afterwards means re-homing key
|
||||
handling and re-implementing every tool in TypeScript.
|
||||
|
||||
- **D-02:** **One assistant, many front doors.** Extend the existing mesh assistant into a
|
||||
shared service: one tool registry, one backend selector, one place keys live. Mesh/LoRa,
|
||||
AIUI chat and (later) Pine voice are callers distinguished by permission scope. Avoids two
|
||||
divergent security models. Note the existing peer-facing controls — `trusted_only`,
|
||||
`allowed_contacts`, `denied_askers` — are a per-caller scope mechanism that already exists.
|
||||
|
||||
- **D-03:** **Split by nature.** The node-side registry owns everything that reads or changes
|
||||
the node (system, bitcoin, network, wallet, files, media). The existing `ContextBroker`
|
||||
keeps only what must run in the browser — `navigate`, `open-app`, `launch-app`, `theme` —
|
||||
and remains the consent surface pushing `permissions:update`. Nothing is discarded; each
|
||||
side owns what only it can do.
|
||||
|
||||
- **D-08:** Chat history lives **node-side in the per-node data dir** (`/var/lib/archipelago`),
|
||||
inheriting the node's backup, factory-reset and future LUKS story rather than growing a
|
||||
second sensitive-data location.
|
||||
|
||||
### Model backends
|
||||
|
||||
- **D-04:** Backend chain is **local Ollama first, with Claude *and* Routstr as fallbacks**.
|
||||
Node data never leaves the node when a local model is available. The assistant already
|
||||
reports `ollama_detected` / `claude_available`, so the selection signals exist.
|
||||
**Routstr (<https://github.com/routstr>) is explicitly in scope at the user's request** —
|
||||
it is an OpenAI-compatible endpoint paid per request in Cashu ecash, with providers, models
|
||||
and prices discovered over Nostr. All three of those substrates already exist in this
|
||||
codebase (`core/archipelago/src/streaming/` holds Cashu token handling and the
|
||||
`list-mints`/`configure-mints` RPCs; Nostr discovery is ADR-003/ADR-006).
|
||||
|
||||
- **D-05:** Routstr spending is authorized by a **prepaid budget the user sets**. Inference
|
||||
spends silently within the allowance, then stops and asks. The ceiling is hard — a
|
||||
prompt-injected model cannot exceed it.
|
||||
— **Reversibility:** reversible — the ceiling is a config value, not a contract.
|
||||
|
||||
- **D-07:** The **local model does get tools**, and every write needs confirmation regardless
|
||||
of backend. A mis-called tool from a weak local model surfaces as a confirmation prompt the
|
||||
user rejects, not a wrong action. Consequence: the confirm gate does the safety work, so
|
||||
**backend choice stays a privacy decision rather than a safety one**.
|
||||
|
||||
### Authority and sandboxing
|
||||
|
||||
- **D-06:** Tools are a **curated allowlist of hand-written tools** — each with its own
|
||||
schema, permission category, and destructive/confirm flag. The model never sees the full RPC
|
||||
surface. No auto-generation from the dispatcher: every capability the chat has must be a
|
||||
decision someone made, which is the only way the sandbox claim stays true.
|
||||
— **Reversibility:** reversible — adding tools later is additive; the allowlist is the point.
|
||||
|
||||
- **D-09:** First-cut authority is **reads within granted categories + app lifecycle
|
||||
(start/stop/restart) + settings writes**. Explicitly excluded from chat reach: keys, seeds,
|
||||
wallet spends, federation trust, factory reset. Those stay UI-only.
|
||||
— **Reversibility:** costly — widening later is safe, but any capability shipped and then
|
||||
withdrawn breaks a behaviour users will have learned.
|
||||
|
||||
- **D-10:** **Tool authority never derives from content.** Peer-supplied text (file names,
|
||||
content descriptions, mesh chat, Nostr posts) enters the context inside explicit
|
||||
untrusted-content delimiters that mark it as data, not instructions. The tool layer takes
|
||||
its permissions solely from the user's grants and the confirm gate. An injected "now restart
|
||||
bitcoin" still has to clear a human confirmation naming the real action. Pattern-stripping
|
||||
filters were considered and **rejected** as an arms race that reads as a guarantee it isn't.
|
||||
|
||||
- **D-11:** Write confirmations render **in neode-ui's trusted chrome, outside the iframe**,
|
||||
drawn by the host from the node's own description of the pending action — never by AIUI and
|
||||
never from model-authored text. The iframe cannot spoof, restyle or pre-click it. Uses the
|
||||
project's mandated Teleport-to-body modal pattern.
|
||||
— **Reversibility:** costly — this is the load-bearing anti-spoofing property; moving the
|
||||
dialog inside the iframe later would invalidate the threat model, not just the styling.
|
||||
|
||||
- **D-16:** All 10 permission categories (`apps`, `system`, `network`, `wallet`, `files`,
|
||||
`media`, `search`, `ai-local`, `notes`, `bitcoin`) **default closed** on a fresh node.
|
||||
Nothing is shared with the model until deliberately granted. The assistant looks
|
||||
unconfigured until the user opens categories — accepted cost.
|
||||
|
||||
- **Hard constraint from Phase 10:** the `UNAUTHENTICATED_METHODS` hard-refuse gates and the
|
||||
loopback/auth boundaries must hold with AIUI on the other side of them. They are not to be
|
||||
widened to accommodate this phase. See `10-CONTEXT.md` D-01..D-04.
|
||||
|
||||
### Content surfaces
|
||||
|
||||
- **D-12:** **Feed the existing grids from Archy, replacing the LLM-synth source.** AIUI's
|
||||
design is kept exactly — `FilmGrid`, `SongGrid`, `NewsGrid`, the detail views — and what
|
||||
fills them changes: peer files, IndeeHub movies, owned/paid content and node media arrive as
|
||||
real records instead of being regex-scraped out of model prose.
|
||||
— **Reversibility:** reversible — the grids are prop-driven; the data source behind them is
|
||||
swappable.
|
||||
|
||||
- **D-13:** **Build a real music library** — albums, artists, tracks, tag/metadata extraction,
|
||||
an index that stays fresh. The user chose this over the narrower MIME-filtered-files option
|
||||
after being told no library domain exists today. It lands as **its own wave of plans inside
|
||||
Phase 13, not blocking the rest** — peer files, movies and conversational control ship on
|
||||
their own track and the library lights up `SongGrid` when ready.
|
||||
— **Reversibility:** one-way — an album/artist/track schema and its on-disk index become a
|
||||
persisted data model with a migration cost once nodes have indexed libraries; changing the
|
||||
entity model afterwards needs a reindex path, not just a code change.
|
||||
|
||||
- **D-14:** **IndeeHub and peer video are surfaced through the content + paid-unlock
|
||||
subsystem that already exists** (invoices, `X-Payment-Token`, Range streaming). No new
|
||||
payment rail. The cross-node "archipelago content source" from the Phase 2 note is deferred
|
||||
— it is a distribution and payments feature spanning federation, not an AIUI surface.
|
||||
|
||||
### Delivery and the two-repo split
|
||||
|
||||
- **D-15:** AIUI is **built and shipped with the frontend, versioned and verified** — the
|
||||
rsync path is kept because it is the one that works, but made deliberate: AIUI's commit
|
||||
pinned in this repo, `VITE_BASE_PATH=/aiui/` enforced by the build script rather than
|
||||
remembered, and a post-deploy check that **fetches a live asset** instead of trusting a
|
||||
directory listing. Making AIUI a signed-catalog app was considered and rejected for this
|
||||
phase: `*-ui` apps are outside the catalog by design today, and changing that platform rule
|
||||
mid-phase is its own work.
|
||||
|
||||
- **D-17:** AIUI **keeps its standalone mode**; embedded mode delegates to the node. It goes
|
||||
on working on its own with its own proxy for development and for anyone running it outside a
|
||||
node; when `embedded=true` it hands the loop, the tools and the key to Archy. The dev loop
|
||||
stays fast — no node required to work on the UI.
|
||||
|
||||
- **D-18:** **Push access to the AIUI repo is confirmed before planning starts**, treated as a
|
||||
prerequisite rather than discovered mid-plan. Last time this surfaced at execution and left
|
||||
neode-ui shipping two query params that were inert no-ops against every deployed AIUI build
|
||||
until a maintainer merged (see `.planning/WINDOWS.md` window 4).
|
||||
|
||||
### Claude's Discretion
|
||||
|
||||
- What the music library indexes over (own filebrowser `Music` folder, peer audio, or both),
|
||||
the tag-extraction library, and where the index lives — within D-13's bounds.
|
||||
- Streaming/token delivery for chat responses; context-window budgeting over node data.
|
||||
- Which specific tools make the first curated allowlist, within D-09's authority ceiling.
|
||||
- Routstr provider selection strategy among Nostr-advertised providers.
|
||||
- Per-category mapping of the 10 permission categories onto individual tools.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### The existing AIUI bridge (this is NOT greenfield — read before designing anything)
|
||||
- `neode-ui/src/types/aiui-protocol.ts` — protocol v1.0.0, `aiui:` message prefix, the
|
||||
request/response contract. Defines `AIContextCategory` (10 categories) and `AIActionType`
|
||||
(`install-app | open-app | navigate | launch-app | search-web | read-file | tail-logs`).
|
||||
- `neode-ui/src/stores/aiPermissions.ts` — the 10 user-toggled permission categories with
|
||||
labels; `isEnabled` / `toggle`.
|
||||
- `neode-ui/src/services/contextBroker.ts` — the 624-line origin-scoped postMessage broker
|
||||
that "checks permissions, fetches data from Pinia stores, sanitizes it (strips sensitive
|
||||
fields), and responds". The asset D-03 splits.
|
||||
- `neode-ui/src/services/__tests__/contextBroker.test.ts`, `neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts` — existing coverage to keep green.
|
||||
- `neode-ui/src/views/Chat.vue` — the iframe embed, `aiuiUrl` construction, origin check, the
|
||||
`ready` handshake, `allow="microphone"`.
|
||||
|
||||
### The existing node-side assistant (the thing D-02 extends)
|
||||
- `core/archipelago/src/api/rpc/mesh/assistant.rs` — `mesh.assistant-status` /
|
||||
`mesh.assistant-configure`; reports `ollama_detected`, `claude_available`, `models`,
|
||||
`trusted_only`, `allowed_contacts`, `denied_askers`; key at `data_dir/secrets/claude-api-key`.
|
||||
- `core/archipelago/src/mesh/listener/assist.rs` — `run_assist`, `is_sender_allowed`,
|
||||
`call_ollama`, `call_claude`, `cap_reply`. **Q&A only — no tool-calling today.**
|
||||
- `core/archipelago/src/api/rpc/dispatcher.rs` — the method registry (`mesh.assistant-*` at
|
||||
~445). **Confirmed: there are no `pine.*` methods** — Pine has no RPC surface.
|
||||
|
||||
### Routstr (new integration, user-requested)
|
||||
- <https://github.com/routstr> — org; `routstr-core`, `routstrd`, `routstr-sdk`, `routstr-chat`.
|
||||
- <https://docs.routstr.com/> — protocol docs.
|
||||
- `core/archipelago/src/streaming/` — existing Cashu handling (`gate.rs` verifies/receives
|
||||
tokens, `pricing.rs`, `session.rs`) and the `streaming.list-mints` / `.configure-mints` RPCs.
|
||||
Note: currently `#![allow(dead_code)]`, "suppress dead_code until callers land".
|
||||
|
||||
### Content subsystem (what D-12/D-14 wire the grids to)
|
||||
- `core/archipelago/src/content_server.rs` — `ContentItem` shape (`id`, `filename`,
|
||||
`mime_type`, `size_bytes`, `description`, `access`, `availability`, `added_at`),
|
||||
`AccessControl` (`Free | PeersOnly | Paid`), `parse_range_header`, the paid-preview logic
|
||||
and the ISOBMFF faststart check.
|
||||
- `core/archipelago/src/api/handler/content.rs` — `GET /content`, `/content/<id>`,
|
||||
`/preview`, `/invoice`; Range → 206 with `Content-Range`; 402 body with `price_sats`.
|
||||
- `core/archipelago/src/api/handler/proxy.rs:188-265` — the peer Range-streaming proxy
|
||||
(`/api/peer-content/<onion>/<id>`). Its docstring explains why base64 blobs broke seeking.
|
||||
- `core/archipelago/src/api/rpc/content.rs` — the `content.*` RPCs including
|
||||
`browse-peer`, `download-peer*`, `preview-peer`; auto-filing by MIME at ~668.
|
||||
- `neode-ui/src/composables/useAudioPlayer.ts`, `neode-ui/src/components/GlobalAudioPlayer.vue`
|
||||
— the singleton bottom-bar player. **Audio never opens the lightbox** — enforced in 5 places.
|
||||
- `neode-ui/src/api/filebrowser-client.ts` — the scoped-token pattern (`app.filebrowser-token`)
|
||||
that D-01 follows. **Known leak to fix rather than propagate:** `streamUrl` puts the JWT in
|
||||
the URL query string.
|
||||
|
||||
### Prior phase context (locked decisions that constrain this phase)
|
||||
- `.planning/phases/10-key-material-hardening/10-CONTEXT.md` — D-01..D-04, the
|
||||
`UNAUTHENTICATED_METHODS` hard-refuse gates. **Must not be widened.**
|
||||
- `.planning/phases/02-ui-performance/02-CONTEXT.md` — D-14 (the shipped AIUI embed defaults)
|
||||
and the Deferred Ideas block, which is the origin of this phase.
|
||||
- `.planning/phases/02-ui-performance/02-AIUI-D14.md` — the embed parameter contract, AIUI's
|
||||
repo location and branch, and the push-access history.
|
||||
- `.planning/WINDOWS.md` window 4 — the 403 that made D-18 a prerequisite.
|
||||
|
||||
### Project invariants
|
||||
- `CLAUDE.md` — commit/push discipline, rootless-Podman invariant, the frontend-build verify
|
||||
rule (grep the built bundle), "verify on the real node before any tag".
|
||||
- `.planning/PROJECT.md` — ADR-003 (Nostr discovery), ADR-006 (DID-signed, trust tiers),
|
||||
ADR-008 (dual keys from one seed), ADR-009 (container security).
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- **The permission + consent layer already exists** — 10 categories, a store, a broker that
|
||||
sanitizes, and tests. This phase extends it rather than inventing it.
|
||||
- **The assistant already abstracts two model backends** and already holds a key server-side
|
||||
at `data_dir/secrets/claude-api-key` — the pattern D-01 generalizes.
|
||||
- **Cashu, Nostr and Lightning are all already in-tree**, which is why Routstr is a smaller
|
||||
lift here than it would be elsewhere.
|
||||
- **Range-streaming media delivery is solved** — both own files (filebrowser `/api/raw`) and
|
||||
peer files (the Rust proxy). The grids need data, not a transport.
|
||||
|
||||
### Established Patterns
|
||||
- Audio belongs to the global bottom-bar player, never the lightbox (enforced in 5 call sites).
|
||||
- Modals Teleport to body for a full-screen backdrop (project rule, repeatedly reinforced).
|
||||
- Scoped tokens minted by an authenticated RPC, credentials never reaching the browser.
|
||||
|
||||
### Integration Points
|
||||
- `dispatcher.rs` — where new assistant/tool RPCs register.
|
||||
- `ContextBroker.handleMessage` — where the browser-only action split (D-03) lands.
|
||||
- `ChatPage.vue` → `ContentGridView.vue` → the `*Grid` components — the live render tree the
|
||||
Archy data must reach (**note `ContentPanel.vue` is dead; do not build through it**).
|
||||
|
||||
### Landmines found during scouting (verified, not assumed)
|
||||
- **AIUI's grids are fed by regex-parsing the model's own reply text** (`updatePanelFromText`
|
||||
→ `contentExtraction.ts`), resolving IDs against fixture catalogs that are themselves
|
||||
injected into the system prompt (`useAI.ts:24-34`). The largest data bucket is
|
||||
LLM-synthesized, not an API awaiting a base URL.
|
||||
- **Every "real" data path in AIUI is Vite dev middleware** — all six plugins are
|
||||
`configureServer`/`configurePreviewServer` only, so they are **absent from a static `dist/`
|
||||
deploy**. On a node, TMDB posters, web search, RSS and filesystem all 404.
|
||||
- **`vite-fs.ts:7` hardcodes `PROJECTS_ROOT = '/Users/dorian/Projects'`** — broken on any
|
||||
other machine, including the Linux dev box.
|
||||
- **`ContentPanel.vue` is dead code**, taking `ArchyAppsGrid` (the Archy bridge grid),
|
||||
`FavoritesGrid`, `DiscoverPanel`, `RecipeDetail` and `AppDetail` with it. Clicking a recipe
|
||||
or an app currently does nothing.
|
||||
- **`ShareModal.vue`'s mime map omits `m4a`/`aac`/`opus`/`wma`** — those share as
|
||||
`application/octet-stream`, so they never route to the audio player and are auto-filed to
|
||||
`Documents` instead of `Music`. Relevant to D-13.
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Routstr was named by the user directly, with the repo link, and asked to be planned in as
|
||||
part of the backend work — not treated as a future option.
|
||||
- The sandbox framing is the user's own: "we must sandbox and protect the users sensitive
|
||||
keys, information, etc whatever they allow access to." The last clause is the design brief —
|
||||
authority is bounded by what the user allows, not by what the model asks for.
|
||||
- The origin of this phase is the user's Phase 2 wording: AIUI "talks to the node safely when
|
||||
permissioned, without leaking data, **using the same command surface as Pine** and everything
|
||||
else enableable in settings." D-02's shared-service shape is that sentence made concrete.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- **Cross-node "archipelago content source" with payments** — any IndeeHub install plugs into
|
||||
every node's content, with payments; same for music. A federation distribution + payments
|
||||
feature; its own phase (deferred at D-14).
|
||||
- **AIUI Nostr integration polish** — "make the Nostr integration in AIUI more beautiful."
|
||||
Carried over from Phase 2's deferred list, still not scoped here.
|
||||
- **Reviving `ContentPanel.vue` and the plugin-renderer path** — considered and rejected for
|
||||
this phase; the dead-code inventory should be resolved as cleanup, not as architecture.
|
||||
- **AIUI's dev-only Vite middleware** (`vite-tmdb`, `vite-rss`, `vite-web-search`, `vite-fs`,
|
||||
`vite-music-search`, `vite-dev-chats`) — needs a production answer eventually; only the parts
|
||||
D-12 replaces are in scope now.
|
||||
- **Pine voice reusing the tool registry** — D-02 makes it possible and is the reason for the
|
||||
shared-service shape, but wiring the voice pipeline to it is not in this phase.
|
||||
|
||||
### Reviewed Todos (not folded)
|
||||
- *Connected-nodes list must scroll at row-matched height* — keyword match only; belongs to
|
||||
Phase 1 (UIFIX-02, already complete).
|
||||
- *Fedimint gateway must not install with a pre-set password* — keyword match only; Phase 1
|
||||
FED-07 territory.
|
||||
- *Keep FIPS/Tor pills on cloud files and show them on mobile* — keyword match only; Phase 1
|
||||
UIFIX-01.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 13-AIUI — Conversational Node Control & Content Surfaces*
|
||||
*Context gathered: 2026-08-03*
|
||||
-201
@@ -1,201 +0,0 @@
|
||||
# Phase 13: AIUI — Conversational Node Control & Content Surfaces - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-08-03
|
||||
**Phase:** 13-aiui-functional-conversational-node-control-and-content-surf
|
||||
**Areas discussed:** Where the tool-calling loop lives, How much authority the chat gets, Content scope and what "music" means, Two-repo split and delivery
|
||||
|
||||
---
|
||||
|
||||
## Where the tool-calling loop lives
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Node-side in Rust | Binary owns loop, tool registry and key; AIUI is a thin client | ✓ |
|
||||
| Node proxies the model, AIUI runs the loop | Key stays server-side but tools execute through the ContextBroker | |
|
||||
| Browser-side in AIUI | AIUI calls the model directly; key lives in the browser | |
|
||||
|
||||
**User's choice:** Node-side in Rust
|
||||
**Notes:** Chosen with the tradeoff stated — this makes the RPC surface a contract AIUI and the later voice pipeline are written against.
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| One assistant, many front doors | Extend the existing mesh assistant into a shared service | ✓ |
|
||||
| Separate subsystem for AIUI | Leave the radio-shaped mesh assistant alone, build beside it | |
|
||||
| Shared backend, separate authority | Share the key/plumbing, keep tool registries strictly separate | |
|
||||
|
||||
**User's choice:** One assistant, many front doors
|
||||
**Notes:** Directly realizes the user's Phase 2 wording — "the same command surface as Pine".
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Split by nature: node does data+control, broker does UI | Node owns node-touching tools; broker keeps navigate/open-app/theme + consent | ✓ |
|
||||
| Broker becomes consent-only | Strip back to permissions and theme | |
|
||||
| Keep the broker as the single front door | Everything forwards through the broker | |
|
||||
|
||||
**User's choice:** Split by nature
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Local Ollama when present, Claude as fallback | Node data stays local when a local model exists | ✓ (amended) |
|
||||
| Claude by default, Ollama opt-in | Best tool-calling reliability, context leaves the node | |
|
||||
| User picks at setup, no default | Explicit choice, no implicit default | |
|
||||
|
||||
**User's choice:** Option 1, **amended by the user** — "but we also want to integrate this as part of it, please plan that too `https://github.com/routstr` so it would be local Ollama or Claude/Routstr as fallback"
|
||||
**Notes:** Routstr was researched during the discussion rather than assumed: OpenAI-compatible endpoint, Cashu ecash per request, Nostr provider/model/price discovery. All three substrates already exist in-tree, which is why it is a smaller lift here than elsewhere.
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Prepaid budget, auto-spend within it | Hard ceiling a prompt-injected model cannot exceed | ✓ |
|
||||
| Confirm every paid request | Maximum control, unusable with a multi-call tool loop | |
|
||||
| Routstr only when explicitly selected | No automatic fallback to a paid path | |
|
||||
|
||||
**User's choice:** Prepaid budget the user sets
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Curated allowlist of hand-written tools | Every capability is a deliberate decision | ✓ |
|
||||
| Auto-generate from the RPC dispatcher | Fast coverage, blast radius = whatever the allowlist forgets | |
|
||||
| Tiered: curated for writes, generated for reads | Broad reads, hand-written mutations | |
|
||||
|
||||
**User's choice:** Curated allowlist of hand-written tools
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Local model gets tools, writes confirmed anyway | Confirm gate does the safety work | ✓ |
|
||||
| Reads local, writes escalate to the strong model | Undercuts the privacy default at the sensitive moment | |
|
||||
| Require a tool-capable local model | Honest but costs weak-hardware users the feature | |
|
||||
|
||||
**User's choice:** Local model gets tools; every write needs confirmation regardless
|
||||
**Notes:** Consequence recorded in CONTEXT.md — backend choice becomes a privacy decision, not a safety one.
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Node-side, in the existing per-node data dir | Inherits backup, factory-reset, future LUKS | ✓ |
|
||||
| Browser-only, never persisted server-side | Nothing accumulates on disk | |
|
||||
| Ephemeral — no history at all | Strongest privacy, no memory | |
|
||||
|
||||
**User's choice:** Node-side in the per-node data dir
|
||||
|
||||
---
|
||||
|
||||
## How much authority the chat gets
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Reads + app lifecycle + settings writes | Keys, seeds, wallet spends, federation trust, factory reset excluded | ✓ |
|
||||
| Read-only first | Prove the sandbox before granting power | |
|
||||
| Full control including wallet and payments | An LLM adjacent to spending authority | |
|
||||
|
||||
**User's choice:** Reads within granted categories + app lifecycle + settings writes
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Authority never derives from content; untrusted text fenced and labelled | Injected instructions still face a human confirm | ✓ |
|
||||
| Keep peer content out of the model entirely | Removes the injection path and much of the appeal | |
|
||||
| Sanitize and strip suspicious patterns | Rejected as an arms race that reads as a guarantee | |
|
||||
|
||||
**User's choice:** Fenced and labelled; authority never derives from content
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| In neode-ui's trusted chrome, outside the iframe | Iframe cannot spoof, restyle or pre-click it | ✓ |
|
||||
| Inside AIUI, styled as part of the conversation | Better feel, drawn by the influenced context | |
|
||||
| Node-issued confirmation token, UI-agnostic | Strongest and works for voice; more protocol to build | |
|
||||
|
||||
**User's choice:** neode-ui's trusted chrome, outside the iframe
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| All closed; user opens what they want | Matches the sandbox promise literally | ✓ |
|
||||
| Low-sensitivity open, sensitive closed | Immediately useful, harder claim to defend | |
|
||||
| Open on first grant, per-category prompts in context | Just-in-time consent, more moving parts | |
|
||||
|
||||
**User's choice:** All 10 categories default closed
|
||||
|
||||
---
|
||||
|
||||
## Content scope — and what "music" means
|
||||
|
||||
Presented alongside verified research findings: AIUI's grids are fed by regex-parsing the model's own reply text against fixture catalogs injected into the system prompt; every "real" data path is Vite dev middleware absent from a static `dist/` deploy; `vite-fs.ts:7` hardcodes `/Users/dorian/Projects`; `ContentPanel.vue` is dead code taking `ArchyAppsGrid`, `FavoritesGrid`, `DiscoverPanel`, `RecipeDetail` and `AppDetail` with it.
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Feed the existing grids from Archy | Keep the design, replace the LLM-synth source | ✓ |
|
||||
| New Archy-native surfaces alongside | Doubles surface area, splits the design language | |
|
||||
| Revive ContentPanel and the Archy bridge path | Risks investing in an abandoned architecture | |
|
||||
|
||||
**User's choice:** Feed the existing grids from Archy
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Audio files from the two transports you already have | MIME-filtered files, no new entities; folds in the m4a/aac/opus mime bug | |
|
||||
| Build a real library — albums, artists, metadata | A substantial backend domain | ✓ |
|
||||
| Leave music to wavlake, wire only files and video | Music already works in prod against wavlake | |
|
||||
|
||||
**User's choice:** Build a real library
|
||||
**Notes:** Chosen after being told explicitly that no library domain exists today and that it deserves its own phase. Concern raised once, user decided, proceeded — sequencing handled by the follow-up below.
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Its own plans inside Phase 13, not blocking the rest | Phase still delivers if the library runs long | ✓ |
|
||||
| Library first — the rest follows | Cleanest data model, delays everything visible | |
|
||||
| Split it into its own phase | Its own discussion round | |
|
||||
|
||||
**User's choice:** Its own non-blocking wave inside Phase 13
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Surface this node's + peers' existing content; no new payment rail | Uses the invoice/X-Payment-Token/Range flow that exists | ✓ |
|
||||
| Include the cross-node content source with payments | The full Phase 2 vision; a federation distribution feature | |
|
||||
| Movies out of scope this phase | Narrowest cut | |
|
||||
|
||||
**User's choice:** Surface existing content through the existing paid-unlock subsystem
|
||||
|
||||
---
|
||||
|
||||
## Two-repo split and delivery
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Built and shipped with the frontend, versioned and verified | Pin the commit, enforce the base path, fetch a live asset to verify | ✓ |
|
||||
| Make AIUI a real catalog app | Architecturally right; changes a platform rule mid-phase | |
|
||||
| Vendor AIUI's build output into this repo | One artifact, loses source separation | |
|
||||
|
||||
**User's choice:** Built and shipped with the frontend, versioned and verified
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Keep standalone; embedded mode delegates to the node | Dev loop stays fast, no node needed to work on the UI | ✓ |
|
||||
| Embedded-only from here | Less surface, loses AIUI's independent life | |
|
||||
| Standalone with the node as an optional backend | "Optional" risks the secure path being the forgotten one | |
|
||||
|
||||
**User's choice:** Keep standalone; embedded mode delegates
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Confirm push access before planning starts | Treats it as a prerequisite, not a mid-plan discovery | ✓ |
|
||||
| Work on a branch, hand merges to a maintainer | Human gate mid-phase, same inert-until-merged risk | |
|
||||
| Plan the archy side to degrade gracefully | Robust, but designs for a half-landed state throughout | |
|
||||
|
||||
**User's choice:** Confirm push access before planning starts
|
||||
|
||||
---
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- What the music library indexes over, the tag-extraction library, and where the index lives.
|
||||
- Streaming/token delivery for chat responses; context-window budgeting over node data.
|
||||
- Which specific tools make the first curated allowlist, within the authority ceiling.
|
||||
- Routstr provider selection among Nostr-advertised providers.
|
||||
- Per-category mapping of the 10 permission categories onto individual tools.
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- Cross-node "archipelago content source" with payments (federation distribution feature).
|
||||
- AIUI Nostr integration polish (carried from Phase 2's deferred list).
|
||||
- Reviving `ContentPanel.vue` and the plugin-renderer path — cleanup, not architecture.
|
||||
- A production answer for AIUI's dev-only Vite middleware beyond what this phase replaces.
|
||||
- Wiring Pine's voice pipeline to the shared tool registry.
|
||||
-518
@@ -1,518 +0,0 @@
|
||||
# Phase 13: AIUI — Conversational Node Control & Content Surfaces - Research
|
||||
|
||||
**Researched:** 2026-08-03
|
||||
**Domain:** LLM tool-calling bridge (Rust), Cashu/Nostr paid-inference integration, cross-repo Vue embed architecture, media/content indexing
|
||||
**Confidence:** MEDIUM-HIGH — the Rust and neode-ui sides are fully source-verified; the AIUI repo side is fully source-verified (cloned, read directly); Routstr protocol details are CITED from official docs (not independently protocol-tested against a live Routstr node, which does not exist in this environment).
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
**Where the agent loop lives**
|
||||
- **D-01:** The agent loop (model call → tool call → result → model) runs **node-side in Rust**. The `archipelago` binary owns the loop, the tool registry, and the model key. AIUI becomes a thin chat client. Rationale: the key never reaches the browser; tool authorization sits where session auth already lives; Pine/voice can reuse the same tools later. Reversibility: costly.
|
||||
- **D-02:** **One assistant, many front doors.** Extend the existing mesh assistant into a shared service: one tool registry, one backend selector, one place keys live. Mesh/LoRa, AIUI chat and (later) Pine voice are callers distinguished by permission scope. The existing peer-facing controls (`trusted_only`, `allowed_contacts`, `denied_askers`) are a per-caller scope mechanism that already exists.
|
||||
- **D-03:** **Split by nature.** The node-side registry owns everything that reads or changes the node (system, bitcoin, network, wallet, files, media). The existing `ContextBroker` keeps only what must run in the browser (`navigate`, `open-app`, `launch-app`, `theme`) and remains the consent surface pushing `permissions:update`.
|
||||
- **D-08:** Chat history lives **node-side in the per-node data dir** (`/var/lib/archipelago`), inheriting the node's backup/factory-reset/LUKS story.
|
||||
|
||||
**Model backends**
|
||||
- **D-04:** Backend chain is **local Ollama first, with Claude *and* Routstr as fallbacks**. Node data never leaves the node when a local model is available. Routstr is explicitly in scope at the user's request — an OpenAI-compatible endpoint paid per request in Cashu ecash, providers/models/prices discovered over Nostr.
|
||||
- **D-05:** Routstr spending is authorized by a **prepaid budget the user sets**. Inference spends silently within the allowance, then stops and asks. The ceiling is hard. Reversibility: reversible.
|
||||
- **D-07:** The **local model does get tools**, and every write needs confirmation regardless of backend. Backend choice stays a privacy decision, not a safety one.
|
||||
|
||||
**Authority and sandboxing**
|
||||
- **D-06:** Tools are a **curated allowlist of hand-written tools** — each with its own schema, permission category, and destructive/confirm flag. The model never sees the full RPC surface. No auto-generation from the dispatcher. Reversibility: reversible (adding tools is additive).
|
||||
- **D-09:** First-cut authority is **reads within granted categories + app lifecycle (start/stop/restart) + settings writes**. Explicitly excluded from chat reach: keys, seeds, wallet spends, federation trust, factory reset. Reversibility: costly.
|
||||
- **D-10:** **Tool authority never derives from content.** Peer-supplied text enters the context inside explicit untrusted-content delimiters marking it as data, not instructions. Pattern-stripping filters were considered and **rejected**.
|
||||
- **D-11:** Write confirmations render **in neode-ui's trusted chrome, outside the iframe**, drawn by the host from the node's own description of the pending action — never by AIUI, never from model-authored text. Uses the Teleport-to-body modal pattern. Reversibility: costly — the load-bearing anti-spoofing property.
|
||||
- **D-16:** All 10 permission categories (`apps`, `system`, `network`, `wallet`, `files`, `media`, `search`, `ai-local`, `notes`, `bitcoin`) **default closed** on a fresh node.
|
||||
- **Hard constraint from Phase 10:** the `UNAUTHENTICATED_METHODS` hard-refuse gates and the loopback/auth boundaries must hold with AIUI on the other side of them. Not to be widened.
|
||||
|
||||
**Content surfaces**
|
||||
- **D-12:** **Feed the existing grids from Archy, replacing the LLM-synth source.** AIUI's design is kept exactly; what fills `FilmGrid`, `SongGrid`, `NewsGrid`, the detail views changes to real records. Reversibility: reversible — the grids are prop-driven.
|
||||
- **D-13:** **Build a real music library** — albums, artists, tracks, tag/metadata extraction, an index that stays fresh. Lands as **its own wave inside Phase 13, not blocking the rest**. Reversibility: one-way — a persisted data model with a migration cost once nodes have indexed libraries.
|
||||
- **D-14:** **IndeeHub and peer video are surfaced through the content + paid-unlock subsystem that already exists** (invoices, `X-Payment-Token`, Range streaming). No new payment rail. The cross-node "archipelago content source" is deferred.
|
||||
|
||||
**Delivery and the two-repo split**
|
||||
- **D-15:** AIUI is **built and shipped with the frontend, versioned and verified** — rsync path kept but made deliberate: AIUI's commit pinned, `VITE_BASE_PATH=/aiui/` enforced by the build script, a post-deploy check that fetches a live asset. Making AIUI a signed-catalog app was considered and **rejected** for this phase.
|
||||
- **D-17:** AIUI **keeps its standalone mode**; embedded mode delegates to the node. Dev loop stays fast — no node required to work on the UI.
|
||||
- **D-18:** **Push access to the AIUI repo is confirmed before planning starts** — CONFIRMED by the orchestrator per the phase brief; plan freely against `git.tx1138.com/lfg2025/AIUI` branch `development`.
|
||||
|
||||
### Claude's Discretion
|
||||
- What the music library indexes over (own filebrowser `Music` folder, peer audio, or both), the tag-extraction library, and where the index lives — within D-13's bounds.
|
||||
- Streaming/token delivery for chat responses; context-window budgeting over node data.
|
||||
- Which specific tools make the first curated allowlist, within D-09's authority ceiling.
|
||||
- Routstr provider selection strategy among Nostr-advertised providers.
|
||||
- Per-category mapping of the 10 permission categories onto individual tools.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- Cross-node "archipelago content source" with payments (federation distribution + payments feature; own phase).
|
||||
- AIUI Nostr integration polish (carried from Phase 2's deferred list).
|
||||
- Reviving `ContentPanel.vue` and the plugin-renderer path (considered and rejected; dead-code cleanup, not architecture).
|
||||
- AIUI's dev-only Vite middleware beyond what D-12 replaces (`vite-tmdb`, `vite-rss`, `vite-web-search`, `vite-fs`, `vite-music-search`, `vite-dev-chats`).
|
||||
- Pine voice reusing the tool registry (D-02 makes it *possible*; wiring voice is not in this phase).
|
||||
- **Not in scope (from the phase Domain block):** cross-node content distribution with payments; wallet spends, seed/key operations, federation trust changes, factory reset as chat-reachable actions; Nostr integration polish; reviving the dead `ContentPanel.vue` architecture.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| AIUI-01 | Human-language node control — typed chat request reaches a real node action | §1 (gating question, verified), §2 (tool-calling loop), §4 (confirm gate) settle the mechanism; §"Curated Tool Allowlist" gives concrete RPC candidates |
|
||||
| AIUI-02 | Conversational settings — system settings reachable by conversation, scoped to grants | Same tool-registry mechanism as AIUI-01; `system.settings.get`/`system.settings.set` are existing RPCs to wrap as tools |
|
||||
| AIUI-03 | Content surfaces made real — peer files, music, IndeeHub movies, owned/paid content render live | §5 (content surfaces) maps `ContentItem`/`content.*` RPCs onto AIUI's `Film`/`Song`/`Podcast` prop shapes; §6 (music library) covers the one genuinely new data domain |
|
||||
| AIUI-04 | Sandboxed by construction, permissioned by the user | §4 (confirm gate mechanism) + new §"Same-Origin Sandbox Gap" (a verified architectural finding: today's iframe embed has no hard browser-enforced boundary) |
|
||||
| AIUI-05 | Delivery and build — AIUI reaches nodes through a real, verifiable update path | §7 (delivery) — current deploy scripts, the `VITE_BASE_PATH` requirement, and the missing live-asset check are all verified from source |
|
||||
| AIUI-06 | Verified on device, embedded iframe, mobile included | §Validation Architecture |
|
||||
</phase_requirements>
|
||||
|
||||
## Summary
|
||||
|
||||
AIUI today is not a dormant blank canvas waiting for wiring — it is an **actively working, unauthenticated, key-holding proxy straight to Anthropic**, running in production nginx config on every node that has had `setup-aiui-server.sh` run against it. The canonical `image-recipe/configs/nginx-archipelago.conf` proxies `/aiui/api/claude/` to a standalone Python HTTP server (`claude-api-proxy.py`, port 3142, its own systemd unit, its own `ANTHROPIC_API_KEY` env var — a *different* key than the Rust daemon's `data_dir/secrets/claude-api-key`) with **no session-cookie gate at all** ("API key managed by proxy, no session gate needed" — verified in the nginx config comment). This proxy bypasses the entire Rust JSON-RPC dispatcher: no `UNAUTHENTICATED_METHODS` gate, no CSRF check, no RBAC `role.can_access()` check, no involvement of `session::extract_session_cookie`. Anyone who can reach the node's web port can spend the node owner's Claude API budget with zero authentication. This is a pre-existing, currently-live exposure this phase's D-01 (move the agent loop node-side into the authenticated Rust surface) directly closes as a side effect of doing the phase correctly — but it needs to be named explicitly as a finding, because it is more severe than "chat can't act on the node" and is not mentioned in CONTEXT.md.
|
||||
|
||||
Both halves of CONTEXT.md's central gating claim are **verified true against source**: `mesh/listener/assist.rs`'s `call_ollama` posts to Ollama's `/api/generate` (not `/api/chat`) with a bare prompt string and no `tools` field; `call_claude` posts to the Anthropic Messages API with a single user message and no `tool_use`/`tools` field. Both are single-shot Q&A, no loop. `dispatcher.rs` registers only `mesh.assistant-status`/`mesh.assistant-configure` — grep for `"pine.` across the entire dispatcher returns nothing; Pine has no RPC surface, and what CONTEXT.md called "the intent→action path Pine already proves" is, on inspection, Home Assistant's own `intent_script`/Assist framework seeded by `package/pine_ha.rs` — four **read-only** hardcoded intents (block height, peer count, sync status, Lightning balance) answered from REST-sensor state, not a Rust-side action-executing loop. HA's Claude conversation agent does get real LLM tool-calling via `llm_hass_api: ["assist"]`, but only over HA's own intents — none of which write to the node. So even Pine's voice path does not yet prove an action-taking loop; it proves Q&A-with-structured-intents at the HA layer. This corrects CONTEXT.md's framing and matters for scoping AIUI-01's "first tool-calling loop in this codebase" honestly.
|
||||
|
||||
On the AIUI side (cloned, `development` branch, read directly): `useAI.ts`'s chat send path calls `streamClaude`/`streamOpenRouter` against `${BASE}api/claude/v1/messages` / `${BASE}api/openrouter` — i.e., exactly the nginx proxy above, or the browser's own vaulted API key. There is **no client-side tool-calling either**: the "Archy actions" AIUI's system prompt describes (`open-app`, `install-app`, `read-file`, `tail-logs`, `navigate`) are informational prose injected into the system prompt by `useArchy.ts`'s `buildArchyContext()`; only `readFile`/`tailLogs` are ever actually invoked by AIUI code, and both are called directly by UI components — never parsed out of a model response. AIUI has zero machinery today for turning an LLM's stated intent into an executed action; everything the model "does" today is either prose or a `[[tag:...]]` regex match consumed by `contentExtraction.ts`/`useContentPanel.ts` to render a content card. This phase must build the tool-calling protocol from scratch on both sides.
|
||||
|
||||
A real anti-spoofing confirmation pattern already exists to extend, not invent: `contextBroker.ts`'s `install-app` handler dispatches a `CustomEvent('aiui:install-request')` for neode-ui's own UI to render a confirmation, then awaits `aiui:install-response` with a 60s timeout — this is D-11's mechanism today, just for one action type. It needs a second: `aiui-protocol.ts`'s `AIActionType` union has no `tool-call`/`confirm` member yet.
|
||||
|
||||
For Routstr, the phase is not starting from zero on the payment side: `crate::swarm::payment::auto_pay_token` (used today by `streaming.prepare-payment`, the swarm content-payment path) already does exactly D-05's job — build a `cashuA` token for a given price against a set of `accepted_mints`, hard-capped by a `PaymentPolicy::with_budget`, degrading to `None` (never erroring) when unaffordable. Routstr's documented contract (CITED, not independently tested) accepts payment as `Authorization: Bearer cashuA...` or an `X-Cashu` header on an OpenAI-compatible `POST /v1/chat/completions`, and advertises providers via Nostr kind `38421` events — `nostr-sdk = "0.44"` is already a dependency with a working `build_nostr_client` (Tor-proxy aware) in `nostr_discovery.rs` to subscribe from.
|
||||
|
||||
For content surfaces, `ArchyAppsGrid.vue` is confirmed dead (only referenced by dead `ContentPanel.vue` and its own test). All six of AIUI's "real data" Vite plugins are confirmed `configureServer`-only or `configureServer`+`configurePreviewServer`-only (verified per-file), so none run against the static `dist/` a node actually serves. `vite-fs.ts`'s hardcoded `/Users/dorian/Projects` is further confirmation this was never meant to reach a node. The grids' prop shapes (`Film`, `Song`, `Podcast`, etc. — all display-oriented with `posterUrl`/`coverUrl`/`sources[]`) do not match Archy's `ContentItem` (`id`, `filename`, `mime_type`, `access`, `availability`) — an adapter layer is required, not a straight pass-through.
|
||||
|
||||
**Primary recommendation:** Build one new node-side "assistant" service module (extending, not replacing, `mesh/listener/assist.rs`'s backend-calling code) that owns a hand-written tool registry, a multi-turn loop per backend, and new RPC methods (`assistant.chat`, `assistant.confirm-tool`, `assistant.list-tools`, `assistant.history`) reached only via neode-ui's `contextBroker.ts` (new `chat:request`/`chat:response`/`tool:confirm-request` postMessage types) — never by AIUI fetching the RPC endpoint directly, even though nothing currently stops it (see the Same-Origin Sandbox Gap finding). Ship content surfaces as a straight `content.*` → grid adapter first (D-12, no new backend work beyond what exists), then land the music library (D-13) as its own wave using `lofty` for tag extraction. Treat the currently-live Claude proxy exposure and the iframe's lack of a hard sandbox boundary as findings the plan must explicitly decide how to handle (fix, accept-with-mitigation, or defer with a named risk) rather than silently working around.
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Agent loop (model call → tool call → result) | API/Backend (Rust daemon) | — | D-01: key + tool authority must stay server-side |
|
||||
| Tool registry + permission scoping | API/Backend (Rust daemon) | — | D-06/D-09: curated allowlist, RBAC-adjacent, must not be derivable from the browser |
|
||||
| Chat transport (AIUI ↔ Archy) | Browser/Client (postMessage bridge) | API/Backend (RPC over the neode-ui session) | AIUI has no session cookie path of its own by design; neode-ui's `contextBroker.ts` is the only channel today, and D-03 keeps it that way |
|
||||
| Write-confirmation UI | Frontend Server / Browser (neode-ui trusted chrome) | — | D-11: must render outside the iframe, Teleport-to-body, drawn from node-authored text |
|
||||
| Browser-only actions (navigate, open-app, theme) | Browser/Client (`ContextBroker`) | — | D-03: nothing server-side can perform a client-side navigation |
|
||||
| Routstr payment (Cashu token build) | API/Backend (Rust daemon, `swarm::payment`) | — | Wallet/mint state is server-side; reuses existing `auto_pay_token` |
|
||||
| Routstr provider discovery (Nostr) | API/Backend (Rust daemon, `nostr-sdk`) | — | Relay connections should route through the node's existing Tor-proxy-aware Nostr client, not the browser |
|
||||
| Content surfaces (peer files, IndeeHub, paid content) | API/Backend (`content_server.rs`, `content.*` RPCs) | Browser/Client (AIUI grids, prop-adapted) | Data ownership and access control (`AccessControl::Paid`) must stay server-enforced; AIUI only renders |
|
||||
| Music library index | Database/Storage (`/var/lib/archipelago`) + API/Backend (indexer) | Browser/Client (`SongGrid` consumer) | D-13: a persisted, migration-sensitive data model — indexing must not run in the browser |
|
||||
| Media playback (Range streaming) | API/Backend (existing `/content/<id>`, `/api/raw`, peer proxy) | Browser/Client (`GlobalAudioPlayer`) | Already solved; grids need data, not a new transport |
|
||||
| AIUI static delivery | CDN/Static (nginx `/aiui/` location, frontend rsync) | — | D-15: built artifact, not a live service |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `reqwest` | 0.11 (already in `archipelago/Cargo.toml`, `rustls-tls`+`socks`+`json`+`stream` features) | HTTP client for Ollama/Claude/Routstr calls | Already the codebase's only HTTP client; `socks` feature already present for Tor-proxied calls |
|
||||
| `serde_json` | 1.0 (in-tree) | Tool-call schema construction, RPC params | Already universal in this codebase |
|
||||
| `nostr-sdk` | 0.44 (in-tree, `nip04`+`nip44` features) | Routstr provider discovery (kind 38421 subscribe) | Already a dependency with a working Tor-aware client builder (`nostr_discovery.rs::build_nostr_client`) — no new crate needed |
|
||||
| `tokio` | 1, `full` features (in-tree) | Async runtime for the multi-turn tool loop | Already universal |
|
||||
|
||||
### Supporting (new, for the music library — D-13)
|
||||
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `lofty` [ASSUMED — training-knowledge recommendation, not yet added to Cargo.toml; registry existence confirmed] | 0.24 (crates.io `max_version`, **VERIFIED: crates.io API**, 808K downloads, repo `github.com/Serial-ATA/lofty-rs`) | Read ID3/FLAC/M4A/OGG/WAV/APE tag metadata (title/artist/album/track/duration) in one unified API | Primary recommendation for D-13's tag extraction — broad multi-format support in one crate, avoids needing a separate parser per container format |
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| `lofty` (metadata-only) | `symphonia` (0.6, **VERIFIED: crates.io**, 9.4M downloads, `github.com/pdeljanov/Symphonia`) | Symphonia is a full audio *decoder* (needed for playback/transcoding, not tagging) — much heavier dependency surface for a job that's purely "read tags." Not needed here since playback already goes through the existing Range-streaming path, not server-side decode. |
|
||||
| `lofty` (multi-format) | `id3` (1.17.1, **VERIFIED: crates.io**, 11.2M downloads, `codeberg.org/polyfloyd/rust-id3`) | ID3-only (MP3). Higher download count reflects broad MP3-tagging use elsewhere, not superiority for a library that must also cover FLAC/M4A/OGG. |
|
||||
| Curated hand-written tool allowlist (D-06, locked) | Auto-generate tool schemas from `dispatcher.rs`'s method table | Explicitly rejected by D-06 — the model must never see the full RPC surface; every capability must be a deliberate decision |
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
# cargo add is run from core/ per CLAUDE.md
|
||||
cd core && cargo add lofty --package archipelago
|
||||
```
|
||||
|
||||
**Version verification:** `lofty` 0.24.0, `symphonia` 0.6.0, `id3` 1.17.1 confirmed live via the crates.io API (`crates.io/api/v1/crates/<name>`) on 2026-08-03 — **VERIFIED: crates.io registry**, not merely a training-data guess. `reqwest`/`serde_json`/`nostr-sdk`/`tokio` versions read directly from `core/archipelago/Cargo.toml` — **VERIFIED: in-tree Cargo.toml**.
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|
||||
|---------|----------|-----|-----------|-------------|---------|-------------|
|
||||
| `lofty` | crates.io | Long-running project (Serial-ATA/lofty-rs, active) | 808,246 total | github.com/Serial-ATA/lofty-rs | Not run through `gsd-tools query package-legitimacy check` in this session (tool unavailable in this environment) — manually checked: real GitHub org, active repo, substantial download count, no suspicious signals found | `[ASSUMED — recommend a `checkpoint:human-verify` before `cargo add`]` |
|
||||
| `symphonia` | crates.io | Long-running (pdeljanov/Symphonia) | 9,452,628 total | github.com/pdeljanov/Symphonia | Same manual-check basis — not needed for this phase's scope (tagging only), listed for completeness | Not adopted — informational only |
|
||||
| `id3` | crates.io | Long-running (rust-id3) | 11,202,064 total | codeberg.org/polyfloyd/rust-id3 | Same manual-check basis | Not adopted — informational only |
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none.
|
||||
**Packages flagged as suspicious [SUS]:** none by manual inspection, but `lofty` was not run through the automated `package-legitimacy check` seam (tool unavailable in this research session) — the plan must gate its `cargo add` behind a `checkpoint:human-verify` per the package-legitimacy protocol's own fallback rule for `[ASSUMED]` packages.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```text
|
||||
Browser (neode-ui page, authenticated session)
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Chat.vue │
|
||||
│ ┌───────────────────────────────┐ postMessage (same-origin, │
|
||||
│ │ <iframe src="/aiui/..."> │◄──origin-checked by broker)──┐ │
|
||||
│ │ AIUI (thin chat client) │ │ │
|
||||
│ │ - renders chat UI │──chat:request (userText)───►│ │
|
||||
│ │ - NO model key, NO RPC │◄─chat:response (token/done)─┤ │
|
||||
│ │ session of its own │ │ │
|
||||
│ │ - content grids (prop-fed) │◄─context:response (films, │ │
|
||||
│ │ │ songs, ...)────────────────┤ │
|
||||
│ └───────────────────────────────┘ │ │
|
||||
│ ▼ │
|
||||
│ ContextBroker (contextBroker.ts) ── rpcClient.call() ── uses the │
|
||||
│ page's OWN session cookie + CSRF token (same auth as every other │
|
||||
│ neode-ui RPC call) │
|
||||
│ │ │
|
||||
│ │ new: assistant.chat / assistant.confirm-tool / │
|
||||
│ │ content.* / streaming.* RPCs (HTTP POST, session-gated) │
|
||||
└───────────┼───────────────────────────────────────────────────────────┘
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
│ archipelago daemon (Rust) │
|
||||
│ │
|
||||
│ api::rpc::dispatcher — session + CSRF + RBAC gate (mod.rs:264-330) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ NEW: assistant service (extends mesh/listener/assist.rs's backend │
|
||||
│ callers) — owns: │
|
||||
│ - tool registry (D-06 curated allowlist, permission-tagged) │
|
||||
│ - multi-turn loop per backend (Ollama /api/chat tools, │
|
||||
│ Claude Messages API tool_use, Routstr OpenAI-shape) │
|
||||
│ - pending-confirmation queue (D-11: node authors the confirm text) │
|
||||
│ - chat history persisted under data_dir (D-08) │
|
||||
│ │ │ │
|
||||
│ ▼ tool call, permission-checked ▼ pending write │
|
||||
│ existing RPC handlers (system.*, package.*, → confirm:request │
|
||||
│ container-*, bitcoin.*, content.*, mesh.*) pushed to broker → │
|
||||
│ — the SAME handlers every other authenticated neode-ui trusted │
|
||||
│ caller uses, no new "AI-only" backdoor chrome modal │
|
||||
│ │
|
||||
│ Backend selection: Ollama (local, free) → Claude (secrets/claude- │
|
||||
│ api-key) → Routstr (Nostr-discovered provider + Cashu budget via │
|
||||
│ swarm::payment::auto_pay_token, D-05 hard cap) │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
core/archipelago/src/
|
||||
├── assistant/ # NEW — the D-02 shared service
|
||||
│ ├── mod.rs # public API: chat(), confirm_tool(), list_tools()
|
||||
│ ├── tools.rs # D-06 curated tool registry + schemas
|
||||
│ ├── backends/
|
||||
│ │ ├── ollama.rs # /api/chat with tools[] (extends assist.rs::call_ollama)
|
||||
│ │ ├── claude.rs # Messages API with tools[] / tool_use blocks
|
||||
│ │ └── routstr.rs # OpenAI-shape POST + Cashu payment attach
|
||||
│ ├── loop_.rs # multi-turn tool-call loop, backend-agnostic
|
||||
│ ├── confirm.rs # D-11 pending-confirmation queue
|
||||
│ └── history.rs # D-08 node-side chat persistence
|
||||
├── api/rpc/
|
||||
│ └── assistant_chat.rs # NEW RPC handlers: assistant.chat, .confirm-tool, etc.
|
||||
├── music/ # NEW — D-13 music library (own wave)
|
||||
│ ├── index.rs # on-disk index format + freshness
|
||||
│ ├── tags.rs # lofty-based extraction
|
||||
│ └── mod.rs
|
||||
neode-ui/src/
|
||||
├── services/contextBroker.ts # EXTENDED — new chat:*, tool:confirm-* message types
|
||||
├── types/aiui-protocol.ts # EXTENDED — new AIUIRequest/ArchyResponse variants
|
||||
└── components/ # NEW — trusted-chrome confirm modal (Teleport to body)
|
||||
```
|
||||
|
||||
### Pattern 1: Tool-call confirmation via node-authored text (D-11)
|
||||
|
||||
**What:** The Rust assistant, not AIUI and not the model's raw text, writes the human-readable description of a pending destructive action. That description is pushed to neode-ui via a new postMessage type; neode-ui renders it in a Teleport-to-body modal outside the iframe; the user's yes/no is sent back over the same authenticated RPC channel (not postMessage) so the iframe cannot forge it.
|
||||
|
||||
**When to use:** Every tool call where `destructive: true` or `confirm: true` in the D-06 tool schema — which per D-07 is *every write*, regardless of backend.
|
||||
|
||||
**Example (extends the existing install-app pattern, `contextBroker.ts`):**
|
||||
```typescript
|
||||
// Source: existing pattern at neode-ui/src/services/contextBroker.ts:140-196
|
||||
// (install-app confirm flow — the model to extend for tool-call confirms)
|
||||
window.dispatchEvent(new CustomEvent('aiui:install-request', {
|
||||
detail: { requestId: id, appId, marketplaceUrl, version },
|
||||
}))
|
||||
const responseHandler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail as { requestId: string; confirmed: boolean }
|
||||
if (detail.requestId !== id) return
|
||||
window.removeEventListener('aiui:install-response', responseHandler)
|
||||
// ... proceed or decline
|
||||
}
|
||||
window.addEventListener('aiui:install-response', responseHandler)
|
||||
setTimeout(() => window.removeEventListener('aiui:install-response', responseHandler), 60000)
|
||||
```
|
||||
The new tool-confirm flow should NOT reuse `aiui:install-request` (that event is install-specific); it needs its own `aiui:tool-confirm-request`/`response` pair, driven by RPC-fetched (not postMessage-fetched) pending-action text so the iframe cannot inject the description.
|
||||
|
||||
### Pattern 2: Backend-agnostic tool-call loop shape
|
||||
|
||||
**What:** Ollama's `/api/chat` (not `/api/generate`, which `call_ollama` uses today) accepts a `tools` array of `{type: "function", function: {name, description, parameters}}` and returns `message.tool_calls`. Anthropic's Messages API accepts `tools: [{name, description, input_schema}]` and returns `content` blocks of `type: "tool_use"`; the loop must send a follow-up `tool_result` content block keyed by `tool_use_id`. Both require re-invoking the backend after executing the tool, i.e. a real loop rather than the single `call_ollama`/`call_claude` request-response used by mesh assist today.
|
||||
**When to use:** All three backends (Ollama, Claude, Routstr — Routstr is OpenAI-compatible, so its tool-calling shape matches OpenAI's `tools`/`tool_calls`, distinct from both Ollama's and Anthropic's shapes — three distinct wire formats to normalize).
|
||||
**Note:** `assist.rs`'s `OLLAMA_TIMEOUT` (60s) and `MAX_REPLY_CHARS`/chunking constants are mesh-airtime-specific and should NOT be reused as-is for the AIUI path, which has no radio bandwidth constraint — the new assistant module needs its own timeout/streaming constants.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Auto-generating tool schemas from `dispatcher.rs`'s method table:** explicitly rejected by D-06. Every tool must be a hand-written, reviewed decision — this is the only way "the model never sees the full RPC surface" stays true rather than becoming an implementation detail nobody re-checks.
|
||||
- **AIUI fetching `/rpc` (or any authenticated endpoint) directly:** nothing in the current CSP or iframe attributes technically prevents this (see "Same-Origin Sandbox Gap" below) — but doing so would make the browser-side `ContextBroker`/`aiui-protocol.ts` sandbox purely decorative. All new capability must be added as new postMessage message types, never as a new same-origin fetch from AIUI's own code.
|
||||
- **Reusing `data_dir/secrets/claude-api-key` as the ONLY key ledger while the `claude-api-proxy.py`/port-3142 path with its separate `ANTHROPIC_API_KEY` env var still exists:** two live Claude credential paths with different auth postures is itself a landmine (see below) — the plan must decide to retire, consolidate, or explicitly gate the legacy proxy, not silently leave both running.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Cashu token construction for Routstr payment | A new BDHKE/Cashu wallet client | `crate::wallet::ecash` + `crate::swarm::payment::auto_pay_token` (already in-tree, already budget-capped, already degrades to `None` on any failure) | Exact fit for D-05's "prepaid budget, silent spend, hard stop" requirement — already tested (`over_budget_declines_without_touching_wallet`, `zero_budget_is_origin_only`) |
|
||||
| Nostr provider discovery (Routstr kind 38421) | A raw WebSocket relay client | `nostr-sdk = "0.44"` + `nostr_discovery.rs::build_nostr_client` (Tor-proxy aware) | Already a dependency, already has the Tor-routing pattern this codebase requires for all Nostr traffic |
|
||||
| Audio/video tag extraction for the music library | A hand-rolled ID3/FLAC/MP4 parser | `lofty` (D-13) | Multi-format tag parsing is a well-solved, edge-case-heavy problem (ID3v1 vs v2.2/2.3/2.4, FLAC Vorbis comments, MP4 atoms) — not worth re-implementing |
|
||||
| Confirmation UI anti-spoofing | A new "trust the iframe's postMessage payload" confirm dialog | The existing Teleport-to-body / outside-iframe pattern (D-11), extending `aiui:install-request`'s shape | The codebase already has one correct instance of this pattern; a second bespoke one risks diverging in a security-relevant way |
|
||||
|
||||
**Key insight:** almost every primitive D-04/D-05/D-13 need already exists somewhere in this codebase in a slightly different shape (mesh assist's backend calls, swarm's payment auto-pay, the install-app confirm flow, the FileBrowser scoped-token pattern). The phase's real net-new work is a **tool-calling loop** and a **grid-data adapter** — not new payment/discovery/confirmation primitives.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Treating the live `claude-api-proxy.py` (port 3142) as dormant or as "the Claude assistant"
|
||||
|
||||
**What goes wrong:** A plan that assumes "AIUI's Claude chat isn't wired to anything yet" will miss that on any node where `setup-aiui-server.sh` has run, `/aiui/api/claude/` is a **working, unauthenticated** passthrough to `api.anthropic.com` using a node-owner-funded key, entirely bypassing the Rust RPC auth stack. This is verified in `image-recipe/configs/nginx-archipelago.conf:49-60` and `scripts/deploy-to-target.sh:875-940` (the embedded `claude-api-proxy.py`).
|
||||
**Why it happens:** The proxy was built as a pragmatic stopgap to get AIUI's chat "working" during the demo/UI-design phase (Phase 2), predating any of the D-01..D-11 security decisions this phase makes.
|
||||
**How to avoid:** The plan must explicitly decide what happens to this proxy: (a) delete it and the nginx location block once `assistant.chat` (D-01) exists, (b) gate it behind session auth as an interim step, or (c) something else — but it cannot be silently left running alongside the new authenticated path, or the phase ships a second, worse, unauthenticated door into the same capability it just spent effort locking down.
|
||||
**Warning signs:** Any verification step that only tests the *new* `assistant.chat` RPC's auth and never checks whether `/aiui/api/claude/` is still reachable unauthenticated is incomplete.
|
||||
|
||||
### Pitfall 2: Assuming the iframe boundary is a hard sandbox
|
||||
|
||||
**What goes wrong:** AIUI-04 ("sandboxed by construction") is easy to read as "the browser enforces this." Verified from source: the AIUI iframe (`Chat.vue:34-42`) has **no `sandbox` attribute**, is served **same-origin** (`/aiui/`, confirmed via `aiuiUrl` computed and the nginx `location /aiui/` block sharing the same server block as neode-ui), and the site's CSP (`connect-src 'self' ws: wss: http://$host:* https:`) does not restrict same-origin fetches. This means AIUI's own JavaScript, running in the user's authenticated session, is not browser-prevented from calling `/rpc` directly with the ambient session cookie — the entire "AIUI never gets an RPC session" property is a **code-discipline convention** (AIUI's code simply doesn't do this today), not an enforced boundary.
|
||||
**Why it happens:** The embed was built for a same-origin production deploy (nginx path-based routing) specifically so cookies/theming could flow naturally — origin isolation was never a design goal until this phase's threat model made it one.
|
||||
**How to avoid:** The plan needs to explicitly decide the sandbox's actual mechanism: a `sandbox` iframe attribute (careful — `allow-scripts allow-same-origin` together is a well-known escape pattern and must NOT both be set unless there is a compensating origin split), a stricter `connect-src` CSP scoped only to the `/aiui/` response (e.g. disallow `connect-src` to `/rpc` from that document), or accepting the convention-based boundary explicitly as a residual risk with compensating controls (e.g. server-side rate limiting / anomaly detection on `assistant.chat` regardless of caller). Silence on this in the plan is itself a gap.
|
||||
**Warning signs:** A plan that says "AIUI can't reach the RPC surface" without naming the specific enforcement mechanism.
|
||||
|
||||
### Pitfall 3: Conflating Pine's "intent→action path" with a working action-executing loop
|
||||
|
||||
**What goes wrong:** Scoping AIUI-01 as "expose what Pine already does for voice" undersells the actual work: Pine's HA-side intents (`package/pine_ha.rs`) are four **hardcoded, read-only** Q&A intents (block height/peers/sync/balance) resolved from REST-sensor state, not a general tool-calling framework, and they never write to the node.
|
||||
**Why it happens:** CONTEXT.md's phrasing ("the Pine stack already proves the intent→action path exists for voice") reads as if a generalized action framework exists; verification shows only Q&A exists anywhere in this codebase today (mesh assist AND Pine/HA).
|
||||
**How to avoid:** Scope AIUI-01's tool-calling loop as **genuinely new engineering** (the first action-executing agent loop in this codebase), not as "extending an existing action mechanism." The reusable parts are the backend-calling code shape (`call_ollama`/`call_claude`) and the permission-gating pattern (`is_sender_allowed`), not an existing loop.
|
||||
**Warning signs:** A plan task that says "wire AIUI into the existing Pine action framework" — there isn't one to wire into.
|
||||
|
||||
### Pitfall 4: Assuming AIUI's grid components can consume `ContentItem` directly
|
||||
|
||||
**What goes wrong:** `content_server.rs::ContentItem` (`id`, `filename`, `mime_type`, `size_bytes`, `description`, `access`, `availability`, `added_at`) has no overlap in shape with AIUI's `Film`/`Song`/`Podcast` types (`posterUrl`, `coverUrl`, `sources: FilmSource[]` with `type: 'plex'|'nextcloud'|...`, `genres`, `runtime`, `director`, etc.). A plan that treats this as "just point the grid at the RPC" will produce broken/empty cards.
|
||||
**Why it happens:** AIUI's types were designed for a rich third-party metadata catalog (TMDB-style); Archy's content model is a generic file-sharing record with access control.
|
||||
**How to avoid:** Build an explicit adapter layer (Rust RPC response shape → AIUI prop shape, or a thin mapping function on the AIUI side) as its own task, with test fixtures pinning the mapping (e.g., what `sources[].type` value represents "this node's own file" vs "a peer's file" vs "IndeeHub").
|
||||
**Warning signs:** A plan that has no explicit "map ContentItem → Film/Song" task.
|
||||
|
||||
### Pitfall 5: The `filebrowser-client.ts` JWT-in-URL pattern being copied for the new tool/content RPCs
|
||||
|
||||
**What goes wrong:** `filebrowser-client.ts::streamUrl()` embeds a JWT in the query string (`?auth=${token}`), justified as "short-lived JWT so exposure in URL is acceptable" — but CONTEXT.md flags this as "the known leak to resolve rather than propagate." A new content-streaming path built by copying this pattern propagates the same leak (URLs land in browser history, server access logs, Referer headers).
|
||||
**Why it happens:** It's the path of least resistance for `<audio>`/`<video>` `src` attributes, which cannot set custom headers.
|
||||
**How to avoid:** For any new streaming URL construction in this phase (peer content via AIUI, paid content), prefer the existing `fetchBlobUrl()`-style header-auth pattern where the consumer can set headers, and where a `<source>`/`<video>` element is unavoidable, scope the token tightly (single-resource, single-use) rather than reusing the general FileBrowser session token.
|
||||
**Warning signs:** Any new `...&auth=${token}` or `...&token=${token}` string concatenation for a long-lived credential.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### The Ollama single-shot call being replaced (what NOT to build on top of as-is)
|
||||
```rust
|
||||
// Source: core/archipelago/src/mesh/listener/assist.rs:429-451 (VERIFIED, read in full)
|
||||
async fn call_ollama(model: &str, prompt: &str) -> anyhow::Result<String> {
|
||||
let client = reqwest::Client::builder().timeout(OLLAMA_TIMEOUT).build()?;
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": false,
|
||||
});
|
||||
let resp = client.post(OLLAMA_URL).json(&body).send().await?;
|
||||
// ... no `tools` field, no multi-turn loop — /api/generate, not /api/chat
|
||||
}
|
||||
```
|
||||
|
||||
### The existing budget-capped Cashu payment primitive (reusable for Routstr, D-05)
|
||||
```rust
|
||||
// Source: core/archipelago/src/swarm/payment.rs:77-101 (VERIFIED, read in full)
|
||||
pub async fn auto_pay_token(
|
||||
data_dir: &Path,
|
||||
policy: &PaymentPolicy, // budget_sats + max_fee_sats
|
||||
accepted_mints: &[String],
|
||||
price_sats: u64,
|
||||
) -> Result<Option<String>> {
|
||||
if !policy.affords(price_sats) { return Ok(None); } // hard cap, D-05
|
||||
match ecash::build_payment_token(data_dir, accepted_mints, price_sats, policy.max_fee_sats).await {
|
||||
Ok(token) => Ok(Some(token)),
|
||||
Err(e) => Ok(None), // never errors on a wallet/mint problem — origin always wins
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### The nginx block that must be reconciled with D-01 (the currently-live unauthenticated proxy)
|
||||
```nginx
|
||||
# Source: image-recipe/configs/nginx-archipelago.conf:49-60 (VERIFIED, canonical production config)
|
||||
location /aiui/api/claude/ {
|
||||
proxy_pass http://127.0.0.1:3142/; # claude-api-proxy.py, own ANTHROPIC_API_KEY, no auth check
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
```
|
||||
|
||||
### Routstr chat-completions call shape (CITED: docs.routstr.com, not independently tested)
|
||||
```
|
||||
POST https://api.routstr.com/v1/chat/completions
|
||||
Authorization: Bearer cashuAeyJ0... (or: X-Cashu: cashuAeyJ0...)
|
||||
Content-Type: application/json
|
||||
|
||||
{"model":"gpt-4","messages":[{"role":"user","content":"..."}],"stream":false}
|
||||
```
|
||||
Discovery: Nostr kind `38421`, tags including `["d","routstr-provider"]`, content carrying `endpoints` (http/onion), `models`, `pricing`. Default relays cited in docs: `wss://relay.damus.io`, `wss://relay.nostr.band`, `wss://nos.lol` — **[CITED: docs.routstr.com — MEDIUM confidence, not cross-verified against a second source or a live provider event]**.
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|---------------|--------|
|
||||
| Mesh assist: `/api/generate`, single prompt, no tools | This phase: `/api/chat` with `tools[]`, multi-turn loop | This phase (net-new) | Ollama backend needs its own request builder distinct from `call_ollama` |
|
||||
| AIUI chat → nginx proxy → Anthropic directly (client-vaulted or proxy-baked key) | AIUI chat → postMessage → neode-ui RPC (session-authed) → Rust assistant service → model | This phase (D-01) | The nginx `/aiui/api/claude/`, `/aiui/api/openrouter/`, `/aiui/api/ollama/` proxy blocks become legacy/dead once migrated — must be explicitly retired or gated, not left dangling |
|
||||
| AIUI content grids fed by regex-parsed model prose (`updatePanelFromText`) against fixture catalogs baked into the system prompt | Grids fed by real `content.*` RPC data via an adapter | This phase (D-12) | System prompt shrinks (no more `filmContext`/`songContext`/`podcastContext` fixture dumps for Archy-sourced content — though AIUI's general recommendation feature for content NOT on this node may still want some fixture/tag mechanism, that's a design choice for the plan) |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- `claude-api-proxy.py` (port 3142) and its nginx blocks — once `assistant.chat` exists, this is a strictly worse, unauthenticated duplicate of the same capability and should not coexist indefinitely.
|
||||
- AIUI's `vite-fs`/`vite-tmdb`/`vite-rss`/`vite-web-search`/`vite-music-search`/`vite-dev-chats` middleware as a "real data" story for production — confirmed dev-only; only the D-12-covered slice (Archy content) gets a production answer this phase, the rest stays explicitly deferred per CONTEXT.md.
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | `lofty` is the right tag-extraction crate for D-13 | Standard Stack / Don't Hand-Roll | Low — swappable later since it's an internal indexer implementation detail behind the music index's own schema; D-13's own note already flags the entity model (not the crate) as the one-way cost |
|
||||
| A2 | Routstr's exact wire contract (headers, kind 38421 tag names, default relay list) as CITED from `docs.routstr.com` | Code Examples / State of the Art | Medium — if the docs site's content has drifted from the actual `routstr-core` implementation, the Rust client's header names or the Nostr filter subscription could be wrong on first integration attempt; the plan should budget a task to test against a real Routstr provider event before hand-writing the full client, not just against the docs |
|
||||
| A3 | No `gsd-tools package-legitimacy check` was run against `lofty`/`symphonia`/`id3` (tool unavailable in this research session) — legitimacy assessed by manual crates.io inspection only | Package Legitimacy Audit | Low-Medium — crates.io download counts and repo links were checked directly via the crates.io API, which is the same signal the automated check would use, but the automated seam's full heuristic set was not run |
|
||||
| A4 | The `claude-api-proxy.py`/port-3142 path is reachable without authentication on **every** node that has run `setup-aiui-server.sh`, not just the specific nodes checked in this session | Common Pitfalls / Summary | High if wrong in the safe direction (i.e. if some nodes actually do have it gated some other way this research didn't find) — but the canonical `image-recipe/configs/nginx-archipelago.conf` (the ISO-shipped, non-manual-script config) has no gate either, so this is the default state for any node built from the current image recipe, which is HIGH confidence, not just this-node-specific |
|
||||
|
||||
**If this table is empty:** N/A — see entries above; none are structural blockers, but A2 and A4 both warrant explicit plan tasks (a live-Routstr-provider smoke test; an audit of which fleet nodes currently expose the unauthenticated Claude proxy).
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **What happens to the port-3142 `claude-api-proxy.py` and its nginx blocks?**
|
||||
- What we know: it is live, unauthenticated, and holds its own API key separate from `secrets/claude-api-key`.
|
||||
- What's unclear: whether any currently-deployed node's users rely on it continuing to work exactly as-is during the migration window, and whether deleting it is this phase's job or a follow-up.
|
||||
- Recommendation: the plan should make an explicit decision (delete-and-replace vs. gate-then-deprecate) with a checkpoint, not leave it implicit.
|
||||
|
||||
2. **What is the actual enforcement mechanism for AIUI-04's "sandboxed by construction"?**
|
||||
- What we know: today there is no `sandbox` iframe attribute, no origin split, and a permissive same-origin CSP.
|
||||
- What's unclear: whether the plan should add a `sandbox` attribute (and handle the `microphone` permission + `allow-same-origin` interaction correctly), tighten CSP for the `/aiui/` response specifically, or explicitly accept the convention-based boundary with compensating server-side controls.
|
||||
- Recommendation: name this as its own task with a concrete decision, since D-11's whole premise ("the iframe cannot spoof... the confirmation dialog") assumes the postMessage channel is the only channel — which is true only by convention today.
|
||||
|
||||
3. **Is Routstr's documented protocol (kind 38421, header names) accurate against the live `routstr-core`/`routstrd` implementation?**
|
||||
- What we know: `docs.routstr.com` describes the shape (CITED, medium confidence).
|
||||
- What's unclear: whether a live provider on the default relays actually publishes exactly this event shape today, given Routstr is a young, actively-developed project.
|
||||
- Recommendation: budget an early spike task that subscribes to the real relays and inspects at least one live kind-38421 event before writing the parser against the docs alone.
|
||||
|
||||
4. **Does the RBAC `role.can_access(&method)` check apply to whatever new `assistant.*` RPC methods this phase adds, and should it?**
|
||||
- What we know: every existing authenticated RPC method goes through `user.role.can_access(&rpc_req.method)` (`api/rpc/mod.rs:296-307`).
|
||||
- What's unclear: whether the AI permission-category model (10 categories, D-16) should be layered on top of, integrated with, or kept fully separate from the existing role/RBAC system.
|
||||
- Recommendation: the planner should decide explicitly rather than let this fall out implicitly from wherever the new RPC methods happen to get registered.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| `cargo` / Rust toolchain | All Rust-side work | ✓ | 1.95.0 (VERIFIED, `cargo --version`) | — |
|
||||
| `~/Projects/AIUI` clone, `development` branch | All AIUI-side work | ✓ | HEAD `6e8b96d`, clean working tree (VERIFIED, `git status`/`git log`) | — |
|
||||
| Push access to `git.tx1138.com/lfg2025/AIUI` | Landing AIUI-side commits (D-18) | Per phase brief: CONFIRMED by orchestrator | — | — |
|
||||
| Ollama (local LLM) | D-04 primary backend | Not probed on a live node in this research session (no node reachable from this environment) | — | Detection code (`detect_ollama()`, `mesh/rpc/mesh/assistant.rs:164-192`) already exists and reports `ollama_detected`/`models` — reuse rather than re-probe |
|
||||
| A live Routstr provider (for protocol verification) | Open Question 3 | ✗ (not reachable from this research environment) | — | Docs-only (CITED) until a spike task runs against real relays |
|
||||
| Node's own `data_dir/secrets/claude-api-key` | Existing mesh assist Claude backend | Not probed (no live node in this environment) | — | Mesh assist code already handles its absence gracefully (`call_claude` returns an error, caught by `run_assist`) |
|
||||
|
||||
**Missing dependencies with no fallback:** none — every dependency either has an existing detection/fallback path in-tree or is deferred to a named spike task (Open Question 3).
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework (Rust) | `cargo test` (in-tree unit/integration tests, e.g. `swarm/payment.rs`'s `#[tokio::test]` suite, `pine_ha.rs`'s `#[test]` suite) |
|
||||
| Framework (neode-ui) | Vitest 3.1 (`neode-ui/package.json` — `"test": "vitest run"`), existing `contextBroker.test.ts`/`chatAiuiEmbed.test.ts` to keep green |
|
||||
| Framework (AIUI) | Not yet inspected in this research pass — `packages/app/src/__tests__/` and `composables/__tests__/` exist (`contentExtraction.test.ts`, `useAI.test.ts`) — planner should confirm AIUI's own `package.json` test command before relying on it |
|
||||
| Config file | `core/archipelago/Cargo.toml` (Rust); `neode-ui/vitest.config.ts` (frontend) |
|
||||
| Quick run command | `cd core && cargo test --package archipelago assistant::` (once the module exists); `cd neode-ui && npx vitest run src/services/__tests__/contextBroker.test.ts` |
|
||||
| Full suite command | `cd core && cargo test` (release-profile per `CARGO_INCREMENTAL=0` if lld errors appear, per CLAUDE.md); `cd neode-ui && npm run test` |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| AIUI-01 | A typed chat request executes a real read-only tool (e.g. "how much space is left" → `system.disk-status`) and returns the real result | integration (Rust) | `cargo test assistant::tests::disk_status_tool_executes` | ❌ Wave 0 — module doesn't exist yet |
|
||||
| AIUI-01 | A typed chat request for a write action (e.g. "restart bitcoin") produces a pending confirmation, NOT an executed action, until confirmed | integration (Rust) + component (Vue) | `cargo test assistant::tests::destructive_tool_requires_confirm`; `npx vitest run src/services/__tests__/toolConfirm.test.ts` | ❌ Wave 0 |
|
||||
| AIUI-01 | An unauthenticated caller cannot reach any new `assistant.*` RPC method | integration (Rust) | `cargo test rpc::middleware::tests::assistant_methods_require_session` | ❌ Wave 0 |
|
||||
| AIUI-02 | A conversational settings change (`system.settings.set` via tool call) is scoped to a granted permission category and refused when not granted | unit (Rust) | `cargo test assistant::tools::tests::settings_tool_respects_category_grant` | ❌ Wave 0 |
|
||||
| AIUI-03 | `content.*` RPC data renders correctly in `FilmGrid`/`SongGrid` via the new adapter (regression-pins the mapping named in Pitfall 4) | unit (Vue/TS) | `npx vitest run src/composables/__tests__/archyContentAdapter.test.ts` | ❌ Wave 0 |
|
||||
| AIUI-04 | AIUI's own code cannot reach `/rpc` or any authenticated endpoint directly (whatever mechanism Open Question 2 resolves to) | integration/manual (per chosen mechanism) | Depends on Open Question 2's resolution | ❌ Wave 0 — mechanism undecided |
|
||||
| AIUI-05 | AIUI's build enforces `VITE_BASE_PATH=/aiui/` and a post-deploy check fetches a live asset by hash | shell/CI | `scripts/build-aiui.sh` (new) exits non-zero if `VITE_BASE_PATH` unset; post-deploy `curl` check on a known asset path | ❌ Wave 0 — no such script exists today |
|
||||
| AIUI-06 | Manual UAT: embedded iframe on archi-dev-box, desktop + mobile viewport | manual | N/A — real-device verification, not automatable | — |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** the relevant quick-run command for the touched module (Rust `assistant::` tests, or the specific Vitest file).
|
||||
- **Per wave merge:** full `cargo test` + full `npm run test` (neode-ui) + AIUI's own test command (to be confirmed).
|
||||
- **Phase gate:** full suite green, plus the AIUI-06 manual on-device pass on archi-dev-box (desktop and mobile), before `/gsd-verify-work`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `core/archipelago/src/assistant/mod.rs` + its `#[cfg(test)]` module — the entire tool-calling loop is net-new, zero existing test coverage.
|
||||
- [ ] `neode-ui/src/services/__tests__/toolConfirm.test.ts` — new confirm-flow coverage (extends the existing `contextBroker.test.ts` pattern).
|
||||
- [ ] `neode-ui/src/composables/__tests__/archyContentAdapter.test.ts` — pins the `ContentItem` → `Film`/`Song`/`Podcast` mapping (Pitfall 4).
|
||||
- [ ] `scripts/build-aiui.sh` (or equivalent) — does not exist; D-15's `VITE_BASE_PATH` enforcement and commit-pinning have no automated check today.
|
||||
- [ ] AIUI's own test command/framework — confirm before wave planning assumes Vitest parity (not verified in this research pass; AIUI's `package.json` was read for build scripts only).
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|----------------|---------|-------------------|
|
||||
| V2 Authentication | yes | New `assistant.*` RPCs go through the existing session-cookie + CSRF stack (`api/rpc/mod.rs:264-330`) — no bespoke auth |
|
||||
| V3 Session Management | yes | Chat history/pending-confirmation state must be scoped to the authenticated session/node, not a separate identity |
|
||||
| V4 Access Control | yes | D-06 curated tool allowlist + D-16 default-closed permission categories + (Open Question 4) RBAC integration decision |
|
||||
| V5 Input Validation | yes | D-10: peer-supplied content must enter the model context inside explicit untrusted-content delimiters; tool-call arguments from the model must be schema-validated against each tool's declared parameters before execution (not just trusted because the model emitted well-formed JSON) |
|
||||
| V6 Cryptography | yes | Routstr Cashu payments reuse `crate::wallet::ecash`/`bdhke.rs` — never hand-roll token construction; model API keys stay server-side (`secrets/claude-api-key` pattern) |
|
||||
| V13 API and Web Service | yes | The port-3142 `claude-api-proxy.py` is a standing V13 violation (unauthenticated proxy to a paid third-party API) that this phase's D-01 should resolve, per Common Pitfall 1 |
|
||||
|
||||
### Known Threat Patterns for this stack
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|----------------------|
|
||||
| Prompt injection via peer-supplied content (filenames, mesh chat, Nostr posts) driving unintended tool calls | Elevation of Privilege | D-10: untrusted-content delimiters + D-11: human confirmation naming the REAL action for every write, regardless of what the model claims it's doing |
|
||||
| Unauthenticated proxy to a paid API (the live port-3142 finding) | Spoofing / Elevation of Privilege / Denial of Service (budget exhaustion) | Retire or session-gate the legacy proxy (Common Pitfall 1) |
|
||||
| Iframe escaping its intended postMessage-only channel via ambient same-origin session cookie | Elevation of Privilege | Resolve Open Question 2 (sandbox attribute / CSP scoping / accepted residual risk with compensating controls) |
|
||||
| Routstr budget exhaustion via repeated/looped tool calls | Denial of Service (financial) | D-05's hard budget cap in `PaymentPolicy` — already proven to degrade to `None` rather than error, must be wired so the loop actually stops and surfaces to the user rather than silently retrying |
|
||||
| Confirmation-dialog spoofing (model-authored text presented as a system confirmation) | Spoofing / Tampering | D-11: node-authored text only, rendered outside the iframe |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence — read directly from source in this session)
|
||||
- `core/archipelago/src/mesh/listener/assist.rs` (full file) — `run_assist`, `is_sender_allowed`, `call_ollama`, `call_claude`
|
||||
- `core/archipelago/src/api/rpc/mesh/assistant.rs`, `core/archipelago/src/api/rpc/dispatcher.rs` (grepped in full for `pine.`/`mesh.assistant`/method registry)
|
||||
- `core/archipelago/src/api/rpc/pine_status.rs`, `core/archipelago/src/api/rpc/package/pine_ha.rs` (full files)
|
||||
- `core/archipelago/src/streaming/mod.rs`, `streaming/gate.rs`, `api/rpc/streaming.rs`, `swarm/payment.rs` (full files)
|
||||
- `core/archipelago/src/nostr_discovery.rs` (partial), `core/archipelago/Cargo.toml` (grepped)
|
||||
- `core/archipelago/src/api/rpc/middleware.rs`, `core/archipelago/src/api/rpc/mod.rs` (auth/CSRF/RBAC flow)
|
||||
- `core/archipelago/src/content_server.rs` (`ContentItem`/`AccessControl` structs)
|
||||
- `neode-ui/src/types/aiui-protocol.ts`, `neode-ui/src/services/contextBroker.ts` (full files)
|
||||
- `neode-ui/src/api/filebrowser-client.ts` (grepped), `neode-ui/src/views/Chat.vue` (partial)
|
||||
- `/home/archipelago/Projects/AIUI` (cloned repo, `development` branch, HEAD `6e8b96d`) — `packages/app/src/composables/useAI.ts`, `useArchy.ts` (full files), `contentExtraction.ts` (partial), `vite-fs.ts`/`vite-tmdb.ts`/`vite-rss.ts`/`vite-web-search.ts`/`vite-music-search.ts`/`vite-dev-chats.ts` (grepped for `configureServer`), `components/content/FilmGrid.vue`/`SongGrid.vue` (partial), `packages/core/src/types/content.ts` (partial)
|
||||
- `image-recipe/configs/nginx-archipelago.conf`, `scripts/setup-aiui-server.sh`, `scripts/deploy-to-target.sh` (all grepped/read for the AIUI/Claude-proxy deploy path)
|
||||
- `apps/aiui/manifest.yml` (full file)
|
||||
- crates.io API (`crates.io/api/v1/crates/lofty`, `/symphonia`, `/id3`) — **VERIFIED: crates.io registry**, queried live in this session
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- `docs.routstr.com` (`/`, `/api/endpoints/`, `/client/integration/`, `/provider/discovery/`) — fetched via WebFetch in this session; official documentation but not cross-verified against a live Routstr node or a second independent source — **[CITED: docs.routstr.com]**
|
||||
- `github.com/routstr` org listing — fetched via WebFetch; confirms no existing Rust SDK, so a hand-written `reqwest`-based client is the correct approach — **[CITED: github.com/routstr]**
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- None used as load-bearing claims; all `[ASSUMED]` items are logged in the Assumptions table above.
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack (Rust-side reuse: reqwest/nostr-sdk/swarm payment): HIGH — all read directly from in-tree source
|
||||
- Standard stack (lofty for music tagging): MEDIUM — crate choice is a training-knowledge recommendation cross-checked against live crates.io data, but not run through the automated package-legitimacy seam
|
||||
- Architecture (tool-calling loop, confirm-gate mechanism, content adapter need): HIGH — every claim traced to specific file:line evidence in both repos
|
||||
- Architecture (Routstr wire protocol): MEDIUM — CITED from official docs only, not independently protocol-tested
|
||||
- Pitfalls (live unauthenticated Claude proxy, same-origin sandbox gap, Pine-is-Q&A-only): HIGH — all independently re-derived from source, not merely repeating CONTEXT.md's claims (and in the live-proxy and same-origin cases, going beyond what CONTEXT.md flagged at all)
|
||||
|
||||
**Research date:** 2026-08-03
|
||||
**Valid until:** ~14 days for the Rust/neode-ui findings (stable, slow-moving codebase areas); ~7 days for the Routstr protocol claims (young, actively-developed external project — re-verify against a live relay before implementation) and for the AIUI repo state (actively developed, `development` branch may move).
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
---
|
||||
phase: 13
|
||||
slug: aiui-functional-conversational-node-control-and-content-surf
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: 2026-08-03
|
||||
---
|
||||
|
||||
# Phase 13 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
> Seeded from `13-RESEARCH.md` § Validation Architecture. Task IDs are filled in by the planner.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
This phase spans **three** test surfaces in **two** repositories.
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework (Rust)** | `cargo test` — in-tree unit/integration tests (precedent: `swarm/payment.rs` `#[tokio::test]`, `pine_ha.rs` `#[test]`) |
|
||||
| **Framework (neode-ui)** | Vitest 3.1 — `neode-ui/package.json` `"test": "vitest run"` |
|
||||
| **Framework (AIUI repo)** | ⚠️ UNCONFIRMED — `packages/app/src/__tests__/` and `composables/__tests__/` exist (`contentExtraction.test.ts`, `useAI.test.ts`) but the test command was not verified. **Wave 0 must confirm before any wave depends on it.** |
|
||||
| **Config file** | `core/Cargo.toml` (Rust) · `neode-ui/vitest.config.ts` (frontend) |
|
||||
| **Quick run command** | `cd core && cargo test --package archipelago assistant::` · `cd neode-ui && npx vitest run src/services/__tests__/contextBroker.test.ts` |
|
||||
| **Full suite command** | `cd core && cargo test` · `cd neode-ui && npm run test` |
|
||||
| **Estimated runtime** | Rust full suite ~minutes; Vitest targeted ~seconds |
|
||||
|
||||
**Build gotcha (CLAUDE.md):** if `cargo test` hits `rust-lld: undefined hidden symbol`, that is incremental-cache corruption — rebuild with `CARGO_INCREMENTAL=0`. Not a real failure.
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** the quick-run command for the touched module (`cargo test assistant::`, or the specific Vitest file)
|
||||
- **After every plan wave:** full `cargo test` + `npm run test` (neode-ui) + AIUI's own test command (once confirmed in Wave 0)
|
||||
- **Before `/gsd-verify-work`:** full suite green **and** the AIUI-06 on-device pass on archi-dev-box (desktop + mobile)
|
||||
- **Max feedback latency:** targeted Vitest < 30s; Rust module tests < 120s
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Requirement-level map seeded from research. **The planner fills Task ID / Plan / Wave / Threat Ref columns** as it decomposes; every row below must end up owned by at least one task.
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| TBD | TBD | TBD | AIUI-01 | — | Typed chat request executes a real read-only tool ("how much space is left" → `system.disk-status`) and returns the real result | integration (Rust) | `cargo test assistant::tests::disk_status_tool_executes` | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | AIUI-01 | D-07/D-11 | A write request ("restart bitcoin") produces a **pending confirmation**, never an executed action, until the human confirms | integration (Rust) + component (Vue) | `cargo test assistant::tests::destructive_tool_requires_confirm`; `npx vitest run src/services/__tests__/toolConfirm.test.ts` | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | AIUI-01 / AIUI-04 | Phase-10 D-01..D-04 | An unauthenticated caller cannot reach any new `assistant.*` RPC method | integration (Rust) | `cargo test rpc::middleware::tests::assistant_methods_require_session` | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | **AIUI-04** | **live exposure** | `/aiui/api/claude/` and `/aiui/api/openrouter/` are **no longer reachable without a session** (see Manual-Only + note below) | integration/shell | `curl -s -o /dev/null -w '%{http_code}' http://<node>/aiui/api/claude/` returns 401/403 with no cookie | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | AIUI-02 | D-16 | A conversational settings change is scoped to a granted permission category and **refused** when not granted | unit (Rust) | `cargo test assistant::tools::tests::settings_tool_respects_category_grant` | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | AIUI-04 | D-10 | Peer-supplied text inside untrusted-content delimiters cannot escalate tool authority; an injected "restart bitcoin" still requires a human confirm naming the real action | unit (Rust) | `cargo test assistant::tests::injected_instruction_does_not_grant_authority` | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | AIUI-03 | — | `content.*` RPC data renders in `FilmGrid`/`SongGrid` through the new adapter (pins the shape mismatch found in research) | unit (Vue/TS) | `npx vitest run src/composables/__tests__/archyContentAdapter.test.ts` | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | AIUI-03 | — | Audio routes to the global bottom-bar player, never the lightbox (regression-pins the rule enforced in 5 call sites) | unit (Vue/TS) | `npx vitest run src/composables/__tests__/useAudioPlayer.test.ts` | ⚠️ partial | ⬜ pending |
|
||||
| TBD | TBD | TBD | AIUI-05 | D-15 | Build enforces `VITE_BASE_PATH=/aiui/`; script exits non-zero if unset | shell/CI | `scripts/build-aiui.sh` (new) | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | AIUI-05 | D-15 | Post-deploy check **fetches a live asset over HTTP** rather than trusting a directory listing | shell | `curl` a hashed asset resolved via `sw.js`, assert 200 + content | ❌ W0 | ⬜ pending |
|
||||
| TBD | TBD | TBD | AIUI-06 | — | Embedded iframe on archi-dev-box, desktop + mobile | manual | N/A | — | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `core/archipelago/src/assistant/` + its `#[cfg(test)]` module — the tool-calling loop is net-new; **zero** existing coverage
|
||||
- [ ] `neode-ui/src/services/__tests__/toolConfirm.test.ts` — new confirm-flow coverage, extending the `contextBroker.test.ts` pattern
|
||||
- [ ] `neode-ui/src/composables/__tests__/archyContentAdapter.test.ts` — pins the `ContentItem` → `Film`/`Song`/`Podcast` mapping
|
||||
- [ ] `scripts/build-aiui.sh` (or equivalent) — does not exist; D-15's `VITE_BASE_PATH` enforcement + commit-pinning have no automated check today
|
||||
- [ ] **Confirm AIUI's own test command** before any wave assumes Vitest parity — unverified in research
|
||||
- [ ] Keep green: `contextBroker.test.ts`, `chatAiuiEmbed.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Embedded AIUI works in the real iframe | AIUI-06 | Real-device rendering in the actual embed context; `dev:mock` does not reproduce it | Load neode-ui Chat view on archi-dev-box, desktop **and** mobile viewport; exercise a read tool, a confirmed write, and a content grid |
|
||||
| Frontend bundle actually shipped | AIUI-05 | Node `assets/` is a never-pruned graveyard — a disk grep reports "deployed" before the deploy | Resolve live chunks via `sw.js`, fetch over HTTP, grep the **fetched** bytes for the new string |
|
||||
| Confirm dialog is un-spoofable by the iframe | AIUI-04 / D-11 | Anti-spoofing is a visual/trust property of the host chrome | Verify the dialog renders outside the iframe, Teleports to body, full-screen backdrop, text drawn from the node's description — not model-authored |
|
||||
| Routstr pays a live request | D-04 / D-05 | Research confidence on the Routstr protocol is MEDIUM — cited from docs, never run against a live provider | Spike against a real provider before the integration is trusted; budget ceiling must hard-stop |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions Blocking Full Validation
|
||||
|
||||
Carried from `13-RESEARCH.md` § Open Questions — each needs a planner decision, and two change what "validated" even means:
|
||||
|
||||
1. **The port-3142 proxy** — `/aiui/api/claude/` and `/aiui/api/openrouter/` are proxied with **no session gate** (`image-recipe/configs/nginx-archipelago.conf`, verified). Anyone reaching the node's web port can spend the owner's API budget. Removed, gated, or superseded by D-01's node-side loop?
|
||||
2. **Iframe sandbox mechanism** — AIUI is same-origin today, no `sandbox` attribute, permissive CSP. AIUI-04's "sandboxed by construction" is currently a code-discipline convention, not browser-enforced. Attribute, CSP, or accepted-and-documented risk?
|
||||
3. **Routstr protocol accuracy** — needs a spike against a live provider before it is load-bearing.
|
||||
4. **RBAC integration** — should new `assistant.*` RPCs go through the existing `role.can_access()` check?
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 120s
|
||||
- [ ] AIUI repo test command confirmed
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
-440
@@ -1,440 +0,0 @@
|
||||
---
|
||||
phase: quick-260731-upz
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md
|
||||
- docs/security/PSBT-SIGNING-ARCHITECTURE.md
|
||||
- docs/UNIFIED-TASK-TRACKER.md
|
||||
- core/archipelago/src/seed.rs
|
||||
autonomous: false
|
||||
requirements: [QUICK-UPZ-01, QUICK-UPZ-02, QUICK-UPZ-03]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md exists and every single finding carries file:line evidence from the real tree — no finding is asserted from the research file alone (QUICK-UPZ-01)"
|
||||
- "Each of [ARCHY-1] [ARCHY-2] [ARCHY-3] [ARCHY-4] is explicitly CONFIRMED, REFUTED, or UNVERIFIED against real code, and a refuted finding says so plainly rather than being quietly dropped (QUICK-UPZ-01)"
|
||||
- "The audit covers all six mandated secret classes: master BIP-39 seed, LND aezeed, fleet release-root + catalog/manifest signing keys, node identity keys (nostr/FIPS/Reticulum), container::secrets generated_secrets, and session tokens/CSRF (QUICK-UPZ-01)"
|
||||
- "[ARCHY-3] (one-ISO-many-nodes correlation) is answered from image-recipe/ evidence where the tree can answer it, and everything the tree cannot answer is listed in a separate, explicitly-labelled on-node verification checklist marked UNVERIFIED — never asserted as verified (QUICK-UPZ-01)"
|
||||
- "Every finding has Severity (Critical/High/Medium/Low/Informational) + evidence + exploitability + blast radius + concrete remediation, and there is a 'What we do right' section (QUICK-UPZ-01)"
|
||||
- "docs/security/PSBT-SIGNING-ARCHITECTURE.md exists and covers: descriptor-only watch-only wallet, the create->export->sign->import->finalize->broadcast loop with named Core RPCs, single-sig-HW and wsh(sortedmulti) multisig tiers, air-gap transport choice, LND's honest limits, hot wallet as explicitly-secondary, migration for existing hot-seed users, and a phased rollout that a future /gsd-plan-phase can consume (QUICK-UPZ-02)"
|
||||
- "The PSBT spec states plainly that a routing node's channel/revocation/HTLC keys cannot be air-gapped, and splits the design into on-chain (PSBT-protectable) vs lightning (necessarily hot) (QUICK-UPZ-02)"
|
||||
- "No produced document contains any real secret value — no mnemonic words, no private keys, no tokens, no passwords; secrets are referenced by path/variable name only (QUICK-UPZ-01, QUICK-UPZ-02, QUICK-UPZ-03)"
|
||||
- "A prioritised remediation backlog exists in the audit doc and the resulting open items appear in docs/UNIFIED-TASK-TRACKER.md in that file's existing tier/checkbox format (QUICK-UPZ-03)"
|
||||
- "If [ARCHY-1] is confirmed, the mnemonic-generation call site takes its RNG as an injected parameter (OsRng in production) and a test proves the injected RNG is the one actually used — a test that cannot exist before the change; if it is not applied, the audit records why in a greppable line (QUICK-UPZ-03)"
|
||||
- "The other agent's work is untouched: container/secrets.rs, federation/storage.rs, pip.ts, Cloud.vue, OnboardingSeedGenerate.vue and the new composables/ files are read-only in this plan, and no commit authored by this plan contains any of those paths"
|
||||
artifacts:
|
||||
- "docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (evidence-backed audit + prioritised remediation backlog + on-node verification checklist)"
|
||||
- "docs/security/PSBT-SIGNING-ARCHITECTURE.md (watch-only/multisig/air-gap/LND spec + phased rollout)"
|
||||
- "docs/UNIFIED-TASK-TRACKER.md (updated with the resulting open items)"
|
||||
- "core/archipelago/src/seed.rs + its test module (ONLY if [ARCHY-1] is confirmed and the fix is small/obviously-correct/testable)"
|
||||
key_links:
|
||||
- "core/archipelago/src/seed.rs is the fan-out root — every other key class (node Ed25519 did:key, nostr, FIPS, release-root signing, per-identity keys, BIP-84 bitcoin, LND aezeed via HKDF) descends from the one mnemonic, so a defect there has strictly larger blast radius than a hardware wallet's"
|
||||
- "bip39 2.1.0's Mnemonic::generate -> generate_in -> generate_in_with(&mut rand::thread_rng(), ...) is the transitive-default hop that makes the entropy source implicit at Archipelago's call site — this is the exact structural shape of the COLDCARD defect (T1)"
|
||||
- "docs/hardware-signer-design.md already exists (TROPIC01 air-gapped signer, exploratory stub) — the PSBT spec must cross-link it as the future first-party signer, not duplicate or contradict it"
|
||||
- "image-recipe/build-debian-iso.sh + image-recipe/archipelago-scripts/install-to-disk.sh + image-recipe/configs/*.service are the only places that can bake a random-seed or order key generation against crng init — they are the whole [ARCHY-3] evidence surface in-tree"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Turn the confirmed 2026-07-30 Coinkite COLDCARD low-entropy incident into three concrete
|
||||
Archipelago artifacts:
|
||||
|
||||
1. **A real entropy & seed-generation security audit** of this codebase (`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`) —
|
||||
performed against actual code with `file:line` evidence, not a restatement of the research.
|
||||
2. **A PSBT-first signing architecture spec** (`docs/security/PSBT-SIGNING-ARCHITECTURE.md`) —
|
||||
watch-only descriptors, multisig, air-gap transport, honest LND limits, hot wallet as an
|
||||
explicitly-secondary tier, and a phased rollout a future `/gsd-plan-phase` can consume.
|
||||
3. **A prioritised remediation backlog** wired into `docs/UNIFIED-TASK-TRACKER.md`, plus — only
|
||||
if the audit proves it necessary — one small, obviously-correct, independently-tested
|
||||
hardening fix.
|
||||
|
||||
Purpose: Archipelago derives its **entire key hierarchy from one 24-word BIP-39 mnemonic**,
|
||||
including the **fleet release-root signing key**. A Coldcard-class entropy defect here would not
|
||||
just drain wallets, it would let an attacker forge signed release manifests for the whole fleet.
|
||||
The research found no such defect today — but it did find the *structural shape* that produced
|
||||
T1, and it flagged the ISO/first-boot entropy story as the single most plausible real exposure.
|
||||
|
||||
Output: 3 commits on `main` (audit doc, spec doc, backlog + optional fix), pushed via `gitea-ai`.
|
||||
|
||||
**This is an audit-and-spec task, not a feature build.** Do NOT implement PSBT, watch-only,
|
||||
multisig, or any wallet/signing behaviour. Do NOT refactor beyond the single gated fix in Task 3.
|
||||
|
||||
**Tracer-first decomposition deliberately does not apply here** — there are no layers to slice
|
||||
through; this is a deliverable set. Task 1 is the load-bearing evidence pass and Tasks 2 and 3
|
||||
strictly depend on its findings. Execute the three tasks **in order**.
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
@/home/archipelago/Projects/archy/CLAUDE.md
|
||||
@/home/archipelago/Projects/archy/.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md
|
||||
|
||||
**Read the RESEARCH.md above IN FULL before starting.** It contains the confirmed incident
|
||||
analysis, the historical low-entropy catalogue (T1–T7), the greppable audit checklist with
|
||||
exact dangerous/correct API names, findings [ARCHY-1]…[ARCHY-5], and the LND capability matrix.
|
||||
Use it to aim the audit and to justify the spec's technical choices — do **not** re-derive it,
|
||||
and do **not** copy it wholesale into the deliverables.
|
||||
|
||||
## Facts already established — do not re-derive
|
||||
|
||||
- **The RNG-touching surface in this tree is bounded and already enumerated.** 33 Rust files
|
||||
under `core/*/src` and 7 TypeScript/Vue files under `neode-ui/src` match the RNG API set.
|
||||
Task 1's grep pipeline reproduces exactly this set. There is no need to read the whole repo.
|
||||
- **Dependency versions** (`core/archipelago/Cargo.toml`): `rand = "0.8.5"` (still has fork
|
||||
protection; 0.9.0 removed it), `bip39 = { version = "=2.1.0", features = ["rand"] }`
|
||||
(2.2.2 is current — do not bump in this task), `argon2 = "0.5.3"`, `zeroize = "1.8.2"`.
|
||||
- **`docs/security/` does not exist yet** — create it.
|
||||
- **`docs/hardware-signer-design.md` already exists** (171 lines, 2026-06-24, exploratory
|
||||
TROPIC01 air-gapped-signer stub). Task 2 must cross-link and stay consistent with it.
|
||||
- **`docs/UNIFIED-TASK-TRACKER.md`** is 268 lines, organised as `## Tier 0 — Quick / mechanical,
|
||||
no blockers`, `## Tier 1 — Medium effort, unblocked`, etc., with `- [ ]` / `- [x] ~~struck~~`
|
||||
items, ordered fastest/simplest first. Match that format exactly.
|
||||
- **`image-recipe/_archived/` is out of audit scope** (dead auto-installer path). Note it as
|
||||
explicitly excluded in the audit doc so the next auditor does not re-derive that.
|
||||
- **`core/archipelago/src/seed.rs` is CLEAN** in git (safe to edit in Task 3). Its test module
|
||||
starts at line 479.
|
||||
|
||||
## CONCURRENT-AGENT HAZARD — read carefully
|
||||
|
||||
Another agent has uncommitted work in this shared tree. These files are **dirty**:
|
||||
|
||||
```
|
||||
core/archipelago/src/container/secrets.rs <- IN AUDIT SCOPE, read-only
|
||||
core/archipelago/src/federation/storage.rs
|
||||
neode-ui/src/utils/pip.ts
|
||||
neode-ui/src/views/Cloud.vue
|
||||
neode-ui/src/views/OnboardingSeedGenerate.vue <- IN AUDIT SCOPE, read-only
|
||||
neode-ui/src/composables/usePaidItemViewer.ts (untracked)
|
||||
neode-ui/src/composables/usePipSession.ts (untracked)
|
||||
neode-ui/src/composables/__tests__/*.test.ts (untracked)
|
||||
```
|
||||
|
||||
`container/secrets.rs` and `OnboardingSeedGenerate.vue` are **both in audit scope AND dirty**.
|
||||
Read them **as they are on disk**. Do NOT modify them, do NOT revert them, do NOT `git stash`,
|
||||
do NOT `git checkout` them. If the audit finds something in them, write it up as a finding with
|
||||
a note that the file had uncommitted third-party changes at audit time.
|
||||
|
||||
**That agent is committing to these paths live** (`4b5367eb` federation/storage.rs, `bc9a210c`
|
||||
Cloud.vue, `3288a02d` pip.ts all landed after this plan was written). So expect the tree and the
|
||||
log to move underneath you. That is normal and is not your problem to fix. The invariant you owe
|
||||
is narrow and absolute: **no commit you author may contain any of those paths.** Do not rebase,
|
||||
do not reset, do not revert their commits, and re-read a file rather than trusting a stale read
|
||||
if you see it change.
|
||||
|
||||
**Staging rule (CLAUDE.md, non-negotiable):** always `git add <explicit paths>`. Never
|
||||
`git add -A`, never `git add .`, never `git commit -a`.
|
||||
|
||||
## Honesty requirements — apply to all three tasks
|
||||
|
||||
- **Never claim a path is safe without `file:line` evidence.** "I grepped and found nothing" is
|
||||
a valid finding only if you state the grep and the directories it covered.
|
||||
- **Anything that cannot be verified from this environment is `UNVERIFIED`**, listed in the
|
||||
on-node verification checklist, never asserted as checked. Real hardware (`.228` / dev-box)
|
||||
is not reachable from this task.
|
||||
- **Never put a real secret into a document.** Reference the path or variable name
|
||||
(`master_seed.enc`, `STRIPE_SECRET_KEY`), never a value. This includes example/illustrative
|
||||
mnemonics — use `<24 words>` or `word1 … word24` placeholders, never a real wordlist.
|
||||
- Mark research-derived claims that you could not confirm in-tree as `[FROM RESEARCH,
|
||||
NOT RE-VERIFIED]` rather than laundering them into audit findings.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Entropy & seed-generation security audit against the real codebase</name>
|
||||
<files>docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md</files>
|
||||
<read_first>
|
||||
core/archipelago/src/seed.rs (full — the fan-out root),
|
||||
core/archipelago/src/api/rpc/seed_rpc.rs (the network boundary, [ARCHY-4]),
|
||||
core/archipelago/src/container/secrets.rs (DIRTY — read only),
|
||||
neode-ui/src/views/OnboardingSeedGenerate.vue (DIRTY — read only),
|
||||
image-recipe/build-debian-iso.sh + image-recipe/archipelago-scripts/install-to-disk.sh ([ARCHY-3]),
|
||||
docs/adr/ (for the ADR-005 Argon2 parameter cross-check)
|
||||
</read_first>
|
||||
<action>
|
||||
Perform a real audit and write it to `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` (create
|
||||
`docs/security/` first). Do the evidence collection with the bounded pipeline below FIRST, then
|
||||
write — do not write findings before you have their line numbers.
|
||||
|
||||
**Step A — collect evidence (bounded; ~10 bash calls, do not wander outside these paths).**
|
||||
Run these and keep the output as your evidence corpus. `core/*/src` deliberately excludes
|
||||
`core/target/`; exclude `image-recipe/_archived/` from conclusions:
|
||||
|
||||
```
|
||||
grep -rnE 'SmallRng|seed_from_u64|::from_seed\(|rand::rngs::mock|StdRng' core/*/src --include=*.rs
|
||||
grep -rnE 'OsRng|thread_rng|rand::random|getrandom|SystemRandom' core/*/src --include=*.rs
|
||||
grep -rn -B3 -A3 -E 'SystemTime::now|as_nanos|Instant::now' core/*/src --include=*.rs | grep -iE 'key|seed|nonce|salt|token|secret|password|mnemonic'
|
||||
grep -rn -B2 -A2 -E 'Math\.random|getRandomValues|crypto\.subtle|jsbn|SecureRandom\(' neode-ui/src --include=*.ts --include=*.vue
|
||||
grep -rnE '\$RANDOM|/dev/urandom|/dev/random|openssl rand|uuidgen|random\.random|random\.randint|shuf ' scripts/ image-recipe/ --include=*.sh --include=*.py
|
||||
grep -rniE 'random-seed|urandom|jitterentropy|haveged|rng-tools|rngd|crng' image-recipe/ --include=*.sh --include=*.service --include=*.conf
|
||||
find image-recipe -name 'random-seed' -o -name '*.seed'
|
||||
grep -rniE '(info|warn|error|debug|trace)!\(.*(mnemonic|seed|privkey|private_key|passphrase|aezeed)' core/*/src --include=*.rs
|
||||
grep -rn 'derive(Debug' core/archipelago/src/seed.rs core/archipelago/src/identity.rs core/archipelago/src/credentials/store.rs
|
||||
cd core && cargo tree -i rand | head -40 && cargo tree -i getrandom | head -40
|
||||
```
|
||||
Run `cargo audit` only if `command -v cargo-audit` succeeds; if absent, record that as a gap
|
||||
(the research explicitly recommends `cargo audit`/`cargo deny` in CI rather than a snapshot).
|
||||
|
||||
**Step B — trace every secret class.** For each of these, trace from the syscall to the consumer
|
||||
and record `file:line` at every hop. Any hop where the entropy source is a *default* rather than
|
||||
an *argument* is a T1-shaped structural risk and must be called out as such:
|
||||
(1) user Bitcoin/LND wallet seed; (2) LND aezeed (`HKDF(seed, "archipelago/lnd/entropy/v1")`);
|
||||
(3) **fleet release-root signing key** + catalog/manifest signing keys; (4) node identity keys
|
||||
(nostr / FIPS / Reticulum); (5) `container::secrets` `generated_secrets` materialisation
|
||||
(0600/rootless per CLAUDE.md — verify the mode is actually set, don't assume);
|
||||
(6) session tokens + CSRF; (7) onboarding seed generation in the UI.
|
||||
|
||||
**Step C — adjudicate [ARCHY-1] … [ARCHY-4] individually.** Each gets its own subsection headed
|
||||
with the tag and a verdict of exactly `CONFIRMED`, `REFUTED`, or `UNVERIFIED`:
|
||||
- **[ARCHY-1]** — check `core/archipelago/src/seed.rs` around line 92 for the `bip39::Mnemonic::generate(24)`
|
||||
call, then confirm the transitive default by reading the vendored crate at
|
||||
`~/.cargo/registry/src/*/bip39-2.1.0/src/lib.rs` (research cites line 297). State whether the
|
||||
entropy source is chosen at the call site or by the dependency.
|
||||
- **[ARCHY-2]** — verify the `kernel_csprng_ready()` probe near `seed.rs:52-91` really uses the
|
||||
nonblocking flag *as a probe only* and that no key material is drawn from that path.
|
||||
- **[ARCHY-3]** — the highest-unknown item. From `image-recipe/` evidence answer: does the build
|
||||
bake a populated seed file into the image; is there any first-boot regeneration unit; does the
|
||||
image install `jitterentropy-rngd`/`haveged`/`rng-tools`; can onboarding key generation run
|
||||
before the kernel CSPRNG is initialised on freshly-flashed hardware. Answer what the tree can
|
||||
answer with `file:line`. Everything else goes to the on-node checklist as `UNVERIFIED`.
|
||||
- **[ARCHY-4]** — confirm whether the generated mnemonic crosses the JSON-RPC boundary
|
||||
(`seed_rpc.rs` ~line 147), the in-memory TTL and whether it is cleared at verify time
|
||||
(~lines 205-209), and whether the daemon can be served over plaintext HTTP.
|
||||
|
||||
If a research finding does not survive contact with the code, write `REFUTED` and say why
|
||||
plainly. Do not soften it. Also cross-check open question 9: whether `Argon2::default()` in
|
||||
`seed.rs` matches ADR-005's stated 64MB/3-iteration profile — report the actual numbers.
|
||||
|
||||
**Step D — write the document.** Structure:
|
||||
1. Scope + method (directories covered, greps run, what was explicitly excluded and why —
|
||||
name `image-recipe/_archived/` and `core/target/`).
|
||||
2. Executive summary — the honest one-paragraph verdict.
|
||||
3. Findings table, then one subsection per finding. Every finding carries:
|
||||
`Severity` (Critical/High/Medium/Low/Informational) | Evidence (`file:line`) | Exploitability |
|
||||
Blast radius | Concrete remediation.
|
||||
4. `[ARCHY-1]`…`[ARCHY-4]` adjudication (Step C).
|
||||
5. **What we do right** — a real section, giving credit where the code is correct
|
||||
(zeroization, encrypted-at-rest envelope, the CSPRNG-readiness probe, 24-word enforcement,
|
||||
the correct browser RNG call sites — each with `file:line`).
|
||||
6. **On-node verification checklist (UNVERIFIED)** — the discrete checks that need real hardware,
|
||||
each written as a runnable command an operator can paste on `.228`/dev-box, including the
|
||||
cross-node same-ISO collision test from the research.
|
||||
7. Leave a placeholder heading `## Remediation Backlog` — Task 3 fills it.
|
||||
|
||||
Severity must reflect *this* codebase, not the Coldcard incident. Do not inflate: a benign
|
||||
`Math.random()` that only picks which word to quiz is Low or Informational, and the audit should
|
||||
say so and annotate it so the next auditor does not re-derive that it is benign.
|
||||
|
||||
Do not put any secret value in the document (see the secret-shaped-string gate in `verify`).
|
||||
Commit with `git add docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` only.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>AUD=docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md; test -f "$AUD" || { echo "MISSING FILE"; exit 1; }; for t in ARCHY-1 ARCHY-2 ARCHY-3 ARCHY-4 Severity "What we do right" UNVERIFIED; do grep -qi "$t" "$AUD" || { echo "MISSING: $t"; exit 1; }; done; n=$(grep -cE '\.(rs|vue|ts|sh|py|yml|toml):[0-9]+' "$AUD"); [ "$n" -ge 20 ] || { echo "FAIL: only $n file:line evidence refs, need >=20"; exit 1; }; grep -qE '(xprv|xpub|nsec|npub)[A-Za-z0-9]{40,}|BEGIN [A-Z ]*PRIVATE KEY' "$AUD" && { echo "FAIL: secret-shaped string in audit doc"; exit 1; }; echo OK</automated>
|
||||
</verify>
|
||||
<done>`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` exists; every finding has `file:line` evidence and a severity; all four ARCHY tags carry an explicit CONFIRMED/REFUTED/UNVERIFIED verdict; all six mandated secret classes are traced; the on-node checklist exists and is labelled UNVERIFIED; a "What we do right" section exists; no secret value appears anywhere; committed with explicit paths.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: PSBT / watch-only / multisig architecture spec</name>
|
||||
<files>docs/security/PSBT-SIGNING-ARCHITECTURE.md</files>
|
||||
<read_first>
|
||||
docs/hardware-signer-design.md (existing TROPIC01 signer stub — cross-link, do not duplicate),
|
||||
core/archipelago/src/api/rpc/bitcoin.rs (integration point),
|
||||
core/archipelago/src/seed.rs lines ~214-233 (BIP-84 derivation + LND aezeed HKDF),
|
||||
apps/bitcoin-core/manifest.yml + apps/bitcoin-knots/manifest.yml + apps/lnd/manifest.yml (pinned versions),
|
||||
the RESEARCH.md Part C tables (Core RPCs, LND capability matrix, air-gap formats)
|
||||
</read_first>
|
||||
<action>
|
||||
Write `docs/security/PSBT-SIGNING-ARCHITECTURE.md` — a spec, not a tutorial, and not an
|
||||
implementation. Ground every architectural claim in either RESEARCH.md Part C or a `file:line`
|
||||
from this tree; mark anything from neither as `[UNVERIFIED]`.
|
||||
|
||||
Required sections:
|
||||
|
||||
1. **Target architecture.** Watch-only **descriptor** wallet on the node (Core), created with
|
||||
private keys disabled and populated via descriptor import — descriptor-only from day one,
|
||||
because Core 30 removed BDB legacy wallets. Name the actual Core RPCs for each step of the
|
||||
loop (create funded PSBT -> export -> sign offline -> import -> combine -> finalize ->
|
||||
broadcast) and say which RPCs are wallet-scoped vs node-scoped. Recommend driving UI state
|
||||
from `analyzepsbt` (it reports which role must act next) rather than guessing. Record the
|
||||
current versions the node actually runs from the manifests, and flag `bitcoin-knots:latest`
|
||||
as an unpinned tag at odds with ADR-009 (in-scope to flag, out-of-scope to fix).
|
||||
|
||||
2. **Where each step lives.** Map the loop across the three surfaces: Rust orchestrator
|
||||
(`core/archipelago`), `neode-ui`, and the companion app. Be explicit that the BIP-84 private
|
||||
key stays in the daemon's encrypted store and the **xpub only** goes into the Core descriptor
|
||||
wallet — the private key is never imported into Core.
|
||||
|
||||
3. **Tiers.** Tier 1 single-sig with an external hardware signer; Tier 2 `wsh(sortedmulti(k,...))`
|
||||
multisig with BIP-48 paths (`m/48'/coin'/account'/2'` for P2WSH) and descriptor exchange.
|
||||
State why `sortedmulti` over ordered `multi`. Mandate key-origin annotation
|
||||
`[fingerprint/derivation]` — hardware signers cannot locate their key without it. Treat
|
||||
taproot/MuSig2 multisig as future work and say why (unconfirmed 2026 support).
|
||||
|
||||
4. **Air-gapped transport.** Pick a format and justify it against the companion app's *existing*
|
||||
QR scanner and shipped SeedQR capability. Compare BBQr (sequential) vs BC-UR v2 (fountain-coded,
|
||||
order-independent, degrades gracefully in poor light) vs microSD/file. Be honest about QR
|
||||
density: a real multi-input multisig PSBT exceeds single-QR capacity, so animated multi-frame
|
||||
is mandatory and a file fallback must always be offered. Cross-link
|
||||
`docs/hardware-signer-design.md` as the future first-party signer and keep the format choice
|
||||
consistent with it.
|
||||
|
||||
5. **LND — what is and is not achievable.** Reproduce the capability matrix as a decision table:
|
||||
watch-only + remote signer YES; signer fully offline NO (it must accept a live inbound gRPC
|
||||
connection); air-gapping channel/revocation/HTLC keys NO; PSBT channel funding YES; opening a
|
||||
channel with zero LND wallet balance YES; self-broadcasting the funding transaction NEVER
|
||||
(encode that as a hard UI rule — funds can be lost). Name the required xpub accounts and the
|
||||
taproot import gotcha. Then split the whole design into **on-chain balance: genuinely
|
||||
PSBT-protectable** vs **lightning balance: necessarily hot**, and give the exact honest
|
||||
user-facing sentence the UI should use. Any copy implying a routing node's channel keys are
|
||||
cold is misleading — say so.
|
||||
|
||||
6. **Hot wallet as the explicitly-secondary option.** Hard separation of on-chain and Lightning
|
||||
balances in the data model and the UI (never one blended number); server-enforced per-tx and
|
||||
rolling-daily spend limits with anything above forced onto the PSBT path; reuse of the
|
||||
existing at-rest encryption envelope; zeroization; and the explicit cold/warm/hot tiering in
|
||||
the UI. State the design principle from the incident: T1's survivors were the users who took
|
||||
the *optional* extra step, so the safe path must be the **default**, not the option. Also
|
||||
spell out how to nudge toward PSBT without making the hot path feel broken or punitive.
|
||||
|
||||
7. **Migration for existing hot-seed users.** The honest advice implied by the incident: a
|
||||
software fix does not repair an already-generated seed. Specify the sequence — generate a new
|
||||
key, verify the backup and a receive address, send a test transaction, migrate funds, retain
|
||||
the old backup until confirmed — and state clearly which Archipelago users this does and does
|
||||
not apply to based on Task 1's findings (do not over-alarm if Task 1 found no defect; do not
|
||||
under-state if it did).
|
||||
|
||||
8. **Phased rollout.** Concrete, plannable phases with dependencies and what each unlocks. This
|
||||
document is the input to a future `/gsd-plan-phase`, so each phase needs a name, a goal
|
||||
sentence, its dependencies, and 2-5 candidate requirement lines. Suggested shape (adjust with
|
||||
reasoning): descriptor watch-only read path -> PSBT construct/export -> external-signer import
|
||||
and finalize -> air-gap transport -> multisig -> LND remote signing -> hot-wallet limits.
|
||||
Note explicitly which phases need real-hardware verification.
|
||||
|
||||
Do NOT write implementation code. Do NOT add dependencies. Do NOT modify wallet or signing
|
||||
behaviour anywhere in the tree. Commit with `git add docs/security/PSBT-SIGNING-ARCHITECTURE.md`
|
||||
only.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>SPEC=docs/security/PSBT-SIGNING-ARCHITECTURE.md; test -f "$SPEC" || { echo "MISSING FILE"; exit 1; }; for t in walletcreatefundedpsbt analyzepsbt finalizepsbt importdescriptors disable_private_keys sortedmulti BIP-48 remotesigner "necessarily hot" migration Phase hardware-signer-design; do grep -qi "$t" "$SPEC" || { echo "MISSING: $t"; exit 1; }; done; grep -qE '(xprv|xpub|nsec|npub)[A-Za-z0-9]{40,}|BEGIN [A-Z ]*PRIVATE KEY' "$SPEC" && { echo "FAIL: secret-shaped string in spec"; exit 1; }; echo OK</automated>
|
||||
</verify>
|
||||
<done>`docs/security/PSBT-SIGNING-ARCHITECTURE.md` exists covering all eight required sections; the LND section states plainly that channel/revocation/HTLC keys cannot be air-gapped and that the funding transaction must never be self-broadcast; on-chain vs lightning are split into separately-protectable tiers; the hot wallet is framed as explicitly secondary; the rollout is phased with dependencies and candidate requirements; `docs/hardware-signer-design.md` is cross-linked; no code changed; committed with explicit paths.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Remediation backlog, tracker update, and the one gated hardening fix</name>
|
||||
<files>docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md, docs/UNIFIED-TASK-TRACKER.md, core/archipelago/src/seed.rs</files>
|
||||
<behavior>
|
||||
Only if [ARCHY-1] was CONFIRMED in Task 1, the mnemonic-generation path becomes RNG-injectable
|
||||
and the following test exists and passes (it cannot exist before the change, because there is
|
||||
no seam to inject through):
|
||||
- `mnemonic_generation_uses_injected_rng`: driving generation with a deterministic
|
||||
`CryptoRng + RngCore` test RNG produces a stable, asserted 24-word mnemonic (known-answer),
|
||||
proving the passed RNG — not an implicit transitive default — is the one actually consumed.
|
||||
- `mnemonic_generation_is_256_bit`: generated mnemonics are 24 words and two successive
|
||||
productions from the real entropy source differ.
|
||||
Existing seed tests stay green.
|
||||
</behavior>
|
||||
<action>
|
||||
**Part A — remediation backlog (always).** Fill the `## Remediation Backlog` placeholder left in
|
||||
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`. Prioritise by severity x effort, fastest/most
|
||||
valuable first. Each item needs: the finding it closes, the concrete change, the file(s), an
|
||||
effort estimate, and whether it needs real-hardware verification. Anything that needs a proper
|
||||
phase (PSBT work, ISO first-boot entropy regeneration, confining seed-bearing RPCs to
|
||||
loopback/TLS) is listed here as a backlog item and explicitly **not** implemented in this task.
|
||||
|
||||
**Part B — tracker (always).** Add the resulting open items to `docs/UNIFIED-TASK-TRACKER.md`
|
||||
using that file's existing conventions: `- [ ]` checkboxes, bold lead sentence, indented
|
||||
continuation lines, placed in the correct existing Tier (Tier 0 = quick/mechanical/no blockers,
|
||||
Tier 1 = medium effort/unblocked, etc.) rather than in a new section at the top. Link back to
|
||||
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` and `docs/security/PSBT-SIGNING-ARCHITECTURE.md`
|
||||
so the tracker stays the single entry point. Use `Edit` for scoped insertions — never rewrite the
|
||||
whole file.
|
||||
|
||||
**Part C — the one gated fix (conditional).** Apply this **only if Task 1 recorded [ARCHY-1] as
|
||||
CONFIRMED**. It is the only code change authorised by this plan.
|
||||
|
||||
Refactor `core/archipelago/src/seed.rs` so the mnemonic-generation call site takes its RNG as an
|
||||
**injected parameter** instead of inheriting a transitive dependency's default: an internal
|
||||
helper that accepts `&mut (impl CryptoRng + RngCore)` and generates a 24-word English mnemonic
|
||||
through the injectable `bip39` entry point, with the production caller passing `OsRng`. Add a
|
||||
comment at the call site pinning the rationale (a bare reference to this audit and to T1 — an
|
||||
explicit source beats an implicit one, and a future `rand`/`bip39` bump must not silently rebind
|
||||
it). Then add the two tests from `<behavior>` to the existing test module (`seed.rs:479`). The
|
||||
known-answer test is what makes this a fix rather than a comment: it is impossible to write
|
||||
against the pre-refactor code because there is no seam to inject through.
|
||||
|
||||
Do **not** bump `bip39` or `rand` versions. Do **not** change the derivation, the word count,
|
||||
the empty-passphrase decision, the at-rest encryption, or anything else in `seed.rs`.
|
||||
|
||||
You MAY additionally fix the `core/archipelago/src/totp.rs` modulo bias (rejection sampling or
|
||||
`SliceRandom::choose` in place of `% charset.len()`) **only if** it is a <=10-line change with its
|
||||
own test and Task 1 confirmed it. Anything beyond these two goes to the backlog. If neither is
|
||||
applied, write a greppable line `ARCHY-1: NOT APPLIED` into the audit doc with the reason.
|
||||
|
||||
Build and test from `core/` (workspace root). If the build hits a `rust-lld: undefined hidden
|
||||
symbol` error, that is incremental-cache corruption — rebuild with `CARGO_INCREMENTAL=0`.
|
||||
|
||||
**Part D — commit and push.** Commit Part A+B together and Part C separately (if applied), always
|
||||
with explicit paths: `git add docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md docs/UNIFIED-TASK-TRACKER.md`
|
||||
then `git add core/archipelago/src/seed.rs`. Never `git add -A`. Then push all three commits with
|
||||
`git push gitea-ai main` (`main` is protected; `gitea-ai` is the push account). If the push fails,
|
||||
report the exact error — do not force-push, do not retarget another remote, do not amend history.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>AUD=docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md; grep -qi "Remediation Backlog" "$AUD" || { echo "FAIL: no remediation backlog"; exit 1; }; grep -q "ENTROPY-SEED-AUDIT-2026-07-31" docs/UNIFIED-TASK-TRACKER.md || { echo "FAIL: tracker missing audit link"; exit 1; }; grep -q "PSBT-SIGNING-ARCHITECTURE" docs/UNIFIED-TASK-TRACKER.md || { echo "FAIL: tracker missing spec link"; exit 1; }; FORBID='container/secrets\.rs|federation/storage\.rs|OnboardingSeedGenerate\.vue|utils/pip\.ts|views/Cloud\.vue'; for c in $(git log --format=%H -10 -- "$AUD" docs/UNIFIED-TASK-TRACKER.md core/archipelago/src/seed.rs core/archipelago/src/totp.rs); do git show --name-only --pretty=format: "$c" | grep -qE "$FORBID" && { echo "FAIL: commit $c mixed in the other agent's files"; exit 1; }; done; if grep -q "ARCHY-1: NOT APPLIED" "$AUD"; then echo "OK (fix deliberately not applied, reason recorded)"; else ( cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago mnemonic_generation_uses_injected_rng 2>&1 | grep -qE 'test result: ok\. [1-9]' ) || { echo "FAIL: injected-rng known-answer test missing or failing"; exit 1; }; ( cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago seed:: 2>&1 | grep -q 'test result: ok' ) || { echo "FAIL: existing seed tests not green"; exit 1; }; echo OK; fi</automated>
|
||||
<human-check>Show the reviewer the full `git diff` of `core/archipelago/src/seed.rs` (and `totp.rs` if touched) BEFORE pushing, plus the output of `git log --oneline -3` and `git show --stat HEAD`. This is master-seed generation code for every new node — get explicit confirmation that the diff is limited to making the RNG explicit + adding tests, and that no derivation, word count, passphrase, or at-rest-encryption behaviour changed. If [ARCHY-1] was not applied, show the recorded reason instead and confirm that is the right call.</human-check>
|
||||
</verify>
|
||||
<done>The audit doc has a prioritised, actionable remediation backlog; `docs/UNIFIED-TASK-TRACKER.md` carries the new open items in its existing tier/checkbox format and links both new docs; either the injectable-RNG fix is applied in `seed.rs` with a known-answer test that passes and could not exist before the change, or `ARCHY-1: NOT APPLIED` plus a reason is recorded; no commit authored by this plan contains any of the other agent's five files; all commits staged by explicit path; the `seed.rs` diff was human-reviewed before push; commits pushed via `gitea-ai`.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| kernel CSPRNG -> userspace key generation | every secret in the system crosses here; a weak or unready source here is unrecoverable |
|
||||
| daemon -> JSON-RPC/websocket client | the master mnemonic currently crosses this boundary ([ARCHY-4]); plaintext HTTP is in use on LAN in places |
|
||||
| build host -> flashed ISO -> N nodes | a single image is written to many nodes; anything entropy-bearing baked in is shared fleet-wide ([ARCHY-3]) |
|
||||
| this task -> committed documentation | audit/spec artifacts are public-facing repo content and could leak secrets or false assurance |
|
||||
| this task -> shared working tree | a concurrent agent's uncommitted work can be clobbered by careless staging |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-UPZ-01 | Information Disclosure | produced audit/spec docs | critical | mitigate | No secret values in any doc — paths/variable names only; enforced by the secret-shaped-string negative gate in Tasks 1 and 2 `<verify>` |
|
||||
| T-UPZ-02 | Repudiation / false assurance | audit findings without evidence | high | mitigate | Every finding requires `file:line`; `>=20` evidence refs enforced by Task 1 `<verify>`; unverifiable items forced into an `UNVERIFIED` on-node checklist |
|
||||
| T-UPZ-03 | Tampering | `core/archipelago/src/seed.rs` (master-seed generation for every node) | critical | mitigate | Fix is gated on [ARCHY-1] being CONFIRMED, bounded to RNG injection + tests, proven by a known-answer test, and human-reviewed via `<human-check>` before push |
|
||||
| T-UPZ-04 | Tampering | concurrent agent's uncommitted work in the shared tree | high | mitigate | Explicit-path staging only (never `git add -A`); dirty files are read-only; Task 3 `<verify>` asserts the five dirty files are still dirty |
|
||||
| T-UPZ-05 | Information Disclosure | master mnemonic over plaintext-HTTP JSON-RPC ([ARCHY-4]) | high | transfer | Audited and written up with remediation (loopback/TLS confinement, shorter TTL); implementation deferred to a proper phase, not done here |
|
||||
| T-UPZ-06 | Spoofing | fleet release-root signing key derived from the same mnemonic | critical | mitigate | Explicitly traced as a first-class secret class in Task 1 Step B — a seed defect forges release manifests fleet-wide, strictly larger blast radius than a wallet |
|
||||
| T-UPZ-07 | Elevation of Privilege | one-ISO-many-nodes entropy correlation ([ARCHY-3]) | high | mitigate | `image-recipe/` evidence grep answers what the tree can answer; the rest becomes a runnable on-node checklist including the cross-node same-ISO collision test |
|
||||
| T-UPZ-SC | Tampering | package installs | low | accept | This plan adds no dependencies and installs nothing; `cargo audit` is invoked read-only if already present |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. Both documents exist under `docs/security/` and pass their automated gates.
|
||||
2. Every audit finding carries `file:line` evidence and a severity; all four ARCHY tags are adjudicated.
|
||||
3. Nothing that requires real hardware is claimed as verified — it appears in the UNVERIFIED on-node checklist instead.
|
||||
4. No secret value appears in any produced document.
|
||||
5. `docs/UNIFIED-TASK-TRACKER.md` carries the new open items in its existing format and links both docs.
|
||||
6. If code changed: `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago seed::` is green and the new known-answer test passes.
|
||||
7. No commit authored by this plan contains any of the concurrent agent's five files (they commit to those paths themselves — that is expected and is not a failure).
|
||||
8. All commits pushed via `gitea-ai`, or the exact push failure reported.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` is an audit a security reviewer would accept: evidence-backed, severity-classified, honest about what it could not verify, and generous where the code is right.
|
||||
- `docs/security/PSBT-SIGNING-ARCHITECTURE.md` is directly plannable — a future `/gsd-plan-phase` can pick up its phase breakdown without re-deriving the architecture.
|
||||
- The remediation backlog is in `docs/UNIFIED-TASK-TRACKER.md`, so the work is not stranded in a doc nobody reads.
|
||||
- At most one small, human-reviewed, test-proven code change landed; everything larger is queued as a backlog item.
|
||||
- Zero disruption to the concurrent agent's uncommitted work.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-SUMMARY.md` when done.
|
||||
</output>
|
||||
-482
@@ -1,482 +0,0 @@
|
||||
# Quick Task 260731-upz — Research
|
||||
|
||||
**Researched:** 2026-07-31
|
||||
**Domain:** Wallet entropy / RNG security; BIP-39 seed generation; PSBT + watch-only + multisig signing architecture
|
||||
**Confidence:** HIGH on Part A (primary vendor + independent researcher sources, dated within 48h), HIGH on Part B (primary docs + direct codebase inspection), MEDIUM-HIGH on Part C (official BIP/Core/LND docs; some 2026-current details noted as unverified)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
### Honesty verdict on Part A: **THE INCIDENT IS REAL AND CONFIRMED.**
|
||||
|
||||
The user's "conkite" is **Coinkite**, and the incident is the **COLDCARD entropy incident**, disclosed **2026-07-30** — i.e. *yesterday*, still actively unfolding as of today. This is not a training-data recollection; it is confirmed by the vendor's own advisory and technical backgrounder, by an independent technical analysis from Block's engineering team, and by on-chain evidence. No fabrication or analogue-substitution was required.
|
||||
|
||||
**One-paragraph version:** A 2021 refactor moved COLDCARD seed generation from the hand-written hardware-TRNG call `ckcc.rng_bytes()` to `ngu.random.bytes()`. Because `libngu`'s guard used `#ifndef MICROPY_HW_ENABLE_RNG` rather than testing the macro's *value*, and COLDCARD's board config defines that macro **as `0`**, the `#error` never fired and the call silently bound to MicroPython's **Yasmarang** software fallback PRNG — seeded from the chip UID's low 32 bits, SysTick, and RTC registers. Effective seed entropy dropped from a nominal 128 bits to **~40 bits on Mk2/Mk3** and **≤2^32 practically on Mk4/Mk5/Q** (a later "fix" reseeded Yasmarang with only **four bytes** of an otherwise-excellent secure-element digest). On 2026-07-30 an attacker swept **594.51 BTC across ~500 transactions in ~15–25 minutes**; the total across the confirmed + provisional sets is **1,082.65 BTC from 1,195 addresses (~$70M)**. Fixed firmware shipped 2026-07-31. Firmware updates **do not repair existing seeds** — affected users must generate new seeds and migrate.
|
||||
|
||||
### Why this matters to Archipelago, specifically
|
||||
|
||||
Archipelago derives **its entire key hierarchy from one 24-word BIP-39 mnemonic** (`core/archipelago/src/seed.rs`): node Ed25519 `did:key`, node Nostr key, FIPS mesh transport key, the **fleet release-root signing key**, per-identity Ed25519 + Nostr keys, the BIP-84 Bitcoin Core wallet, and the LND aezeed entropy. A Coldcard-class entropy defect here would not just drain wallets — it would let an attacker **forge signed release manifests and catalogs for the entire fleet**. The blast radius is strictly larger than a hardware wallet's.
|
||||
|
||||
The good news from direct inspection: Archipelago's entropy path is **structurally sound** — `bip39::Mnemonic::generate(24)` resolves to `rand::thread_rng()`, which in `rand 0.8.5` is a genuine CSPRNG (ChaCha12 seeded from `getrandom(2)`, with fork protection still present in 0.8.x). There is **no Coldcard-class defect present.** But there are five findings worth acting on, three of them structural rather than cryptographic — including the exact *shape* of failure that bit Coinkite (entropy source chosen implicitly by a transitive dependency's default, not stated at the call site).
|
||||
|
||||
**Primary recommendation:** (1) Make the entropy source **explicit and type-pinned** at every key-generation call site and add a regression test that fails if it changes; (2) audit the **ISO/first-boot entropy** story, which is Archipelago's single most plausible real low-entropy exposure given it ships flashable images to a fleet; (3) adopt **PSBT-first** on-chain signing with Bitcoin Core descriptor watch-only wallets, and be honest with users that **LND cannot be meaningfully air-gapped** for a routing node — remote signing moves keys, it does not remove hot-key exposure.
|
||||
|
||||
---
|
||||
|
||||
## 2. Part A — The Incident + Low-Entropy Compromise Catalogue
|
||||
|
||||
### A.1 The COLDCARD entropy incident (2026-07-30 → ongoing)
|
||||
|
||||
#### What was affected
|
||||
|
||||
| Product | Firmware range affected | Fixed in | Effective entropy |
|
||||
|---|---|---|---|
|
||||
| COLDCARD **Mk2 / Mk3** | v4.0.0 / 4.0.1 – 4.1.9 (from 2021-03-17) | **4.2.0** | **~40 bits** [1][3] |
|
||||
| COLDCARD **Mk4 / Mk5** (standard) | v5.0.0 – before 5.6.0 | **5.6.0** | **~72 bits nominal, ≤2^32 practical** [1][3] |
|
||||
| COLDCARD **Mk4 / Mk5** (Edge) | before 6.6.0X | **6.6.0X** | as above |
|
||||
| COLDCARD **Q** (standard) | before 1.5.0Q | **1.5.0Q** | as above |
|
||||
| COLDCARD **Q** (Edge) | before 6.6.0QX | **6.6.0QX** | as above |
|
||||
| COLDCARD **Mk1** | all v3.0.6 | n/a | outside the regression [3] |
|
||||
| **TAPSIGNER / OPENDIME / SATSCARD** | — | — | **unaffected** (different codebases) [1] |
|
||||
|
||||
Coinkite's framing is explicit: *"Exposure depends on the firmware used when a secret was generated, not the device's manufacturing date."* [3]
|
||||
|
||||
#### The defect, precisely
|
||||
|
||||
Three compounding bugs, all documented in primary sources:
|
||||
|
||||
**Bug 1 — the macro guard.** COLDCARD board configs (`stm32/COLDCARD/mpconfigboard.h:76-77`, `stm32/COLDCARD_MK4/mpconfigboard.h:77-78`, `stm32/COLDCARD_Q1/mpconfigboard.h:79-80`) set:
|
||||
|
||||
```c
|
||||
#define MICROPY_HW_ENABLE_RNG (0)
|
||||
```
|
||||
|
||||
...deliberately, because COLDCARD supplies its own hardware-RNG wrapper. But `libngu/ngu/random.c:22-31` guarded with:
|
||||
|
||||
```c
|
||||
#ifndef MICROPY_HW_ENABLE_RNG
|
||||
#error "get a HW TRNG plz"
|
||||
#endif
|
||||
```
|
||||
|
||||
`#ifndef` tests only that the macro *exists*, not that it is *enabled*. Defined-as-zero passes. The build silently bound `ngu.random.bytes()` to MicroPython's software fallback. [3] Coinkite's own postmortem: *"the carefully crafted TRNG code I wrote **was being** used, but just by chance, and only for less important things."* [1]
|
||||
|
||||
**Bug 2 — the Yasmarang fallback's seeding.** MicroPython's fallback PRNG (Yasmarang, never intended for cryptographic use) initialises in `ports/stm32/rng.c` from:
|
||||
|
||||
```c
|
||||
pad = UID_low32 ^ SysTick->VAL;
|
||||
n = RTC->TR; // time register
|
||||
d = RTC->SSR; // sub-second register
|
||||
```
|
||||
|
||||
None of these is a cryptographic entropy source: the MCU UID is a **fixed per-chip identifier** (only its low 32 bits used), SysTick is a predictable counter with ~80,000 distinct values on Mk2/Mk3 (~120,000 on current devices), and the RTC registers are time-correlated and may be effectively static at cold boot. After init, *"every subsequent output is a deterministic state transition"* with no further entropy collection. [3]
|
||||
|
||||
**Bug 3 — the 32-bit reseed (Mk4/Q/Mk5 "mitigation").** Later firmware attempted to reseed from the secure elements (commit `01cb43f7`):
|
||||
|
||||
```python
|
||||
a = callgate.read_rng(1) # 32 bytes from SE1
|
||||
b = callgate.read_rng(2) # 8 bytes from SE2
|
||||
n = ngu.hash.sha256d(a + b)
|
||||
n, = ustruct.unpack('I', n[0:4]) # <-- FOUR BYTES ONLY
|
||||
ngu.random.reseed(n)
|
||||
```
|
||||
|
||||
and `random_reseed()` in C does:
|
||||
|
||||
```c
|
||||
STATIC mp_obj_t random_reseed(mp_obj_t arg) {
|
||||
yasmarang_pad = mp_obj_get_int_truncated(arg); // sets ONE state word
|
||||
return mp_const_none;
|
||||
}
|
||||
```
|
||||
|
||||
Excellent secure-element entropy was **truncated to 32 bits**, fed into a single state word, with no DRBG, no full-state reset, and no periodic reseeding. [3]
|
||||
|
||||
#### Search-space reduction and the exploitation mechanism
|
||||
|
||||
Block's analysis gives the numbers [3]:
|
||||
|
||||
- **Mk2/Mk3 (no reseed):** `2^0` if UID and call history are known; ~`2^16.29` with unknown SysTick; broad ceiling across all timer fields ~`2^40.7`.
|
||||
- **Mk4/Q/Mk5 (32-bit reseed):** at most `2^32`, ~`2^31` average enumeration. The `2^73.27` "raw ceiling" is explicitly disclaimed: *"this is not 73-bit cryptographic security. The timer fields are correlated, may occupy much smaller ranges, and can potentially be observed or reconstructed."*
|
||||
|
||||
Attack loop: an attacker holding any **xpub, address, or public key** enumerates candidate Yasmarang streams offline, derives wallets from each candidate, and uses **the public blockchain as a validation oracle** — stop on address match, then sweep. For paper wallets the oracle is direct.
|
||||
|
||||
The critical generalisable lesson, stated as an inequality [3]:
|
||||
|
||||
```
|
||||
≤ 2^32 candidate RNG outputs
|
||||
↓ SHA256d / PBKDF2 / any deterministic hash
|
||||
≤ 2^32 candidate wallet seeds
|
||||
```
|
||||
|
||||
**Deterministic hashing cannot manufacture entropy.** Wrapping a weak source in SHA256d, HKDF, or PBKDF2-2048 does not widen the output family. This directly rebuts the intuition that "we hash it, so it's fine."
|
||||
|
||||
#### Blast radius beyond seed generation
|
||||
|
||||
The same `ngu.random` stream also fed [3]: paper-wallet secp256k1 private keys, Seed-XOR mask splits, ephemeral ECDH keys for device cloning and USB encryption, Key Teleport temporary credentials, Web2FA TOTP secrets and nonce material, and Secure Notes password generation. **A single compromised RNG contaminates every consumer of it** — a point that applies verbatim to Archipelago's `seed.rs` fan-out.
|
||||
|
||||
#### Timeline
|
||||
|
||||
| Date | Event |
|
||||
|---|---|
|
||||
| May 2018 | MicroPython Yasmarang fallback introduced upstream [1] |
|
||||
| 2021-01-28 | Vulnerable `libngu` STM32 guard introduced [3] |
|
||||
| 2021-03-01 | COLDCARD migrates seed generation to libngu (commit `b18723dd`) [3] |
|
||||
| 2021-03-17 | Firmware v4.0.0 ships the vulnerable path [3] |
|
||||
| 2022-03-11 | 32-bit reseed API added [3] |
|
||||
| 2022-03-14 | First production Mk4 v5.0.0 includes the (insufficient) reseed [3] |
|
||||
| **2026-07-30** | Theft reports surface; Block + researchers investigate; **Coinkite advisory published** [2][3] |
|
||||
| **2026-07-31 09:33 EDT** | Fixed firmware released [5] |
|
||||
| **2026-07-31 12:39 EDT** | Advisory updated: fixed firmware available for **every** affected model/track [2] |
|
||||
|
||||
#### Scope of loss
|
||||
|
||||
- Confirmed sweep: **500 transactions, 594.51 BTC, ~15 minutes** [4]
|
||||
- Provisional reconstructed set: **695 further transactions, 488.14 BTC** [4]
|
||||
- Combined: **1,195 unique source addresses, 1,082.65 BTC**, ~**$70M** within the first 24h [4][5]
|
||||
- 562 BTC consolidated into a single address [5]
|
||||
|
||||
`coldcardentropy.org` provides a **client-side-only** address checker over the 1,195-address dataset (*"Lookup happens locally in your browser. No query is sent or logged"*) and correctly cautions that address matches *"do not prove ownership, cause, or that a wallet is otherwise safe."* [4]
|
||||
|
||||
#### Vendor response and the mitigations that actually held
|
||||
|
||||
- **Dice rolls saved people.** 50–98 fair, private, unrecorded rolls contributed ≥128 bits independently; ≥99 rolls ≈256 bits. Coinkite does not consider such seeds at risk from the RNG issue alone. [2] Users who used the optional dice feature were unknowingly compensating for the hardware failure. Defence-in-depth on entropy paid off literally.
|
||||
- **BIP-39 passphrases help but are not a pass.** Coinkite advises migration even with a strong passphrase. [2]
|
||||
- **Firmware updates do not repair existing seeds.** Update → generate a *new* seed → verify backup and a receive address → send a test transaction → migrate → retain the old backup until confirmed. [1][2]
|
||||
|
||||
#### The AI angle (attributed opinion, not established fact)
|
||||
|
||||
NVK (Coinkite co-founder) claims *"AI-assisted code review can now find latent bugs at a speed that is outpacing even the industry's most seasoned experts,"* suggesting attackers used AI to audit the wallet codebase. [5] **Treat as an unverified attribution** — no source establishes attacker methodology. Its planning-relevant implication is real regardless: **latent entropy bugs that survived five years of human review are now cheap to find at scale.** Age of code is no longer evidence of safety.
|
||||
|
||||
---
|
||||
|
||||
### A.2 Historical low-entropy compromise catalogue — threat checklist
|
||||
|
||||
This is the checklist the follow-on audit should run against Archipelago.
|
||||
|
||||
| # | Incident | Year | Root cause | Search space | Lesson / audit check |
|
||||
|---|---|---|---|---|---|
|
||||
| **T1** | **COLDCARD entropy incident** [1][2][3][4] | 2021–2026 | Build-time macro guard (`#ifndef` vs value test) silently bound seed generation to a non-crypto software PRNG; later 32-bit truncated reseed | 2^40 (Mk3) / ≤2^32 (Mk4+) | **A refactor can silently change your entropy backend.** Pin the RNG at the call site by *type*, not by transitive default. Add a test that asserts the source. |
|
||||
| **T2** | **Milk Sad** — Libbitcoin Explorer `bx seed`, CVE-2023-39910 [6] | 2017–2023 | Mersenne Twister (`mt19937`) seeded with **32 bits of system time** | 2^32 | **Never seed a crypto secret from a clock.** MT19937 is not a CSPRNG; its presence anywhere in a key path is disqualifying. |
|
||||
| **T3** | **Trust Wallet browser extension**, CVE-2023-31290 [7] | 2022–2023 | `mt19937` seeded with a 32-bit value; exploited in the wild Dec 2022 / Mar 2023; >$6M lost | 2^32 (~4B mnemonics, hours on one machine) | Same class as T2 in a *different language/ecosystem*. Audit **every** language in the stack, not just the primary one. |
|
||||
| **T4** | **Randstorm** — BitcoinJS / JSBN `SecureRandom()` [8] | 2011–2015 | JSBN's `SecureRandom()` combined with broken browser `Math.random()` implementations (notably Chrome) | Practically brute-forceable; ~1.4M BTC in weak-key wallets; est. $1.2–2.1B at risk | **Browser RNG is a supply-chain dependency.** Use `crypto.getRandomValues` only; never `Math.random()` in any key path. |
|
||||
| **T5** | **Profanity** vanity-address generator → **Wintermute** [9] | 2022 | 32-bit seed fed to `mt19937_64` to produce a 256-bit key | 2^32; all 7-char vanity addresses crackable in ~50 days on 1,000 GPUs; **$162.5M** loss | Third-party "convenience" key generators are key-material producers. Treat them as such. |
|
||||
| **T6** | **Android `SecureRandom`** [ASSUMED — training knowledge, not re-verified this session] | 2013 | Improper `SecureRandom` initialisation on Android led to repeated ECDSA `k` nonces → private key recovery from two signatures | Direct key recovery | **Nonce reuse in ECDSA is instant key disclosure.** Prefer RFC6979 deterministic nonces. |
|
||||
| **T7** | **Blockchain.info R-value reuse** [ASSUMED — training knowledge, not re-verified this session] | 2014–2015 | Repeated ECDSA `r` values from a faulty RNG path | Direct key recovery | Same as T6; also a *detectable* on-chain signal — duplicate `r` across signatures. |
|
||||
|
||||
**The unifying pattern across all seven:** the failure is almost never in the cryptographic primitive. It is in **where the bits came from** — a clock, a chip ID, a browser, a 32-bit integer, or a default that got silently rebound by a refactor. And in five of seven cases the *effective* search space was exactly or near **2^32**, because 32-bit seeding is the recurring anti-pattern.
|
||||
|
||||
---
|
||||
|
||||
## 3. Part B — Entropy & Seed Generation Audit Checklist
|
||||
|
||||
Actionable and greppable. Findings marked **[ARCHY-n]** are results of direct inspection of this codebase during this research and are pre-verified.
|
||||
|
||||
### B.1 Linux CSPRNG sourcing
|
||||
|
||||
**Correct:**
|
||||
- `getrandom(2)` **without** `GRND_NONBLOCK` — blocks until the pool is initialised, then never blocks again. This is the correct primitive on modern Linux (kernel ≥3.17; behaviour improved in 5.6+ and again in 5.17/5.18 where `/dev/random` and `/dev/urandom` converge). Since kernel 5.6 the `getrandom()` blocking path is the only one that guarantees an initialised pool.
|
||||
- `/dev/urandom` — acceptable **only after** the pool is known-initialised. It **never blocks**, including before initialisation, which is exactly the early-boot hazard.
|
||||
- `GRND_NONBLOCK` is correct **only** for *probing* readiness (returns `EAGAIN` when unseeded), never for drawing key material.
|
||||
|
||||
**Dangerous:**
|
||||
- Reading `/dev/urandom` during early boot / initramfs / first-boot provisioning.
|
||||
- Any userspace entropy "mixing" that *replaces* rather than *supplements* the kernel CSPRNG.
|
||||
- Trusting `RDRAND`/`RDSEED` as a sole source. Current posture: fine as **one input** into the kernel pool (which is what Linux does), never as the exclusive source — the microarchitectural trust argument has not improved.
|
||||
|
||||
**The image/clone problem — this is Archipelago's highest-risk real exposure:**
|
||||
Archipelago **ships flashable ISOs to a fleet**. Three distinct hazards:
|
||||
1. **A baked `random-seed` file.** If the ISO or the built rootfs contains a populated `/var/lib/systemd/random-seed` (or `/var/lib/urandom/random-seed`), **every node flashed from that image starts from the same credit**. Must be verified absent (or zero-length) in the image.
|
||||
2. **Early-boot seed generation on freshly-flashed hardware.** Onboarding generates the master seed very early, potentially before the pool has accumulated much. `getrandom(2)` blocking makes this *safe but slow*; the failure mode is a hang, not a weak key — which is the correct trade.
|
||||
3. **VM / container clones.** If any node image is ever cloned post-first-boot, the cloned pool state is shared.
|
||||
|
||||
**Mitigations to spec:** `jitterentropy-rngd` (kernel ≥5.6 also has an in-kernel jitter source) or `haveged` in the image for headless/low-peripheral hardware; explicit removal of any seed file at image build; a first-boot unit that regenerates the seed file; `RNDADDENTROPY` (via `rngd`) only where a *trusted* hardware source exists.
|
||||
|
||||
**Audit commands:**
|
||||
```bash
|
||||
# Is a seed file baked into the image?
|
||||
find image-recipe/ -name "random-seed" -o -name "*.seed"
|
||||
# On a freshly-flashed node, before any key generation:
|
||||
cat /proc/sys/kernel/random/entropy_avail
|
||||
systemd-analyze blame | grep -i random
|
||||
journalctl -b | grep -i "crng init\|random: " # look for "crng init done" timestamp
|
||||
```
|
||||
Correlate the `crng init done` timestamp against the timestamp of seed generation. **[ARCHY-3]** below.
|
||||
|
||||
### B.2 Rust specifics
|
||||
|
||||
**Grep for these — dangerous in a key path:**
|
||||
```
|
||||
rand::random # CSPRNG-backed in rand 0.8, but source is implicit
|
||||
SmallRng # NOT cryptographic — disqualifying
|
||||
StdRng::seed_from_u64 # deterministic from 64 bits — disqualifying
|
||||
::from_seed( # check what the seed is
|
||||
rand::rngs::mock
|
||||
SystemTime::now() # near any key/nonce/salt generation
|
||||
.as_nanos() # ditto
|
||||
```
|
||||
|
||||
**Grep for these — correct:**
|
||||
```
|
||||
rand::rngs::OsRng # direct getrandom(2); no userspace state
|
||||
getrandom::getrandom
|
||||
ring::rand::SystemRandom
|
||||
rand::thread_rng # a CSPRNG, but see the nuance below
|
||||
```
|
||||
|
||||
**`rand::thread_rng()` — the nuance that matters here.** In `rand 0.8.x`, `ThreadRng` is `ReseedingRng<ChaCha12Core, OsRng>`: seeded from `getrandom(2)`, reseeded every 64 KiB, implements `CryptoRng`. It **is** cryptographically acceptable. Two version-sensitive caveats [10]:
|
||||
- **Fork protection was removed in `rand 0.9.0` (2025-01-27).** The changelog: *"Remove fork-protection from `ReseedingRng` and `ThreadRng`. Instead, it is recommended to call `ThreadRng::reseed` on fork."* Archipelago is on **`rand 0.8.5`, which still has fork protection** — but a future bump to 0.9/0.10 silently removes it. Archipelago's orchestrator forks/spawns constantly.
|
||||
- **`rand 0.9.1` (2025-04-17)** added an explicit upstream policy statement: *"rand is not a crypto library."* [10] Take the maintainers at their word: for key material, prefer `OsRng` (renamed `SysRng` in `rand 0.10.0`, 2026-02-08 [10]).
|
||||
|
||||
**RustSec status:** the only directly relevant advisory found is **RUSTSEC-2021-0023** (`rand_core` 0.6.0–0.6.1: `le::read_u32_into` / `read_u64_into` under-fill the destination buffer; category *crypto-failure*) [11]. No current advisory found against `rand 0.8.5`, `getrandom`, `bip39`, `rust-bitcoin`, or `bdk`. **The audit should run `cargo audit` / `cargo deny` in CI rather than relying on this snapshot.** Bumping `rand` to 0.9+ requires the explicit fork-reseed treatment above.
|
||||
|
||||
**secp256k1 nonces:** prefer **RFC6979 deterministic nonces** (`sign_ecdsa` in `rust-secp256k1` is RFC6979 by default) over randomised nonces. This eliminates the T6/T7 class entirely. If you use randomised or auxiliary-randomness variants (`sign_ecdsa_with_noncedata`, BIP-340 aux rand), the randomness must come from `OsRng`.
|
||||
|
||||
**Zeroization:** `zeroize` / `ZeroizeOnDrop` on every seed, mnemonic, and derived-key type. Watch for the classic escapes: `String`/`Vec` reallocation leaves copies behind; `format!`/`to_string()` on secret types; `#[derive(Debug)]` on a struct holding key bytes; `Clone` on secret types.
|
||||
|
||||
#### Archipelago findings (direct inspection)
|
||||
|
||||
**[ARCHY-1] — STRUCTURAL, the Coldcard-shaped one. `core/archipelago/src/seed.rs:92`**
|
||||
|
||||
```rust
|
||||
let mnemonic = bip39::Mnemonic::generate(24)
|
||||
```
|
||||
|
||||
In `bip39 2.1.0` this resolves through `generate` → `generate_in` → `generate_in_with(&mut rand::thread_rng(), language, word_count)` (verified by reading `~/.cargo/registry/.../bip39-2.1.0/src/lib.rs:297`). So **the entropy source for Archipelago's entire key hierarchy — including the fleet release-root signing key — is chosen by a transitive dependency's default, not stated at the call site.**
|
||||
|
||||
*This is not a vulnerability today.* `thread_rng()` in 0.8.5 is a CSPRNG with fork protection. But it is **precisely the structural pattern that produced T1**: a call whose entropy backend is determined by build/dependency configuration rather than by the calling code. A `bip39` minor bump, a `rand` major bump, or a feature-flag change could rebind it without a compile error.
|
||||
|
||||
Recommended (planning input, not applied here):
|
||||
```rust
|
||||
use rand::rngs::OsRng;
|
||||
let mnemonic = bip39::Mnemonic::generate_in_with(
|
||||
&mut OsRng, bip39::Language::English, 24
|
||||
)?;
|
||||
```
|
||||
plus a regression test asserting 256-bit entropy and a comment pinning the rationale. Note `bip39` is pinned `=2.1.0` while 2.2.2 is current — review its changelog before bumping.
|
||||
|
||||
**[ARCHY-2] — GOOD, keep. `core/archipelago/src/seed.rs:52-91`**
|
||||
The `kernel_csprng_ready()` probe uses `GRND_NONBLOCK` correctly *as a probe only* and logs a `warn!` when the pool is uninitialised. The doc comment correctly reasons that `getrandom(2)` blocks so a seed can never be drawn from an unseeded pool. This is exactly right and better than most implementations. Two hardening notes: (a) the invariant depends on `getrandom` (the crate) using the blocking syscall — worth an explicit test rather than a comment; (b) consider elevating the warn to a **structured event persisted to disk**, so a post-hoc audit of any node can answer "was the pool ready when this seed was born?" — the question Coldcard owners cannot answer today.
|
||||
|
||||
**[ARCHY-3] — HIGH PRIORITY, unverified, ISO-specific.** Nothing in this research verified whether the built ISO ships a populated `/var/lib/systemd/random-seed`, nor whether `crng init done` reliably precedes onboarding seed generation on freshly-flashed hardware. Given Archipelago ships a *single image to many nodes*, this is the most plausible route to a real cross-node entropy correlation. Must be checked on real hardware (see Open Questions).
|
||||
|
||||
**[ARCHY-4] — MEDIUM, seed crosses the network boundary. `core/archipelago/src/api/rpc/seed_rpc.rs:147`**
|
||||
The generated mnemonic is returned to the web client as `words: Vec<String>` over JSON-RPC, and held server-side in memory under a 10-minute TTL (`MNEMONIC_TTL`), deliberately not cleared at verify time (`seed_rpc.rs:205-209`, with a documented rationale about client aborts). Archipelago is **served over plain HTTP on LAN in places** (memory: `.116` runs nginx :80 with `ARCHY_SCHEME=http`). A 24-word master mnemonic that unlocks the release-root signing key traversing plaintext HTTP on a shared LAN is a genuine exposure — independent of RNG quality. Mitigations to spec: confine seed-bearing RPCs to loopback/onboarding-only, force TLS for those methods, shrink the TTL, and treat the in-memory hold as a deliberate, documented, time-boxed risk.
|
||||
|
||||
**[ARCHY-5] — LOW, modulo bias. `core/archipelago/src/totp.rs:305`**
|
||||
```rust
|
||||
let idx = (rand::random::<u8>() as usize) % charset.len();
|
||||
```
|
||||
Classic modulo bias whenever `charset.len()` does not divide 256 — a small, uniform-distribution defect in generated passwords/backup codes, not a catastrophic one. Fix with rejection sampling or `rand::seq::SliceRandom::choose`.
|
||||
|
||||
**Also noted (no action required):** `storage_crypto.rs:39` and `credentials/store.rs:69` draw 96-bit ChaCha20-Poly1305 nonces via `rand::random()`. CSPRNG-backed and fine; be aware of the random-nonce birthday bound (~2^32 messages per key) if either key becomes long-lived and high-volume.
|
||||
|
||||
### B.3 JS / TS / browser specifics
|
||||
|
||||
**Dangerous — grep:** `Math.random`, `Date.now()` near key generation, `new Date().getTime()`, `jsbn`, `SecureRandom(` (the T4 signature), any `bip39`/`bitcoinjs-lib` mnemonic generation in the browser.
|
||||
|
||||
**Correct:** `crypto.getRandomValues(new Uint8Array(n))` (browser), `crypto.randomBytes(n)` (Node), `crypto.webcrypto.getRandomValues` (Node ≥15).
|
||||
|
||||
**The secure-context fact that matters for Archipelago** [12]: `Crypto.getRandomValues()` is **the only member of the `Crypto` interface usable from an insecure context** — it works over plain `http://`. `crypto.subtle` / `SubtleCrypto` **requires a secure context** and will be `undefined` over plain HTTP. Since Archipelago serves the UI over plain HTTP on LAN in places, any code path that reaches for `crypto.subtle` will fail there while `getRandomValues` keeps working. Max 65,536 bytes per `getRandomValues` call (`QuotaExceededError` beyond).
|
||||
|
||||
**Archipelago frontend findings (direct inspection):**
|
||||
- ✅ `neode-ui/src/views/OnboardingVerify.vue:107` and `neode-ui/src/views/web5/Web5.vue:185` use `crypto.getRandomValues` — correct, and correct under plain HTTP.
|
||||
- ⚠️ `neode-ui/src/views/OnboardingSeedVerify.vue:159` uses `Math.floor(Math.random() * max)` to choose which mnemonic word indices to quiz. **Not key material** — the indices only select a UX challenge; an attacker who could predict them still learns nothing. **Low severity**, but it is a `Math.random()` call inside a *seed-handling view*, which is the kind of thing an auditor should either fix or annotate so the next auditor doesn't have to re-derive that it's benign.
|
||||
- ✅ `rpc-client.ts` (retry jitter), `Login.vue:317` (progress bar), `BootScreen.vue` (starfield) — `Math.random()` is correct here; non-security.
|
||||
|
||||
### B.4 BIP-39 correctness
|
||||
|
||||
- **Entropy lengths:** 128 bits → 12 words; 256 bits → 24 words. Archipelago uses 24/256 and enforces `word_count != 24` rejection on restore (`seed.rs:112`) — good.
|
||||
- **Checksum:** first `ENT/32` bits of `SHA256(entropy)` appended. A valid checksum proves *format*, **not entropy quality** — it would have passed cleanly on every drained Coldcard.
|
||||
- **Seed derivation:** `PBKDF2-HMAC-SHA512`, 2048 rounds, salt = `"mnemonic" + passphrase`. Archipelago uses an **empty passphrase** (`seed.rs:100`), which is a defensible product decision but removes the second factor that partially protected some Coldcard users. Worth an explicit decision record.
|
||||
- **Hazards to check:** brain wallets (never); user-supplied dice entropy (must be *added to*, never *replace*, system entropy — and note that dice were exactly what saved Coldcard users); wordlist normalisation (NFKD, and language must be pinned); any "compress the mnemonic to a short code" feature.
|
||||
- **The T1 inequality, restated as an audit rule:** *if `N` bits enter the KDF, at most `2^N` seeds can exit it.* Count the bits at the **source**, never at the output.
|
||||
|
||||
### B.5 Memory and at-rest handling
|
||||
|
||||
- `zeroize` / `ZeroizeOnDrop` on all seed types — Archipelago's `MasterSeed` does this (`seed.rs:47-50`). ✅
|
||||
- Never log seed material at any level — `seed.rs:18` states this as an invariant; the audit should *verify* it by grepping for `mnemonic` / `seed` inside `tracing::`, `format!`, `Display`/`Debug` impls, and error strings (a mnemonic embedded in an `anyhow` context string will reach the log).
|
||||
- Avoid swap for the daemon: `MemoryDenyWriteExecute`, and consider `mlock`/`memfd` for the in-memory pending mnemonic; or disable swap on nodes.
|
||||
- File permissions: `master_seed.enc` / `lnd_aezeed.enc` must be `0600`, owned by the service user. Archipelago already encrypts at rest with **Argon2 + ChaCha20-Poly1305** (`seed.rs:238-260`, salt/nonce from `OsRng`). ✅ — note `Argon2::default()` parameters vs ADR-005's stated 64MB/3-iteration profile; worth confirming they match.
|
||||
- **The seed should ideally never cross the RPC/websocket boundary at all** — see [ARCHY-4].
|
||||
|
||||
### B.6 Seed display and QR
|
||||
|
||||
Archipelago already ships SeedQR (Passport-Prime-compatible; memory notes LND aezeed is text-only by design). Audit items: no seed in clipboard by default; screenshot-hostile display where the platform permits; SeedQR rendered client-side from data already on screen rather than fetched as an image; the QR must never be logged or cached; and the companion app's scanner must not persist scanned frames.
|
||||
|
||||
### B.7 Verification techniques an auditor can run
|
||||
|
||||
1. **Call-graph trace.** For every secret, trace from the syscall to the consumer. Any hop where the source is a *default* rather than an *argument* is a T1-shaped risk.
|
||||
2. **Dependency-default sweep.** `cargo tree -i rand` / `-i getrandom`; for each crate that generates key material, read its `generate()` to find which RNG it defaults to. This is how [ARCHY-1] was found and is the single highest-yield technique for this bug class.
|
||||
3. **`cargo audit` / `cargo deny` in CI** — do not rely on a point-in-time RustSec snapshot.
|
||||
4. **Boot-order evidence.** Correlate `crng init done` from `journalctl -b` against the seed-generation timestamp on freshly-flashed hardware.
|
||||
5. **Cross-node collision test.** Flash N nodes from the same ISO, generate a seed on each without user interaction, and confirm all N differ *and* that their first 64 bytes show no structure. This is the empirical test that would have caught T1.
|
||||
6. **NIST SP 800-90B-style spot checks** on the *raw source* (not the KDF output) — min-entropy estimation, repetition-count and adaptive-proportion health tests. Note these test the source, and a broken source wrapped in SHA256 will pass output-side tests (Yasmarang output would pass most statistical suites; that is why they didn't catch it).
|
||||
7. **On-chain nonce check** for any ECDSA signing: scan for duplicate `r` values.
|
||||
|
||||
---
|
||||
|
||||
## 4. Part C — PSBT / Watch-Only / Multisig Landscape + LND Capability Matrix
|
||||
|
||||
### C.1 PSBT (BIP-174 / BIP-370)
|
||||
|
||||
PSBT is the interchange format for not-yet-fully-signed transactions plus the metadata signers need. [13]
|
||||
|
||||
**Core RPCs and the loop:**
|
||||
|
||||
| RPC | Type | Role |
|
||||
|---|---|---|
|
||||
| `walletcreatefundedpsbt` | wallet | Create PSBT with inputs/outputs, auto-add inputs + change, attach metadata |
|
||||
| `walletprocesspsbt` | wallet | Add UTXO/key/script data, optionally sign, finalize where possible |
|
||||
| `descriptorprocesspsbt` | **node** | Process a PSBT against a supplied descriptor list — **no wallet required** |
|
||||
| `utxoupdatepsbt` | node | Fill in UTXO data from the node's UTXO set |
|
||||
| `analyzepsbt` | node | Report what each input still needs and the next required role |
|
||||
| `joinpsbts` | node | Merge distinct PSBTs into one transaction |
|
||||
| `combinepsbt` | node | Merge signatures for the **same** transaction from multiple signers |
|
||||
| `finalizepsbt` | node | Produce the network-serialized tx |
|
||||
| `sendrawtransaction` | node | Broadcast |
|
||||
|
||||
**Canonical flow:** `walletcreatefundedpsbt` (watch-only) → export → sign offline → import → `combinepsbt` (multisig) → `finalizepsbt` → `sendrawtransaction`. `analyzepsbt` is the right thing to drive UI state from — it tells you literally which role must act next, so the UI never has to guess.
|
||||
|
||||
**PSBTv2 / BIP-370** removes the fixed `PSBT_GLOBAL_UNSIGNED_TX` field and distributes transaction data into per-input/per-output fields, enabling interactive construction. **PSBTv2 support has been merged into Bitcoin Core** [14]. **[UNVERIFIED]** — I did not confirm which released Core version first exposes PSBTv2 at the RPC surface, nor its current hardware-signer support breadth. Treat **PSBTv1 as the interop baseline** and PSBTv2 as opportunistic.
|
||||
|
||||
**Bitcoin Core 30.0 is a hard constraint:** BDB **legacy wallets can no longer be created or loaded** (migrate via `migratewallet`); 11 legacy RPCs removed. [14] **Archipelago runs `bitcoin:28.4` and `bitcoin-knots:latest`** (`apps/bitcoin-core/manifest.yml`, `apps/bitcoin-knots/manifest.yml`). Any PSBT work should be built **descriptor-only** from day one — do not add anything that depends on legacy wallets, and note that `bitcoin-knots:latest` is an unpinned tag, which is separately at odds with ADR-009's pinned-tag mandate.
|
||||
|
||||
### C.2 Watch-only via descriptors (BIP-380–386)
|
||||
|
||||
- `importdescriptors` imports output descriptors; a wallet imported with **public** descriptors only (`xpub`/`tpub`, no private keys) **structurally cannot sign** — this is the correct way to build an unsignable wallet, far better than any flag.
|
||||
- Key origin annotation `[fingerprint/derivation]` (e.g. `wpkh([d34db33f/84h/0h/0h]xpub.../0/*)`) is **mandatory** for hardware signers to locate their own key.
|
||||
- Every descriptor carries a checksum; Core rejects descriptors with a wrong one.
|
||||
- Create with `createwallet ... disable_private_keys=true`, then `importdescriptors`.
|
||||
|
||||
**Archipelago integration point:** `core/archipelago/src/api/rpc/bitcoin.rs` already derives a BIP-84 `m/84'/0'/0'` key from the master seed (`seed.rs:214-224`). The PSBT-first design should export the **xpub at that path** into a Core descriptor watch-only wallet and keep the private key in the daemon's encrypted store, used only to sign PSBTs — never imported into Core.
|
||||
|
||||
### C.3 Multisig
|
||||
|
||||
- **`wsh(sortedmulti(k, xpub1/…, xpub2/…, xpub3/…))`** is the standard. `sortedmulti` (BIP-67) lexicographically sorts keys in the resulting script, so **the wallet can be recreated without preserving xpub order** — a real operational win. Use `sortedmulti` unless you have a specific reason for ordered `multi`.
|
||||
- Bitcoin Core ships a canonical worked example: `doc/multisig-tutorial.md` and the functional test `test/functional/wallet_multisig_descriptor_psbt.py` — the latter is the best copyable reference for the exact RPC sequence. [15]
|
||||
- **BIP-48** derivation for multisig accounts: `m/48'/coin'/account'/script_type'` (`2'` = P2WSH). Use it; every coordinator expects it.
|
||||
- **Taproot / MuSig2 multisig:** `tr(...)` descriptors exist; **[UNVERIFIED]** — I did not confirm the 2026 state of MuSig2 key-aggregation support in Bitcoin Core's descriptor wallet or in hardware signers. **Ship `wsh(sortedmulti(...))`; treat taproot multisig as future work.**
|
||||
- **Reference implementations worth copying:** Sparrow (best all-round coordinator UX; auto-detects BBQr vs UR by connected device), Nunchuk (mobile multisig + key-sharing UX), Caravan (browser coordinator, now with BC-UR v2 QR support), Specter (Core-native). Coinkite publishes a Core-specific 2-of-2 descriptor guide. [16]
|
||||
|
||||
### C.4 Air-gapped transport formats
|
||||
|
||||
| Format | Origin | Mechanism | Notes |
|
||||
|---|---|---|---|
|
||||
| **BBQr** | Coinkite (`bbqr.org`) | Data split across sequential QR frames; receiver accumulates | Simpler; needs the frames it missed. Coldcard's native format. [17] |
|
||||
| **UR / BC-UR (v2)** | Blockchain Commons | **Fountain codes** (rateless erasure) — any sufficient subset of frames reconstructs the payload, order-independent | **More robust in noisy scanning.** Preferred if implementing one. [17][18] |
|
||||
| **SeedQR** | SeedSigner | Static QR of mnemonic word indices | Seed transport, not PSBT. Archipelago already ships this. |
|
||||
| **NFC** | Coinkite | Tapsigner / Satscard | Card products; unaffected by T1. |
|
||||
| **microSD / file** | universal | `.psbt` file exchange | Highest capacity, no density limits, slowest UX. **Most reliable for large PSBTs.** |
|
||||
|
||||
**Device support (from sources; some entries incomplete):** Coldcard → BBQr (native) + microSD + NFC; Foundation Passport and Keystone → UR; SeedSigner → BC-UR v2 [17][18]. **[UNVERIFIED]** — Jade, Krux, BitBox, Ledger, Trezor QR/format support was not confirmed this session.
|
||||
|
||||
**Density reality:** a QR maxes out around ~2,953 bytes at the largest version with lowest error correction, and far less at practical camera-scannable densities. A multi-input multisig PSBT routinely exceeds that, so **animated multi-frame is mandatory, not optional**, and microSD should always be offered as the fallback.
|
||||
|
||||
**Archipelago integration point:** the companion mobile app already has a QR scanner and SeedQR support. Adding **UR (fountain-coded)** for PSBT is the highest-leverage air-gap feature — it degrades gracefully in poor lighting, which is where BBQr's sequential model frustrates users.
|
||||
|
||||
### C.5 LND capability matrix — be honest with users
|
||||
|
||||
**Remote signing** splits `lnd` into a watch-only instance (xpubs only, internet-facing) and a signer instance (private keys, reachable only via a single inbound gRPC connection). [19]
|
||||
|
||||
Signer config:
|
||||
```ini
|
||||
[Application Options]
|
||||
nolisten=true
|
||||
nobootstrap=true
|
||||
rpclisten=10019
|
||||
[bitcoin]
|
||||
bitcoin.active=true
|
||||
bitcoin.mainnet=true
|
||||
bitcoin.node=nochainbackend
|
||||
```
|
||||
Watch-only config:
|
||||
```ini
|
||||
[remotesigner]
|
||||
remotesigner.enable=true
|
||||
remotesigner.rpchost=<signer_host:port>
|
||||
remotesigner.tlscertpath=<signer tls.cert>
|
||||
remotesigner.macaroonpath=<signer custom macaroon>
|
||||
```
|
||||
Setup: `lncli wallet accounts list > accounts-signer.json` on the signer → `lncli createwatchonly accounts-signer.json` on the watch-only node. Minimal signer macaroon: `lncli bakemacaroon --save_to signer.custom.macaroon message:write signer:generate address:read onchain:write`. Migration of an existing node: `remotesigner.migrate-wallet-to-watch-only=true` (purges private key material in place). [19]
|
||||
|
||||
Required xpub accounts at level-3 derivation: purpose **49** (NP2WKH), **84** (P2WKH), **86** (P2TR), and **1017** accounts 0–255 (node identity, channels, watchtower, HTLCs). Taproot requires v0.15.3-beta+ and a manual `lncli wallet accounts import --address_type p2tr <xpub> default` on upgrade, else `"account 0 not found"`. [19]
|
||||
|
||||
| Capability | Possible with LND today? | Detail |
|
||||
|---|---|---|
|
||||
| Watch-only `lnd` + separate signer | ✅ Yes | `remotesigner.*`; signer needs no chain backend (`bitcoin.node=nochainbackend`) [19] |
|
||||
| Signer fully offline | ❌ **No** | Signer must accept a **live inbound gRPC connection**. "Offline except one connection" ≠ air-gapped. [19] |
|
||||
| Air-gap channel/revocation/HTLC keys | ❌ **No** | These live in the signer and must sign **on demand, at protocol speed**. A routing node cannot tolerate human-in-the-loop signing. This is the hard limit. [19] |
|
||||
| PSBT funding of channels | ✅ Yes | `lncli openchannel --psbt` interactive flow; `PsbtShim` via `FundingStateStep`; batch by passing the returned PSBT as `base_psbt` [20] |
|
||||
| Open channels with zero LND wallet balance | ✅ Yes | The `--psbt` flow explicitly supports funding from an external wallet [20] |
|
||||
| Self-broadcast of the funding tx | ❌ **Never** | *"Do not publish the finished transaction by yourself or with another tool — lnd must publish it in the proper funding flow order or the funds can be lost."* [20] **Hard rule; encode it in the UI.** |
|
||||
| Sign arbitrary messages / on-chain txs externally | ✅ Yes | `signrpc` / `walletrpc` (`signer:generate`, `onchain:write`) [19] |
|
||||
| aezeed vs BIP-39 | aezeed is LND's own 24-word format | Archipelago sidesteps the mismatch by deriving 16 bytes of **aezeed entropy** from the BIP-39 master seed via `HKDF(seed, "archipelago/lnd/entropy/v1")` (`seed.rs:226-233`) — so the LND wallet is reproducible from the one mnemonic. Good design; document that the aezeed itself is text-only (no SeedQR) by design. |
|
||||
| Move private keys between instances post-init | ❌ Not supported [19] |
|
||||
| Add accounts dynamically without wallet reconstruction | ❌ Not supported [19] |
|
||||
|
||||
**The honest user-facing statement:** *A Lightning routing node's channel keys are necessarily hot. Remote signing relocates them to a hardened machine; it does not make them cold. Only your on-chain balance can be genuinely PSBT-protected.* Any UI that implies otherwise is misleading, and this incident is a good reason to be conservative in that copy.
|
||||
|
||||
### C.6 Hot wallet as a responsible secondary
|
||||
|
||||
If a hot wallet ships alongside a PSBT-first design:
|
||||
1. **Hard separation of on-chain and Lightning balances** in the data model and in the UI — never one "balance" number.
|
||||
2. **Spend limits** on the hot path (per-tx and rolling daily), enforced **server-side**, with anything above the limit forced onto the PSBT path.
|
||||
3. **Encrypted at rest** with the existing Argon2 + ChaCha20-Poly1305 envelope; key material never in the UI, never over RPC.
|
||||
4. **Explicit tiering in the UI:** cold (PSBT/watch-only) → warm (hot on-chain, limited) → hot (Lightning, unavoidably). Name the tradeoff rather than hiding it.
|
||||
5. **Default to the safe path.** T1's survivors were the users who took the *optional* extra step (dice rolls). Design so the safe path is the default, not the option.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open Questions / Could Not Verify
|
||||
|
||||
1. **[ARCHY-3] ISO entropy** — Does the built ISO ship a populated `/var/lib/systemd/random-seed`? Does `crng init done` precede onboarding seed generation on freshly-flashed hardware? Does the image include `jitterentropy-rngd`/`haveged`? **Must be checked on real hardware; not answerable from this environment.** Highest-priority unknown.
|
||||
2. **Cross-node seed collision test** — never run to my knowledge. The N-node same-ISO test in B.7(5) is cheap and is the empirical proof.
|
||||
3. **PSBTv2 in released Core** — merged [14], but the first release exposing it at the RPC surface, and its hardware-signer support breadth, were not confirmed.
|
||||
4. **Taproot / MuSig2 descriptor multisig** — 2026 state in Core and hardware signers not confirmed. Recommendation stands: ship `wsh(sortedmulti(...))`.
|
||||
5. **Hardware-signer format matrix** — Jade, Krux, BitBox, Ledger, Trezor QR/UR/BBQr support unconfirmed.
|
||||
6. **CVE assignment for the Coldcard incident** — no CVE ID found in any source as of 2026-07-31. Given disclosure was <48h ago, one may not exist yet. Searched: "Coldcard entropy bug CVE 2026 advisory MICROPY_HW_ENABLE_RNG".
|
||||
7. **T6 (Android SecureRandom 2013) and T7 (Blockchain.info R-value reuse)** — included from training knowledge, marked `[ASSUMED]`; not re-verified with live sources this session. Their *lesson* (RFC6979) is independently well-established.
|
||||
8. **AI-assisted discovery of the Coldcard bug** — NVK's attribution [5] is an opinion, not established fact. No source establishes attacker methodology.
|
||||
9. **Argon2 parameters** — `seed.rs` uses `Argon2::default()`; ADR-005 specifies 64MB / 3 iterations. Whether the default matches was not confirmed.
|
||||
10. **`bitcoin-knots:latest`** — unpinned image tag in `apps/bitcoin-knots/manifest.yml`, which appears to conflict with ADR-009's pinned-tag mandate. Out of scope here; flagged for the follow-on.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sources
|
||||
|
||||
All accessed **2026-07-31**.
|
||||
|
||||
**Primary — the incident**
|
||||
1. Coinkite, *"Technical Deep Dive into the Entropy Issue"* — https://blog.coinkite.com/entropy-technical-backgrounder/ (vendor postmortem; `ckcc.rng_bytes()` → `ngu.random.bytes()`, `random.c:22-31` guard, entropy figures, timeline)
|
||||
2. Coinkite, *"Coldcard Security Advisory"* — https://blog.coinkite.com/coldcard-mk3-seed-generation-warning/ (published 2026-07-30; updated 2026-07-31 12:39 EDT; affected/fixed versions, dice exception, user actions)
|
||||
3. Block Engineering, *"Predictable RNG Fallback and 32-Bit Reseed in COLDCARD Firmware"* — https://engineering.block.xyz/blog/predictable-rng-fallback-and-32-bit-reseed-in-coldcard-firmware (**deepest technical source**: file/line refs, Yasmarang seeding, `random_reseed()`, search-space math, commit hashes, timeline)
|
||||
4. *"COLDCARD Entropy Incident — Address Check and Evidence"* — https://coldcardentropy.org/ (client-side address checker; 1,195 addresses / 1,082.65 BTC dataset)
|
||||
5. Bitcoin Magazine, *"Coinkite Releases Fixed Firmware After Coldcard Bug; AI Likely Involved In The Breach"* — https://bitcoinmagazine.com/business/coinkite-releases-fixed-firmware-after-coldcard-bug-ai-likely-involved-in-the-hack (fixed-firmware timing, NVK attribution, ~$70M/24h)
|
||||
- Corroborating secondary (not relied on for technical claims): Bitcoin Magazine https://bitcoinmagazine.com/news/coldcard-wallet-exposed-after-bitcoin-hack ; Protos https://protos.com/coldcard-attack-25-minutes-500-wallets-38m-in-btc-gone/
|
||||
|
||||
**Primary — historical catalogue**
|
||||
6. CVE-2023-39910 (Milk Sad, Libbitcoin Explorer 3.0.0–3.6.0) — https://nvd.nist.gov/vuln/detail/CVE-2023-39910 ; https://osv.dev/vulnerability/CVE-2023-39910 ; GHSA-prgj-h7jq-7p9h ; disclosure: https://milksad.info/
|
||||
7. CVE-2023-31290 (Trust Wallet Core <3.1.1 / extension <0.0.183) — https://nvd.nist.gov/vuln/detail/CVE-2023-31290 ; GHSA-pm4f-pggw-8jwc ; https://milksad.info/disclosure.html ; Ledger analysis: https://www.ledger.com/blog/funds-of-every-wallet-created-with-the-trust-wallet-browser-extension-could-have-been-stolen
|
||||
8. Unciphered, *"Randstorm: You Can't Patch a House of Cards"* — https://www.unciphered.com/disclosure-of-vulnerable-bitcoin-wallet-library-2/
|
||||
9. Amber Group, *"Exploiting the Profanity Flaw"* — https://medium.com/amber-group/exploiting-the-profanity-flaw-e986576de7ab ; CertiK Wintermute analysis: https://www.certik.com/resources/blog/uGiY0j3hwOzQOMcDPGoz9-wintermute-hack-
|
||||
|
||||
**Primary — Rust / browser entropy**
|
||||
10. rand CHANGELOG — https://github.com/rust-random/rand/blob/master/CHANGELOG.md (0.9.0 2025-01-27 fork-protection removal; 0.9.1 2025-04-17 "rand is not a crypto library"; 0.10.0 2026-02-08 `OsRng`→`SysRng`)
|
||||
11. RUSTSEC-2021-0023 (`rand_core` 0.6.0–0.6.1) — https://github.com/RustSec/advisory-db/blob/main/crates/rand_core/RUSTSEC-2021-0023.md ; database: https://rustsec.org/advisories/
|
||||
12. MDN, `Crypto.getRandomValues()` — https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues (**only** `Crypto` member usable from an insecure context; 65,536-byte limit; `SubtleCrypto` requires secure context)
|
||||
|
||||
**Primary — PSBT / descriptors / multisig / LND**
|
||||
13. Bitcoin Core, `doc/psbt.md` — https://github.com/bitcoin/bitcoin/blob/master/doc/psbt.md
|
||||
14. Bitcoin Core 30.0 release notes — https://bitcoincore.org/en/releases/30.0/ (BDB legacy wallet removal, `migratewallet`, PSBTv2/BIP-370 merge)
|
||||
15. Bitcoin Core, `doc/multisig-tutorial.md` — https://github.com/bitcoin/bitcoin/blob/master/doc/multisig-tutorial.md ; `test/functional/wallet_multisig_descriptor_psbt.py` — https://github.com/bitcoin/bitcoin/blob/master/test/functional/wallet_multisig_descriptor_psbt.py ; `doc/descriptors.md` — https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md
|
||||
16. Coinkite, *"Descriptors & Multisig"* (Core 2-of-2) — https://coldcard.com/docs/bitcoin-core-2of2desc/
|
||||
17. BBQr specification — https://bbqr.org/ ; Coinkite, *"Bitcoin Air-Gap Signing Methods"* — https://coldcard.com/learn/advanced-concepts/air-gap-signing-methods
|
||||
18. Blockchain Commons, *"Animated QRs"* (UR / fountain codes) — https://developer.blockchaincommons.com/animated-qrs/
|
||||
19. LND, `docs/remote-signing.md` — https://github.com/lightningnetwork/lnd/blob/master/docs/remote-signing.md
|
||||
20. LND, `docs/psbt.md` — https://github.com/lightningnetwork/lnd/blob/master/docs/psbt.md ; Builder's Guide PSBT — https://docs.lightning.engineering/lightning-network-tools/lnd/psbt ; bulk PSBT — https://docs.lightning.engineering/lightning-network-tools/lnd/bulk-psbt ; PR #3722 (external funding / `PsbtShim`) — https://github.com/lightningnetwork/lnd/pull/3722
|
||||
|
||||
**Codebase inspection (this session, 2026-07-31)** — `core/archipelago/src/seed.rs`, `core/archipelago/src/api/rpc/seed_rpc.rs`, `core/archipelago/src/totp.rs`, `core/archipelago/Cargo.toml`, `~/.cargo/registry/src/**/bip39-2.1.0/src/lib.rs`, `neode-ui/src/views/Onboarding*.vue`, `apps/bitcoin-core/manifest.yml`, `apps/bitcoin-knots/manifest.yml`, `apps/lnd/manifest.yml`.
|
||||
|
||||
**Where live web contradicted prior knowledge:** the Coldcard entropy incident post-dates my training and was unknown to me before this session — every claim in §A.1 comes from the sources above, not from memory. The `rand` fork-protection removal in 0.9.0 and the `OsRng`→`SysRng` rename in 0.10.0 also corrected my priors.
|
||||
-210
@@ -1,210 +0,0 @@
|
||||
---
|
||||
phase: quick-260731-upz
|
||||
plan: 01
|
||||
subsystem: security
|
||||
status: complete
|
||||
tags: [security, entropy, bip39, seed, psbt, audit, bitcoin, lnd]
|
||||
requires: []
|
||||
provides:
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md
|
||||
- docs/security/PSBT-SIGNING-ARCHITECTURE.md
|
||||
- injectable-RNG seam in core/archipelago/src/seed.rs
|
||||
affects:
|
||||
- core/archipelago/src/seed.rs
|
||||
- docs/UNIFIED-TASK-TRACKER.md
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Key-generation entropy source is passed as an argument, never inherited from a dependency default"
|
||||
- "Injection seam + deterministic test RNG as the regression guard for entropy-source rebinding"
|
||||
key-files:
|
||||
created:
|
||||
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md
|
||||
- docs/security/PSBT-SIGNING-ARCHITECTURE.md
|
||||
modified:
|
||||
- core/archipelago/src/seed.rs
|
||||
- docs/UNIFIED-TASK-TRACKER.md
|
||||
decisions:
|
||||
- "image-recipe/_archived/ is NOT dead code — build-debian-iso.sh execs it; it is the live ISO builder and therefore in audit scope"
|
||||
- "ARCHY-1 fix applied as an injectable-RNG seam with a known-answer test; no derivation, word-count, passphrase or at-rest-encryption behaviour changed"
|
||||
- "ARCHY-5 refuted as a present defect (32 divides 256, so no modulo bias today) but retained as a latent one"
|
||||
- "PSBT spec ships wsh(sortedmulti) and defers taproot/MuSig2 as UNVERIFIED; BC-UR v2 chosen over BBQr on graceful-degradation grounds"
|
||||
- "Migration section deliberately does NOT tell Archipelago users to rotate seeds — the audit found no entropy defect, and over-alarming has real cost"
|
||||
metrics:
|
||||
duration: ~75min
|
||||
completed: 2026-08-01
|
||||
---
|
||||
|
||||
# Quick Task 260731-upz: Entropy/Seed Audit + PSBT Signing Architecture — Summary
|
||||
|
||||
Turned the confirmed 2026-07-30 Coinkite COLDCARD low-entropy incident into an
|
||||
evidence-backed audit of Archipelago's own entropy paths, a plannable PSBT-first signing
|
||||
spec, a prioritised remediation backlog wired into the tracker, and one small, test-proven
|
||||
hardening fix to master-seed generation.
|
||||
|
||||
## ⚠️ Push deliberately withheld
|
||||
|
||||
**Nothing was pushed.** Per explicit instruction, the `core/archipelago/src/seed.rs` diff is
|
||||
held for human review before it leaves this machine — it is master-seed generation code for
|
||||
every new node.
|
||||
|
||||
**Review commands:**
|
||||
|
||||
```bash
|
||||
git show 8b51b7e2 # the full seed.rs diff (122 insertions, 2 deletions)
|
||||
git log --oneline -4 # this plan's four commits
|
||||
git show --stat 8b51b7e2
|
||||
```
|
||||
|
||||
**Commits awaiting review, all on `main`, none pushed:**
|
||||
|
||||
| Commit | Type | Contents |
|
||||
|---|---|---|
|
||||
| `f11db4ea` | docs | `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` (the audit) |
|
||||
| `5faf1a3c` | docs | `docs/security/PSBT-SIGNING-ARCHITECTURE.md` (the spec) |
|
||||
| `5ba80e49` | docs | Remediation backlog + F-13 + tracker items |
|
||||
| `8b51b7e2` | **fix** | **`core/archipelago/src/seed.rs` — the diff to review** |
|
||||
|
||||
**What to check in `8b51b7e2`:** that the change is limited to (a) routing mnemonic
|
||||
generation through a helper that takes its RNG as a parameter, with `OsRng` passed at the
|
||||
production call site, and (b) two new tests — and that **no** derivation path, word count,
|
||||
BIP-39 passphrase decision, or at-rest encryption behaviour changed. It does not, but that
|
||||
is the thing worth confirming with your own eyes.
|
||||
|
||||
## Headline result
|
||||
|
||||
**No Coldcard-class entropy defect exists in this codebase.** Every first-party
|
||||
key-generation call site draws from a genuine CSPRNG. There is no Mersenne Twister, no
|
||||
clock-seeded key, no `SmallRng`, no `seed_from_u64`, and no `Math.random()` in any browser
|
||||
key path. The code also does several things better than most implementations (audit §5).
|
||||
|
||||
**But the audit found something more urgent than anything entropy-related.**
|
||||
|
||||
## The Critical finding (F-01) — not what we went looking for
|
||||
|
||||
`seed.generate` and `seed.restore` are in `UNAUTHENTICATED_METHODS`
|
||||
(`core/archipelago/src/api/rpc/middleware.rs:24-28`), which skips session, RBAC **and** CSRF.
|
||||
Neither handler checks whether onboarding is already complete, and
|
||||
`NodeIdentity::from_seed` (`core/archipelago/src/identity.rs:79-114`) overwrites `node_key`,
|
||||
`nostr_secret` and the FIPS mesh key **unconditionally**. There is no rate limit. The
|
||||
endpoint is proxied to the LAN over plaintext HTTP
|
||||
(`image-recipe/configs/nginx-archipelago.conf:11`, `:165`, `:192`) and mesh peers can reach
|
||||
it too (`core/archipelago/src/server.rs:2080`).
|
||||
|
||||
**One unauthenticated POST can take over or destroy a live node's identity**, and
|
||||
`seed.restore` lets the attacker choose the mnemonic. The guard already exists and is simply
|
||||
never called — `NodeIdentity::key_exists` (`identity.rs:117`).
|
||||
|
||||
Surfaced by tracing secret classes (3) and (4) end-to-end rather than only checking where
|
||||
their bits come from. Queued as backlog **R-01** and as a Tier 2 tracker item; it changes an
|
||||
authentication boundary on a live fleet and needs its own phase.
|
||||
|
||||
## ARCHY findings — adjudicated
|
||||
|
||||
| Tag | Verdict | Note |
|
||||
|---|---|---|
|
||||
| **[ARCHY-1]** | **CONFIRMED** | `seed.rs:92` → `bip39-2.1.0/src/lib.rs:311-313` → `:296-298` (`&mut rand::thread_rng()`) → `:267-283`. **FIXED.** |
|
||||
| **[ARCHY-2]** | **CONFIRMED (positive)** | The `GRND_NONBLOCK` probe is used as a probe only; its byte is discarded; no key material comes from it. Better than most. |
|
||||
| **[ARCHY-3]** | **PARTIALLY CONFIRMED** | The feared version does not exist. Three of four sub-questions answered from the tree; the rest is an UNVERIFIED on-node checklist. |
|
||||
| **[ARCHY-4]** | **CONFIRMED, and worse** | Every claim checks out, plus it is an integrity/availability exposure too — that is F-01. |
|
||||
| **[ARCHY-5]** | **REFUTED as a present defect** | `totp.rs:305`'s charset is 32 chars and 32 divides 256, so bias is **zero** today. Latent, not live. Stated plainly rather than dropped. |
|
||||
| Open Q9 | **DIVERGENCE CONFIRMED** | `Argon2::default()` = 19 MiB / t=2 / p=1; ADR-005 says 64 MB / 3. |
|
||||
|
||||
## Two findings the research did not predict
|
||||
|
||||
- **F-03 (High)** — the installed rootfs is a **cached container export shared by every
|
||||
node**, baking SSH host keys and a TLS keypair. Per-device regeneration exists and is
|
||||
correct in intent, but both branches are **fail-open** and `touch "$MARKER"` runs
|
||||
**unconditionally** (`image-recipe/_archived/build-auto-installer-iso.sh:1647`, `:1659`,
|
||||
`:1663`), so one transient failure permanently leaves that node on the image-wide shared
|
||||
keys, visible only in a log file.
|
||||
- **F-13 (High)** — `bitcoin.rs:203` passes `disable_private_keys=false` and `:229-231`
|
||||
imports `wpkh(xprv/...)`, so the BIP-84 account **private** key is persisted in Bitcoin
|
||||
Core's `wallet.dat` (with an empty wallet passphrase) in addition to the Argon2 envelope.
|
||||
The descriptors also carry no key-origin annotation, so no hardware signer could use them.
|
||||
|
||||
## Scoping correction worth carrying forward
|
||||
|
||||
`image-recipe/_archived/` is **not dead code**. `image-recipe/build-debian-iso.sh:19-40`
|
||||
copies `_archived/build-auto-installer-iso.sh` to a temp path, rewrites its relative paths,
|
||||
and `exec`s it. **The "archived" auto-installer is the live ISO builder.** The plan scoped it
|
||||
out; treating it as dead would have made [ARCHY-3] unanswerable and hidden F-03 entirely.
|
||||
|
||||
## Deliverables
|
||||
|
||||
**`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`** — 13 findings, each with severity,
|
||||
`file:line` evidence, exploitability, blast radius and concrete remediation; all five ARCHY
|
||||
tags adjudicated; all six mandated secret classes traced; a 13-item "What we do right"
|
||||
section; a 7-item UNVERIFIED on-node checklist with paste-ready commands; and an R-00…R-15
|
||||
remediation backlog. **103 `file:line` evidence references** (gate required ≥20).
|
||||
|
||||
**`docs/security/PSBT-SIGNING-ARCHITECTURE.md`** — watch-only descriptor wallets, the full
|
||||
Core RPC loop with wallet- vs node-scoped RPCs, `analyzepsbt`-driven UI state, Tier 1
|
||||
single-sig and Tier 2 `wsh(sortedmulti)` on BIP-48, BC-UR v2 vs BBQr vs file transport, the
|
||||
honest LND capability matrix, the hot wallet as an explicitly-secondary tier, migration
|
||||
guidance, and a 7-phase rollout with dependencies and candidate requirements. Cross-links
|
||||
and answers two open items in `docs/hardware-signer-design.md`.
|
||||
|
||||
**`docs/UNIFIED-TASK-TRACKER.md`** — 9 new items in the file's existing tier/checkbox format:
|
||||
4 in Tier 0, 3 in Tier 1, 4 in Tier 2 (including the Critical F-01 item and PSBT Phase 1).
|
||||
|
||||
## The one code change
|
||||
|
||||
`core/archipelago/src/seed.rs` — `generate_mnemonic_with<R: CryptoRng + RngCore>` calls
|
||||
bip39's **injectable** `generate_in_with`; `MasterSeed::generate()` passes `OsRng` explicitly.
|
||||
|
||||
`mnemonic_generation_uses_injected_rng` asserts the result equals
|
||||
`bip39::Mnemonic::from_entropy(<the exact bytes the test RNG emitted>)` — the direct proof
|
||||
that the **injected** RNG, not bip39's transitive default, is the one consumed — plus a
|
||||
known-answer pin and a determinism check. **This test cannot be written against the previous
|
||||
code**, because `Mnemonic::generate(24)` exposes no seam.
|
||||
|
||||
**Verified:** `CARGO_INCREMENTAL=0 cargo test -p archipelago seed::` → **25 passed, 0
|
||||
failed** (23 pre-existing + 2 new).
|
||||
|
||||
**Honest limitation, recorded in the audit:** this removes a *future* failure mode. It does
|
||||
not retroactively change seeds generated before it, which came from `rand::thread_rng()` —
|
||||
a genuine CSPRNG, so nothing is weakened, but their guarantee rests on `rand 0.8.5`'s
|
||||
behaviour rather than on this call site.
|
||||
|
||||
## Deviations from plan
|
||||
|
||||
1. **`image-recipe/_archived/` brought into scope** (plan said excluded). Justified above;
|
||||
documented in the audit's §1 so the next auditor does not re-derive it.
|
||||
2. **F-13 added to the audit during Task 3.** Discovered while reading `bitcoin.rs` for the
|
||||
PSBT spec. It belongs to secret class (1), which Task 1 was required to trace, so it was
|
||||
written up rather than left in the spec alone.
|
||||
3. **R-12 (`totp.rs` modulo bias) NOT applied**, though the plan permitted it. `[ARCHY-5]`
|
||||
was refuted as a present defect — 32 divides 256, so there is no bias today. Changing
|
||||
working crypto code for a latent-only issue did not meet the plan's "small and obviously
|
||||
correct" bar during a security-sensitive pass. Queued as R-12.
|
||||
4. **`cargo audit` not run** — `cargo-audit` is not installed. Recorded as gap F-07 with
|
||||
CI remediation R-05 rather than silently skipped.
|
||||
|
||||
## Not done, deliberately
|
||||
|
||||
- **No push, no tag, no deploy** (see the banner above).
|
||||
- **No PSBT/watch-only/multisig implementation** — the spec is a spec.
|
||||
- **`core/archipelago/src/container/secrets.rs` untouched** (backlog R-13) — it carried
|
||||
another agent's uncommitted work. Read-only for the audit, as required.
|
||||
|
||||
## Concurrent-agent hygiene
|
||||
|
||||
All four commits verified against the forbidden-path list: **no commit authored by this plan
|
||||
contains any of the other agents' files.** Every commit staged by explicit path; no
|
||||
`git add -A`, no `git add .`, no `git commit -a`. The submodule guard (`indeedhub`) ran
|
||||
before each commit and passed. Their uncommitted work (`ScreensaverRing.vue`,
|
||||
`SendBitcoinModal.vue`, `WalletScanModal.vue`, and the earlier set) is intact.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — FOUND
|
||||
- `docs/security/PSBT-SIGNING-ARCHITECTURE.md` — FOUND
|
||||
- `core/archipelago/src/seed.rs` — modified, tests green
|
||||
- Commits `f11db4ea`, `5faf1a3c`, `5ba80e49`, `8b51b7e2` — all FOUND in `git log`
|
||||
- Task 1 verify gate — OK (103 evidence refs, all required tokens present, no secret-shaped
|
||||
strings)
|
||||
- Task 2 verify gate — OK (all 12 required tokens present, no secret-shaped strings)
|
||||
- Task 3 verify gate — OK (backlog present, both tracker links present, no forbidden paths in
|
||||
any of the four commits)
|
||||
- No real secret value appears in any produced document — verified by pattern scan on both.
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
created: 2026-07-31T19:00:00.000Z
|
||||
title: Build the installer ISO for v1.7.119-alpha
|
||||
area: tooling
|
||||
severity: minor
|
||||
files:
|
||||
- scripts/build-iso-release.sh
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
v1.7.119-alpha is released and its OTA assets are published and checksum-verified, but the **installer ISO was never built**. Three attempts were blocked by a dirty working tree — `build-iso-release.sh`'s preflight hard-fails on any uncommitted change, and this checkout is shared by phase 1 executors and a separate BotFights session, so the tree is rarely clean for long. Not urgent: the ISO only matters for fresh installations; everyone updating is served by the working OTA.
|
||||
|
||||
## Solution
|
||||
|
||||
Run during a genuine pause in phase 1 execution (between waves), not racing against it:
|
||||
|
||||
```
|
||||
TMPDIR=/home/archipelago/tmp/iso-scratch bash scripts/build-iso-release.sh
|
||||
```
|
||||
|
||||
Keep the QEMU boot test (no `--no-qemu`).
|
||||
|
||||
**Preconditions:** `git status --porcelain` empty on `main`.
|
||||
|
||||
**Launch it as an explicit background job.** The build runs well past 10 minutes; twice it hit the tool's foreground timeout and once that killed an in-progress `cargo test` outright.
|
||||
|
||||
**Build from current `main` HEAD, not a detached tag checkout** — decision already made, don't re-derive. Preflight requires `git rev-parse --abbrev-ref HEAD == "main"` (a detached checkout returns `HEAD` and fails), and the archipelago version identity (Cargo.toml / package.json / CHANGELOG / manifest, all `1.7.119-alpha`) is untouched by commits made after the tag. BotFights is versioned and shipped independently via the signed catalog, so building at HEAD ships the security-fixed 1.2.11 rather than reverting to the vulnerable 1.2.9 current at tag time.
|
||||
|
||||
**Gates already seen passing** (mostly cached, expect them to pass fast): git-diff-check, cargo-fmt, catalog-drift, whats-new-sync, ui-type-check, ui-unit-tests (788), cargo-check. **Not yet reached:** cargo-test-weekly (~1500s), verify-artifacts, build-iso, iso-smoke, qemu-boot.
|
||||
|
||||
`TMPDIR` matters while `/tmp` (12G tmpfs) is tight — honored natively by mktemp-based scripts and by `test-iso-qemu.sh` since commit `6c2b6668` fixed its hardcoded `/tmp` paths.
|
||||
@@ -1,60 +0,0 @@
|
||||
---
|
||||
created: 2026-08-01T04:05:00.000Z
|
||||
title: Make archi-dev-box double as a fresh test node (both shapes, no ISO flash)
|
||||
area: testing-infra
|
||||
severity: major
|
||||
files:
|
||||
- core/archipelago/src/config.rs (the env seams: ARCHIPELAGO_DATA_DIR:125, ARCHIPELAGO_BIND:129, ARCHIPELAGO_PORT_OFFSET:155, ARCHIPELAGO_APPS_DIR)
|
||||
- core/container/src/port_manager.rs (port_offset applied at :44)
|
||||
- core/archipelago/src/auth.rs (:182 is_onboarding_complete — "fresh" is decided purely from state inside data_dir)
|
||||
- tests/lifecycle/ (existing gate harness — candidate host for the new node profile)
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Dorian (2026-08-01): wants archi-dev-box to serve as a testing node that can be exercised
|
||||
"as if it's a new node", running alongside the existing Linux desktop app install, **without
|
||||
flashing the ISO**. Both shapes are wanted — this is a testing node, so it needs to cover
|
||||
first-run UX *and* real app lifecycle.
|
||||
|
||||
Today the box runs one real production-ish node: `archipelago.service` (systemd, enabled,
|
||||
`/usr/local/bin/archipelago`) against a heavily-populated `/var/lib/archipelago` (bitcoin,
|
||||
btcpay, botfights, blobs, live LND/mesh state). That node must not be disturbed — it is the
|
||||
dev-pair deploy target gated before every OTA.
|
||||
|
||||
## Solution
|
||||
|
||||
Two shapes, both ISO-free. Ship A first, then B.
|
||||
|
||||
**(A) Lightweight second instance, same Linux user.** Own empty `ARCHIPELAGO_DATA_DIR`, own
|
||||
`ARCHIPELAGO_BIND` port, `ARCHIPELAGO_PORT_OFFSET` set, mesh/Reticulum disabled. Boots
|
||||
un-onboarded, so it exercises the true first-run path: seed generation, password/setup,
|
||||
identity keygen, node naming, onboarding UI. Cheap to create and destroy — the natural
|
||||
regression harness for onboarding changes.
|
||||
|
||||
**(B) Second Linux user** (`useradd` + `loginctl enable-linger` + its own rootless podman
|
||||
namespace + own data dir). Gives a genuinely independent node where app install / uninstall /
|
||||
reinstall lifecycle is real, not shared. This is what makes it a *testing node* rather than a
|
||||
first-run mock.
|
||||
|
||||
## Hazards (verified by grep 2026-08-01, must be designed around)
|
||||
|
||||
1. **Hardcoded paths defeat `ARCHIPELAGO_DATA_DIR`.** Several constants point at
|
||||
`/var/lib/archipelago` literally and ignore the override: `bitcoin_rpc.rs:10`
|
||||
(`SECRETS_PATH`), `container/lnd.rs:131` (`ARCHY_DATA_DIR`), `electrs_status.rs:15`,
|
||||
`api/rpc/package/pine_ha.rs:34-36`, `bootstrap.rs:242` (secrets dir), `disk_monitor.rs:41`.
|
||||
Under shape (A) a test instance that installs **Bitcoin, LND, electrumx, or Pine/HA would
|
||||
read and write the LIVE node's files.** Those four are off-limits in (A); shape (B) fixes
|
||||
this properly via a different user's paths — or the constants get plumbed through config,
|
||||
which is arguably the real fix and a candidate follow-up.
|
||||
2. **Rootless podman is per-Linux-user.** In (A) both instances share one container
|
||||
namespace: `PORT_OFFSET` resolves port collisions, container *name* collisions it does not.
|
||||
3. **Reticulum/mesh contention.** The live daemon holds `/dev/mesh-radio` and
|
||||
`identity/node_key`; a second instance would fight it for the radio. Mesh must be off for
|
||||
the test node (or the radio explicitly assigned to one of them).
|
||||
|
||||
## Notes
|
||||
|
||||
Raised while resuming quick task 260731-upz (entropy/seed audit) — shape (A) is also the
|
||||
natural on-node harness for that audit's UNVERIFIED checklist, since fresh-seed generation is
|
||||
exactly the `[ARCHY-1]` path under review. Sequence this after 260731-upz lands.
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
---
|
||||
created: 2026-08-01T09:45:00.000Z
|
||||
title: Verify FED-07 gateway credential rotation on a real node (01-16 Task 2)
|
||||
area: security
|
||||
severity: major
|
||||
files:
|
||||
- core/archipelago/src/container/secrets.rs
|
||||
- core/archipelago/src/container/prod_orchestrator.rs
|
||||
- .planning/phases/01-federation-mesh-hardening/01-16-PLAN.md
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
FED-07's code is complete and pushed (`42652547` removed every shipped credential,
|
||||
`9e2d2ef2` added detection + rotation for nodes already carrying one), but **plan 01-16's
|
||||
blocking Task 2 checkpoint has never been run**, so the requirement is not closed. Two
|
||||
things are unproven:
|
||||
|
||||
1. **That the recreate actually preserves the gateway's data.** Rotation deliberately
|
||||
avoids any teardown: it changes the credential, which changes `secret_env_hash`, which
|
||||
the drift check reads as a container-label mismatch, which fires the platform's own
|
||||
recreate around the unchanged data directory, ports, volumes and container name. That
|
||||
chain is confirmed by reading the code (`prod_orchestrator.rs:3309` computes the hash,
|
||||
`:3374` compares the label) but has **never been observed running**.
|
||||
2. **That the rotated credential actually authenticates** and the old shipped one is
|
||||
rejected.
|
||||
|
||||
## Why it was deferred (2026-08-01)
|
||||
|
||||
Checkpoint step 1 was run read-only on archi-dev-box. Findings:
|
||||
|
||||
- **The node is CLEAN** — the hash file is present, `600`, `archipelago:archipelago`, and
|
||||
is *not* the shipped default. The legacy `fedimint-gateway-password` file also exists.
|
||||
- **There is no `fedimint-gateway` container.** The app is installed at
|
||||
`/opt/archipelago/apps/fedimint-gateway`, but nothing is running and
|
||||
`/var/lib/archipelago/fedimint-gateway` is empty. `archy-fedimint-ui` and
|
||||
`fedimint-clientd` are running, but they are not the gateway.
|
||||
|
||||
So the rotation path cannot fire naturally here, and steps 5–6 (data survived, gateway
|
||||
authenticates) have nothing to exercise. Proving it on this box means installing and
|
||||
starting the gateway first, then deliberately seeding the old value — which the plan's
|
||||
Planner Assumption already anticipates and requires be recorded.
|
||||
|
||||
The deploy itself is the blocker: **30 containers are running with 4–8 days uptime**
|
||||
(IndeeHub relay/minio/postgres/ffmpeg, Immich, BTCPay + nbxplorer, netbird, portainer,
|
||||
strfry, searxng, the archy UI set), the `archipelago` system service is active, and
|
||||
CLAUDE.md's standing rule is that restarting it SIGKILLs containers until Quadlet is the
|
||||
default. Step 8 then wants `tests/lifecycle/run-gate.sh` on the same box, which cycles
|
||||
install/stop/start/reinstall/reboot-survive. Dorian chose to hold rather than take that
|
||||
blast radius unattended.
|
||||
|
||||
## Solution
|
||||
|
||||
Run 01-16 Task 2's eight steps in a window where restarting `archipelago` on the target is
|
||||
acceptable. Two viable routes:
|
||||
|
||||
- **On archi-dev-box:** install + start `fedimint-gateway`, put some state in its data
|
||||
directory, seed the old hash into
|
||||
`/var/lib/archipelago/secrets/fedimint-gateway-hash`, deploy this build, restart, then
|
||||
run steps 3–8. Record that the affected state was seeded deliberately.
|
||||
- **On a node that runs the gateway for real** (.228 is the heavy 14-app node): the
|
||||
rotation path may exist naturally there, which is stronger evidence than seeding.
|
||||
|
||||
Either way, record in `01-16-SUMMARY.md`: the affected-or-clean verdict, the exact deploy
|
||||
command, whether the `secret_env_hash` recreate was observed firing, the gate result, and
|
||||
the fact that archy-x250-dev was offline (single-node verification with the second-node
|
||||
gap recorded honestly is the expected pattern).
|
||||
|
||||
## Related, found while doing 01-16
|
||||
|
||||
- **Operator recovery gap:** `handle_package_credentials`
|
||||
(`core/archipelago/src/api/rpc/package/install.rs:2093`) is a hardcoded if-chain covering
|
||||
only `filebrowser` and `photoprism`. `fedimint-gateway` falls through to an empty list,
|
||||
so after a rotation the operator has no in-UI way to get the new password — only the
|
||||
`0600` file the log line names. Adding one arm closes it; the UI already renders whatever
|
||||
the RPC returns.
|
||||
- **`apps/photoprism/manifest.yml:35` ships `PHOTOPRISM_ADMIN_PASSWORD=archipelago`**, and
|
||||
that same literal is handed to the UI. Same defect class as FED-07 on a different app —
|
||||
wants its own requirement.
|
||||
</content>
|
||||
@@ -1,111 +0,0 @@
|
||||
---
|
||||
created: 2026-08-02T12:10:00.000Z
|
||||
title: Companion 0.5.27 handover — node/web-side clipboard + QR scanner work
|
||||
area: ui
|
||||
severity: major
|
||||
files:
|
||||
- neode-ui/src/main.ts (:10-24 clipboard polyfill — the fake readText is the bug)
|
||||
- neode-ui/src/utils/clipboard.ts (to create — one util for 30 call sites)
|
||||
- neode-ui/src/views/web5/utils.ts (:39 safeClipboardWrite — best existing base)
|
||||
- neode-ui/src/components/WalletScanModal.vue (prewarm, torch, don't re-init between panes)
|
||||
- docs/qr-scanner-snappiness-handover.md (three factual corrections — see below)
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Handover from the companion-app workstream (build **0.5.27, versionCode 47**), 2026-08-02.
|
||||
All of it is **node-repo / `neode-ui/` side** — none was implemented there, the companion was
|
||||
the only thing changed. Captured verbatim-in-substance so it is not lost.
|
||||
|
||||
### 1. Companion now shims `navigator.clipboard` — do not clobber it
|
||||
|
||||
0.5.27 adds a native `ArchipelagoClipboard` bridge, injected on every page load, pointing
|
||||
`navigator.clipboard.readText/writeText` at the Android clipboard. Fixes copy **and** paste
|
||||
in-app with zero web changes, in both the kiosk WebView and the in-app browser (BTCPay, LND).
|
||||
|
||||
Contract to preserve:
|
||||
- Shim runs at `onPageStarted` and `onPageFinished`; sets `window.__archyClipboardPatched = true`.
|
||||
- Defines `navigator.clipboard` as **configurable** if absent, then assigns `readText`/`writeText`
|
||||
onto whatever object is there.
|
||||
- Reads return via `window.__archyClipboardResult(text)`.
|
||||
- **Do not** unconditionally re-define `navigator.clipboard` after page load. **Do not**
|
||||
`Object.freeze` it. Today's `main.ts` polyfill is safe *only* because it is guarded by
|
||||
`if (!navigator.clipboard)`.
|
||||
|
||||
### 2. Web-side clipboard bugs — still open, affect plain browsers
|
||||
|
||||
Native is fixed; the same code is broken in any **plain-HTTP** browser (LAN/mesh — non-secure
|
||||
context, so `navigator.clipboard` is undefined).
|
||||
|
||||
- `neode-ui/src/main.ts:10-24` — the polyfill defines `async readText() { return '' }`. That
|
||||
makes `SendBitcoinModal.vue:425`'s `canReadClipboard` **true**, so "Paste invoice" renders,
|
||||
fires, gets `''`, and silently does nothing. Fix: drop the fake `readText` (or define it only
|
||||
when a real source exists) so the button correctly hides.
|
||||
- **30 `writeText` call sites, three patterns:**
|
||||
- ~8 duplicate their own `execCommand` fallback — `Server.vue:762`, `Apps.vue:708`,
|
||||
`Credentials.vue:392`, `settings/AccountInfoSection.vue`, `settings/TwoFactorSection.vue`.
|
||||
- ~10 are bare `navigator.clipboard.writeText(x).catch(() => {})` —
|
||||
`ReceiveBitcoinModal.vue:149`, `SendBitcoinModal.vue:326`/`:574`,
|
||||
`OnboardingSeedVerify.vue:201`, `OnboardingDid.vue:216`/`:223`,
|
||||
`settings/BackupSection.vue:270`, `PeerFiles.vue:1110`/`:1415` — these show "Copied!"
|
||||
whether or not anything reached the clipboard.
|
||||
- `views/web5/utils.ts:39` `safeClipboardWrite` is the best existing base.
|
||||
- The `execCommand` fallbacks are fragile: no `focus()`, no `readonly`, no `setSelectionRange`,
|
||||
and **the return value is never checked**, so failure is invisible.
|
||||
|
||||
**Suggested shape:** one `src/utils/clipboard.ts` exporting `copyText()` / `readText()` /
|
||||
`canPaste()`, preferring native bridge → async Clipboard API → hardened `execCommand`, toasting
|
||||
"Copied" only on real success. Repoint all 30 sites at it.
|
||||
|
||||
**Paste affordances that don't exist yet** (bare textareas today): `WalletScanModal.vue` paste
|
||||
field, ecash token (`views/web5/Web5SendReceiveModals.vue:160`), **signed PSBT**, federation
|
||||
invite code (`views/federation/JoinModal.vue:16`).
|
||||
|
||||
### 3. QR scanner — web-side items still open
|
||||
|
||||
Native items are done in 0.5.27. Remaining on the web side:
|
||||
- **Pre-warm the camera** — start `getUserMedia` when the modal opens (action pane), not when
|
||||
the scan pane is reached; hide the preview until needed.
|
||||
- **Torch toggle** — `qr-scanner` exposes `hasFlash()` / `turnFlashOn()`.
|
||||
- **Constraints** — `{ focusMode: 'continuous', width: { ideal: 1280 } }`.
|
||||
- **Don't stop/start between panes** — amount → scan currently re-inits the scanner; keep the
|
||||
paused stream alive for the modal's lifetime.
|
||||
- Already done upstream: 10 scans/sec where `BarcodeDetector` exists.
|
||||
- **New optional hook:** `window.ArchipelagoQr?.prewarm?.()` — safe to call repeatedly, safe when
|
||||
absent. The companion also self-prewarms on every node page load, so this is a small extra win.
|
||||
|
||||
### 4. Corrections to `docs/qr-scanner-snappiness-handover.md` (fix the doc)
|
||||
|
||||
That doc's native section assumed ML Kit and is wrong on three points — leaving it uncorrected
|
||||
invites someone to "optimise" the scanner backwards:
|
||||
- The native scanner uses **ZXing** (Apache-2.0, on-device, no telemetry), **not ML Kit**. ML Kit
|
||||
was rejected as a proprietary Google/Play-Services dependency, against project dependency
|
||||
policy. There is therefore no model cold-start to pay.
|
||||
- `FORMAT_QR_CODE`-only and `STRATEGY_KEEP_ONLY_LATEST` were **already in place** before this round.
|
||||
- **Do not drop analysis resolution to 1280×720.** 1920×1080 is a deliberate 0.5.22 fix: at 720p,
|
||||
dense bolt11 invoice QRs were undecodable on far-focusing lenses (e.g. Pixel 9a main) while
|
||||
sparse address QRs still read — that was the original "scanner doesn't pick up invoices" report.
|
||||
|
||||
What 0.5.27 changed natively, for the record: two-tier decode (cheap centre-70% pass ~18/s plus
|
||||
the thorough full-frame `TRY_HARDER` + inverted-retry pass ~5/s, replacing a single expensive
|
||||
pass capped ~7/s); camera/decoder prewarm; torch toggle in wallet-scan and pairing scanners;
|
||||
tap-to-focus with 4s suppression of periodic centre autofocus; zoom hunt alternating 1×/1.5×
|
||||
after ~3s with no decode; success haptic on first hit only (so animated QRs don't buzz per frame).
|
||||
|
||||
### 5. Not a web issue, noted for completeness
|
||||
|
||||
The companion regained "swipe away in recents = restart": the retained kiosk WebView (kept so
|
||||
remote ⇄ dashboard doesn't reload) is now released when the activity finishes, because the FIPS
|
||||
mesh service keeps the process alive and the static WebView was surviving the swipe. A
|
||||
**Restart** card was added to the hub menu as the manual path.
|
||||
|
||||
## Solution
|
||||
|
||||
Route into **Phase 11 (Wallet Experience & LND UI Parity)** rather than a standalone pass —
|
||||
§2's signed-PSBT paste affordance and §3's scanner items are the same surface as **WALLET-05**
|
||||
(the PSBT air-gap round trip), and `WalletScanModal.vue` is named in both. The clipboard utility
|
||||
(§2) is broader than Phase 11 and can land independently; the doc corrections (§4) are a
|
||||
five-minute fix that should not wait for a phase.
|
||||
|
||||
**Supersedes** any overlapping assumptions in `docs/qr-scanner-snappiness-handover.md` — that
|
||||
doc is now known-wrong on the three points in §4.
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
---
|
||||
created: 2026-08-02T12:30:00.000Z
|
||||
title: Migrate VPS2 IP (146.59.87.168) to its domain across registry references
|
||||
area: infra
|
||||
severity: major
|
||||
files:
|
||||
- apps/*/manifest.yml (the bulk — every `image:` line)
|
||||
- .gitmodules
|
||||
- .github/workflows/*.yml (both)
|
||||
- app-catalog/catalog.json (signed — changing it forces a re-sign)
|
||||
- Android/**/FipsPreferences.kt, Android/**/PartyScreen.kt (compiled constants)
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
**98 operational files still carry the bare IP `146.59.87.168`.** The domain
|
||||
(`source.archipelago-foundation.org`) was only ever adopted for the *git remote* — the IP is
|
||||
still baked into every **container registry reference**, which is a different thing entirely:
|
||||
|
||||
```
|
||||
image: 146.59.87.168:3000/lfg2025/gatewayd:v0.10.0
|
||||
```
|
||||
|
||||
Plus `.gitmodules`, both CI workflow files, `app-catalog/catalog.json`, and the Android
|
||||
companion (`FipsPreferences.kt`, `PartyScreen.kt`). A further **117 hits live in `.planning/`
|
||||
docs — those are historical records and must stay as they are.**
|
||||
|
||||
An IP in every manifest is exactly the kind of thing that bites when the VPS moves. It is also
|
||||
conspicuous in a public repo, so the open-source-readiness work will want it done
|
||||
(see `docs/OPEN-SOURCE-READINESS-PLAN.md`).
|
||||
|
||||
## Why this is not a find-and-replace
|
||||
|
||||
1. **The registry has to actually answer on the domain.** `source.archipelago-foundation.org`
|
||||
currently serves Gitea over **HTTPS on 443**, while images are pulled from **:3000 over plain
|
||||
HTTP**. Podman treats `host:3000` and `domain` as *different registries*, so changing the
|
||||
string means every node re-pulls every image under the new name — and any node that cannot
|
||||
resolve or trust the new host fails to pull at all.
|
||||
2. **It invalidates the signed catalog.** `app-catalog/catalog.json` carries image references,
|
||||
so changing them breaks the signature and forces a regenerate-and-re-sign — which needs the
|
||||
release mnemonic, same as a release.
|
||||
3. **The Android companion ships compiled constants**, so it needs its own APK rebuild to follow.
|
||||
|
||||
## Solution
|
||||
|
||||
Its own plan with a real rollout order — **not** something to slip into a release:
|
||||
|
||||
1. Registry serving on the domain (TLS, and a decision on whether images move to 443 or the
|
||||
domain also exposes :3000)
|
||||
2. Manifests
|
||||
3. Catalog regenerate + re-sign (mnemonic ceremony)
|
||||
4. APK rebuild
|
||||
|
||||
Sequencing matters because steps 2–4 are useless — and actively breaking — until step 1 holds.
|
||||
|
||||
## Notes
|
||||
|
||||
Analysis produced by the concurrent companion/release agent on 2026-08-02 before its session
|
||||
ended; recorded here so it is not lost. It had not touched any of the work.
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
created: 2026-08-02T15:30:00.000Z
|
||||
title: Add a "name your node" step to onboarding (sets the real hostname)
|
||||
area: ui
|
||||
severity: major
|
||||
files:
|
||||
- neode-ui/src/views/OnboardingName.vue (to create — match the existing onboarding step design)
|
||||
- neode-ui/src/router/index.ts (:26-76 — the onboarding child routes, in flow order)
|
||||
- neode-ui/src/composables/useOnboarding.ts (step persistence / resume)
|
||||
- neode-ui/src/views/settings/AccountInfoSection.vue (the existing post-onboarding rename UI — reuse its validation)
|
||||
- core/archipelago/src/api/rpc/system/handlers.rs (:462 hostnamectl, :58 + :765 regenerate_tls_cert — backend already exists, no change expected)
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Dorian (2026-08-02): wants a step in the onboarding flow to name your node — in the same
|
||||
design language as the existing steps — which changes the actual hostname.
|
||||
|
||||
Today naming only exists **after** onboarding, in `settings/AccountInfoSection.vue`. A fresh
|
||||
node keeps its install-time default until the user goes looking for the setting.
|
||||
|
||||
## What already exists (no backend work expected)
|
||||
|
||||
`server.set-name` (`dispatcher.rs:459` → `handle_server_set_name`) already:
|
||||
- runs `sudo hostnamectl set-hostname <name>` (`system/handlers.rs:462`)
|
||||
- regenerates the self-signed TLS cert with a SAN covering `<name>`, `<name>.local`,
|
||||
`localhost`, `127.0.0.1` (`:58` → `regenerate_tls_cert`, `:765`)
|
||||
- reloads nginx
|
||||
|
||||
Current onboarding order (`router/index.ts:26-76`): Intro → Options → Path → SeedGenerate →
|
||||
SeedVerify / SeedRestore → Did → Identity → Backup → Verify → Done.
|
||||
|
||||
## The hazard that decides the design
|
||||
|
||||
**Renaming mid-flow can disconnect the user before their seed is backed up.**
|
||||
|
||||
The browser is connected to the node over its current hostname and current TLS cert. `set-name`
|
||||
changes both: the mDNS `.local` name moves, and the cert is reissued. A user onboarding at
|
||||
`https://archipelago.local` who renames to `mynode` can lose the session **in the middle of
|
||||
onboarding** — potentially between seed generation and seed verification, which is the worst
|
||||
possible moment to drop someone.
|
||||
|
||||
That makes step placement a design decision, not an implementation detail. Roughly:
|
||||
|
||||
1. **Last, just before Done** — everything security-critical (seed shown, verified, backed up)
|
||||
is already complete, so a dropped connection costs nothing but a reload. Safest.
|
||||
2. **First, before anything else** — the rename happens while there is nothing to lose, but the
|
||||
user is asked to name a node before they have any context for what it is, and they may still
|
||||
be mid-redirect when the cert changes.
|
||||
3. **Defer the apply** — collect the name early for good UX, call `set-name` only at the end.
|
||||
Best of both, at the cost of holding state across steps.
|
||||
|
||||
Option 3 or 1 is almost certainly right. This needs deciding explicitly rather than by
|
||||
whichever screen the code lands on.
|
||||
|
||||
## Also needs deciding / checking
|
||||
|
||||
- **Validation + slugification.** Hostnames are RFC-1123: lowercase alphanumerics and hyphens,
|
||||
≤63 chars, no leading/trailing hyphen. A user will type `Dorian's Node`. Decide whether to
|
||||
slugify silently, show the slug live ("will be reachable at `dorians-node.local`"), or reject.
|
||||
Reuse whatever `AccountInfoSection.vue` already does rather than inventing a second rule.
|
||||
- **Does the rename propagate everywhere it should?** The Reticulum daemon takes a
|
||||
`--display-name`, and mesh/FIPS surfaces show node names. Confirm whether `set-name` updates
|
||||
those or whether the node keeps its old name on the mesh until restart.
|
||||
- **Reconnection UX.** If the cert/hostname change does drop the session, the step should say so
|
||||
in advance and tell the user where to come back to — not fail silently into a dead tab.
|
||||
- **Skippable?** A node with no name is fine; forcing a decision at first run is friction. Decide
|
||||
whether the step has a "keep the default" path.
|
||||
|
||||
## Solution
|
||||
|
||||
Own scope — a quick task or a small plan, not a freehand edit, because of the disconnect hazard
|
||||
above. Sequence it **after** the in-flight `regenerate_tls_cert` atomicity fix lands (that fix
|
||||
makes the rename path write the key to a staging file and validate before swapping, instead of
|
||||
truncating the live key in place if openssl fails partway — renaming is exactly the path it
|
||||
protects).
|
||||
@@ -1,20 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.120-alpha (2026-08-02)
|
||||
|
||||
- **Security, and the reason to take this update: two ports on your node handed anyone who could reach them complete control of your money, with no password.** The Lightning app's port answered a plain web request with the LND admin macaroon, the TLS certificate and the node's onion address — everything needed to drain the wallet remotely, and the onion meant an attacker kept that ability even after losing access to your network. The Bitcoin app's port reached Bitcoin Core's control interface using credentials the node itself supplied on the caller's behalf, with a wallet loaded. Anything on your home network, your Tailscale network or the mesh could use either one. Both now require you to be logged in. If your node has been reachable by anyone you do not fully trust, treat the Lightning macaroon and the Bitcoin RPC password as known to them.
|
||||
- The Bitcoin and Lightning app screens can no longer be published as public Tor addresses automatically. They were one app-id away from being handed a worldwide, permanent address as a silent side effect of being installed — which would have re-opened the hole above to the entire internet. Turning Tor on for them deliberately still works; it just never happens on its own.
|
||||
- **Fixes shipped inside the program now actually reach apps that your system keeps running.** A container the node had been told to uninstall, but that the system service manager kept alive anyway, was quietly skipped by the part of the node that applies configuration — so it never received updates that shipped with the program. This was found the hard way: the Bitcoin control-interface fix above appeared to be installed and silently was not, while the Lightning half applied correctly, which is the most misleading way for a security fix to fail. Both halves are now proven to land on a real node.
|
||||
- The Lightning and Bitcoin node screens have been rebuilt to match what umbrelOS offers. Lightning gains Overview, Channels, Activity, Insights, Connect and Settings tabs with a sats/BTC switch; Bitcoin gains Insights, Peers, Connect and Sharing. Along the way: every copy button on those screens silently did nothing (the browser blocks clipboard access inside an embedded page) and now works; the channels link led to a dead page; and Node ID showed a bare key instead of the full address someone can actually connect to.
|
||||
- Updates to the Bitcoin screen show up without a hard refresh. The page was being cached by the browser, so a freshly updated screen kept rendering the previous one.
|
||||
- The AI sidebar loads again. It was asking for its program files at an address that pointed at the main app's files, where they do not exist, so it silently loaded nothing.
|
||||
- The navigation above the bottom bar no longer follows you between screens. Back buttons and the mesh tab bar stayed pinned over every other page once you had visited the screen that owns them. Keeping tabs loaded in the background — the change that made switching between them instant — means leaving a screen hides it rather than destroying it, and this floating navigation sits outside the screen it belongs to, so it was never being hidden with it. It is now tied to whether its own screen is on display. The speed is unchanged: the screens are still kept loaded, so returning to one is still instant.
|
||||
- Wallet: Lightning actions are now offered based on whether you actually have a usable channel rather than just a running node, sending is gated the same way, and an invoice you cannot yet receive offers to install a Lightning node instead of simply failing.
|
||||
- Onboarding and viewing fixes: the "I have written down my recovery words" tickbox is findable on short screens, paid pictures and videos open in the app's own viewer with a visible loading state instead of a blank browser tab, picture-in-picture survives changing tabs, and the FIPS/Tor labels on peer cards stay put instead of wrapping into the card below.
|
||||
- Key-material hardening across the node: a node that is already set up refuses to have its identity replaced by an unauthenticated request; first-boot secret generation now fails loudly instead of silently continuing with shared keys; the node proves its TLS certificate and key are actually a matching pair; the Bitcoin Core wallet path that kept a second copy of your spending key outside the encrypted store has been removed; and every place the node generates a key, token or nonce now names its source of randomness explicitly, enforced at build time.
|
||||
- Federation and mesh: a rotated gateway credential now reaches the already-running container instead of leaving the old one in place, sync failures are surfaced to you instead of being swallowed, and nodes can share their Lightning connection details with a chosen peer over the mesh — the groundwork for opening channels with nodes you already talk to.
|
||||
- Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. Two nodes on the fleet still share SSH host keys with each other (detection shipped, rotation is a deliberate operator decision and has not been performed). Bitcoin Core can now reach Tor from its container, but is not yet routed through it — the network mode is becoming a setting you choose, and until then Core's peers remain on the clear internet.
|
||||
|
||||
## v1.7.119-alpha (2026-07-31)
|
||||
|
||||
- Wallet payments now work on nodes whose channels are private/unannounced. Every invoice-creation call site — the wallet's own Receive flow, and the seller-side paid-content/peer-files flow — only ever sent LND the amount and memo, so LND defaulted private to false and returned invoices with no route hints. Any node whose only usable channel is private or unannounced (the common shape for a channel someone opened to you) was silently unpayable through the wallet, and unpayable through paid file/content sales too. Both call sites now set LND's private flag correctly; this was broken in the field and is the main reason for this release.
|
||||
|
||||
@@ -22,20 +22,14 @@ app:
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: host
|
||||
network_policy: bridge
|
||||
|
||||
# Host networking: the container's nginx listens on 18083 directly (see
|
||||
# docker/lnd-ui/nginx.conf), because it has to proxy the archipelago backend
|
||||
# on 127.0.0.1:5678 same-origin — a bridge container cannot reach that, and
|
||||
# the cross-origin fallback broke the app on http-only nodes. `ports:` is
|
||||
# intentionally empty because host networking bypasses port mapping, exactly
|
||||
# as in apps/bitcoin-ui/manifest.yml.
|
||||
#
|
||||
# This previously declared `bridge` with 18083:80, which publishes the host
|
||||
# port to a container port where nothing listens. scripts/container-specs.sh
|
||||
# carried the identical mistake and was fixed alongside this; recreating from
|
||||
# it on archi-dev-box left :18083 refusing connections.
|
||||
ports: []
|
||||
# Bridge networking via archy-net. Container nginx listens on 80;
|
||||
# host nginx proxies /app/lnd/ -> 127.0.0.1:18083 -> container:80.
|
||||
ports:
|
||||
- host: 18083
|
||||
container: 80
|
||||
protocol: tcp
|
||||
|
||||
volumes: []
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.120-alpha"
|
||||
version = "1.7.119-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.120-alpha"
|
||||
version = "1.7.119-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -600,62 +600,12 @@ impl ApiHandler {
|
||||
))
|
||||
}
|
||||
|
||||
// Session probe for app-container nginx `auth_request` gates.
|
||||
//
|
||||
// App UIs run their own nginx and proxy selected paths into this
|
||||
// backend. Some of those paths inject credentials the caller never
|
||||
// supplied (bitcoin-ui's /bitcoin-rpc/ adds Bitcoin Core's Basic
|
||||
// auth), which makes the proxy itself the authorization boundary —
|
||||
// and nginx has no way to validate a session cookie on its own. This
|
||||
// endpoint gives it one: 204 when the request carries a valid
|
||||
// session, 401 otherwise. Body is deliberately empty; `auth_request`
|
||||
// discards it and it must never become an oracle.
|
||||
(Method::GET, "/auth/session-check") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.header("Cache-Control", "no-store")
|
||||
.body(hyper::Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
// LND connect info — REQUIRES A SESSION. This response is a complete
|
||||
// remote-control package for the node's Lightning wallet: the admin
|
||||
// macaroon, the TLS cert, the gRPC/REST ports and the onion address.
|
||||
// Anyone who receives it can drain the wallet from anywhere, and the
|
||||
// onion means they keep that ability after losing network access.
|
||||
//
|
||||
// It used to carry no backend check, on two premises that were both
|
||||
// false in production:
|
||||
//
|
||||
// "nginx validates the session cookie" — the MAIN nginx does. But
|
||||
// the lnd-ui app container runs its OWN nginx on :18083 that
|
||||
// proxies /lnd-connect-info straight here, forwarding whatever
|
||||
// cookies arrived, including none. That second front door never
|
||||
// performed the presence check the premise depended on.
|
||||
//
|
||||
// "the backend is bound to 127.0.0.1 so only nginx can reach it" —
|
||||
// true of the backend socket, but irrelevant: :18083 is a reachable
|
||||
// proxy INTO it, it binds 0.0.0.0, and it is explicitly on the
|
||||
// fips0 mesh allowlist (fips/app_ports.rs). So an unauthenticated
|
||||
// GET from any mesh peer, LAN host or Tailscale peer returned the
|
||||
// admin macaroon. Verified live on archi-dev-box 2026-08-02.
|
||||
//
|
||||
// The lesson generalises: an auth check performed by one reverse
|
||||
// proxy is not an auth check, because it only holds for traffic that
|
||||
// arrived through that proxy. Authorisation belongs at the resource.
|
||||
// Do not remove this in favour of a front-door check again.
|
||||
//
|
||||
// 401s carry CORS headers for the same reason /proxy/lnd/ does: the
|
||||
// wallet UI fetches this cross-origin, so a bare 401 without them
|
||||
// surfaces in the browser as an unreadable CORS failure.
|
||||
// LND connect info — nginx validates session cookie (presence check),
|
||||
// backend is bound to 127.0.0.1 so only nginx can reach it.
|
||||
// No backend auth check here because the LND UI iframe fetches this
|
||||
// endpoint and the session cookie flow is validated at the nginx layer.
|
||||
(Method::GET, "/lnd-connect-info") => {
|
||||
let origin = self.app_cors_origin(&headers);
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized_cors(&origin));
|
||||
}
|
||||
Self::handle_lnd_connect_info(self.rpc_handler.clone(), &origin).await
|
||||
}
|
||||
|
||||
|
||||
@@ -122,13 +122,7 @@ impl RpcHandler {
|
||||
// get a unique suffix so each device keeps its own credential;
|
||||
// explicitly named devices keep replace-in-place semantics.
|
||||
if name == "companion" {
|
||||
// KEY-05: source named. Two bytes of display-name disambiguation, not
|
||||
// key material — the credential itself is minted by
|
||||
// `device_tokens::create`, which is guarded. Unguarded here because a
|
||||
// degenerate predicate on a 2-byte draw false-positives once in 256.
|
||||
let mut suffix = [0u8; 2];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut suffix);
|
||||
name = format!("companion-{}", hex::encode(suffix));
|
||||
name = format!("companion-{}", hex::encode(rand::random::<[u8; 2]>()));
|
||||
}
|
||||
let token = crate::device_tokens::create(&self.config.data_dir, &name).await?;
|
||||
Ok(serde_json::json!({ "name": name, "token": token }))
|
||||
@@ -202,23 +196,11 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!(is_setup))
|
||||
}
|
||||
|
||||
/// Create the node's user account. Unauthenticated by necessity: no account
|
||||
/// exists yet when the onboarding wizard reaches the password screen.
|
||||
///
|
||||
/// D-04 verdict: **gated, in addition to the pre-existing `is_setup()`
|
||||
/// rejection.** The `is_setup()` check alone fails open in a drift case: on
|
||||
/// a provisioned node whose `user.json` is missing or was deleted it would
|
||||
/// still run — and it does more than create an account, it also rewrites the
|
||||
/// OS login password via `crate::auth::change_ssh_password` (below), which
|
||||
/// is an unauthenticated privilege escalation on a live node (T-10-08). The
|
||||
/// gate closes that case using the seed/onboarding signals, which survive a
|
||||
/// deleted `user.json`.
|
||||
pub(super) async fn handle_auth_setup(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
// Prevent re-setup if already set up. Kept ahead of the gate so the
|
||||
// existing, more specific message survives for this common case.
|
||||
// Prevent re-setup if already set up
|
||||
let is_setup = self.auth_manager.is_setup().await?;
|
||||
if is_setup {
|
||||
tracing::warn!("[onboarding] setup rejected — already set up");
|
||||
@@ -227,9 +209,6 @@ impl RpcHandler {
|
||||
));
|
||||
}
|
||||
|
||||
super::onboarding_gate::ensure_onboarding_open(&self.config.data_dir, &self.auth_manager)
|
||||
.await?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
@@ -268,32 +247,7 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!(true))
|
||||
}
|
||||
|
||||
/// Mark onboarding complete.
|
||||
///
|
||||
/// This one takes the OPPOSITE guard to the rest of the D-04 sweep, and it
|
||||
/// is the most important addition in it. The method is unauthenticated
|
||||
/// (`middleware.rs:12`) and it SETS the very flag
|
||||
/// `onboarding_gate::ensure_onboarding_open` reads. Without a guard, one
|
||||
/// unauthenticated call against a fresh node marks it onboarded and
|
||||
/// permanently locks it out of its own onboarding — a denial of service
|
||||
/// created BY the gate (T-10-04). So: refuse until a user account exists.
|
||||
///
|
||||
/// Verified safe against the real wizard before shipping:
|
||||
/// * The live flow never calls this before `auth.setup`. It is
|
||||
/// `/onboarding/intro → path → seed → seed-verify → identity → done →
|
||||
/// /login`, and `views/Login.vue:405-425` posts `auth.setup` from that
|
||||
/// last screen. The onboarding flag is then set by
|
||||
/// `auth.rs:203-217`'s auto-heal inference, not by this RPC.
|
||||
/// * The only caller of this method is `OnboardingVerify.vue:157`, on the
|
||||
/// `/onboarding/verify` route — reachable only from
|
||||
/// `/onboarding/backup`, which nothing in the app navigates to any more.
|
||||
/// * Even on that dead path the refusal is invisible: `completeOnboarding`
|
||||
/// wraps the call in `callWithRetry` (`useOnboarding.ts:64-68`), which
|
||||
/// returns `null` on a non-retryable error instead of throwing, and
|
||||
/// `proceed()` catches anyway.
|
||||
pub(super) async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
|
||||
super::onboarding_gate::ensure_user_account_exists(&self.auth_manager).await?;
|
||||
|
||||
self.auth_manager.complete_onboarding().await?;
|
||||
tracing::info!("[onboarding] onboarding marked complete");
|
||||
|
||||
|
||||
@@ -406,20 +406,10 @@ impl RpcHandler {
|
||||
|
||||
/// Restore identity from an encrypted DID backup JSON.
|
||||
/// Params: { backup: { version, blob, ... }, passphrase }
|
||||
///
|
||||
/// D-04 verdict: **gated.** This is unauthenticated
|
||||
/// (`middleware.rs:30`) and reaches
|
||||
/// `backup::identity::restore_encrypted_backup`, which writes
|
||||
/// `identity/node_key` unconditionally at `backup/identity.rs:113-117` —
|
||||
/// the same overwrite primitive F-01 names, behind a different door.
|
||||
/// Fixing `seed.restore` alone would have moved the door, not closed it.
|
||||
pub(super) async fn handle_backup_restore_identity(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
super::onboarding_gate::ensure_onboarding_open(&self.config.data_dir, &self.auth_manager)
|
||||
.await?;
|
||||
|
||||
let backup = params
|
||||
.get("backup")
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'backup' parameter"))?;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
/// Retry configuration for [`bitcoin_rpc_post_with_retry`].
|
||||
///
|
||||
@@ -154,18 +155,144 @@ impl RpcHandler {
|
||||
.await
|
||||
}
|
||||
|
||||
// NOTE: the Bitcoin Core wallet-init handler that used to live here was deleted in
|
||||
// Phase 10 (D-07b) to close audit finding F-13. It derived the BIP-84 account
|
||||
// extended *private* key, stringified it, and imported `wpkh(xprv/0/*)` /
|
||||
// `wpkh(xprv/1/*)` into Core's `wallet.dat` — a second copy of the node's spending
|
||||
// key, outside the daemon's Argon2 + ChaCha20-Poly1305 envelope. It had no caller
|
||||
// anywhere in the repo; LND is the wallet the UI drives.
|
||||
//
|
||||
// Do NOT reintroduce a Bitcoin Core wallet path that imports private keys. If a
|
||||
// Core wallet is ever needed again it must be watch-only by construction
|
||||
// (`disable_private_keys = true`, xpub descriptors with a `[fingerprint/derivation]`
|
||||
// key origin). Full rationale, evidence and the deleted symbol's name:
|
||||
// docs/security/KEY-03-SIGNING-POSTURE.md
|
||||
/// Initialize a Bitcoin Core descriptor wallet with keys derived from the master seed.
|
||||
/// Creates a blank wallet and imports BIP-84 (native segwit) descriptors.
|
||||
/// Requires: password re-verification, encrypted seed on disk.
|
||||
pub(super) async fn handle_bitcoin_init_wallet_from_seed(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'password' for seed access"))?;
|
||||
let wallet_name = params
|
||||
.get("wallet_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("archipelago");
|
||||
|
||||
// Verify user password.
|
||||
self.auth_manager
|
||||
.verify_password(password)
|
||||
.await
|
||||
.context("Password verification failed")?;
|
||||
|
||||
// Load encrypted seed.
|
||||
let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password)
|
||||
.await
|
||||
.context("Failed to load encrypted seed")?;
|
||||
let seed = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
|
||||
|
||||
// Derive BIP-84 account xprv.
|
||||
let xprv = crate::seed::derive_bitcoin_xprv(&seed)?;
|
||||
let mut xprv_str = xprv.to_string();
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
// Step 1: Create a blank descriptor wallet.
|
||||
let create_result = self
|
||||
.bitcoin_rpc_call::<serde_json::Value>(
|
||||
&client,
|
||||
"createwallet",
|
||||
&[
|
||||
serde_json::json!(wallet_name), // wallet_name
|
||||
serde_json::json!(false), // disable_private_keys
|
||||
serde_json::json!(true), // blank
|
||||
serde_json::json!(""), // passphrase
|
||||
serde_json::json!(false), // avoid_reuse
|
||||
serde_json::json!(true), // descriptors
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
match create_result {
|
||||
Ok(_) => tracing::info!("Created blank descriptor wallet '{}'", wallet_name),
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("already exists") {
|
||||
tracing::info!(
|
||||
"Wallet '{}' already exists, importing descriptors",
|
||||
wallet_name
|
||||
);
|
||||
} else {
|
||||
xprv_str.zeroize();
|
||||
return Err(e.context("Failed to create wallet"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Import BIP-84 descriptors (external + internal/change).
|
||||
// Format: wpkh(xprv/0/*) for receive, wpkh(xprv/1/*) for change.
|
||||
let external_desc = format!("wpkh({}/0/*)", xprv_str);
|
||||
let internal_desc = format!("wpkh({}/1/*)", xprv_str);
|
||||
|
||||
// Get checksums from Bitcoin Core.
|
||||
let ext_info: serde_json::Value = self
|
||||
.bitcoin_rpc_call(
|
||||
&client,
|
||||
"getdescriptorinfo",
|
||||
&[serde_json::json!(external_desc)],
|
||||
)
|
||||
.await
|
||||
.context("getdescriptorinfo failed for external descriptor")?;
|
||||
|
||||
let int_info: serde_json::Value = self
|
||||
.bitcoin_rpc_call(
|
||||
&client,
|
||||
"getdescriptorinfo",
|
||||
&[serde_json::json!(internal_desc)],
|
||||
)
|
||||
.await
|
||||
.context("getdescriptorinfo failed for internal descriptor")?;
|
||||
|
||||
let ext_desc_with_checksum = ext_info
|
||||
.get("descriptor")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?;
|
||||
let int_desc_with_checksum = int_info
|
||||
.get("descriptor")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?;
|
||||
|
||||
let import_params = serde_json::json!([
|
||||
{
|
||||
"desc": ext_desc_with_checksum,
|
||||
"timestamp": "now",
|
||||
"active": true,
|
||||
"internal": false,
|
||||
"range": [0, 1000],
|
||||
},
|
||||
{
|
||||
"desc": int_desc_with_checksum,
|
||||
"timestamp": "now",
|
||||
"active": true,
|
||||
"internal": true,
|
||||
"range": [0, 1000],
|
||||
}
|
||||
]);
|
||||
|
||||
let _import_result: serde_json::Value = self
|
||||
.bitcoin_rpc_call(&client, "importdescriptors", &[import_params])
|
||||
.await
|
||||
.context("importdescriptors failed")?;
|
||||
|
||||
// Zeroize the xprv string from memory.
|
||||
xprv_str.zeroize();
|
||||
|
||||
tracing::info!(
|
||||
"Bitcoin Core wallet '{}' initialized from master seed (BIP-84)",
|
||||
wallet_name
|
||||
);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"initialized": true,
|
||||
"wallet_name": wallet_name,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Free-function counterpart to `RpcHandler::bitcoin_rpc_call`.
|
||||
|
||||
@@ -119,11 +119,9 @@ impl RpcHandler {
|
||||
"bitcoin.relay-create-tor-service" => {
|
||||
self.handle_bitcoin_relay_create_tor_service().await
|
||||
}
|
||||
// NOTE: the Bitcoin Core wallet-init arm that used to sit here was deleted in
|
||||
// Phase 10 (D-07b, F-13). Its handler derived the BIP-84 account xprv and
|
||||
// imported it into Core's wallet.dat, duplicating the spending key outside the
|
||||
// Argon2 envelope. It had no caller. The `lnd.` arm below is a different,
|
||||
// still-live endpoint. See docs/security/KEY-03-SIGNING-POSTURE.md.
|
||||
"bitcoin.init-wallet-from-seed" => {
|
||||
self.handle_bitcoin_init_wallet_from_seed(params).await
|
||||
}
|
||||
"lnd.getinfo" => self.handle_lnd_getinfo().await,
|
||||
"lnd.listchannels" => self.handle_lnd_listchannels().await,
|
||||
"lnd.closedchannels" => self.handle_lnd_closedchannels().await,
|
||||
@@ -422,8 +420,6 @@ impl RpcHandler {
|
||||
"mesh.send-psbt" => self.handle_mesh_send_psbt(params).await,
|
||||
"mesh.broadcast-presence" => self.handle_mesh_broadcast_presence(params).await,
|
||||
"mesh.presence-list" => self.handle_mesh_presence_list(params).await,
|
||||
"mesh.lightning-peers" => self.handle_mesh_lightning_peers(params).await,
|
||||
"mesh.send-lightning-info" => self.handle_mesh_send_lightning_info(params).await,
|
||||
"mesh.contacts-list" => self.handle_mesh_contacts_list(params).await,
|
||||
"mesh.contacts-save" => self.handle_mesh_contacts_save(params).await,
|
||||
"mesh.contacts-block" => self.handle_mesh_contacts_block(params).await,
|
||||
|
||||
@@ -262,16 +262,6 @@ impl RpcHandler {
|
||||
if let Some(state) = &n.last_state {
|
||||
obj["last_state"] = serde_json::to_value(state).unwrap_or_default();
|
||||
}
|
||||
// FED-02: surface the most recent sync failure so the operator
|
||||
// sees a stale peer in the UI instead of it living only in the
|
||||
// node's debug log. Omitted (not null) when the last attempt
|
||||
// succeeded, so a recovered peer's badge disappears.
|
||||
if let Some(err) = &n.last_sync_error {
|
||||
obj["last_sync_error"] = serde_json::json!(err);
|
||||
}
|
||||
if let Some(at) = &n.last_sync_error_at {
|
||||
obj["last_sync_error_at"] = serde_json::json!(at);
|
||||
}
|
||||
obj
|
||||
})
|
||||
.collect();
|
||||
@@ -669,8 +659,6 @@ impl RpcHandler {
|
||||
fips_npub,
|
||||
last_transport: None,
|
||||
last_transport_at: None,
|
||||
last_sync_error: None,
|
||||
last_sync_error_at: None,
|
||||
};
|
||||
|
||||
federation::add_node(&self.config.data_dir, node).await?;
|
||||
|
||||
@@ -15,13 +15,6 @@ struct LndInfo {
|
||||
balance_sats: i64,
|
||||
channel_balance_sats: i64,
|
||||
pending_open_balance: i64,
|
||||
/// This node's Lightning identity pubkey, or `None` when LND did not
|
||||
/// report one or reported one that is not a compressed secp256k1 key.
|
||||
/// Never fabricated: the caller can tell "not available" from "available".
|
||||
identity_pubkey: Option<String>,
|
||||
/// The connection URIs LND advertises for this node (`pubkey@host:port`).
|
||||
/// Empty when LND advertises none — an honest absence, not a placeholder.
|
||||
uris: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -31,40 +24,6 @@ struct LndGetInfoResponse {
|
||||
num_peers: Option<u32>,
|
||||
synced_to_chain: Option<bool>,
|
||||
block_height: Option<u64>,
|
||||
#[serde(default)]
|
||||
identity_pubkey: Option<String>,
|
||||
#[serde(default)]
|
||||
uris: Vec<String>,
|
||||
}
|
||||
|
||||
/// A compressed secp256k1 pubkey is 66 hexadecimal characters. Mirrors the
|
||||
/// check `handle_lnd_openchannel` performs before dialling a peer, so a key
|
||||
/// this function passes is one that handler would accept.
|
||||
fn is_valid_identity_pubkey(pubkey: &str) -> bool {
|
||||
pubkey.len() == 66 && pubkey.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Map LND's reported identity onto the RPC response.
|
||||
///
|
||||
/// Split out from the HTTP flow so it is testable without a live LND. A
|
||||
/// malformed pubkey yields `None` rather than propagating a key that
|
||||
/// `lnd.openchannel` would later reject — surfacing the problem here, where
|
||||
/// the operator is reading their own node's identity, beats surfacing it at
|
||||
/// the moment they try to open a channel.
|
||||
fn map_identity(get_info: &LndGetInfoResponse) -> (Option<String>, Vec<String>) {
|
||||
let identity_pubkey = match get_info.identity_pubkey.as_deref() {
|
||||
Some(pubkey) if is_valid_identity_pubkey(pubkey) => Some(pubkey.to_string()),
|
||||
Some(bad) => {
|
||||
tracing::warn!(
|
||||
len = bad.len(),
|
||||
"LND getinfo returned an identity_pubkey that is not 66 hex characters — \
|
||||
reporting no identity rather than a key lnd.openchannel would reject"
|
||||
);
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
(identity_pubkey, get_info.uris.clone())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -125,11 +84,7 @@ impl RpcHandler {
|
||||
},
|
||||
};
|
||||
|
||||
let (identity_pubkey, uris) = map_identity(&get_info);
|
||||
|
||||
let info = LndInfo {
|
||||
identity_pubkey,
|
||||
uris,
|
||||
alias: get_info.alias.unwrap_or_default(),
|
||||
num_active_channels: get_info.num_active_channels.unwrap_or(0),
|
||||
num_peers: get_info.num_peers.unwrap_or(0),
|
||||
@@ -263,81 +218,3 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A real compressed secp256k1 pubkey shape: 66 hex characters.
|
||||
const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
|
||||
|
||||
fn parse(body: &str) -> LndGetInfoResponse {
|
||||
serde_json::from_str(body).expect("LND getinfo body must deserialize")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_body_yields_identity_and_uris() {
|
||||
let parsed = parse(&format!(
|
||||
r#"{{"alias":"archy","identity_pubkey":"{GOOD_PUBKEY}",
|
||||
"uris":["{GOOD_PUBKEY}@1.2.3.4:9735","{GOOD_PUBKEY}@abcd.onion:9735"]}}"#
|
||||
));
|
||||
let (pubkey, uris) = map_identity(&parsed);
|
||||
|
||||
assert_eq!(pubkey.as_deref(), Some(GOOD_PUBKEY));
|
||||
assert_eq!(
|
||||
uris.len(),
|
||||
2,
|
||||
"both advertised URIs must survive the mapping"
|
||||
);
|
||||
assert!(uris[0].starts_with(GOOD_PUBKEY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_fields_yield_honest_absence_not_a_fabricated_identity() {
|
||||
// The pre-existing fields must still deserialize with the new ones absent —
|
||||
// this is the body every node running an older LND build returns.
|
||||
let parsed = parse(r#"{"alias":"archy","num_peers":3,"synced_to_chain":true}"#);
|
||||
let (pubkey, uris) = map_identity(&parsed);
|
||||
|
||||
assert!(pubkey.is_none(), "must not invent an identity");
|
||||
assert!(uris.is_empty(), "must not invent a URI");
|
||||
assert_eq!(parsed.alias.as_deref(), Some("archy"));
|
||||
assert_eq!(parsed.num_peers, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_pubkey_is_dropped_rather_than_propagated() {
|
||||
// Too short, non-hex, and empty must all be refused. Propagating any of
|
||||
// them would push the failure to lnd.openchannel, far from the cause.
|
||||
for bad in ["deadbeef", "", &"z".repeat(66), &GOOD_PUBKEY[..65]] {
|
||||
let parsed = parse(&format!(r#"{{"identity_pubkey":"{bad}"}}"#));
|
||||
let (pubkey, _) = map_identity(&parsed);
|
||||
assert!(
|
||||
pubkey.is_none(),
|
||||
"malformed pubkey {bad:?} must map to None, not be forwarded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_pubkey_does_not_discard_the_advertised_uris() {
|
||||
// The two facts are independent: a bad identity must not silently cost
|
||||
// the caller the URI list, which is the datum the picker actually needs.
|
||||
let parsed = parse(&format!(
|
||||
r#"{{"identity_pubkey":"nope","uris":["{GOOD_PUBKEY}@1.2.3.4:9735"]}}"#
|
||||
));
|
||||
let (pubkey, uris) = map_identity(&parsed);
|
||||
|
||||
assert!(pubkey.is_none());
|
||||
assert_eq!(uris.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_pubkey_shape_matches_the_openchannel_rule() {
|
||||
assert!(is_valid_identity_pubkey(GOOD_PUBKEY));
|
||||
assert!(is_valid_identity_pubkey(&"0".repeat(66)));
|
||||
assert!(!is_valid_identity_pubkey(&"0".repeat(65)));
|
||||
assert!(!is_valid_identity_pubkey(&"0".repeat(67)));
|
||||
assert!(!is_valid_identity_pubkey(&"g".repeat(66)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -698,43 +698,11 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(-1);
|
||||
|
||||
// Report whether this PSBT carries the BIP-32 key-origin data an external
|
||||
// signer needs to locate its own key. Best-effort by design: a decode
|
||||
// failure degrades to `null`, never to an error. A user's send must not
|
||||
// fail because an inspection helper could not parse something.
|
||||
let key_origin = match psbt_key_origin_report(&funded_psbt) {
|
||||
Ok(report) => {
|
||||
if !report.all_inputs_have_key_origin {
|
||||
// This is the exact condition under which a hardware signer
|
||||
// refuses the PSBT, so name it here rather than letting the
|
||||
// user discover it as an opaque failure at the device.
|
||||
tracing::warn!(
|
||||
input_count = report.input_count,
|
||||
inputs_with_key_origin = report.inputs_with_key_origin,
|
||||
"PSBT is missing BIP-32 key origin on one or more inputs; an external signer will not be able to locate its key"
|
||||
);
|
||||
}
|
||||
serde_json::json!({
|
||||
"input_count": report.input_count,
|
||||
"inputs_with_key_origin": report.inputs_with_key_origin,
|
||||
"all_inputs_have_key_origin": report.all_inputs_have_key_origin,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"Could not inspect PSBT for key origin; reporting null"
|
||||
);
|
||||
serde_json::Value::Null
|
||||
}
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"psbt_base64": funded_psbt,
|
||||
"change_output_index": change_output_index,
|
||||
"total_amount_sats": total_amount,
|
||||
"fee_rate_sat_per_vbyte": sat_per_vbyte,
|
||||
"key_origin": key_origin,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1158,139 +1126,10 @@ fn build_invoice_request_body(amount_sats: i64, memo: &str) -> serde_json::Value
|
||||
})
|
||||
}
|
||||
|
||||
/// What an external signer needs in order to find its own key in a PSBT.
|
||||
///
|
||||
/// A hardware signer locates the key it must sign with by reading each input's
|
||||
/// BIP-32 key-origin data (`[fingerprint/derivation]`). An input carrying none is
|
||||
/// an input the device cannot sign — it refuses rather than guesses. This is the
|
||||
/// protection D-09 was really about; with Bitcoin Core's descriptors deleted under
|
||||
/// D-07b, the PSBT itself is where key origin now has to be checked.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct PsbtKeyOriginReport {
|
||||
input_count: usize,
|
||||
inputs_with_key_origin: usize,
|
||||
all_inputs_have_key_origin: bool,
|
||||
}
|
||||
|
||||
/// Inspect a base64 PSBT and report how many of its inputs carry BIP-32 key origin.
|
||||
///
|
||||
/// An input counts as carrying key origin when either its `bip32_derivation` map
|
||||
/// (ECDSA / segwit v0) or its `tap_key_origins` map (taproot) is non-empty.
|
||||
///
|
||||
/// A PSBT with **zero** inputs reports `all_inputs_have_key_origin: false` rather
|
||||
/// than vacuous truth — an inputless PSBT cannot be signed at all, and answering
|
||||
/// "yes, everything a signer needs is present" would be actively misleading.
|
||||
///
|
||||
/// This is an *inspection*, never a precondition: callers must degrade to a null
|
||||
/// report on error, not fail the user's transaction (see `handle_lnd_create_psbt`).
|
||||
fn psbt_key_origin_report(psbt_base64: &str) -> Result<PsbtKeyOriginReport> {
|
||||
let raw = base64::engine::general_purpose::STANDARD
|
||||
.decode(psbt_base64.trim())
|
||||
.context("PSBT is not valid base64")?;
|
||||
|
||||
let psbt = bitcoin::psbt::Psbt::deserialize(&raw).context("PSBT failed to deserialize")?;
|
||||
|
||||
let input_count = psbt.inputs.len();
|
||||
let inputs_with_key_origin = psbt
|
||||
.inputs
|
||||
.iter()
|
||||
.filter(|input| !input.bip32_derivation.is_empty() || !input.tap_key_origins.is_empty())
|
||||
.count();
|
||||
|
||||
Ok(PsbtKeyOriginReport {
|
||||
input_count,
|
||||
inputs_with_key_origin,
|
||||
all_inputs_have_key_origin: input_count > 0 && inputs_with_key_origin == input_count,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a minimal, genuinely unsigned one-input PSBT with no key origin on
|
||||
/// any input. Built programmatically rather than pasted as opaque base64 so
|
||||
/// the fixture states what it is.
|
||||
fn unsigned_one_input_psbt() -> bitcoin::psbt::Psbt {
|
||||
use bitcoin::{
|
||||
absolute::LockTime, transaction::Version, Amount, OutPoint, ScriptBuf, Sequence,
|
||||
Transaction, TxIn, TxOut, Witness,
|
||||
};
|
||||
|
||||
let tx = Transaction {
|
||||
version: Version::TWO,
|
||||
lock_time: LockTime::ZERO,
|
||||
input: vec![TxIn {
|
||||
previous_output: OutPoint::null(),
|
||||
script_sig: ScriptBuf::new(),
|
||||
sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
|
||||
witness: Witness::new(),
|
||||
}],
|
||||
output: vec![TxOut {
|
||||
value: Amount::from_sat(10_000),
|
||||
script_pubkey: ScriptBuf::new(),
|
||||
}],
|
||||
};
|
||||
|
||||
bitcoin::psbt::Psbt::from_unsigned_tx(tx).expect("unsigned tx is a valid PSBT")
|
||||
}
|
||||
|
||||
fn psbt_to_base64(psbt: &bitcoin::psbt::Psbt) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(psbt.serialize())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psbt_without_derivations_reports_no_key_origin() {
|
||||
let psbt = unsigned_one_input_psbt();
|
||||
let report = psbt_key_origin_report(&psbt_to_base64(&psbt)).expect("valid PSBT");
|
||||
|
||||
assert_eq!(report.input_count, 1);
|
||||
assert_eq!(report.inputs_with_key_origin, 0);
|
||||
assert!(
|
||||
!report.all_inputs_have_key_origin,
|
||||
"an input with no bip32_derivation is one a hardware signer cannot sign"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psbt_with_derivations_reports_key_origin() {
|
||||
use bitcoin::bip32::{DerivationPath, Fingerprint};
|
||||
use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
|
||||
|
||||
let secp = Secp256k1::new();
|
||||
let sk = SecretKey::from_slice(&[0x11u8; 32]).expect("valid secret key");
|
||||
let pk = PublicKey::from_secret_key(&secp, &sk);
|
||||
let path: DerivationPath = "m/84'/0'/0'/0/0".parse().expect("valid BIP-84 path");
|
||||
let fingerprint = Fingerprint::from([0xde, 0xad, 0xbe, 0xef]);
|
||||
|
||||
let mut psbt = unsigned_one_input_psbt();
|
||||
psbt.inputs[0]
|
||||
.bip32_derivation
|
||||
.insert(pk, (fingerprint, path));
|
||||
|
||||
let report = psbt_key_origin_report(&psbt_to_base64(&psbt)).expect("valid PSBT");
|
||||
|
||||
assert_eq!(report.input_count, 1);
|
||||
assert_eq!(report.inputs_with_key_origin, 1);
|
||||
assert!(report.all_inputs_have_key_origin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_psbt_is_an_error_not_a_panic() {
|
||||
// Not base64 at all.
|
||||
assert!(psbt_key_origin_report("not a psbt!!!").is_err());
|
||||
|
||||
// Valid base64, but truncated PSBT bytes.
|
||||
let psbt = unsigned_one_input_psbt();
|
||||
let serialized = psbt.serialize();
|
||||
let truncated =
|
||||
base64::engine::general_purpose::STANDARD.encode(&serialized[..serialized.len() / 2]);
|
||||
assert!(psbt_key_origin_report(&truncated).is_err());
|
||||
|
||||
// Empty input.
|
||||
assert!(psbt_key_origin_report("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invoice_request_body_always_sets_private_true() {
|
||||
let body = build_invoice_request_body(1_234, "test memo");
|
||||
|
||||
@@ -1368,298 +1368,4 @@ impl RpcHandler {
|
||||
.await?;
|
||||
Ok(serde_json::json!({ "sent": true, "message_id": msg.id, "sender_seq": seq }))
|
||||
}
|
||||
|
||||
/// mesh.lightning-peers — the meshed peers that have advertised a Lightning
|
||||
/// URI, i.e. the "public/other" side of the channel-open picker (FED-05).
|
||||
///
|
||||
/// Returns an empty array, never an error, when no peer has advertised —
|
||||
/// "nobody yet" is a normal state on a fresh node, not a fault.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_lightning_peers(
|
||||
&self,
|
||||
_params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let state = svc.shared_state();
|
||||
let peer_vec: Vec<_> = state.peers.read().await.values().cloned().collect();
|
||||
Ok(serde_json::json!({ "peers": build_lightning_peer_list(&peer_vec) }))
|
||||
}
|
||||
|
||||
/// mesh.send-lightning-info — advertise THIS node's Lightning URI to one
|
||||
/// chosen peer.
|
||||
///
|
||||
/// Requires an explicit target. There is deliberately no broadcast form:
|
||||
/// this discloses the node's payment endpoint, and who learns it is the
|
||||
/// operator's choice, not a side effect of being in radio range (T-01-13).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_send_lightning_info(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let contact_id = parse_send_lightning_target(params.as_ref())?;
|
||||
|
||||
// Read our own URI from the lnd.getinfo path. Refuse rather than send an
|
||||
// empty advertisement: a peer that stored "" would show us in its picker
|
||||
// as a target it can never dial.
|
||||
let info = self
|
||||
.handle_lnd_getinfo()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Cannot read this node's Lightning info: {e}"))?;
|
||||
let uri = info
|
||||
.get("uris")
|
||||
.and_then(|u| u.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|u| u.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"This node has no advertised Lightning URI to share — LND may be down, or \
|
||||
configured with no externally reachable address"
|
||||
)
|
||||
})?;
|
||||
if !message_types::is_valid_lightning_uri(&uri) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"This node's own Lightning URI is malformed; refusing to advertise it"
|
||||
));
|
||||
}
|
||||
let alias = info
|
||||
.get("alias")
|
||||
.and_then(|a| a.as_str())
|
||||
.filter(|a| !a.is_empty())
|
||||
.map(|a| a.to_string());
|
||||
|
||||
let payload_struct = message_types::LightningInfoPayload { uri, alias };
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let seq = svc.next_send_seq(contact_id).await;
|
||||
let payload = message_types::encode_payload(&payload_struct)?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::LightningInfo, payload).with_seq(seq);
|
||||
let wire = envelope.to_wire()?;
|
||||
let typed_json = serde_json::to_value(&payload_struct).ok();
|
||||
let msg = svc
|
||||
.send_typed_wire(
|
||||
contact_id,
|
||||
wire,
|
||||
"lightning_info",
|
||||
"Shared Lightning connection info",
|
||||
typed_json,
|
||||
seq,
|
||||
)
|
||||
.await?;
|
||||
info!(contact_id, seq, "Sent lightning_info to a chosen mesh peer");
|
||||
Ok(serde_json::json!({ "sent": true, "message_id": msg.id, "sender_seq": seq }))
|
||||
}
|
||||
}
|
||||
|
||||
/// The required target for `mesh.send-lightning-info`.
|
||||
///
|
||||
/// Split out so the "a target is mandatory" contract is testable without a mesh
|
||||
/// service — that contract is the whole of T-01-13's mitigation, so it should
|
||||
/// not be provable only by reading the code.
|
||||
fn parse_send_lightning_target(params: Option<&serde_json::Value>) -> Result<u32> {
|
||||
let params =
|
||||
params.ok_or_else(|| anyhow::anyhow!("Missing params: a target contact_id is required"))?;
|
||||
let contact_id = params["contact_id"].as_u64().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Missing contact_id: mesh.send-lightning-info requires an explicit target and has no \
|
||||
broadcast form"
|
||||
)
|
||||
})?;
|
||||
u32::try_from(contact_id).map_err(|_| anyhow::anyhow!("contact_id out of range"))
|
||||
}
|
||||
|
||||
/// Build the deduplicated, deterministically ordered Lightning-peer list.
|
||||
///
|
||||
/// Pure so the dedup and ordering contracts are testable without a mesh
|
||||
/// service. Ordering matters for a real reason: the picker must not reshuffle
|
||||
/// between reads, or an operator clicking a row can hit a different node than
|
||||
/// the one they aimed at.
|
||||
fn build_lightning_peer_list(peers: &[crate::mesh::types::MeshPeer]) -> Vec<serde_json::Value> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Dedup by the AUTHENTICATING key, never the firmware routing key: a radio
|
||||
// contact and its federation twin are one node and must not be offered
|
||||
// twice (T-01-11). Peers with no key at all fall back to contact_id, which
|
||||
// is unique per record.
|
||||
let mut best: HashMap<String, &crate::mesh::types::MeshPeer> = HashMap::new();
|
||||
for peer in peers.iter().filter(|p| p.lightning_uri.is_some()) {
|
||||
let key = peer
|
||||
.identity_pubkey_hex()
|
||||
.map(|k| k.to_ascii_lowercase())
|
||||
.unwrap_or_else(|| format!("contact:{}", peer.contact_id));
|
||||
best.entry(key)
|
||||
.and_modify(|kept| {
|
||||
// Newest advertisement wins. last_heard is RFC3339; parse rather
|
||||
// than string-compare so a differing offset can't misorder.
|
||||
let kept_at = chrono::DateTime::parse_from_rfc3339(&kept.last_heard).ok();
|
||||
let this_at = chrono::DateTime::parse_from_rfc3339(&peer.last_heard).ok();
|
||||
if this_at >= kept_at {
|
||||
*kept = peer;
|
||||
}
|
||||
})
|
||||
.or_insert(peer);
|
||||
}
|
||||
|
||||
let mut out: Vec<&crate::mesh::types::MeshPeer> = best.into_values().collect();
|
||||
// Sort by display name, then contact_id as the tiebreak, so two peers
|
||||
// sharing a name still have a total order and the list is stable across
|
||||
// reads (a HashMap's iteration order is not).
|
||||
out.sort_by(|a, b| {
|
||||
a.advert_name
|
||||
.to_lowercase()
|
||||
.cmp(&b.advert_name.to_lowercase())
|
||||
.then(a.contact_id.cmp(&b.contact_id))
|
||||
});
|
||||
|
||||
out.into_iter()
|
||||
.map(|p| {
|
||||
serde_json::json!({
|
||||
"contact_id": p.contact_id,
|
||||
"name": p.advert_name,
|
||||
"lightning_uri": p.lightning_uri,
|
||||
"last_heard": p.last_heard,
|
||||
"reachable": p.reachable,
|
||||
"hops": p.hops,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lightning_peer_tests {
|
||||
use super::*;
|
||||
use crate::mesh::types::MeshPeer;
|
||||
|
||||
const URI_A: &str =
|
||||
"03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90@1.2.3.4:9735";
|
||||
const URI_B: &str =
|
||||
"02ffeeddccbbaa998877665544332211ffeeddccbbaa998877665544332211ffee@5.6.7.8:9735";
|
||||
|
||||
fn peer(contact_id: u32, name: &str, arch: Option<&str>) -> MeshPeer {
|
||||
MeshPeer {
|
||||
contact_id,
|
||||
advert_name: name.into(),
|
||||
did: None,
|
||||
pubkey_hex: Some(format!("routing{contact_id}")),
|
||||
arch_pubkey_hex: arch.map(|s| s.into()),
|
||||
x25519_pubkey: None,
|
||||
rssi: None,
|
||||
snr: None,
|
||||
last_heard: "2026-08-02T10:00:00+00:00".into(),
|
||||
hops: 1,
|
||||
last_advert: 0,
|
||||
reachable: true,
|
||||
pkc_capable: false,
|
||||
lat: None,
|
||||
lon: None,
|
||||
lightning_uri: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_advertised_peers_is_an_empty_list_not_an_error() {
|
||||
assert!(build_lightning_peer_list(&[]).is_empty());
|
||||
// Peers exist, but none has advertised Lightning.
|
||||
let quiet = vec![peer(1, "a", None), peer(2, "b", None)];
|
||||
assert!(build_lightning_peer_list(&quiet).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_peers_that_advertised_are_listed() {
|
||||
let mut with = peer(1, "has-lightning", None);
|
||||
with.lightning_uri = Some(URI_A.into());
|
||||
let list = build_lightning_peer_list(&[with, peer(2, "no-lightning", None)]);
|
||||
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0]["name"], "has-lightning");
|
||||
assert_eq!(list[0]["lightning_uri"], URI_A);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_peer_that_advertised_twice_appears_once_with_the_newer_uri() {
|
||||
// Same node seen as two records (radio + federation twin) sharing an
|
||||
// authenticating key — the picker must offer it once, not twice.
|
||||
let mut older = peer(1, "twin", Some("ARCHKEY"));
|
||||
older.lightning_uri = Some(URI_A.into());
|
||||
older.last_heard = "2026-08-02T10:00:00+00:00".into();
|
||||
|
||||
let mut newer = peer(2, "twin", Some("archkey")); // case-insensitive match
|
||||
newer.lightning_uri = Some(URI_B.into());
|
||||
newer.last_heard = "2026-08-02T11:30:00+00:00".into();
|
||||
|
||||
let list = build_lightning_peer_list(&[older.clone(), newer.clone()]);
|
||||
assert_eq!(list.len(), 1, "twins must collapse to one entry");
|
||||
assert_eq!(list[0]["lightning_uri"], URI_B, "the newer URI must win");
|
||||
|
||||
// Order of the input must not change the outcome.
|
||||
let reversed = build_lightning_peer_list(&[newer, older]);
|
||||
assert_eq!(reversed[0]["lightning_uri"], URI_B);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_differing_offset_cannot_misorder_the_newest_advertisement() {
|
||||
// 09:30-01:00 is 10:30 UTC — LATER than 10:00Z, though it string-sorts
|
||||
// earlier. Parsing rather than string-comparing is what makes this pass.
|
||||
let mut utc = peer(1, "twin", Some("k"));
|
||||
utc.lightning_uri = Some(URI_A.into());
|
||||
utc.last_heard = "2026-08-02T10:00:00+00:00".into();
|
||||
|
||||
let mut offset = peer(2, "twin", Some("k"));
|
||||
offset.lightning_uri = Some(URI_B.into());
|
||||
offset.last_heard = "2026-08-02T09:30:00-01:00".into();
|
||||
|
||||
let list = build_lightning_peer_list(&[utc, offset]);
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0]["lightning_uri"], URI_B);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordering_is_stable_and_deterministic_across_reads() {
|
||||
let mut peers = Vec::new();
|
||||
for (id, name) in [(3, "Zulu"), (1, "alpha"), (2, "Mike"), (9, "alpha")] {
|
||||
let mut p = peer(id, name, Some(&format!("key{id}")));
|
||||
p.lightning_uri = Some(URI_A.into());
|
||||
peers.push(p);
|
||||
}
|
||||
|
||||
let first = build_lightning_peer_list(&peers);
|
||||
// A HashMap's iteration order is not stable, so run it repeatedly over a
|
||||
// shuffled input: the output must be byte-identical every time.
|
||||
for _ in 0..8 {
|
||||
peers.rotate_left(1);
|
||||
assert_eq!(build_lightning_peer_list(&peers), first);
|
||||
}
|
||||
|
||||
let names: Vec<_> = first.iter().map(|e| e["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, vec!["alpha", "alpha", "Mike", "Zulu"]);
|
||||
// Same name -> contact_id breaks the tie, ascending.
|
||||
assert_eq!(first[0]["contact_id"], 1);
|
||||
assert_eq!(first[1]["contact_id"], 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_requires_an_explicit_target_and_has_no_broadcast_form() {
|
||||
assert!(parse_send_lightning_target(None).is_err(), "no params");
|
||||
assert!(
|
||||
parse_send_lightning_target(Some(&serde_json::json!({}))).is_err(),
|
||||
"params without contact_id must be refused, not treated as broadcast"
|
||||
);
|
||||
assert!(
|
||||
parse_send_lightning_target(Some(&serde_json::json!({"broadcast": true}))).is_err(),
|
||||
"there is no broadcast escape hatch"
|
||||
);
|
||||
assert!(
|
||||
parse_send_lightning_target(Some(&serde_json::json!({"contact_id": 1u64 << 40})))
|
||||
.is_err(),
|
||||
"an out-of-range contact_id must error, not silently truncate to another peer"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_send_lightning_target(Some(&serde_json::json!({"contact_id": 42}))).unwrap(),
|
||||
42
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ mod names;
|
||||
mod network;
|
||||
mod node;
|
||||
mod nostr;
|
||||
mod onboarding_gate;
|
||||
mod openwrt;
|
||||
mod package;
|
||||
pub(crate) use package::wyoming_satellite_keeper;
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
//! Onboarding-posture gate for the unauthenticated, identity-mutating RPCs.
|
||||
//!
|
||||
//! Closes F-01 (Critical) of `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`:
|
||||
//! `seed.generate` / `seed.restore` / `seed.save-encrypted` /
|
||||
//! `backup.restore-identity` / `auth.setup` all sit in
|
||||
//! `middleware::UNAUTHENTICATED_METHODS`, and several of them reach
|
||||
//! `NodeIdentity::from_seed` (`identity.rs:79-114`) or
|
||||
//! `backup::identity::restore_encrypted_backup` (`backup/identity.rs:112-117`),
|
||||
//! both of which overwrite `identity/node_key` unconditionally. Before this
|
||||
//! gate, a single unauthenticated JSON-RPC POST from anywhere on the LAN — or
|
||||
//! from any FIPS mesh peer — replaced a live node's Ed25519 identity, Nostr
|
||||
//! node key and FIPS transport key.
|
||||
//!
|
||||
//! Those endpoints cannot simply be removed from the unauthenticated list:
|
||||
//! they are *legitimately* pre-auth, because no user account exists until
|
||||
//! `auth.setup` runs at the very end of the onboarding wizard. So instead of
|
||||
//! authenticating the caller, this gate asks a different question — "is this
|
||||
//! node still un-provisioned?" — and refuses once the answer is no.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// The D-04 sweep set: every method in `UNAUTHENTICATED_METHODS` that can
|
||||
/// mutate node identity or credentials. Each of these either calls
|
||||
/// [`ensure_onboarding_open`] or carries a written, evidence-backed verdict for
|
||||
/// why it does not (`auth.onboardingComplete` takes the *opposite* guard — see
|
||||
/// `api/rpc/auth.rs::handle_auth_onboarding_complete`).
|
||||
///
|
||||
/// This constant is the anti-drift anchor for `gate_calls_are_present`, the
|
||||
/// source-guard test at the bottom of this file. It does not itself dispatch
|
||||
/// anything, so it is dead in a non-test build by design — it exists to make
|
||||
/// the sweep set reviewable in one place and to fail a test when a sixth door
|
||||
/// is added without a gate.
|
||||
#[allow(dead_code)]
|
||||
pub(in crate::api::rpc) const IDENTITY_MUTATING_ONBOARDING_METHODS: &[&str] = &[
|
||||
"seed.generate",
|
||||
"seed.restore",
|
||||
"seed.save-encrypted",
|
||||
"backup.restore-identity",
|
||||
"auth.setup",
|
||||
"auth.onboardingComplete",
|
||||
];
|
||||
|
||||
/// The refusal text. MUST begin with `Not supported:` — `sanitize_error_message`
|
||||
/// (`middleware.rs:47-71`) only lets an error through to the caller when it
|
||||
/// starts with a known prefix, and `Not supported` is already on that list.
|
||||
/// Anything else would reach the operator as "Operation failed. Check server
|
||||
/// logs for details.", which is a dead end rather than a refusal.
|
||||
///
|
||||
/// Kept under the sanitizer's 200-character truncation limit so the recovery
|
||||
/// path (D-02) survives intact.
|
||||
const REFUSAL: &str = "Not supported: this node is already provisioned. Re-keying requires the \
|
||||
authenticated system.factory-reset, after which the normal onboarding \
|
||||
restore flow works.";
|
||||
|
||||
/// Return `Ok(())` only while the node is still un-provisioned; otherwise
|
||||
/// refuse with [`REFUSAL`].
|
||||
///
|
||||
/// # Signals (D-03 / D-03a)
|
||||
///
|
||||
/// Three independent signals, OR-ed. ANY one of them saying "provisioned"
|
||||
/// refuses — the gate never trusts a single signal alone to say "open", which
|
||||
/// is what makes it fail safe when the signals drift apart (a real state:
|
||||
/// `auth.rs:193-217` carries auto-heal logic for exactly that drift).
|
||||
///
|
||||
/// | Signal | Source | Fresh node | Mid-onboarding | Provisioned |
|
||||
/// |---|---|---|---|---|
|
||||
/// | `AuthManager::is_setup()` (`auth.rs:116-119`, `user.json` exists) | disk | false | false | true |
|
||||
/// | `AuthManager::is_onboarding_complete()` (`auth.rs:182-219`) | disk + flag | false | false | true |
|
||||
/// | `crate::seed::seed_exists()` (`seed.rs:384-386`, `identity/master_seed.enc`) | disk | false | false | true (legacy nodes: false — covered by the other two) |
|
||||
///
|
||||
/// # Why `NodeIdentity::key_exists` is NOT one of them
|
||||
///
|
||||
/// The audit's suggested remediation, and the phase's own D-03, both named
|
||||
/// `NodeIdentity::key_exists` (`identity.rs:117`) as the on-disk "this node is
|
||||
/// onboarded" signal. **It is unusable, and a gate keyed on it would brick
|
||||
/// first boot on every new node.** `Server::new` (`server.rs:63-71`) calls
|
||||
/// `NodeIdentity::load_or_create` on *both* branches of its fresh-vs-existing
|
||||
/// check, and `load_or_create` (`identity.rs:47-67`) generates and writes a
|
||||
/// random temporary node key when none exists — its own comment says "Fresh
|
||||
/// install — create a temporary identity. Onboarding will overwrite this with
|
||||
/// seed-derived keys." So `key_exists` is `true` on every node that has booted
|
||||
/// even once, onboarded or not, and refusing on it would refuse
|
||||
/// `seed.generate` on a node that has never been onboarded.
|
||||
///
|
||||
/// `identity::fips_key_exists` was rejected for a related reason: the FIPS key
|
||||
/// is written by `NodeIdentity::from_seed` (`identity.rs:108`), i.e. by the
|
||||
/// *first* seed step, so it is already true midway through the wizard. Gating
|
||||
/// on it would break a generate-then-restore switchback inside onboarding.
|
||||
///
|
||||
/// This correction is pinned by the test
|
||||
/// `allows_on_fresh_temp_dir_even_though_node_key_exists`, not by this comment.
|
||||
///
|
||||
/// # Failure handling
|
||||
///
|
||||
/// An I/O error from any signal is treated as **provisioned** (fail safe), not
|
||||
/// as open. A gate that opens when it cannot read the disk is not a gate.
|
||||
///
|
||||
/// # Disclosure
|
||||
///
|
||||
/// The refusal deliberately does not say *which* signal fired. A one-bit
|
||||
/// "provisioned" answer discloses nothing beyond what `auth.isOnboardingComplete`
|
||||
/// already discloses — that method is itself in `UNAUTHENTICATED_METHODS`
|
||||
/// (`middleware.rs:9`) — but a per-signal breakdown would disclose more.
|
||||
pub(in crate::api::rpc) async fn ensure_onboarding_open(
|
||||
data_dir: &Path,
|
||||
auth: &crate::auth::AuthManager,
|
||||
) -> anyhow::Result<()> {
|
||||
// `unwrap_or(true)` is the fail-safe: an unreadable user.json or
|
||||
// onboarding.json means we cannot prove the node is fresh, so we refuse.
|
||||
let user_account_exists = auth.is_setup().await.unwrap_or(true);
|
||||
let onboarding_marked_complete = auth.is_onboarding_complete().await.unwrap_or(true);
|
||||
let encrypted_seed_on_disk = crate::seed::seed_exists(data_dir);
|
||||
|
||||
if user_account_exists || onboarding_marked_complete || encrypted_seed_on_disk {
|
||||
// Log the deciding signals for the operator; the caller gets one bit.
|
||||
tracing::warn!(
|
||||
user_account_exists,
|
||||
onboarding_marked_complete,
|
||||
encrypted_seed_on_disk,
|
||||
"[onboarding-gate] refused an identity-mutating onboarding RPC on a provisioned node"
|
||||
);
|
||||
anyhow::bail!(REFUSAL);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The OPPOSITE guard, for `auth.onboardingComplete` only.
|
||||
///
|
||||
/// That method is unauthenticated and SETS the flag [`ensure_onboarding_open`]
|
||||
/// reads, so without this an attacker could call it once against a fresh node
|
||||
/// and permanently lock it out of onboarding — a denial of service created by
|
||||
/// the gate itself (T-10-04). Onboarding cannot legitimately be "complete"
|
||||
/// before a user account exists, so refuse until it does.
|
||||
///
|
||||
/// Failure handling is the mirror image of the main gate: an unreadable
|
||||
/// `user.json` means we cannot prove an account exists, so we refuse
|
||||
/// (`unwrap_or(false)`). Refusing here is safe — the flag is also inferred by
|
||||
/// `AuthManager::is_onboarding_complete`'s auto-heal path (`auth.rs:203-217`)
|
||||
/// once the account is set up, so nothing depends on this RPC succeeding.
|
||||
pub(in crate::api::rpc) async fn ensure_user_account_exists(
|
||||
auth: &crate::auth::AuthManager,
|
||||
) -> anyhow::Result<()> {
|
||||
if !auth.is_setup().await.unwrap_or(false) {
|
||||
tracing::warn!("[onboarding-gate] refused auth.onboardingComplete — no user account yet");
|
||||
anyhow::bail!(
|
||||
"Not supported: onboarding cannot be completed before a user account exists. \
|
||||
Set a password first."
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::AuthManager;
|
||||
|
||||
/// A temp data dir plus its AuthManager, in the state a genuinely fresh
|
||||
/// node is in: nothing written yet.
|
||||
fn fresh() -> (tempfile::TempDir, AuthManager) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let auth = AuthManager::new(dir.path().to_path_buf());
|
||||
(dir, auth)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allows_on_fresh_node() {
|
||||
let (dir, auth) = fresh();
|
||||
assert!(ensure_onboarding_open(dir.path(), &auth).await.is_ok());
|
||||
}
|
||||
|
||||
/// Pins the D-03a scoping correction as a test rather than a comment:
|
||||
/// every booted node has a `node_key` on disk (`server.rs:63-71` ->
|
||||
/// `identity.rs:47-67`), so a gate keyed on `NodeIdentity::key_exists`
|
||||
/// would refuse onboarding on a node that has never been onboarded.
|
||||
#[tokio::test]
|
||||
async fn allows_on_fresh_temp_dir_even_though_node_key_exists() {
|
||||
let (dir, auth) = fresh();
|
||||
let identity_dir = dir.path().join("identity");
|
||||
std::fs::create_dir_all(&identity_dir).unwrap();
|
||||
std::fs::write(identity_dir.join("node_key"), [7u8; 32]).unwrap();
|
||||
|
||||
assert!(
|
||||
crate::identity::NodeIdentity::key_exists(&identity_dir),
|
||||
"precondition: the boot-time node key is on disk"
|
||||
);
|
||||
assert!(
|
||||
ensure_onboarding_open(dir.path(), &auth).await.is_ok(),
|
||||
"a boot-time node_key must NOT be read as 'onboarded' — that would \
|
||||
brick first boot on every fresh node"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refuses_when_user_json_exists() {
|
||||
let (dir, auth) = fresh();
|
||||
auth.setup_user("password123").await.unwrap();
|
||||
|
||||
let err = ensure_onboarding_open(dir.path(), &auth).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().starts_with("Not supported:"),
|
||||
"refusal must survive sanitize_error_message: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refuses_when_onboarding_flag_set() {
|
||||
let (dir, auth) = fresh();
|
||||
// Writes onboarding.json even with no user account — the drift case.
|
||||
auth.complete_onboarding().await.unwrap();
|
||||
assert!(!auth.is_setup().await.unwrap(), "no user.json in this case");
|
||||
|
||||
assert!(ensure_onboarding_open(dir.path(), &auth).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refuses_when_encrypted_seed_on_disk() {
|
||||
let (dir, auth) = fresh();
|
||||
let identity_dir = dir.path().join("identity");
|
||||
std::fs::create_dir_all(&identity_dir).unwrap();
|
||||
std::fs::write(identity_dir.join("master_seed.enc"), b"ciphertext").unwrap();
|
||||
assert!(crate::seed::seed_exists(dir.path()));
|
||||
|
||||
assert!(ensure_onboarding_open(dir.path(), &auth).await.is_err());
|
||||
}
|
||||
|
||||
/// The refusal must reach the caller intact rather than being collapsed
|
||||
/// into "Operation failed. Check server logs for details.", and it must
|
||||
/// name the D-02 recovery path.
|
||||
#[tokio::test]
|
||||
async fn refusal_survives_the_error_sanitizer_and_names_the_recovery_path() {
|
||||
let sanitized = crate::api::rpc::middleware::sanitize_error_message(REFUSAL);
|
||||
assert_ne!(
|
||||
sanitized,
|
||||
"Operation failed. Check server logs for details."
|
||||
);
|
||||
assert!(
|
||||
sanitized.contains("system.factory-reset"),
|
||||
"the refusal must not be a dead end: {sanitized}"
|
||||
);
|
||||
}
|
||||
|
||||
/// T-10-04: `auth.onboardingComplete` must not be usable to lock a fresh
|
||||
/// node out of its own onboarding.
|
||||
#[tokio::test]
|
||||
async fn onboarding_complete_guard_requires_a_user_account() {
|
||||
let (_dir, auth) = fresh();
|
||||
|
||||
let err = ensure_user_account_exists(&auth).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().starts_with("Not supported:"),
|
||||
"refusal must survive sanitize_error_message: {err}"
|
||||
);
|
||||
|
||||
auth.setup_user("password123").await.unwrap();
|
||||
assert!(
|
||||
ensure_user_account_exists(&auth).await.is_ok(),
|
||||
"once the account exists, completing onboarding is legitimate"
|
||||
);
|
||||
}
|
||||
|
||||
/// Anti-drift source guard: every method in
|
||||
/// [`IDENTITY_MUTATING_ONBOARDING_METHODS`] must still carry its guard in
|
||||
/// the handler body that serves it. Deleting any single
|
||||
/// `ensure_onboarding_open` call fails this test instead of shipping.
|
||||
///
|
||||
/// Matching is done on source text rather than behaviour because the
|
||||
/// handlers are `RpcHandler` methods, and constructing an `RpcHandler`
|
||||
/// needs an orchestrator, port allocator, session store and metrics store.
|
||||
#[test]
|
||||
fn every_identity_mutating_method_still_carries_its_guard() {
|
||||
const SEED_RPC: &str = include_str!("seed_rpc.rs");
|
||||
const BACKUP_RPC: &str = include_str!("backup_rpc.rs");
|
||||
const AUTH_RPC: &str = include_str!("auth.rs");
|
||||
|
||||
// method -> (source file, the fn whose body serves it, guard call)
|
||||
let coverage: &[(&str, &str, &str, &str)] = &[
|
||||
(
|
||||
"seed.generate",
|
||||
SEED_RPC,
|
||||
"async fn handle_seed_generate",
|
||||
"ensure_onboarding_open",
|
||||
),
|
||||
(
|
||||
"seed.restore",
|
||||
SEED_RPC,
|
||||
"async fn restore_node_identity_from_words",
|
||||
"ensure_onboarding_open",
|
||||
),
|
||||
(
|
||||
"seed.save-encrypted",
|
||||
SEED_RPC,
|
||||
"async fn handle_seed_save_encrypted",
|
||||
"ensure_onboarding_open",
|
||||
),
|
||||
(
|
||||
"backup.restore-identity",
|
||||
BACKUP_RPC,
|
||||
"async fn handle_backup_restore_identity",
|
||||
"ensure_onboarding_open",
|
||||
),
|
||||
(
|
||||
"auth.setup",
|
||||
AUTH_RPC,
|
||||
"async fn handle_auth_setup",
|
||||
"ensure_onboarding_open",
|
||||
),
|
||||
// The opposite guard — see `ensure_user_account_exists`.
|
||||
(
|
||||
"auth.onboardingComplete",
|
||||
AUTH_RPC,
|
||||
"async fn handle_auth_onboarding_complete",
|
||||
"ensure_user_account_exists",
|
||||
),
|
||||
];
|
||||
|
||||
for method in IDENTITY_MUTATING_ONBOARDING_METHODS {
|
||||
assert!(
|
||||
coverage.iter().any(|(m, ..)| m == method),
|
||||
"{method} is in the sweep set but no source guard covers it"
|
||||
);
|
||||
}
|
||||
|
||||
for (method, source, signature, guard) in coverage {
|
||||
let start = source
|
||||
.find(signature)
|
||||
.unwrap_or_else(|| panic!("{signature} not found — did {method} get renamed?"));
|
||||
let body = fn_body(&source[start..]);
|
||||
|
||||
assert!(
|
||||
body.contains(guard),
|
||||
"{method}: {signature} no longer calls {guard} — the F-01 gate was removed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `{ .. }` block of the function `src` starts with, by brace matching.
|
||||
/// Deliberately exact: a looser "up to the next fn" slice would let a
|
||||
/// neighbouring handler's guard call satisfy the assertion for a handler
|
||||
/// whose own guard had been deleted.
|
||||
fn fn_body(src: &str) -> &str {
|
||||
let open = src.find('{').expect("function has a body");
|
||||
let mut depth = 0usize;
|
||||
for (i, c) in src[open..].char_indices() {
|
||||
match c {
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return &src[open..open + i + 1];
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
panic!("unbalanced braces while scanning a function body");
|
||||
}
|
||||
|
||||
/// The headline F-01 regression: an already-provisioned node refuses
|
||||
/// `seed.restore` with attacker-chosen words, and its identity is
|
||||
/// byte-identical afterwards. This test cannot pass without the gate.
|
||||
#[tokio::test]
|
||||
async fn provisioned_node_refuses_restore_and_identity_bytes_are_unchanged() {
|
||||
let (dir, auth) = fresh();
|
||||
let data_dir = dir.path();
|
||||
let identity_dir = data_dir.join("identity");
|
||||
|
||||
// 1) The node's real identity, derived from seed A.
|
||||
let (_mnemonic_a, seed_a) = crate::seed::MasterSeed::generate().unwrap();
|
||||
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed_a)
|
||||
.await
|
||||
.unwrap();
|
||||
let nostr_a = crate::seed::derive_node_nostr_key(&seed_a).unwrap();
|
||||
std::fs::write(
|
||||
identity_dir.join("nostr_secret"),
|
||||
nostr_a.secret_key().display_secret().to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 2) The node is provisioned.
|
||||
auth.complete_onboarding().await.unwrap();
|
||||
|
||||
// 3) Snapshot the key material an attacker would be trying to replace.
|
||||
let node_key_before = std::fs::read(identity_dir.join("node_key")).unwrap();
|
||||
let nostr_secret_before = std::fs::read(identity_dir.join("nostr_secret")).unwrap();
|
||||
|
||||
// 4) The attack: a valid but attacker-chosen 24-word mnemonic, posted
|
||||
// unauthenticated at seed.restore.
|
||||
let (attacker_mnemonic, _seed_b) = crate::seed::MasterSeed::generate().unwrap();
|
||||
let attacker_words: Vec<String> = attacker_mnemonic.words().map(str::to_string).collect();
|
||||
assert_eq!(attacker_words.len(), 24);
|
||||
|
||||
let result = super::super::seed_rpc::restore_node_identity_from_words(
|
||||
data_dir,
|
||||
&auth,
|
||||
&attacker_words,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a provisioned node must refuse seed.restore"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(identity_dir.join("node_key")).unwrap(),
|
||||
node_key_before,
|
||||
"identity/node_key was overwritten by an unauthenticated caller (F-01)"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(identity_dir.join("nostr_secret")).unwrap(),
|
||||
nostr_secret_before,
|
||||
"identity/nostr_secret was overwritten by an unauthenticated caller (F-01)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -646,18 +646,18 @@ pub(super) async fn get_app_config(
|
||||
allocator: &mut PortAllocator,
|
||||
rpc_user: &str,
|
||||
rpc_pass: &str,
|
||||
) -> Result<(
|
||||
) -> (
|
||||
Vec<String>,
|
||||
Vec<String>,
|
||||
Vec<String>,
|
||||
Option<String>,
|
||||
Option<Vec<String>>,
|
||||
)> {
|
||||
) {
|
||||
if let Some(config) = dynamic_app_config(app_id).await {
|
||||
return Ok(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
Ok(match app_id {
|
||||
match app_id {
|
||||
"homeassistant" | "home-assistant" => (
|
||||
vec!["8123:8123".to_string()],
|
||||
vec!["/var/lib/archipelago/home-assistant:/config".to_string()],
|
||||
@@ -1049,13 +1049,10 @@ pub(super) async fn get_app_config(
|
||||
]),
|
||||
),
|
||||
"fedimint-gateway" => {
|
||||
// FED-07: no fallback literal. A fresh install self-provisions its
|
||||
// own credential; a node that can't obtain one fails this install
|
||||
// outright (propagated via `?`) rather than start an
|
||||
// unauthenticated/default-credentialed gateway.
|
||||
let gateway_secrets_dir = std::path::Path::new("/var/lib/archipelago/secrets");
|
||||
crate::container::secrets::ensure_gateway_credential(gateway_secrets_dir)?;
|
||||
let fedi_hash = crate::container::secrets::gateway_bcrypt_hash(gateway_secrets_dir)?;
|
||||
let fedi_hash = read_secret(
|
||||
"fedimint-gateway-hash",
|
||||
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC",
|
||||
);
|
||||
(
|
||||
vec!["8176:8176".to_string(), "9737:9737".to_string()],
|
||||
vec!["/var/lib/archipelago/fedimint-gateway:/data".to_string()],
|
||||
@@ -1199,5 +1196,5 @@ pub(super) async fn get_app_config(
|
||||
tracing::warn!("No catalog runtime config found for app: {} — using minimal defaults", app_id);
|
||||
(vec![], vec![], vec![], None, None)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,11 +717,6 @@ fn order_present_containers(package_id: &str, containers: Vec<String>) -> Vec<St
|
||||
|
||||
/// Configure Fedimint Gateway to use LND instead of LDK.
|
||||
/// Modifies ports, volumes, and command args in place when LND credentials exist.
|
||||
///
|
||||
/// `fedi_hash` is the already-resolved per-install gateway credential
|
||||
/// (`container::secrets::gateway_bcrypt_hash`) — this function does not read
|
||||
/// the secrets file itself, so there is exactly one read site and one
|
||||
/// failure point for that credential (FED-07).
|
||||
pub(super) fn configure_fedimint_lnd(
|
||||
host_ip: &str,
|
||||
ports: &mut Vec<String>,
|
||||
@@ -729,13 +724,20 @@ pub(super) fn configure_fedimint_lnd(
|
||||
custom_args: &mut Option<Vec<String>>,
|
||||
rpc_user: &str,
|
||||
rpc_pass: &str,
|
||||
fedi_hash: &str,
|
||||
) {
|
||||
let lnd_cert = "/var/lib/archipelago/lnd/tls.cert";
|
||||
let lnd_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
if std::path::Path::new(lnd_cert).exists() && std::path::Path::new(lnd_macaroon).exists() {
|
||||
info!("LND detected with credentials — configuring gateway in lnd mode");
|
||||
|
||||
// Read bcrypt hash from secrets file, fall back to default
|
||||
let fedi_hash =
|
||||
std::fs::read_to_string("/var/lib/archipelago/secrets/fedimint-gateway-hash")
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| {
|
||||
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string()
|
||||
});
|
||||
|
||||
ports.retain(|p| p != "9737:9737");
|
||||
volumes.push(format!("{}:/lnd/tls.cert:ro", lnd_cert));
|
||||
volumes.push(format!("{}:/lnd/admin.macaroon:ro", lnd_macaroon));
|
||||
@@ -746,7 +748,7 @@ pub(super) fn configure_fedimint_lnd(
|
||||
"--listen".to_string(),
|
||||
"0.0.0.0:8176".to_string(),
|
||||
"--bcrypt-password-hash".to_string(),
|
||||
fedi_hash.to_string(),
|
||||
fedi_hash,
|
||||
"--network".to_string(),
|
||||
"bitcoin".to_string(),
|
||||
"--bitcoind-url".to_string(),
|
||||
|
||||
@@ -307,19 +307,24 @@ impl RpcHandler {
|
||||
let deps = self.gate_install_deps(package_id).await?;
|
||||
check_bitcoin_pruning_compatibility(package_id).await?;
|
||||
log_optional_dep_info(package_id, &deps);
|
||||
let repaired_bitcoin_conf =
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
// Materialise the RPC password file before any install path
|
||||
// runs. The orchestrator path resolves secret_env from
|
||||
// /var/lib/archipelago/secrets/bitcoin-rpc-password at start
|
||||
// time; if the file is missing, bitcoind exits within ms.
|
||||
// bitcoin_rpc_credentials() generates + persists on first
|
||||
// call (OnceCell-cached), so this is idempotent.
|
||||
let _ = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
ensure_bitcoin_rpc_config().await?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
// Materialise the RPC password file before any install path
|
||||
// runs. The orchestrator path resolves secret_env from
|
||||
// /var/lib/archipelago/secrets/bitcoin-rpc-password at start
|
||||
// time; if the file is missing, bitcoind exits within ms.
|
||||
// bitcoin_rpc_credentials() generates + persists on first
|
||||
// call (OnceCell-cached), so this is idempotent.
|
||||
let _ = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
// A stale datadir bitcoin.conf from an older install conflicts
|
||||
// with the container's -conf=/tmp/rpc.conf launch (see
|
||||
// apps/bitcoin-core & bitcoin-knots manifest.yml) and makes
|
||||
// Bitcoin Core refuse to start at all. Clear it before
|
||||
// (re)install. Unlike the old bind-setting "repair" this was
|
||||
// replacing, it never requires restarting an already-running
|
||||
// container — bitcoind doesn't read this file, so removing it
|
||||
// changes nothing at runtime.
|
||||
remove_stale_bitcoin_conf().await?;
|
||||
}
|
||||
|
||||
// For orchestrator-managed apps, skip the legacy "container exists →
|
||||
// adopt + return" probe entirely. The orchestrator's own install path
|
||||
@@ -389,37 +394,7 @@ impl RpcHandler {
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if state == "running" && repaired_bitcoin_conf {
|
||||
info!(
|
||||
"Restarting existing container {} after bitcoin.conf RPC repair",
|
||||
package_id
|
||||
);
|
||||
let restart_output = tokio::process::Command::new("podman")
|
||||
.args(["restart", package_id])
|
||||
.output()
|
||||
.await
|
||||
.context(
|
||||
"Failed to restart existing container after bitcoin.conf repair",
|
||||
)?;
|
||||
if !restart_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&restart_output.stderr);
|
||||
install_log(&format!(
|
||||
"INSTALL ADOPT FAIL: {} - restart after RPC repair failed: {}",
|
||||
package_id, stderr
|
||||
))
|
||||
.await;
|
||||
return Err(anyhow::anyhow!(
|
||||
"Container {} exists but failed to restart after RPC repair: {}",
|
||||
package_id,
|
||||
stderr
|
||||
));
|
||||
}
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["restart", "archy-bitcoin-ui"])
|
||||
.output()
|
||||
.await;
|
||||
wait_for_adopted_container(package_id, package_id).await?;
|
||||
} else if state != "running" {
|
||||
if state != "running" {
|
||||
// Start the stopped/exited container
|
||||
info!("Starting existing container {} (was {})", package_id, state);
|
||||
let start_output = tokio::process::Command::new("podman")
|
||||
@@ -589,18 +564,11 @@ impl RpcHandler {
|
||||
&rpc_user,
|
||||
&rpc_pass,
|
||||
)
|
||||
.await?
|
||||
.await
|
||||
};
|
||||
|
||||
// Fedimint Gateway: auto-detect LND and switch to lnd mode
|
||||
if package_id == "fedimint-gateway" && deps.has_lnd {
|
||||
// get_app_config's "fedimint-gateway" arm already called
|
||||
// ensure_gateway_credential above, so the secret is guaranteed to
|
||||
// exist here; re-reading it (rather than threading the value
|
||||
// through) keeps one canonical read site in container::secrets.
|
||||
let fedi_hash = crate::container::secrets::gateway_bcrypt_hash(std::path::Path::new(
|
||||
"/var/lib/archipelago/secrets",
|
||||
))?;
|
||||
configure_fedimint_lnd(
|
||||
&self.config.host_ip,
|
||||
&mut ports,
|
||||
@@ -608,7 +576,6 @@ impl RpcHandler {
|
||||
&mut custom_args,
|
||||
&rpc_user,
|
||||
&rpc_pass,
|
||||
&fedi_hash,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -715,9 +682,13 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-install: write config files BEFORE chown (dir is still owned by archipelago user)
|
||||
// Pre-install: clear a stale datadir bitcoin.conf BEFORE chown (dir is
|
||||
// still owned by archipelago user). bitcoind is launched with
|
||||
// -conf=/tmp/rpc.conf (see apps/bitcoin-core & bitcoin-knots
|
||||
// manifest.yml) and never reads a datadir bitcoin.conf — if one
|
||||
// exists, Bitcoin Core's own safety check refuses to start at all.
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
self.write_bitcoin_conf(&rpc_user, &rpc_pass).await?;
|
||||
remove_stale_bitcoin_conf().await?;
|
||||
}
|
||||
|
||||
if package_id == "lnd" {
|
||||
@@ -729,16 +700,7 @@ impl RpcHandler {
|
||||
let searx_dir = "/var/lib/archipelago/searxng";
|
||||
let settings_path = format!("{}/settings.yml", searx_dir);
|
||||
if !tokio::fs::try_exists(&settings_path).await.unwrap_or(false) {
|
||||
// KEY-05: SearXNG's `server.secret_key` signs that app's own
|
||||
// tokens — an app secret, so source named and draw guarded.
|
||||
let mut secret = [0u8; 32];
|
||||
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut secret).map_err(
|
||||
|e| {
|
||||
anyhow::anyhow!(
|
||||
"Refusing to write a SearXNG secret_key from degenerate entropy: {e}"
|
||||
)
|
||||
},
|
||||
)?;
|
||||
let secret: [u8; 32] = rand::random();
|
||||
let secret_hex = hex::encode(secret);
|
||||
let settings = format!(
|
||||
"use_default_settings: true\ngeneral:\n instance_name: Archipelago Search\nserver:\n secret_key: \"{}\"\n bind_address: \"0.0.0.0\"\n port: 8080\n limiter: false\nui:\n default_theme: simple\n",
|
||||
@@ -1435,101 +1397,13 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write bitcoin.conf with rpcauth (salted HMAC hash, no plaintext password).
|
||||
async fn write_bitcoin_conf(&self, rpc_user: &str, rpc_pass: &str) -> Result<()> {
|
||||
let bitcoin_dir = "/var/lib/archipelago/bitcoin";
|
||||
let conf_path = format!("{}/bitcoin.conf", bitcoin_dir);
|
||||
|
||||
// Idempotent: once bitcoin-knots (or a prior install) has started,
|
||||
// the data dir is chowned into the container's user namespace
|
||||
// (e.g. UID 100100 on the host) with 700 perms — the archipelago
|
||||
// daemon can no longer stat or write there. Treat any non-NotFound
|
||||
// error on the conf as "conf already provisioned by the container
|
||||
// user" and skip. Matches the lnd.conf behavior below.
|
||||
match tokio::fs::metadata(&conf_path).await {
|
||||
Ok(_) => {
|
||||
ensure_bitcoin_rpc_config().await?;
|
||||
info!("bitcoin.conf already exists, ensured Bitcoin RPC config");
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(_) => {
|
||||
ensure_bitcoin_rpc_config().await?;
|
||||
info!("bitcoin.conf path inaccessible, ensured Bitcoin RPC config via host helper");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
// KEY-05: the salt is half of the stored `rpcauth=` credential line, so
|
||||
// source named and draw guarded.
|
||||
let mut salt_bytes = [0u8; 16];
|
||||
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut salt_bytes).map_err(|e| {
|
||||
anyhow::anyhow!("Refusing to build an rpcauth line from degenerate salt entropy: {e}")
|
||||
})?;
|
||||
let salt_hex = hex::encode(salt_bytes);
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes())
|
||||
.expect("HMAC accepts any key length");
|
||||
mac.update(rpc_pass.as_bytes());
|
||||
let hash_hex = hex::encode(mac.finalize().into_bytes());
|
||||
let rpcauth_line = format!("rpcauth={}:{}${}", rpc_user, salt_hex, hash_hex);
|
||||
|
||||
// Default to full archive — operators with 2TB+ drives shouldn't be
|
||||
// silently pruned down to 550 MB. Users who want a pruned node can
|
||||
// set `prune=N` in bitcoin.conf themselves after install.
|
||||
//
|
||||
// printtoconsole=0: bitcoind already writes debug.log in the datadir
|
||||
// (self-shrunk on restart); duplicating it to stdout pushed every IBD
|
||||
// "UpdateTip" line through conmon into journald (>1 GB/day). Deep
|
||||
// debugging uses /var/lib/archipelago/bitcoin/debug.log.
|
||||
// rpcbind=0.0.0.0 is REQUIRED inside a container: with rpcallowip set
|
||||
// but no rpcbind, bitcoind binds RPC to 127.0.0.1 in the container
|
||||
// netns only — LND / the Bitcoin UI dialing bitcoin-knots:8332 over
|
||||
// the bridge get connection refused (fresh-install LND crash-loop +
|
||||
// bitcoin-rpc 502, seen on the 1.7.99 ISO). The port publish stays
|
||||
// 127.0.0.1-only on the host, so exposure is unchanged.
|
||||
// Prune sized to the data volume. A full archive needs ~810 GB and
|
||||
// grows; silently writing an unpruned config onto a small disk fills
|
||||
// it mid-IBD (framework node 2026-07-14: unpruned mainnet on a 205 GB
|
||||
// volume). Volumes with real archival headroom (≥1.2 TB) stay full
|
||||
// archive; smaller ones get prune = 25% of the volume, clamped to
|
||||
// [550 MB, 100 GB], leaving room for LND/apps sharing the disk.
|
||||
let prune_line = match bitcoin_data_volume_gb().await {
|
||||
Some(total_gb) if total_gb > 0 && total_gb < 1200 => {
|
||||
let prune_mb = ((total_gb as f64 * 0.25 * 1024.0) as u64).clamp(550, 100_000);
|
||||
info!(
|
||||
volume_gb = total_gb,
|
||||
prune_mb, "Data volume below archival size — enabling sized bitcoin prune"
|
||||
);
|
||||
format!("prune={}\n", prune_mb)
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let bitcoin_conf = format!(
|
||||
"\
|
||||
# rpcauth: salted hash only - no plaintext password in config or CLI\n\
|
||||
{}\n\
|
||||
server=1\n\
|
||||
rpcbind=0.0.0.0\n\
|
||||
rpcallowip=0.0.0.0/0\n\
|
||||
listen=1\n\
|
||||
rpcthreads=16\n\
|
||||
rpcworkqueue=256\n\
|
||||
printtoconsole=0\n\
|
||||
{}",
|
||||
rpcauth_line, prune_line
|
||||
);
|
||||
tokio::fs::create_dir_all(bitcoin_dir)
|
||||
.await
|
||||
.context("Failed to create bitcoin data directory")?;
|
||||
tokio::fs::write(&conf_path, bitcoin_conf)
|
||||
.await
|
||||
.context("Failed to write bitcoin.conf")?;
|
||||
info!("Created bitcoin.conf with rpcauth (no plaintext credentials)");
|
||||
Ok(())
|
||||
}
|
||||
// write_bitcoin_conf removed: bitcoind is launched with -conf=/tmp/rpc.conf
|
||||
// (see apps/bitcoin-core & bitcoin-knots manifest.yml, commit a597c1d9)
|
||||
// and never reads a datadir bitcoin.conf. Writing one here created a
|
||||
// fatal "-conf vs default bitcoin.conf" conflict on every subsequent
|
||||
// start (Bitcoin Core's own datadir-conflict safety check). See
|
||||
// `remove_stale_bitcoin_conf` below, which replaces both this and
|
||||
// `ensure_bitcoin_rpc_config`.
|
||||
|
||||
/// Write LND config file with Bitcoin RPC credentials.
|
||||
async fn write_lnd_conf(&self, rpc_user: &str, rpc_pass: &str) -> Result<()> {
|
||||
@@ -2624,28 +2498,12 @@ async fn wait_for_adopted_container(package_id: &str, container_name: &str) -> R
|
||||
))
|
||||
}
|
||||
|
||||
/// Total size (GB) of the filesystem holding the bitcoin data dir, via
|
||||
/// `df -k`. None when df fails (containers, exotic mounts) — callers treat
|
||||
/// unknown as "don't prune" to preserve archival defaults on big iron.
|
||||
async fn bitcoin_data_volume_gb() -> Option<u64> {
|
||||
let target = if std::path::Path::new("/var/lib/archipelago").exists() {
|
||||
"/var/lib/archipelago"
|
||||
} else {
|
||||
"/"
|
||||
};
|
||||
let output = tokio::process::Command::new("df")
|
||||
.args(["-k", target])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let line = stdout.lines().nth(1)?;
|
||||
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
|
||||
Some(kb / 1024 / 1024)
|
||||
}
|
||||
// bitcoin_data_volume_gb removed with write_bitcoin_conf: it only fed that
|
||||
// function's volume-aware `prune=` line, which bitcoind never read either
|
||||
// (see remove_stale_bitcoin_conf). The manifest's shell entrypoint already
|
||||
// computes DISK_GB_VALUE and hardcodes -prune=550 on small volumes — a
|
||||
// real volume-aware prune fix belongs there, not in a conf file nothing
|
||||
// reads. Tracked as follow-up in bitcoin-conf-crash-patch.md.
|
||||
|
||||
/// One-shot probe: does bitcoind answer an authenticated getblockchaininfo?
|
||||
/// Works during IBD (the call answers with progress while syncing). Goes via
|
||||
@@ -2723,52 +2581,36 @@ async fn wait_for_bitcoin_rpc_gate(package_id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_bitcoin_rpc_config() -> Result<bool> {
|
||||
/// bitcoind reads only `/tmp/rpc.conf` + CLI args at container start (see
|
||||
/// apps/bitcoin-core & bitcoin-knots manifest.yml, commit a597c1d9) — it
|
||||
/// never reads a datadir bitcoin.conf. A leftover file from an older install
|
||||
/// (or a manual edit) makes Bitcoin Core's own datadir-conflict safety check
|
||||
/// refuse to start ("-conf=... vs default bitcoin.conf"). Remove it — via
|
||||
/// the same host-privileged path the old writer/repairer used, since the
|
||||
/// dir may already be chowned into the container's UID namespace by a
|
||||
/// previous start — instead of "repairing" it into existence.
|
||||
async fn remove_stale_bitcoin_conf() -> Result<bool> {
|
||||
let script = r#"
|
||||
set -eu
|
||||
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
|
||||
[ -f "$conf" ] || exit 0
|
||||
changed=0
|
||||
tmp=$(mktemp)
|
||||
awk -F= '
|
||||
/^(server|txindex|rpcbind|rpcallowip|rpcport|listen|bind|dbcache|rpcthreads|rpcworkqueue)=/ {
|
||||
if (seen[$1]++) next
|
||||
}
|
||||
{ print }
|
||||
' "$conf" > "$tmp"
|
||||
if ! cmp -s "$conf" "$tmp"; then
|
||||
cat "$tmp" > "$conf"
|
||||
changed=1
|
||||
fi
|
||||
rm -f "$tmp"
|
||||
ensure_line() {
|
||||
line="$1"
|
||||
key="${line%%=*}"
|
||||
if ! grep -q "^${key}=" "$conf"; then
|
||||
printf '%s\n' "$line" >> "$conf"
|
||||
changed=1
|
||||
fi
|
||||
}
|
||||
ensure_line server=1
|
||||
ensure_line rpcbind=0.0.0.0
|
||||
ensure_line rpcallowip=0.0.0.0/0
|
||||
ensure_line listen=1
|
||||
ensure_line rpcthreads=16
|
||||
ensure_line rpcworkqueue=256
|
||||
[ "$changed" -eq 0 ] && exit 0
|
||||
mv "$conf" "$conf.disabled-$(date +%s)"
|
||||
exit 2
|
||||
"#;
|
||||
let status = host_sudo(&["sh", "-lc", script])
|
||||
.await
|
||||
.context("ensure bitcoin.conf RPC bind settings")?;
|
||||
.context("remove stale bitcoin.conf")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
Some(2) => {
|
||||
install_log("INSTALL REPAIR: bitcoin.conf RPC bind settings added").await;
|
||||
install_log(
|
||||
"INSTALL REPAIR: removed stale bitcoin.conf (conflicts with -conf=/tmp/rpc.conf launch)",
|
||||
)
|
||||
.await;
|
||||
Ok(true)
|
||||
}
|
||||
_ => Err(anyhow::anyhow!(
|
||||
"bitcoin.conf RPC repair helper exited with {}",
|
||||
"bitcoin.conf removal helper exited with {}",
|
||||
status
|
||||
)),
|
||||
}
|
||||
|
||||
@@ -99,13 +99,7 @@ async fn ensure_status_token() -> Option<String> {
|
||||
warn!("pine/HA seed: cannot create {}: {}", NODE_SECRETS_DIR, e);
|
||||
return None;
|
||||
}
|
||||
// KEY-05: a bearer status token written 0600 under NODE_SECRETS_DIR — source
|
||||
// named, 32-byte draw guarded.
|
||||
let mut raw = [0u8; 32];
|
||||
if let Err(e) = crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut raw) {
|
||||
warn!("pine/HA seed: refusing to mint a status token from degenerate entropy: {e}");
|
||||
return None;
|
||||
}
|
||||
let raw: [u8; 32] = rand::random();
|
||||
let token = hex::encode(raw);
|
||||
if let Err(e) = tokio::fs::write(&path, &token).await {
|
||||
warn!("pine/HA seed: writing status token failed: {e}");
|
||||
@@ -485,15 +479,7 @@ async fn seed_claude_conversation(storage: &std::path::Path) -> ClaudeSeed {
|
||||
};
|
||||
}
|
||||
|
||||
// KEY-05: source named. Home Assistant config-entry / subentry ids are
|
||||
// identifiers HA needs only for uniqueness — not credentials and not key
|
||||
// material — so they are drawn unguarded per the classification table in
|
||||
// docs/security/KEY-05-ENTROPY-ENFORCEMENT.md.
|
||||
let id = || {
|
||||
let mut raw = [0u8; 16];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut raw);
|
||||
hex::encode(raw)
|
||||
};
|
||||
let id = |raw: [u8; 16]| hex::encode(raw);
|
||||
// Shape mirrors what HA 2026.7's anthropic config flow creates (entry
|
||||
// version 2.4 with conversation + ai_task subentries). Bookkeeping
|
||||
// fields (created_at/modified_at/discovery_keys) must be written here:
|
||||
@@ -501,7 +487,7 @@ async fn seed_claude_conversation(storage: &std::path::Path) -> ClaudeSeed {
|
||||
// never migrates appended entries — a missing created_at is a
|
||||
// KeyError that crash-loops HA at boot.
|
||||
entries.push(json!({
|
||||
"entry_id": id(),
|
||||
"entry_id": id(rand::random()),
|
||||
"version": 2,
|
||||
"minor_version": 4,
|
||||
"domain": "anthropic",
|
||||
@@ -518,7 +504,7 @@ async fn seed_claude_conversation(storage: &std::path::Path) -> ClaudeSeed {
|
||||
"discovery_keys": {},
|
||||
"subentries": [
|
||||
{
|
||||
"subentry_id": id(),
|
||||
"subentry_id": id(rand::random()),
|
||||
"subentry_type": "conversation",
|
||||
"title": "Claude conversation",
|
||||
"unique_id": null,
|
||||
@@ -532,7 +518,7 @@ async fn seed_claude_conversation(storage: &std::path::Path) -> ClaudeSeed {
|
||||
}
|
||||
},
|
||||
{
|
||||
"subentry_id": id(),
|
||||
"subentry_id": id(rand::random()),
|
||||
"subentry_type": "ai_task_data",
|
||||
"title": "Claude AI Task",
|
||||
"unique_id": null,
|
||||
@@ -599,9 +585,7 @@ async fn seed_wyoming_config_entries(storage: &std::path::Path) -> bool {
|
||||
if exists {
|
||||
continue;
|
||||
}
|
||||
// KEY-05: source named; an HA entry identifier, unguarded (see above).
|
||||
let mut entry_id = [0u8; 16];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut entry_id);
|
||||
let entry_id: [u8; 16] = rand::random();
|
||||
entries.push(json!({
|
||||
"entry_id": hex::encode(entry_id),
|
||||
"version": 1,
|
||||
@@ -678,10 +662,7 @@ async fn seed_assist_pipeline(storage: &std::path::Path, claude_entity: Option<&
|
||||
// ULID-shaped id (26 chars, Crockford base32) — HA only needs uniqueness.
|
||||
let id: String = {
|
||||
const ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
|
||||
// KEY-05: source named; an HA pipeline identifier, unguarded (see above).
|
||||
// The `% 32` reduction is unchanged and unbiased — 32 divides 256 exactly.
|
||||
let mut raw = [0u8; 26];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut raw);
|
||||
let raw: [u8; 26] = rand::random();
|
||||
raw.iter()
|
||||
.map(|b| ALPHABET[(*b % 32) as usize] as char)
|
||||
.collect()
|
||||
|
||||
@@ -87,113 +87,10 @@ fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Restore the node's identity from a 24-word BIP-39 mnemonic.
|
||||
///
|
||||
/// This is the production body of `seed.restore`, extracted out of
|
||||
/// `RpcHandler` so the F-01 regression suite can drive the real path against a
|
||||
/// temp data dir without constructing an `RpcHandler` (which needs an
|
||||
/// orchestrator, port allocator, session store and metrics store).
|
||||
///
|
||||
/// **The gate is the first statement and must stay there.** `seed.restore` is
|
||||
/// in `UNAUTHENTICATED_METHODS`, and everything below this line overwrites
|
||||
/// `identity/node_key`, `identity/nostr_secret` and the FIPS transport key
|
||||
/// unconditionally (`identity.rs:79-114`). Without the gate, one unauthenticated
|
||||
/// POST with an attacker-chosen mnemonic hijacks a live node — F-01, Critical.
|
||||
pub(in crate::api::rpc) async fn restore_node_identity_from_words(
|
||||
data_dir: &std::path::Path,
|
||||
auth: &crate::auth::AuthManager,
|
||||
words: &[String],
|
||||
) -> Result<serde_json::Value> {
|
||||
super::onboarding_gate::ensure_onboarding_open(data_dir, auth).await?;
|
||||
|
||||
let phrase = words.join(" ");
|
||||
let (_mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&phrase)?;
|
||||
|
||||
// Stash the restored words like seed.generate does, so auth.setup can
|
||||
// persist the encrypted backup once the user's password exists and
|
||||
// "Reveal recovery phrase" works on restored nodes too.
|
||||
{
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
*state = Some(OnboardingMnemonicState {
|
||||
words: phrase.clone(),
|
||||
created_at: std::time::Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Derive and write node Ed25519 key.
|
||||
let identity_dir = data_dir.join("identity");
|
||||
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?;
|
||||
|
||||
// Derive and write node-level Nostr key.
|
||||
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
|
||||
let secret_hex = nostr_keys.secret_key().display_secret().to_string();
|
||||
let pubkey_hex_nostr = nostr_keys.public_key().to_hex();
|
||||
tokio::fs::write(identity_dir.join("nostr_secret"), secret_hex.as_bytes()).await?;
|
||||
tokio::fs::write(
|
||||
identity_dir.join("nostr_pubkey"),
|
||||
pubkey_hex_nostr.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
tokio::fs::set_permissions(
|
||||
identity_dir.join("nostr_secret"),
|
||||
std::fs::Permissions::from_mode(0o600),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Initialize identity index.
|
||||
crate::seed::save_identity_index(data_dir, 0).await?;
|
||||
|
||||
// Create default identity from seed.
|
||||
let manager = crate::identity_manager::IdentityManager::new(data_dir).await?;
|
||||
manager
|
||||
.create_from_seed(
|
||||
"Personal".to_string(),
|
||||
crate::identity_manager::IdentityPurpose::Personal,
|
||||
&seed,
|
||||
data_dir,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Get DID and npub for the response.
|
||||
let node_key = crate::seed::derive_node_ed25519(&seed)?;
|
||||
let pubkey_hex = hex::encode(node_key.verifying_key().as_bytes());
|
||||
let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?;
|
||||
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
|
||||
|
||||
// Same as seed.generate: the key is materialised, kick the FIPS
|
||||
// service up without user interaction.
|
||||
spawn_post_onboarding_fips_activate(data_dir.to_path_buf());
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"did": did,
|
||||
"nostr_npub": nostr_npub,
|
||||
"restored": true,
|
||||
}))
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys.
|
||||
/// Returns the words for the user to write down.
|
||||
pub(in crate::api::rpc) async fn handle_seed_generate(&self) -> Result<serde_json::Value> {
|
||||
// Gate BEFORE the lock and before the idempotent fast path. Ordering is
|
||||
// load-bearing in both directions:
|
||||
// * Gate-first is REQUIRED because the fast path below returns the 24
|
||||
// words to an unauthenticated caller. On a provisioned node whose
|
||||
// in-memory mnemonic happened to survive (the encrypted save inside
|
||||
// auth.setup is best-effort and can fail), the fast path is itself a
|
||||
// disclosure — T-10-07.
|
||||
// * Gate-first is SAFE for onboarding because all three gate signals
|
||||
// are false throughout the seed steps: `auth.setup` runs at the very
|
||||
// END of the wizard (router order: onboarding/seed →
|
||||
// onboarding/seed-verify → onboarding/identity → onboarding/done →
|
||||
// /login, where views/Login.vue:405-425 posts auth.setup).
|
||||
super::onboarding_gate::ensure_onboarding_open(&self.config.data_dir, &self.auth_manager)
|
||||
.await?;
|
||||
|
||||
// Serialize concurrent / retried generate calls. The web client aborts
|
||||
// at 15s and retries internally (up to 3x), and the onboarding view
|
||||
// re-fires every 4s while the server is still booting on slow first-boot
|
||||
@@ -263,13 +160,6 @@ impl RpcHandler {
|
||||
|
||||
/// Verify the user wrote down their seed correctly.
|
||||
/// Also confirms the mnemonic by re-deriving and returning DID + npub.
|
||||
///
|
||||
/// D-04 verdict: **deliberately NOT gated.** It compares the submitted
|
||||
/// words against the in-memory copy and re-derives a DID and npub for
|
||||
/// display — it writes no file and mutates no identity (contrast
|
||||
/// `handle_seed_restore`, which calls `NodeIdentity::from_seed`). Leaving
|
||||
/// it open costs nothing, and gating it would break a legitimate retry:
|
||||
/// the view re-submits after a 15s client abort.
|
||||
pub(in crate::api::rpc) async fn handle_seed_verify(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -333,10 +223,6 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// Restore node identity from a 24-word seed phrase.
|
||||
///
|
||||
/// Thin wrapper: parses `params.words` and delegates to
|
||||
/// [`restore_node_identity_from_words`], which carries the onboarding gate
|
||||
/// and the whole restore body.
|
||||
pub(in crate::api::rpc) async fn handle_seed_restore(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -350,24 +236,80 @@ impl RpcHandler {
|
||||
)
|
||||
.context("Invalid words array")?;
|
||||
|
||||
restore_node_identity_from_words(&self.config.data_dir, &self.auth_manager, &words).await
|
||||
let phrase = words.join(" ");
|
||||
let (_mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&phrase)?;
|
||||
|
||||
// Stash the restored words like seed.generate does, so auth.setup can
|
||||
// persist the encrypted backup once the user's password exists and
|
||||
// "Reveal recovery phrase" works on restored nodes too.
|
||||
{
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
*state = Some(OnboardingMnemonicState {
|
||||
words: phrase.clone(),
|
||||
created_at: std::time::Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Derive and write node Ed25519 key.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?;
|
||||
|
||||
// Derive and write node-level Nostr key.
|
||||
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
|
||||
let secret_hex = nostr_keys.secret_key().display_secret().to_string();
|
||||
let pubkey_hex_nostr = nostr_keys.public_key().to_hex();
|
||||
tokio::fs::write(identity_dir.join("nostr_secret"), secret_hex.as_bytes()).await?;
|
||||
tokio::fs::write(
|
||||
identity_dir.join("nostr_pubkey"),
|
||||
pubkey_hex_nostr.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
tokio::fs::set_permissions(
|
||||
identity_dir.join("nostr_secret"),
|
||||
std::fs::Permissions::from_mode(0o600),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Initialize identity index.
|
||||
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
|
||||
|
||||
// Create default identity from seed.
|
||||
let manager = crate::identity_manager::IdentityManager::new(&self.config.data_dir).await?;
|
||||
manager
|
||||
.create_from_seed(
|
||||
"Personal".to_string(),
|
||||
crate::identity_manager::IdentityPurpose::Personal,
|
||||
&seed,
|
||||
&self.config.data_dir,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Get DID and npub for the response.
|
||||
let node_key = crate::seed::derive_node_ed25519(&seed)?;
|
||||
let pubkey_hex = hex::encode(node_key.verifying_key().as_bytes());
|
||||
let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?;
|
||||
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
|
||||
|
||||
// Same as seed.generate: the key is materialised, kick the FIPS
|
||||
// service up without user interaction.
|
||||
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"did": did,
|
||||
"nostr_npub": nostr_npub,
|
||||
"restored": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Encrypt and save the mnemonic to disk for convenience backup.
|
||||
///
|
||||
/// D-04 note: this method has no UI caller today —
|
||||
/// `neode-ui/src/api/rpc-client.ts:334` exposes it, but no view calls it.
|
||||
/// The encrypted save that actually happens during onboarding is
|
||||
/// `save_pending_seed_encrypted`, called from INSIDE `auth.setup`
|
||||
/// (`api/rpc/auth.rs:239`) once a passphrase exists; that call is behind
|
||||
/// `auth.setup`'s own gate and is therefore not gated again here.
|
||||
pub(in crate::api::rpc) async fn handle_seed_save_encrypted(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
super::onboarding_gate::ensure_onboarding_open(&self.config.data_dir, &self.auth_manager)
|
||||
.await?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let passphrase = params
|
||||
.get("passphrase")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use super::*;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -193,14 +192,7 @@ impl RpcHandler {
|
||||
};
|
||||
let (disk_used, disk_total) = read_disk_usage_path(df_target).await.unwrap_or((0, 0));
|
||||
|
||||
// F-03 verdict. Surfacing it here rather than only in a log file is the
|
||||
// point: an operator must be able to see that a node is running
|
||||
// fleet-shared host keys without having to SSH into it — and SSH into
|
||||
// it is exactly the thing a shared host key compromises.
|
||||
let host_secrets = host_secrets_status(Path::new(HOST_SECRETS_STATE_DIR)).await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"host_secrets": host_secrets,
|
||||
"uptime_secs": uptime as u64,
|
||||
"load_avg_1": load.0,
|
||||
"load_avg_5": load.1,
|
||||
@@ -486,364 +478,50 @@ async fn set_system_hostname(hostname: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const TLS_SSL_DIR: &str = "/etc/archipelago/ssl";
|
||||
const TLS_KEY_NAME: &str = "archipelago.key";
|
||||
const TLS_CRT_NAME: &str = "archipelago.crt";
|
||||
const TLS_KEY_STAGING_NAME: &str = "archipelago.key.new";
|
||||
const TLS_CRT_STAGING_NAME: &str = "archipelago.crt.new";
|
||||
|
||||
const SUDO_BIN: &str = "/usr/bin/sudo";
|
||||
const OPENSSL_BIN: &str = "/usr/bin/openssl";
|
||||
const STAT_BIN: &str = "/usr/bin/stat";
|
||||
const INSTALL_BIN: &str = "/usr/bin/install";
|
||||
const MKDIR_BIN: &str = "/usr/bin/mkdir";
|
||||
const MV_BIN: &str = "/usr/bin/mv";
|
||||
const RM_BIN: &str = "/usr/bin/rm";
|
||||
|
||||
/// Modes used only when there is no existing file to copy them from (a node
|
||||
/// whose TLS material has somehow gone missing entirely). The key is private
|
||||
/// material; the cert is public.
|
||||
const TLS_KEY_FALLBACK_MODE: &str = "600";
|
||||
const TLS_CRT_FALLBACK_MODE: &str = "644";
|
||||
|
||||
/// Where `scripts/security/host-secrets-audit.sh` leaves its verdict. Hardcoded
|
||||
/// rather than derived from `config.data_dir` because the script is a systemd
|
||||
/// unit with no view of the daemon's configuration — this is the path both
|
||||
/// sides agree on.
|
||||
const HOST_SECRETS_STATE_DIR: &str = "/var/lib/archipelago";
|
||||
const HOST_SECRETS_AUDIT_FILE: &str = "host-secrets-audit.json";
|
||||
const HOST_KEY_ROTATION_FILE: &str = "host-key-rotation.json";
|
||||
|
||||
/// The `host_secrets` object carried by `system.stats`: has this node been
|
||||
/// judged to be running the fleet-shared, image-baked SSH host keys and TLS
|
||||
/// private key (audit F-03), and has it been rotated since?
|
||||
///
|
||||
/// Three properties this must hold, because `system.stats` is in
|
||||
/// `CACHEABLE_METHODS` (`api/rpc/middleware.rs:41`) and the dashboard polls it:
|
||||
///
|
||||
/// 1. **It never errors.** A missing, truncated or unparseable verdict file
|
||||
/// yields `{"verdict":"unknown"}`. A node that has not run the audit yet —
|
||||
/// every node, until the OTA carrying the unit lands — must not turn its own
|
||||
/// stats call into a failure.
|
||||
/// 2. **It is cheap.** Two small file reads, no process spawn, no key parsing.
|
||||
/// The expensive work (ssh-keygen, openssl) happens in the boot unit, once.
|
||||
/// 3. **It carries no fingerprints.** Those live in the on-disk record only.
|
||||
/// They are public data, but there is no reason to put them in a payload
|
||||
/// that is polled every few seconds — the dashboard needs the verdict, and
|
||||
/// an operator who needs the digests is already on the node.
|
||||
async fn host_secrets_status(dir: &Path) -> serde_json::Value {
|
||||
let unknown = || serde_json::json!({ "verdict": "unknown" });
|
||||
|
||||
let audit: serde_json::Value =
|
||||
match tokio::fs::read_to_string(dir.join(HOST_SECRETS_AUDIT_FILE))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
{
|
||||
Some(v) => v,
|
||||
None => return unknown(),
|
||||
};
|
||||
|
||||
let verdict = audit
|
||||
.get("verdict")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let mut out = serde_json::json!({ "verdict": verdict });
|
||||
|
||||
if let Some(checked_at) = audit.get("checked_at").and_then(|v| v.as_str()) {
|
||||
out["checked_at"] = serde_json::json!(checked_at);
|
||||
}
|
||||
if let Some(evidence) = audit.get("evidence").and_then(|v| v.as_array()) {
|
||||
out["evidence"] = serde_json::json!(evidence);
|
||||
}
|
||||
|
||||
// A rotation record only exists on a node an operator has actually
|
||||
// rotated, so its absence is the normal case and is not reported.
|
||||
if let Some(rotated_at) = tokio::fs::read_to_string(dir.join(HOST_KEY_ROTATION_FILE))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.and_then(|v| {
|
||||
v.get("rotated_at")
|
||||
.and_then(|r| r.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
{
|
||||
out["rotated_at"] = serde_json::json!(rotated_at);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Where this node's TLS material lives, and how we are allowed to touch it.
|
||||
///
|
||||
/// This type exists for two reasons. The first is that every step of the
|
||||
/// generate/validate/swap dance below has to run as root — the live key is
|
||||
/// root-owned 0600 and the daemon is not root — so each step needs the same
|
||||
/// `sudo -n` prefix and the same error handling. The second is testability:
|
||||
/// production uses [`TlsMaterial::production`], while tests point `ssl_dir` at
|
||||
/// a temp dir, drop the `sudo` prefix, and can substitute `openssl_bin` for a
|
||||
/// stub that fails partway through generation. That is the only seam added
|
||||
/// here; nothing else about the module changed.
|
||||
#[derive(Clone, Debug)]
|
||||
struct TlsMaterial {
|
||||
ssl_dir: PathBuf,
|
||||
openssl_bin: PathBuf,
|
||||
/// Prefix every command with `sudo -n`.
|
||||
privileged: bool,
|
||||
}
|
||||
|
||||
impl TlsMaterial {
|
||||
fn production() -> Self {
|
||||
Self {
|
||||
ssl_dir: PathBuf::from(TLS_SSL_DIR),
|
||||
openssl_bin: PathBuf::from(OPENSSL_BIN),
|
||||
privileged: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&self) -> PathBuf {
|
||||
self.ssl_dir.join(TLS_KEY_NAME)
|
||||
}
|
||||
|
||||
fn crt(&self) -> PathBuf {
|
||||
self.ssl_dir.join(TLS_CRT_NAME)
|
||||
}
|
||||
|
||||
// The staging files are siblings of their destinations on purpose: same
|
||||
// directory means same filesystem, which means the final `mv` is a
|
||||
// rename(2) and therefore atomic.
|
||||
fn key_staging(&self) -> PathBuf {
|
||||
self.ssl_dir.join(TLS_KEY_STAGING_NAME)
|
||||
}
|
||||
|
||||
fn crt_staging(&self) -> PathBuf {
|
||||
self.ssl_dir.join(TLS_CRT_STAGING_NAME)
|
||||
}
|
||||
|
||||
fn cmd<S: AsRef<std::ffi::OsStr>>(&self, program: S) -> tokio::process::Command {
|
||||
if self.privileged {
|
||||
let mut cmd = tokio::process::Command::new(SUDO_BIN);
|
||||
cmd.arg("-n").arg(program);
|
||||
cmd
|
||||
} else {
|
||||
tokio::process::Command::new(program)
|
||||
}
|
||||
}
|
||||
|
||||
/// `(mode, uid, gid)` of an existing file, or `None` if it is not there.
|
||||
async fn stat_attrs(&self, path: &Path) -> Option<(String, String, String)> {
|
||||
let mut cmd = self.cmd(STAT_BIN);
|
||||
cmd.arg("-c").arg("%a:%u:%g").arg(path);
|
||||
let out = cmd.output().await.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let mut parts = text.split(':');
|
||||
let mode = parts.next()?.to_string();
|
||||
let uid = parts.next()?.to_string();
|
||||
let gid = parts.next()?.to_string();
|
||||
if mode.is_empty() || uid.is_empty() || gid.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((mode, uid, gid))
|
||||
}
|
||||
|
||||
/// Create `dest` as an empty file that *already* carries `mode` (and the
|
||||
/// live file's owner, when we know it).
|
||||
///
|
||||
/// Pre-creating the staging file is the whole trick for permissions:
|
||||
/// openssl's `-keyout` truncates an existing file rather than recreating
|
||||
/// it, so the mode set here is the mode the new key has from its very
|
||||
/// first byte. There is no window — not even a microsecond — in which the
|
||||
/// freshly generated private key sits on disk group- or world-readable
|
||||
/// waiting for a follow-up `chmod`.
|
||||
async fn create_empty(
|
||||
&self,
|
||||
dest: &Path,
|
||||
mode: &str,
|
||||
owner: Option<(&str, &str)>,
|
||||
) -> Result<()> {
|
||||
let mut cmd = self.cmd(INSTALL_BIN);
|
||||
cmd.arg("-m").arg(mode);
|
||||
if let Some((uid, gid)) = owner {
|
||||
cmd.arg("-o").arg(uid).arg("-g").arg(gid);
|
||||
}
|
||||
cmd.arg("/dev/null").arg(dest);
|
||||
run_checked(cmd, "install (create staging file)").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort removal of both staging files. Called before an attempt (to
|
||||
/// clear anything a previous crash left) and after every attempt, success
|
||||
/// or failure, so staging artefacts never accumulate next to the live cert.
|
||||
async fn clear_staging(&self) {
|
||||
let mut cmd = self.cmd(RM_BIN);
|
||||
cmd.arg("-f")
|
||||
.arg(self.key_staging())
|
||||
.arg(self.crt_staging());
|
||||
let _ = cmd.output().await;
|
||||
}
|
||||
|
||||
/// Regenerate the keypair, validate it, and swap it into place.
|
||||
///
|
||||
/// The contract this upholds: on *any* failure the existing key and cert
|
||||
/// are left byte-for-byte untouched and an error is returned. A rename
|
||||
/// whose cert regeneration fails is a no-op on disk, never a partial write
|
||||
/// that leaves nginx serving a truncated key.
|
||||
async fn regenerate(&self, hostname: &str) -> Result<()> {
|
||||
self.clear_staging().await;
|
||||
let result = self.stage_validate_and_swap(hostname).await;
|
||||
self.clear_staging().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn stage_validate_and_swap(&self, hostname: &str) -> Result<()> {
|
||||
let mut mkdir = self.cmd(MKDIR_BIN);
|
||||
mkdir.arg("-p").arg(&self.ssl_dir);
|
||||
run_checked(mkdir, "mkdir -p ssl dir").await?;
|
||||
|
||||
let key_attrs = self.stat_attrs(&self.key()).await;
|
||||
let crt_attrs = self.stat_attrs(&self.crt()).await;
|
||||
|
||||
let key_mode = private_key_mode(key_attrs.as_ref().map(|(m, _, _)| m.as_str()));
|
||||
let crt_mode = crt_attrs
|
||||
.as_ref()
|
||||
.map(|(m, _, _)| m.clone())
|
||||
.unwrap_or_else(|| TLS_CRT_FALLBACK_MODE.to_string());
|
||||
|
||||
self.create_empty(
|
||||
&self.key_staging(),
|
||||
&key_mode,
|
||||
key_attrs.as_ref().map(|(_, u, g)| (u.as_str(), g.as_str())),
|
||||
)
|
||||
.await?;
|
||||
self.create_empty(
|
||||
&self.crt_staging(),
|
||||
&crt_mode,
|
||||
crt_attrs.as_ref().map(|(_, u, g)| (u.as_str(), g.as_str())),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Cert parameters are deliberately identical to what this function has
|
||||
// always produced: same subject, same SAN construction, same rsa:2048,
|
||||
// same 3650 days. Only *where* openssl writes has changed.
|
||||
let subj = format!("/C=XX/ST=Bitcoin/L=Node/O=Archipelago/CN={hostname}");
|
||||
let san = format!(
|
||||
"subjectAltName=DNS:{hostname},DNS:{hostname}.local,DNS:localhost,IP:127.0.0.1"
|
||||
);
|
||||
let mut gen = self.cmd(&self.openssl_bin);
|
||||
gen.arg("req")
|
||||
.arg("-x509")
|
||||
.arg("-nodes")
|
||||
.arg("-days")
|
||||
.arg("3650")
|
||||
.arg("-newkey")
|
||||
.arg("rsa:2048")
|
||||
.arg("-keyout")
|
||||
.arg(self.key_staging())
|
||||
.arg("-out")
|
||||
.arg(self.crt_staging())
|
||||
.arg("-subj")
|
||||
.arg(&subj)
|
||||
.arg("-addext")
|
||||
.arg(&san);
|
||||
run_checked(gen, "openssl cert regen").await?;
|
||||
|
||||
// Parse both halves back before trusting either. A zero-exit openssl
|
||||
// that somehow produced an empty or truncated artefact still fails
|
||||
// here — nginx is never handed material we have not read back
|
||||
// ourselves. Extracting the public key from each half also proves the
|
||||
// two belong together, so a stale cert can never be paired with a
|
||||
// fresh key.
|
||||
let mut key_pub = self.cmd(&self.openssl_bin);
|
||||
key_pub
|
||||
.arg("pkey")
|
||||
.arg("-in")
|
||||
.arg(self.key_staging())
|
||||
.arg("-pubout");
|
||||
let key_pub = run_checked(key_pub, "openssl pkey (validate new key)").await?;
|
||||
|
||||
let mut crt_pub = self.cmd(&self.openssl_bin);
|
||||
crt_pub
|
||||
.arg("x509")
|
||||
.arg("-in")
|
||||
.arg(self.crt_staging())
|
||||
.arg("-noout")
|
||||
.arg("-pubkey");
|
||||
let crt_pub = run_checked(crt_pub, "openssl x509 (validate new cert)").await?;
|
||||
|
||||
if key_pub.trim_ascii().is_empty() || key_pub.trim_ascii() != crt_pub.trim_ascii() {
|
||||
anyhow::bail!(
|
||||
"regenerated key and certificate do not match — refusing to install them"
|
||||
);
|
||||
}
|
||||
|
||||
// rename(2) within one directory: a reader sees either the whole old
|
||||
// file or the whole new one, never a partial write. Two files cannot
|
||||
// be swapped in a single atomic step, so there is a sub-millisecond
|
||||
// window between the two renames in which cert and key are from
|
||||
// different generations — but both files are fully written and
|
||||
// validated by this point, so the only way to land in that window is a
|
||||
// rename failure on an already-created sibling, which does not need
|
||||
// space or allocation and effectively cannot fail here.
|
||||
self.swap_into_place(&self.crt_staging(), &self.crt())
|
||||
.await?;
|
||||
self.swap_into_place(&self.key_staging(), &self.key())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn swap_into_place(&self, staging: &Path, live: &Path) -> Result<()> {
|
||||
let mut cmd = self.cmd(MV_BIN);
|
||||
cmd.arg("-f").arg(staging).arg(live);
|
||||
run_checked(cmd, "mv (install new TLS material)").await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode to give the freshly generated private key.
|
||||
///
|
||||
/// Copy the live key's mode so a node that deliberately tightened it keeps
|
||||
/// that, but never copy a mode that grants group or other any access — a
|
||||
/// world-readable private key is a bug we should not faithfully reproduce, and
|
||||
/// widening is never allowed.
|
||||
fn private_key_mode(existing: Option<&str>) -> String {
|
||||
match existing.and_then(|m| u32::from_str_radix(m, 8).ok()) {
|
||||
Some(mode) if mode & 0o077 == 0 => format!("{mode:o}"),
|
||||
_ => TLS_KEY_FALLBACK_MODE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a command, failing with its stderr if it exits non-zero.
|
||||
async fn run_checked(mut cmd: tokio::process::Command, what: &str) -> Result<Vec<u8>> {
|
||||
let out = cmd
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("failed to run {what}"))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
if stderr.is_empty() {
|
||||
anyhow::bail!("{what} failed");
|
||||
}
|
||||
anyhow::bail!("{what} failed: {stderr}");
|
||||
}
|
||||
Ok(out.stdout)
|
||||
}
|
||||
|
||||
/// Regenerate the self-signed HTTPS cert (`/etc/archipelago/ssl/archipelago.{crt,key}`)
|
||||
/// with a SAN covering `hostname`, `hostname.local`, `localhost`, and 127.0.0.1, then
|
||||
/// reload nginx so it picks up the new cert. Still self-signed (browsers will warn
|
||||
/// on first visit regardless), but avoids stacking a hostname-mismatch warning on
|
||||
/// top once a node has been renamed away from the install-time default.
|
||||
///
|
||||
/// Generation goes to staging siblings and is parsed back before being swapped
|
||||
/// in — see [`TlsMaterial::regenerate`]. A failed regeneration leaves the live
|
||||
/// key and cert exactly as they were, because a routine rename must never be
|
||||
/// able to take HTTPS down.
|
||||
async fn regenerate_tls_cert(hostname: &str) -> Result<()> {
|
||||
TlsMaterial::production().regenerate(hostname).await?;
|
||||
let subj = format!("/C=XX/ST=Bitcoin/L=Node/O=Archipelago/CN={hostname}");
|
||||
let san =
|
||||
format!("subjectAltName=DNS:{hostname},DNS:{hostname}.local,DNS:localhost,IP:127.0.0.1");
|
||||
let output = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args([
|
||||
"-n",
|
||||
"/usr/bin/openssl",
|
||||
"req",
|
||||
"-x509",
|
||||
"-nodes",
|
||||
"-days",
|
||||
"3650",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-keyout",
|
||||
"/etc/archipelago/ssl/archipelago.key",
|
||||
"-out",
|
||||
"/etc/archipelago/ssl/archipelago.crt",
|
||||
"-subj",
|
||||
&subj,
|
||||
"-addext",
|
||||
&san,
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run openssl")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
anyhow::bail!(
|
||||
"{}",
|
||||
if stderr.is_empty() {
|
||||
"openssl cert regen failed".to_string()
|
||||
} else {
|
||||
stderr
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let reload = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/usr/bin/systemctl", "reload", "nginx"])
|
||||
@@ -1162,317 +840,3 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
const KIOSK_DISPLAY_CONF: &str = "/etc/archipelago/kiosk-display.conf";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tls_regen_tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
/// A `TlsMaterial` pointed at a temp dir, running unprivileged so the test
|
||||
/// needs no sudo. Everything else — the command sequence, the staging
|
||||
/// paths, the validation, the swap — is the production code path.
|
||||
fn material(dir: &Path, openssl_bin: &Path) -> TlsMaterial {
|
||||
TlsMaterial {
|
||||
ssl_dir: dir.to_path_buf(),
|
||||
openssl_bin: openssl_bin.to_path_buf(),
|
||||
privileged: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed the directory the way an installed node looks: a real keypair with
|
||||
/// the key at 0600.
|
||||
fn seed_live_material(dir: &Path) {
|
||||
let key = dir.join(TLS_KEY_NAME);
|
||||
let crt = dir.join(TLS_CRT_NAME);
|
||||
let status = std::process::Command::new(OPENSSL_BIN)
|
||||
.args([
|
||||
"req", "-x509", "-nodes", "-days", "3650", "-newkey", "rsa:2048",
|
||||
])
|
||||
.arg("-keyout")
|
||||
.arg(&key)
|
||||
.arg("-out")
|
||||
.arg(&crt)
|
||||
.arg("-subj")
|
||||
.arg("/C=XX/ST=Bitcoin/L=Node/O=Archipelago/CN=oldname")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.expect("seed openssl");
|
||||
assert!(status.success(), "seeding the live keypair failed");
|
||||
std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
|
||||
/// Write an executable stand-in for openssl. It intercepts `req` (the
|
||||
/// generation step) and delegates everything else — the `pkey`/`x509`
|
||||
/// validation calls — to the real binary.
|
||||
fn stub_openssl(dir: &Path, name: &str, exit_code: u8) -> PathBuf {
|
||||
let path = dir.join(name);
|
||||
let script = format!(
|
||||
r#"#!/bin/sh
|
||||
# Stand-in for openssl that produces the wreckage a killed/ENOSPC openssl
|
||||
# leaves behind: partial PEM in both output files. Exits {exit_code}.
|
||||
if [ "$1" = "req" ]; then
|
||||
key=""; out=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-keyout) key="$2"; shift ;;
|
||||
-out) out="$2"; shift ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[ -n "$key" ] && printf -- '-----BEGIN PRIVATE KEY-----\ntruncated' > "$key"
|
||||
[ -n "$out" ] && printf -- '-----BEGIN CERTIFICATE-----\ntruncated' > "$out"
|
||||
echo "simulated openssl failure" >&2
|
||||
exit {exit_code}
|
||||
fi
|
||||
exec {OPENSSL_BIN} "$@"
|
||||
"#
|
||||
);
|
||||
std::fs::write(&path, script).unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn snapshot(dir: &Path) -> (Vec<u8>, Vec<u8>, u32) {
|
||||
let key = dir.join(TLS_KEY_NAME);
|
||||
let mode = std::fs::metadata(&key).unwrap().permissions().mode() & 0o777;
|
||||
(
|
||||
std::fs::read(&key).unwrap(),
|
||||
std::fs::read(dir.join(TLS_CRT_NAME)).unwrap(),
|
||||
mode,
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_no_staging_left(dir: &Path) {
|
||||
assert!(
|
||||
!dir.join(TLS_KEY_STAGING_NAME).exists(),
|
||||
"staging key was left behind"
|
||||
);
|
||||
assert!(
|
||||
!dir.join(TLS_CRT_STAGING_NAME).exists(),
|
||||
"staging cert was left behind"
|
||||
);
|
||||
}
|
||||
|
||||
/// The defect this whole change exists to fix: when openssl dies partway
|
||||
/// through, the live key and cert must be byte-identical to what they were
|
||||
/// before. Against the previous implementation (openssl writing straight
|
||||
/// to the live paths) this fails — the live key is truncated to a few
|
||||
/// bytes of PEM header and nginx has nothing to serve.
|
||||
#[tokio::test]
|
||||
async fn failed_generation_leaves_live_key_and_cert_untouched() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seed_live_material(dir.path());
|
||||
let before = snapshot(dir.path());
|
||||
|
||||
let openssl = stub_openssl(dir.path(), "openssl-crash", 1);
|
||||
let err = material(dir.path(), &openssl)
|
||||
.regenerate("newname")
|
||||
.await
|
||||
.expect_err("a failing openssl must surface as an error");
|
||||
assert!(
|
||||
err.to_string().contains("openssl cert regen failed"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
|
||||
let after = snapshot(dir.path());
|
||||
assert_eq!(before.0, after.0, "live private key was modified");
|
||||
assert_eq!(before.1, after.1, "live certificate was modified");
|
||||
assert_eq!(before.2, after.2, "live private key mode changed");
|
||||
assert_no_staging_left(dir.path());
|
||||
}
|
||||
|
||||
/// The other half of the guard: openssl exiting 0 is not proof it produced
|
||||
/// anything usable. Truncated output must be caught by the parse-back and
|
||||
/// must likewise leave the live material alone.
|
||||
#[tokio::test]
|
||||
async fn unparseable_output_is_rejected_and_live_material_untouched() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seed_live_material(dir.path());
|
||||
let before = snapshot(dir.path());
|
||||
|
||||
let openssl = stub_openssl(dir.path(), "openssl-garbage", 0);
|
||||
let err = material(dir.path(), &openssl)
|
||||
.regenerate("newname")
|
||||
.await
|
||||
.expect_err("unparseable material must be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("validate new key"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
|
||||
let after = snapshot(dir.path());
|
||||
assert_eq!(before.0, after.0, "live private key was modified");
|
||||
assert_eq!(before.1, after.1, "live certificate was modified");
|
||||
assert_no_staging_left(dir.path());
|
||||
}
|
||||
|
||||
/// Happy path: a real regeneration swaps in a matching pair carrying the
|
||||
/// new hostname, and the key keeps its 0600 mode across the swap.
|
||||
#[tokio::test]
|
||||
async fn successful_regeneration_swaps_in_a_matching_pair_and_keeps_mode() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seed_live_material(dir.path());
|
||||
let before = snapshot(dir.path());
|
||||
|
||||
material(dir.path(), Path::new(OPENSSL_BIN))
|
||||
.regenerate("newname")
|
||||
.await
|
||||
.expect("regeneration should succeed");
|
||||
|
||||
let after = snapshot(dir.path());
|
||||
assert_ne!(before.0, after.0, "the key should have been replaced");
|
||||
assert_eq!(after.2, 0o600, "the new key must still be 0600");
|
||||
assert_no_staging_left(dir.path());
|
||||
|
||||
// The installed pair parses and the two halves belong together.
|
||||
let key_pub = std::process::Command::new(OPENSSL_BIN)
|
||||
.arg("pkey")
|
||||
.arg("-in")
|
||||
.arg(dir.path().join(TLS_KEY_NAME))
|
||||
.arg("-pubout")
|
||||
.output()
|
||||
.unwrap();
|
||||
let crt_pub = std::process::Command::new(OPENSSL_BIN)
|
||||
.arg("x509")
|
||||
.arg("-in")
|
||||
.arg(dir.path().join(TLS_CRT_NAME))
|
||||
.arg("-noout")
|
||||
.arg("-pubkey")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(key_pub.status.success() && crt_pub.status.success());
|
||||
assert_eq!(
|
||||
key_pub.stdout, crt_pub.stdout,
|
||||
"installed pair is mismatched"
|
||||
);
|
||||
|
||||
// And the SAN carries the new hostname, which is why we regenerate.
|
||||
let text = std::process::Command::new(OPENSSL_BIN)
|
||||
.arg("x509")
|
||||
.arg("-in")
|
||||
.arg(dir.path().join(TLS_CRT_NAME))
|
||||
.arg("-noout")
|
||||
.arg("-text")
|
||||
.output()
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&text.stdout);
|
||||
assert!(text.contains("DNS:newname"), "SAN missing hostname: {text}");
|
||||
assert!(text.contains("DNS:newname.local"));
|
||||
assert!(text.contains("IP Address:127.0.0.1"));
|
||||
}
|
||||
|
||||
/// The staging private key must never exist world- or group-readable, not
|
||||
/// even transiently: it is created pre-moded and openssl only truncates it.
|
||||
#[tokio::test]
|
||||
async fn staging_key_is_created_already_locked_down() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let m = material(dir.path(), Path::new(OPENSSL_BIN));
|
||||
m.create_empty(&m.key_staging(), TLS_KEY_FALLBACK_MODE, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let mode = std::fs::metadata(m.key_staging())
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_key_mode_preserves_tight_modes_and_never_widens() {
|
||||
assert_eq!(private_key_mode(Some("600")), "600");
|
||||
assert_eq!(private_key_mode(Some("400")), "400");
|
||||
// A live key that is somehow group/world readable is not reproduced.
|
||||
assert_eq!(private_key_mode(Some("644")), "600");
|
||||
assert_eq!(private_key_mode(Some("640")), "600");
|
||||
// No existing file, or an unparseable mode, falls back to 0600.
|
||||
assert_eq!(private_key_mode(None), "600");
|
||||
assert_eq!(private_key_mode(Some("garbage")), "600");
|
||||
}
|
||||
}
|
||||
|
||||
/// The `host_secrets` object in `system.stats` (phase 10 KEY-02 / audit F-03).
|
||||
///
|
||||
/// These pin the contract that matters for a *cacheable, polled* method: the
|
||||
/// absent and malformed cases must be indistinguishable from a plain "not
|
||||
/// judged yet", never an error and never a panic. Every fleet node is in the
|
||||
/// absent case until the OTA carrying the audit unit reaches it, so the absent
|
||||
/// case is the common one, not the edge one.
|
||||
#[cfg(test)]
|
||||
mod host_secrets_tests {
|
||||
use super::*;
|
||||
|
||||
fn write(dir: &Path, name: &str, body: &str) {
|
||||
std::fs::write(dir.join(name), body).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verdict_is_unknown_when_the_audit_file_is_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let v = host_secrets_status(dir.path()).await;
|
||||
assert_eq!(v["verdict"], "unknown");
|
||||
assert!(v.get("checked_at").is_none());
|
||||
assert!(v.get("rotated_at").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verdict_is_unknown_when_the_audit_file_is_unparseable() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// A half-written file: the script writes to .tmp and renames, so this
|
||||
// should not happen — which is exactly why the daemon must survive it
|
||||
// if it ever does.
|
||||
write(dir.path(), HOST_SECRETS_AUDIT_FILE, "{\"verdict\": \"shar");
|
||||
let v = host_secrets_status(dir.path()).await;
|
||||
assert_eq!(v["verdict"], "unknown");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recorded_verdict_and_evidence_are_surfaced() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(
|
||||
dir.path(),
|
||||
HOST_SECRETS_AUDIT_FILE,
|
||||
r#"{
|
||||
"verdict": "shared",
|
||||
"checked_at": "2026-08-02T12:00:00Z",
|
||||
"evidence": ["shared: /etc/ssh/ssh_host_rsa_key mtime is older than the anchor"],
|
||||
"ssh_host_key_fingerprints": ["/etc/ssh/ssh_host_rsa_key.pub: 3072 SHA256:abc"],
|
||||
"tls_cert_sha256": "AA:BB:CC"
|
||||
}"#,
|
||||
);
|
||||
let v = host_secrets_status(dir.path()).await;
|
||||
assert_eq!(v["verdict"], "shared");
|
||||
assert_eq!(v["checked_at"], "2026-08-02T12:00:00Z");
|
||||
assert_eq!(v["evidence"].as_array().unwrap().len(), 1);
|
||||
|
||||
// The digests stay on disk. A payload polled every few seconds does not
|
||||
// carry them, and a future edit that "helpfully" forwards the whole
|
||||
// file should fail here.
|
||||
assert!(v.get("ssh_host_key_fingerprints").is_none());
|
||||
assert!(v.get("tls_cert_sha256").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rotated_at_is_surfaced_only_when_a_rotation_was_recorded() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(
|
||||
dir.path(),
|
||||
HOST_SECRETS_AUDIT_FILE,
|
||||
r#"{"verdict":"per-node","checked_at":"2026-08-02T12:05:00Z","evidence":[]}"#,
|
||||
);
|
||||
assert!(host_secrets_status(dir.path())
|
||||
.await
|
||||
.get("rotated_at")
|
||||
.is_none());
|
||||
|
||||
write(
|
||||
dir.path(),
|
||||
HOST_KEY_ROTATION_FILE,
|
||||
r#"{"rotated_at":"2026-08-02T12:04:00Z","old_ssh_fingerprints":[],"old_tls_sha256":"AA"}"#,
|
||||
);
|
||||
let v = host_secrets_status(dir.path()).await;
|
||||
assert_eq!(v["verdict"], "per-node");
|
||||
assert_eq!(v["rotated_at"], "2026-08-02T12:04:00Z");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,22 +370,13 @@ impl RpcHandler {
|
||||
|
||||
/// Best-effort auto-exposure of a freshly installed app as a Tor hidden
|
||||
/// service. Skips protocol services (bitcoin/lnd keep their explicit
|
||||
/// flows), the node's own service, the credential-bearing UI proxies in
|
||||
/// [`never_auto_onioned`], apps that already have one, and apps with no
|
||||
/// resolvable web port. Runs detached after install — it never fails the
|
||||
/// caller, it only logs.
|
||||
/// flows), the node's own service, apps that already have one, and apps
|
||||
/// with no resolvable web port. Runs detached after install — it never
|
||||
/// fails the caller, it only logs.
|
||||
pub(in crate::api::rpc) async fn auto_add_tor_service(&self, app_id: &str) {
|
||||
if app_id == "archipelago" || is_protocol_service(app_id) {
|
||||
return;
|
||||
}
|
||||
if never_auto_onioned(app_id) {
|
||||
info!(
|
||||
app = app_id,
|
||||
"Skipping auto Tor service — this app fronts wallet/node RPC and \
|
||||
must not gain a global onion as a side effect of being installed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
// The scanner may still be deriving the launch address on slower
|
||||
// nodes; retry for up to ~5 minutes before giving up quietly.
|
||||
|
||||
@@ -151,77 +151,15 @@ pub(super) fn detect_hidden_service_base() -> String {
|
||||
"/var/lib/tor".to_string()
|
||||
}
|
||||
|
||||
/// The `archy-net` bridge's gateway address and subnet, if the network exists.
|
||||
///
|
||||
/// Derived at runtime, never hardcoded: `archy-net` is created without an
|
||||
/// explicit subnet (see docker-compose.yml), so podman allocates one — it is
|
||||
/// 10.89.0.0/24 on archi-dev-box but there is no guarantee of that on another
|
||||
/// node, and a hardcoded guess would fail silently by binding SOCKS to an
|
||||
/// address no container can reach.
|
||||
///
|
||||
/// Returns `None` when the network is absent or unparseable, which callers must
|
||||
/// treat as "do not widen SOCKS" — failing closed keeps Tor loopback-only
|
||||
/// rather than guessing at an exposure boundary.
|
||||
async fn archy_net_gateway_and_subnet() -> Option<(String, String)> {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"network",
|
||||
"inspect",
|
||||
"archy-net",
|
||||
"--format",
|
||||
"{{range .Subnets}}{{.Gateway}} {{.Subnet}}{{end}}",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let mut parts = text.split_whitespace();
|
||||
let gateway = parts.next()?.to_string();
|
||||
let subnet = parts.next()?.to_string();
|
||||
if gateway.is_empty() || subnet.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((gateway, subnet))
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> {
|
||||
let base = detect_hidden_service_base();
|
||||
let mut lines = vec![
|
||||
"# Auto-generated by Archipelago — do not edit manually".to_string(),
|
||||
"SocksPort 9050".to_string(),
|
||||
"# ControlPort disabled for security".to_string(),
|
||||
String::new(),
|
||||
];
|
||||
|
||||
// Containerised apps (Bitcoin Core/Knots) sit on the archy-net bridge, so
|
||||
// 127.0.0.1:9050 inside them is their OWN loopback — the host's Tor is
|
||||
// genuinely unreachable, which is why Core reported `onion reachable=False,
|
||||
// proxy=''` with every peer on clearnet. Bind a second SOCKS listener on the
|
||||
// bridge gateway so those containers have a Tor path at all.
|
||||
//
|
||||
// SocksPolicy is applied as an explicit accept-list terminated by a reject.
|
||||
// 127.0.0.1 MUST be accepted first: SocksPolicy applies to every SocksPort,
|
||||
// so an accept-list naming only the bridge subnet would lock the daemon out
|
||||
// of its own loopback SOCKS — the node's Tor usage would break in a way that
|
||||
// looks nothing like "we added a listener".
|
||||
match archy_net_gateway_and_subnet().await {
|
||||
Some((gateway, subnet)) => {
|
||||
lines.push(format!("SocksPort {gateway}:9050"));
|
||||
lines.push("SocksPolicy accept 127.0.0.1/32".to_string());
|
||||
lines.push(format!("SocksPolicy accept {subnet}"));
|
||||
lines.push("SocksPolicy reject *".to_string());
|
||||
}
|
||||
None => {
|
||||
lines.push(
|
||||
"# archy-net not found — SOCKS stays loopback-only (fail closed)".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("# ControlPort disabled for security".to_string());
|
||||
lines.push(String::new());
|
||||
|
||||
for svc in &config.services {
|
||||
if !svc.enabled {
|
||||
continue;
|
||||
@@ -484,30 +422,6 @@ pub(in crate::api::rpc) fn is_protocol_service(name: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Apps that must never gain a Tor hidden service *automatically*.
|
||||
///
|
||||
/// These are credential-bearing reverse proxies in front of the node's money:
|
||||
/// `lnd-ui` (host port 18083) serves `/lnd-connect-info` and fronts LND, and
|
||||
/// `bitcoin-ui` (host port 8334) proxies Bitcoin Core RPC with the RPC
|
||||
/// password injected on the caller's behalf. Both are session-gated at the
|
||||
/// backend as of the 2026-08-02 fix, but an onion is a *global, persistent*
|
||||
/// door, and neither should acquire one merely as a side effect of being
|
||||
/// installed.
|
||||
///
|
||||
/// [`is_protocol_service`] does not cover them: it names the daemons (`lnd`,
|
||||
/// `bitcoin-knots`), not their UI sidecars. Without this list,
|
||||
/// `auto_add_tor_service` would publish `onion:80 -> 127.0.0.1:18083` and
|
||||
/// `-> :8334` the moment either app installs — which is exactly the surface
|
||||
/// that leaked the LND admin macaroon, re-exposed worldwide instead of just
|
||||
/// to mesh/LAN peers.
|
||||
///
|
||||
/// This gates only the automatic path. An operator who deliberately enables
|
||||
/// Tor for one of these apps still can; that is an informed choice, not a
|
||||
/// silent default.
|
||||
pub(in crate::api::rpc) fn never_auto_onioned(name: &str) -> bool {
|
||||
matches!(name, "lnd-ui" | "bitcoin-ui")
|
||||
}
|
||||
|
||||
// ─── Config I/O ──────────────────────────────────────────────────
|
||||
|
||||
fn tor_data_dir() -> String {
|
||||
|
||||
@@ -58,18 +58,8 @@ async fn read_password() -> String {
|
||||
}
|
||||
|
||||
/// Generate a cryptographically random password (32 hex chars).
|
||||
///
|
||||
/// KEY-05: this is the node's Bitcoin RPC credential, so the source is named and
|
||||
/// the 16-byte draw is guarded. It returns a bare `String` and its caller
|
||||
/// (`read_password`) is a `OnceCell` initialiser that also returns a bare
|
||||
/// `String`, so a degenerate draw aborts rather than propagating — the condition
|
||||
/// means the kernel CSPRNG is broken, and a predictable Bitcoin RPC password on
|
||||
/// a node that also serves LAN traffic is worse than a loud stop.
|
||||
fn generate_random_password() -> String {
|
||||
let mut bytes = [0u8; 16];
|
||||
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut bytes).unwrap_or_else(|e| {
|
||||
panic!("refusing to generate a Bitcoin RPC password from degenerate entropy: {e} (KEY-05)")
|
||||
});
|
||||
let bytes: [u8; 16] = rand::random();
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
|
||||
@@ -154,9 +154,9 @@ pub async fn ensure_doctor_installed() {
|
||||
}
|
||||
match run_bitcoin_rpc_repair().await {
|
||||
Ok(true) => {
|
||||
info!("Repaired Bitcoin RPC bind settings; running Bitcoin containers left untouched")
|
||||
info!("Removed stale bitcoin.conf; running Bitcoin containers left untouched")
|
||||
}
|
||||
Ok(false) => debug!("Bitcoin RPC bind settings already usable"),
|
||||
Ok(false) => debug!("No stale bitcoin.conf found"),
|
||||
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_apps_dir_repair().await {
|
||||
@@ -358,20 +358,7 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// archipelago-host-secrets-audit.service rides this same path (phase 10
|
||||
// KEY-02 / audit F-03). Nodes already in the field never received 10-03's
|
||||
// ISO-build fix — the first-boot script is installed by the installer, not
|
||||
// shipped by OTA — so a node that hit the old fail-open path is still on
|
||||
// the SSH host key and TLS private key baked into its published ISO, and
|
||||
// will never try again. This unit is how such a node reports itself. It is
|
||||
// DETECT ONLY (D-06: detect-report-then-apply); rotation is operator-driven
|
||||
// via `--apply --yes` and never fires from a unit.
|
||||
let mut host_secrets_unit_installed = false;
|
||||
for unit in [
|
||||
"archipelago-doctor.service",
|
||||
"archipelago-doctor.timer",
|
||||
"archipelago-host-secrets-audit.service",
|
||||
] {
|
||||
for unit in ["archipelago-doctor.service", "archipelago-doctor.timer"] {
|
||||
let src = configs.join(unit);
|
||||
if src.exists() {
|
||||
let src_s = src.to_string_lossy().to_string();
|
||||
@@ -382,9 +369,6 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
if !status.success() {
|
||||
anyhow::bail!("install {} exited with {}", unit, status);
|
||||
}
|
||||
if unit == "archipelago-host-secrets-audit.service" {
|
||||
host_secrets_unit_installed = true;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -427,34 +411,6 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
|
||||
if changed {
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
if host_secrets_unit_installed {
|
||||
// `--now` on purpose: the verdict is the whole deliverable, and
|
||||
// waiting for the next reboot to learn whether a node is running
|
||||
// fleet-shared key material wastes the OTA that just delivered the
|
||||
// means to find out. The unit is Type=oneshot, read-only and exits
|
||||
// in milliseconds on a healthy node. Best-effort: a node that
|
||||
// cannot enable it still boots, and the next OTA retries.
|
||||
match host_sudo(&[
|
||||
"systemctl",
|
||||
"enable",
|
||||
"--now",
|
||||
"archipelago-host-secrets-audit.service",
|
||||
])
|
||||
.await
|
||||
{
|
||||
Ok(status) if status.success() => {
|
||||
info!("Enabled archipelago-host-secrets-audit.service from OTA runtime payload")
|
||||
}
|
||||
Ok(status) => tracing::warn!(
|
||||
"enabling archipelago-host-secrets-audit.service exited with {}",
|
||||
status
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"failed to enable archipelago-host-secrets-audit.service: {}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
if nginx_src.exists() {
|
||||
match host_sudo(&["nginx", "-t"]).await {
|
||||
Ok(status) if status.success() => {
|
||||
@@ -621,52 +577,30 @@ exit 2
|
||||
}
|
||||
|
||||
async fn run_bitcoin_rpc_repair() -> Result<bool> {
|
||||
// Older installs can have a container-owned bitcoin.conf with only rpcauth
|
||||
// and printtoconsole. Repair it at startup so OTA fixes existing nodes
|
||||
// without a manual uninstall/reinstall. Bind/port stay in the container
|
||||
// command line to avoid duplicate RPC endpoint definitions.
|
||||
// bitcoind is launched with -conf=/tmp/rpc.conf and never reads a
|
||||
// datadir bitcoin.conf (apps/bitcoin-core & bitcoin-knots manifest.yml,
|
||||
// commit a597c1d9 — bind/port live only on the container command line).
|
||||
// A leftover file from an older install makes Bitcoin Core's own
|
||||
// datadir-conflict safety check refuse to start on every subsequent
|
||||
// start. Remove it instead of "repairing" it into existence — this
|
||||
// previously wrote server=/rpcbind=/rpcallowip=/listen= into the file,
|
||||
// which is exactly what caused the conflict.
|
||||
let script = r#"
|
||||
set -eu
|
||||
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
|
||||
[ -f "$conf" ] || exit 0
|
||||
changed=0
|
||||
ensure_line() {
|
||||
line="$1"
|
||||
key="${line%%=*}"
|
||||
if ! grep -q "^${key}=" "$conf"; then
|
||||
printf '%s\n' "$line" >> "$conf"
|
||||
changed=1
|
||||
fi
|
||||
}
|
||||
ensure_line server=1
|
||||
# rpcbind=0.0.0.0 is required inside the container: with rpcallowip set but
|
||||
# no rpcbind, bitcoind binds RPC to the container's loopback only and every
|
||||
# dial over the container network (LND, bitcoin-ui) is refused — the fresh-
|
||||
# install "LND took 5 attempts" / bitcoin-rpc 502 failure (host publish stays
|
||||
# 127.0.0.1-only, so exposure is unchanged).
|
||||
ensure_line rpcbind=0.0.0.0
|
||||
ensure_line rpcallowip=0.0.0.0/0
|
||||
ensure_line listen=1
|
||||
# Log-volume fix: printtoconsole=1 duplicated every log line (incl. per-block
|
||||
# IBD "UpdateTip" spam) into journald via conmon on top of the datadir
|
||||
# debug.log bitcoind already writes. Console off; debug.log stays (bitcoind
|
||||
# self-shrinks it on restart).
|
||||
if grep -q '^printtoconsole=1' "$conf"; then
|
||||
sed -i 's/^printtoconsole=1$/printtoconsole=0/' "$conf"
|
||||
changed=1
|
||||
fi
|
||||
[ "$changed" -eq 0 ] && exit 0
|
||||
mv "$conf" "$conf.disabled-$(date +%s)"
|
||||
exit 2
|
||||
"#;
|
||||
let status = host_sudo(&["sh", "-lc", script])
|
||||
.await
|
||||
.context("repair bitcoin.conf RPC bind settings")?;
|
||||
.context("remove stale bitcoin.conf RPC bind settings")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
// Do not restart Bitcoin from bootstrap. During IBD, an automatic
|
||||
// restart can cost hours of progress. The repaired file is only a
|
||||
// fallback for future starts; current containers keep their command-line
|
||||
// RPC args until an operator or update intentionally restarts them.
|
||||
// restart can cost hours of progress. Removing the stale file is
|
||||
// only a fallback for future starts; current containers keep their
|
||||
// command-line RPC args regardless.
|
||||
Some(2) => Ok(true),
|
||||
_ => {
|
||||
warn!("Bitcoin RPC repair helper exited with {}", status);
|
||||
|
||||
@@ -3,47 +3,17 @@ server {
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
# Session gate for the credential-injecting proxy below. Internal: it can
|
||||
# only be reached by nginx's own auth_request subrequest, never by a client.
|
||||
location = /_session_check {
|
||||
internal;
|
||||
proxy_pass http://127.0.0.1:5678/auth/session-check;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-CSRF-Token $http_x_csrf_token;
|
||||
}
|
||||
|
||||
# Bitcoin Core RPC. This proxy ADDS Bitcoin Core's Basic auth that the
|
||||
# caller never supplied, so reaching this location at all is equivalent to
|
||||
# holding the node's RPC credentials — which is why it must be gated here
|
||||
# rather than anywhere upstream.
|
||||
#
|
||||
# It previously had no gate at all. This port (8334) binds 0.0.0.0 AND sits
|
||||
# on the fips0 mesh allowlist (fips/app_ports.rs), so any mesh peer, LAN
|
||||
# host or Tailscale peer could POST authenticated Bitcoin Core RPC —
|
||||
# including wallet methods, with a wallet loaded. Verified live on
|
||||
# archi-dev-box 2026-08-02.
|
||||
#
|
||||
# `Access-Control-Allow-Origin *` is also removed: paired with a proxy that
|
||||
# injects credentials it let any web page a user visited drive this RPC.
|
||||
location /bitcoin-rpc/ {
|
||||
# Preflight carries no cookies by design — answer it before the gate,
|
||||
# otherwise the browser reports an opaque CORS failure instead of a 401.
|
||||
if ($request_method = OPTIONS) { return 204; }
|
||||
auth_request /_session_check;
|
||||
proxy_pass http://127.0.0.1:8332/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Authorization "Basic {{BITCOIN_RPC_AUTH}}";
|
||||
add_header Access-Control-Allow-Origin $scheme://$http_host always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
add_header Vary "Origin" always;
|
||||
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||
if ($request_method = OPTIONS) { return 204; }
|
||||
}
|
||||
location /bitcoin-status {
|
||||
proxy_pass http://127.0.0.1:5678/bitcoin-status;
|
||||
@@ -63,14 +33,5 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
# no-cache so a rebuilt image is actually seen. Without this nginx sends
|
||||
# only ETag/Last-Modified for index.html, and browsers apply heuristic
|
||||
# caching to it — so an OTA that ships a new bitcoin-ui kept rendering the
|
||||
# previous UI until the user hard-refreshed. docker/lnd-ui/nginx.conf has
|
||||
# carried the same header for this reason. "no-cache" (revalidate), not
|
||||
# "no-store", so the ETag still saves the transfer when nothing changed.
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
}
|
||||
|
||||
@@ -1254,13 +1254,6 @@ pub struct ProdContainerOrchestrator {
|
||||
/// secret calls; a rotation (hash change) falls through and
|
||||
/// re-registers.
|
||||
env_secret_cache: Mutex<HashMap<String, String>>,
|
||||
/// App ids whose credential this process rotated off a publicly known
|
||||
/// default (FED-07). A rotation leaves the RUNNING container holding the
|
||||
/// compromised value, so its env drift must be acted on even when the app
|
||||
/// is restart-sensitive — leaving it untouched perpetuates the compromise,
|
||||
/// exactly as leaving published-port drift untouched perpetuates a broken
|
||||
/// container. Consumed (and cleared) by the drift check that recreates it.
|
||||
credential_rotated: Mutex<HashSet<String>>,
|
||||
#[cfg(test)]
|
||||
test_disk_gb: Option<u64>,
|
||||
#[cfg(test)]
|
||||
@@ -1324,7 +1317,6 @@ impl ProdContainerOrchestrator {
|
||||
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
|
||||
use_quadlet_backends: config.use_quadlet_backends,
|
||||
env_secret_cache: Mutex::new(HashMap::new()),
|
||||
credential_rotated: Mutex::new(HashSet::new()),
|
||||
#[cfg(test)]
|
||||
test_disk_gb: None,
|
||||
#[cfg(test)]
|
||||
@@ -1347,7 +1339,6 @@ impl ProdContainerOrchestrator {
|
||||
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
|
||||
use_quadlet_backends: false,
|
||||
env_secret_cache: Mutex::new(HashMap::new()),
|
||||
credential_rotated: Mutex::new(HashSet::new()),
|
||||
test_disk_gb: None,
|
||||
test_bitcoin_host: None,
|
||||
}
|
||||
@@ -1954,60 +1945,6 @@ impl ProdContainerOrchestrator {
|
||||
let user_uninstalled =
|
||||
crate::crash_recovery::load_user_uninstalled(&self.data_dir).await;
|
||||
if user_uninstalled.contains(&app_id) || user_uninstalled.contains(&name) {
|
||||
// The marker says "removed", but the container can still be
|
||||
// RUNNING: a Quadlet unit is owned by systemd, which starts it
|
||||
// on boot entirely independently of this reconciler. On
|
||||
// archi-dev-box (2026-08-02) `bitcoin-ui` sat in this exact
|
||||
// state — marker set, `archy-bitcoin-ui.service` active, port
|
||||
// 8334 published — and so it never received the /bitcoin-rpc/
|
||||
// auth_request gate that a05956c4 shipped INSIDE the binary.
|
||||
// The node looked patched while an unauthenticated caller could
|
||||
// still drive Bitcoin Core RPC through a credential-injecting
|
||||
// proxy.
|
||||
//
|
||||
// A container that is actually running is a live attack surface
|
||||
// whatever a marker says about it, so its security-relevant
|
||||
// config gets reconciled even here. This deliberately does NOT
|
||||
// create, start or resurrect anything — the "must stay removed"
|
||||
// contract is untouched for every path that could.
|
||||
//
|
||||
// Hook failure is swallowed rather than propagated: an app the
|
||||
// user has uninstalled must not be able to fail the reconcile
|
||||
// pass for everything after it.
|
||||
if matches!(
|
||||
self.runtime.get_container_status(&name).await,
|
||||
Ok(status) if matches!(status.state, ContainerState::Running)
|
||||
) {
|
||||
match self.run_pre_start_hooks(&app_id).await {
|
||||
Ok(Some(HookOutcome::Rewritten)) => {
|
||||
tracing::warn!(
|
||||
app_id = %app_id,
|
||||
container = %name,
|
||||
"rewrote config for a user-uninstalled app whose container is \
|
||||
still RUNNING (systemd/Quadlet keeps it alive independently of \
|
||||
reconcile) — restarting so it picks the new config up"
|
||||
);
|
||||
restart_container_scoped_if_pasta(
|
||||
self.runtime.as_ref(),
|
||||
&resolved_manifest,
|
||||
&name,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("reconcile restart {name}"))?;
|
||||
let _ = self.run_post_start_hooks(&app_id).await;
|
||||
return Ok(ReconcileAction::Started);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
app_id = %app_id,
|
||||
error = %e,
|
||||
"config hook failed for a user-uninstalled but running app — \
|
||||
leaving it as-is"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::debug!(
|
||||
app_id = %app_id,
|
||||
container = %name,
|
||||
@@ -2103,24 +2040,7 @@ impl ProdContainerOrchestrator {
|
||||
return Ok(ReconcileAction::Installed);
|
||||
}
|
||||
if self.container_env_drifted(&name, &resolved_manifest).await {
|
||||
// FED-07: a credential we just rotated off a publicly
|
||||
// known default leaves the RUNNING container holding
|
||||
// the compromised value. Restart-sensitivity protects
|
||||
// working services; this service is compromised, and
|
||||
// skipping it perpetuates the exposure — the same
|
||||
// reasoning the published-port carve-out above uses.
|
||||
let rotated = self.credential_rotated.lock().await.remove(&app_id);
|
||||
if rotated {
|
||||
tracing::warn!(
|
||||
app_id = %app_id,
|
||||
container = %name,
|
||||
"recreating restart-sensitive app: its admin credential was \
|
||||
rotated off a publicly known default and the running \
|
||||
container still holds the compromised one (FED-07)"
|
||||
);
|
||||
}
|
||||
if !rotated
|
||||
&& mode == ReconcileMode::ExistingOnly
|
||||
if mode == ReconcileMode::ExistingOnly
|
||||
&& is_restart_sensitive_app(&app_id)
|
||||
{
|
||||
tracing::info!(
|
||||
@@ -3324,45 +3244,6 @@ impl ProdContainerOrchestrator {
|
||||
// `secret_env` resolves — no per-app code, no host provisioning.
|
||||
crate::container::secrets::ensure_generated_secrets(&self.secrets_dir, manifest)?;
|
||||
|
||||
// FED-07 migration. A node installed before the shipped-default
|
||||
// fallbacks were removed is still answering to a credential published
|
||||
// in this repository. Detect that exact value and rotate it, once.
|
||||
//
|
||||
// The rotated hash changes the resolved secret env below, which changes
|
||||
// `secret_env_hash`, which the drift check reads as a label mismatch on
|
||||
// the running container — so the platform's own recreate path rebuilds
|
||||
// it around an unchanged data directory, ports, volumes and container
|
||||
// name. Deliberately no teardown here: a hand-rolled remove-and-run is
|
||||
// the anti-pattern CLAUDE.md names, and it is what would lose the
|
||||
// gateway's state.
|
||||
//
|
||||
// An error propagates rather than being swallowed: the atomic write
|
||||
// leaves the previous credential in place, so surfacing the failure is
|
||||
// strictly better than continuing with a half-rotated gateway.
|
||||
if manifest.app.id == "fedimint-gateway"
|
||||
&& crate::container::secrets::rotate_compromised_gateway_credential(&self.secrets_dir)?
|
||||
{
|
||||
// Mark the app so the drift check below recreates it even though
|
||||
// it is restart-sensitive. Without this the unit is rewritten but
|
||||
// never restarted, and the gateway keeps serving the compromised
|
||||
// credential indefinitely (observed on archi-dev-box 2026-08-01).
|
||||
self.credential_rotated
|
||||
.lock()
|
||||
.await
|
||||
.insert(manifest.app.id.clone());
|
||||
// Names a path, never a value — this line crosses into the node's
|
||||
// logs, which are a wider audience than the 0600 secrets dir.
|
||||
tracing::info!(
|
||||
app = "fedimint-gateway",
|
||||
"Rotated the Fedimint gateway admin credential: this node was carrying a publicly \
|
||||
known default that shipped in the repository (FED-07). The gateway will be \
|
||||
recreated around its existing data. The new password is readable by the service \
|
||||
user at {}/{}.pw",
|
||||
self.secrets_dir.display(),
|
||||
crate::container::secrets::GATEWAY_HASH_SECRET_NAME,
|
||||
);
|
||||
}
|
||||
|
||||
let mut facts = self.detect_host_facts().await;
|
||||
// Only pay the podman cost to detect Knots-vs-Core when this manifest
|
||||
// actually templates the Bitcoin node into its env (mempool — B12).
|
||||
@@ -5213,116 +5094,6 @@ app:
|
||||
}
|
||||
}
|
||||
|
||||
/// A fedimint-gateway manifest shaped like the real one: a bcrypt
|
||||
/// generated secret plus a secret_env that reads it, which is what makes
|
||||
/// the credential participate in secret_env_hash.
|
||||
fn gateway_manifest_yaml() -> &'static str {
|
||||
"app:\n id: fedimint-gateway\n name: Fedimint Gateway\n version: 0.10.0\n container:\n image: x:1\n generated_secrets:\n - name: fedimint-gateway-hash\n kind: bcrypt\n secret_env:\n - key: FEDI_HASH\n secret_file: fedimint-gateway-hash\n"
|
||||
}
|
||||
|
||||
/// FED-07. Rotating a compromised credential leaves the RUNNING container
|
||||
/// holding the old value, so the rotation must flag the app for recreate.
|
||||
/// Without the flag the drift check skips it as restart-sensitive and the
|
||||
/// gateway keeps serving the published default forever — observed on
|
||||
/// archi-dev-box 2026-08-01 before this was wired up.
|
||||
#[tokio::test]
|
||||
async fn rotating_a_compromised_credential_flags_the_app_for_recreate() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt).await;
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let secrets = tmp.path().join("secrets");
|
||||
std::fs::create_dir_all(&secrets).unwrap();
|
||||
// An install carrying the shipped default, with its .pw sibling
|
||||
// present so ensure_one's fast path no-ops and rotation is what acts.
|
||||
std::fs::write(
|
||||
secrets.join("fedimint-gateway-hash"),
|
||||
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(secrets.join("fedimint-gateway-hash.pw"), "stale-plaintext").unwrap();
|
||||
orch.set_secrets_dir(secrets.clone());
|
||||
|
||||
let mut manifest = AppManifest::parse(gateway_manifest_yaml()).unwrap();
|
||||
orch.resolve_dynamic_env(&mut manifest).await.unwrap();
|
||||
|
||||
assert!(
|
||||
orch.credential_rotated
|
||||
.lock()
|
||||
.await
|
||||
.contains("fedimint-gateway"),
|
||||
"a rotated credential must flag its app so the drift check recreates it"
|
||||
);
|
||||
let after = std::fs::read_to_string(secrets.join("fedimint-gateway-hash")).unwrap();
|
||||
assert!(
|
||||
!after.contains("t9YjjxkiktrlYvjajB"),
|
||||
"the compromised value must be gone from the file"
|
||||
);
|
||||
}
|
||||
|
||||
/// The adjacency edge: an app whose credential was NOT rotated must not be
|
||||
/// flagged, or every reconcile tick would recreate restart-sensitive apps.
|
||||
#[tokio::test]
|
||||
async fn a_unique_credential_does_not_flag_the_app() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt).await;
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let secrets = tmp.path().join("secrets");
|
||||
std::fs::create_dir_all(&secrets).unwrap();
|
||||
crate::container::secrets::ensure_gateway_credential(&secrets).unwrap();
|
||||
let before = std::fs::read_to_string(secrets.join("fedimint-gateway-hash")).unwrap();
|
||||
orch.set_secrets_dir(secrets.clone());
|
||||
|
||||
let mut manifest = AppManifest::parse(gateway_manifest_yaml()).unwrap();
|
||||
orch.resolve_dynamic_env(&mut manifest).await.unwrap();
|
||||
|
||||
assert!(
|
||||
orch.credential_rotated.lock().await.is_empty(),
|
||||
"an app with a unique credential must never be flagged for recreate"
|
||||
);
|
||||
assert_eq!(
|
||||
before,
|
||||
std::fs::read_to_string(secrets.join("fedimint-gateway-hash")).unwrap(),
|
||||
"a unique credential must be left byte-identical"
|
||||
);
|
||||
}
|
||||
|
||||
/// Idempotence at the flag level: the second pass finds a value that is no
|
||||
/// longer on the denylist, so it neither rotates nor re-flags. This is what
|
||||
/// stops a recreate loop on every reconcile tick (T-01-73).
|
||||
#[tokio::test]
|
||||
async fn a_second_pass_does_not_re_flag_the_app() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt).await;
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let secrets = tmp.path().join("secrets");
|
||||
std::fs::create_dir_all(&secrets).unwrap();
|
||||
std::fs::write(
|
||||
secrets.join("fedimint-gateway-hash"),
|
||||
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(secrets.join("fedimint-gateway-hash.pw"), "stale-plaintext").unwrap();
|
||||
orch.set_secrets_dir(secrets.clone());
|
||||
|
||||
let mut m1 = AppManifest::parse(gateway_manifest_yaml()).unwrap();
|
||||
orch.resolve_dynamic_env(&mut m1).await.unwrap();
|
||||
// The drift check consumes the flag when it recreates.
|
||||
orch.credential_rotated
|
||||
.lock()
|
||||
.await
|
||||
.remove("fedimint-gateway");
|
||||
|
||||
let mut m2 = AppManifest::parse(gateway_manifest_yaml()).unwrap();
|
||||
orch.resolve_dynamic_env(&mut m2).await.unwrap();
|
||||
assert!(
|
||||
orch.credential_rotated.lock().await.is_empty(),
|
||||
"the second pass must not re-flag — the rotated value is not denylisted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_fresh_build_when_image_absent() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
@@ -6030,79 +5801,6 @@ app:
|
||||
assert!(!calls.iter().any(|c| c.starts_with("create_container:")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_rewrites_security_config_for_a_user_uninstalled_but_running_app() {
|
||||
// Regression, archi-dev-box 2026-08-02: `bitcoin-ui` carried a durable
|
||||
// user-uninstalled marker WHILE systemd/Quadlet kept archy-bitcoin-ui
|
||||
// running and publishing :8334. Reconcile returned on the marker before
|
||||
// reaching the pre-start hook, so the /bitcoin-rpc/ auth_request gate
|
||||
// that a05956c4 shipped inside the binary never reached the rendered
|
||||
// nginx.conf. The node looked patched while an unauthenticated caller
|
||||
// on the LAN or mesh could still drive Bitcoin Core RPC through a
|
||||
// credential-injecting proxy.
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt.clone()).await;
|
||||
|
||||
// Own RenderPaths, not the process-wide static one, so a stale
|
||||
// starting state can't race the other bitcoin-ui tests.
|
||||
let dir = tempfile::TempDir::new().expect("test tmpdir");
|
||||
std::fs::write(dir.path().join("bitcoin-rpc-password"), "test-pass\n")
|
||||
.expect("seed password");
|
||||
let rendered_path = dir.path().join("nginx.conf");
|
||||
std::fs::write(&rendered_path, "# pre-fix config: no auth_request gate\n")
|
||||
.expect("seed stale conf");
|
||||
orch.set_bitcoin_ui_paths(bitcoin_ui::RenderPaths {
|
||||
secret_path: dir.path().join("bitcoin-rpc-password"),
|
||||
rendered_path: rendered_path.clone(),
|
||||
});
|
||||
|
||||
orch.insert_manifest_for_test(
|
||||
build_manifest(
|
||||
"bitcoin-ui",
|
||||
"/opt/archy/docker/bitcoin-ui",
|
||||
"archy-bitcoin-ui:local",
|
||||
),
|
||||
PathBuf::from("/opt/archy/apps/bitcoin-ui"),
|
||||
)
|
||||
.await;
|
||||
rt.set_state("archy-bitcoin-ui", ContainerState::Running);
|
||||
crate::crash_recovery::mark_user_uninstalled(&orch.data_dir, "bitcoin-ui").await;
|
||||
|
||||
let report = orch.reconcile_existing().await;
|
||||
|
||||
let contents = std::fs::read_to_string(&rendered_path)
|
||||
.expect("nginx.conf must still exist after reconcile");
|
||||
assert!(
|
||||
contents.contains("auth_request /_session_check"),
|
||||
"the session gate must reach a running container even when the app \
|
||||
carries an uninstall marker:\n{contents}"
|
||||
);
|
||||
assert!(
|
||||
!contents.contains("{{BITCOIN_RPC_AUTH}}"),
|
||||
"placeholder was not substituted:\n{contents}"
|
||||
);
|
||||
|
||||
// Rewriting the file is not enough — nginx only loads it on restart.
|
||||
let calls = rt.calls();
|
||||
assert!(
|
||||
calls
|
||||
.iter()
|
||||
.any(|c| c == "start_container:archy-bitcoin-ui"),
|
||||
"container must be restarted so nginx picks the new config up: {calls:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
report.actions,
|
||||
vec![("bitcoin-ui".to_string(), ReconcileAction::Started)]
|
||||
);
|
||||
assert!(report.failures.is_empty());
|
||||
|
||||
// The "must stay removed" contract is untouched: nothing is created,
|
||||
// pulled or built for an app the user uninstalled.
|
||||
assert!(!calls.iter().any(|c| c.starts_with("create_container:")));
|
||||
assert!(!calls.iter().any(|c| c.starts_with("pull_image:")));
|
||||
assert!(!calls.iter().any(|c| c.starts_with("build_image:")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_existing_skips_archival_baseline_apps_on_pruned_hosts() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
|
||||
@@ -67,29 +67,18 @@ fn ensure_one(dir: &Path, gs: &GeneratedSecret) -> Result<()> {
|
||||
SecretGenKind::Hex16 => write_secret(&dir.join(&gs.name), &random_hex(16))?,
|
||||
SecretGenKind::Hex32 => write_secret(&dir.join(&gs.name), &random_hex(32))?,
|
||||
SecretGenKind::Base64 => write_secret(&dir.join(&gs.name), &random_base64(32))?,
|
||||
SecretGenKind::Bcrypt => write_bcrypt_pair(dir, &gs.name)?,
|
||||
SecretGenKind::Bcrypt => {
|
||||
let password = random_hex(BCRYPT_PASSWORD_BYTES);
|
||||
let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST)
|
||||
.context("bcrypt-hashing generated password")?;
|
||||
// Primary (server-facing hash) first, then the plaintext sibling.
|
||||
write_secret(&dir.join(&gs.name), &hash)?;
|
||||
write_secret(&dir.join(format!("{}.pw", gs.name)), &password)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a fresh bcrypt credential pair for `name` under `dir`: the
|
||||
/// server-facing hash at `<name>` and its plaintext sibling at `<name>.pw`,
|
||||
/// both 0600 through the atomic [`write_secret`].
|
||||
///
|
||||
/// The single implementation of bcrypt generation on this platform —
|
||||
/// [`ensure_one`]'s `Bcrypt` arm and
|
||||
/// [`rotate_compromised_gateway_credential`] both go through here, so there is
|
||||
/// one place where a credential comes into existence.
|
||||
fn write_bcrypt_pair(dir: &Path, name: &str) -> Result<()> {
|
||||
let password = random_hex(BCRYPT_PASSWORD_BYTES);
|
||||
let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST)
|
||||
.context("bcrypt-hashing generated password")?;
|
||||
// Primary (server-facing hash) first, then the plaintext sibling.
|
||||
write_secret(&dir.join(name), &hash)?;
|
||||
write_secret(&dir.join(format!("{}.pw", name)), &password)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True when `path` exists, is readable by this process, and is non-empty after
|
||||
/// trimming. Any error (missing, permission denied, empty) reads as false.
|
||||
fn readable_nonempty(path: &Path) -> bool {
|
||||
@@ -98,28 +87,9 @@ fn readable_nonempty(path: &Path) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Fill `buf` from an explicitly named `OsRng`, guarded when it is long enough
|
||||
/// for the degenerate predicate's false-positive bound to hold.
|
||||
///
|
||||
/// KEY-05 / F-10: these are the manifest-declared `generated_secrets` — app
|
||||
/// passwords and API keys — and were the original F-10 finding. Every production
|
||||
/// caller requests 16 or 32 bytes, so the guard is live in practice; the short
|
||||
/// branch exists so a future caller asking for fewer cannot trip the guard's
|
||||
/// length assertion, which is a programmer-error panic and not an input
|
||||
/// condition.
|
||||
fn fill_secret_bytes(buf: &mut [u8]) {
|
||||
if buf.len() >= crate::entropy::MIN_GUARDED_LEN {
|
||||
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, buf).unwrap_or_else(|e| {
|
||||
panic!("refusing to generate an app secret from degenerate entropy: {e} (KEY-05)")
|
||||
});
|
||||
} else {
|
||||
rand::rngs::OsRng.fill_bytes(buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn random_hex(bytes: usize) -> String {
|
||||
let mut buf = vec![0u8; bytes];
|
||||
fill_secret_bytes(&mut buf);
|
||||
rand::thread_rng().fill_bytes(&mut buf);
|
||||
hex::encode(buf)
|
||||
}
|
||||
|
||||
@@ -128,127 +98,10 @@ fn random_hex(bytes: usize) -> String {
|
||||
fn random_base64(bytes: usize) -> String {
|
||||
use base64::Engine as _;
|
||||
let mut buf = vec![0u8; bytes];
|
||||
fill_secret_bytes(&mut buf);
|
||||
rand::thread_rng().fill_bytes(&mut buf);
|
||||
base64::engine::general_purpose::STANDARD.encode(buf)
|
||||
}
|
||||
|
||||
/// Canonical secret name for the Fedimint gateway's admin bcrypt hash — must
|
||||
/// match `generated_secrets: fedimint-gateway-hash` in
|
||||
/// `apps/fedimint-gateway/manifest.yml` so the Rust orchestrator, first-boot
|
||||
/// script, reconcile script and both deploy scripts all agree on one file
|
||||
/// (FED-07: before this, scripts wrote `fedimint-gateway-password` while the
|
||||
/// daemon read `fedimint-gateway-hash`).
|
||||
pub const GATEWAY_HASH_SECRET_NAME: &str = "fedimint-gateway-hash";
|
||||
|
||||
/// Detection-only denylist of bcrypt hashes that shipped as hardcoded
|
||||
/// fallback credentials in this repository before FED-07. `t9YjjxkiktrlYvjajB
|
||||
/// /zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC` was substituted for the Fedimint
|
||||
/// gateway's admin password whenever the real per-install secret was
|
||||
/// missing — in `config.rs`, `dependencies.rs`, and every shell install path
|
||||
/// — meaning anyone holding a copy of this repo held the admin credential for
|
||||
/// every gateway that ever took that fallback.
|
||||
///
|
||||
/// This value exists **only** so an install still carrying it can be
|
||||
/// detected and rotated (plan 01-16 owns the migration). It must NEVER be
|
||||
/// passed to a container, written to a fresh install, or handed back to a
|
||||
/// caller by [`gateway_bcrypt_hash`] — that function returns `Err` instead.
|
||||
/// This is the one and only place this value may appear in the tree.
|
||||
const KNOWN_DEFAULT_GATEWAY_HASHES: &[&str] =
|
||||
&["$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC"];
|
||||
|
||||
/// Idempotently ensure the Fedimint gateway's admin credential exists under
|
||||
/// `secrets_dir`: a fresh per-install bcrypt hash plus its `.pw` plaintext
|
||||
/// sibling, both 0600. Delegates to [`ensure_one`] for the actual bcrypt
|
||||
/// generation so there is exactly one implementation of that logic — this
|
||||
/// also means a second call is a no-op (idempotent fast path) and a
|
||||
/// present-but-unreadable file self-heals, so a reconcile tick never rotates
|
||||
/// a working gateway credential out from under it.
|
||||
pub fn ensure_gateway_credential(secrets_dir: &Path) -> Result<()> {
|
||||
fs::create_dir_all(secrets_dir)
|
||||
.with_context(|| format!("creating secrets dir {}", secrets_dir.display()))?;
|
||||
let gs = GeneratedSecret {
|
||||
name: GATEWAY_HASH_SECRET_NAME.to_string(),
|
||||
kind: SecretGenKind::Bcrypt,
|
||||
};
|
||||
ensure_one(secrets_dir, &gs)
|
||||
}
|
||||
|
||||
/// Read the Fedimint gateway's canonical per-install bcrypt hash.
|
||||
///
|
||||
/// Returns `Err` naming the missing file when it is absent, empty, or
|
||||
/// unreadable — callers must propagate that error rather than substitute a
|
||||
/// literal, so an install with no credential fails loudly instead of quietly
|
||||
/// starting an unauthenticated/default-credentialed gateway. Also returns
|
||||
/// `Err` when the stored value matches [`KNOWN_DEFAULT_GATEWAY_HASHES`]: a
|
||||
/// node carrying the shipped default must not be handed that value back by
|
||||
/// this codebase, even to reconfigure itself with the same value it already
|
||||
/// (insecurely) has.
|
||||
pub fn gateway_bcrypt_hash(secrets_dir: &Path) -> Result<String> {
|
||||
let path = secrets_dir.join(GATEWAY_HASH_SECRET_NAME);
|
||||
let hash = fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"gateway credential missing at {} — call ensure_gateway_credential (or wait for the \
|
||||
next reconcile tick) to generate a per-install credential before starting the gateway",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let hash = hash.trim();
|
||||
if hash.is_empty() {
|
||||
anyhow::bail!("gateway credential {} is empty", path.display());
|
||||
}
|
||||
if KNOWN_DEFAULT_GATEWAY_HASHES.contains(&hash) {
|
||||
anyhow::bail!(
|
||||
"gateway credential {} is a publicly known default that shipped hardcoded in this \
|
||||
repository before FED-07 — this install must rotate it (see plan 01-16) before the \
|
||||
gateway can be (re)configured",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// Detect and rotate a Fedimint gateway credential that is a publicly known
|
||||
/// shipped default (FED-07 migration).
|
||||
///
|
||||
/// Returns `Ok(true)` only when the stored hash was an EXACT match for a
|
||||
/// [`KNOWN_DEFAULT_GATEWAY_HASHES`] entry and has been replaced with a freshly
|
||||
/// generated pair. An absent, unreadable, or simply unrecognised-but-unique
|
||||
/// value returns `Ok(false)` and writes nothing: rotation must never fire on
|
||||
/// "anything I did not generate this run", or an operator who deliberately set
|
||||
/// their own credential would have it silently replaced.
|
||||
///
|
||||
/// Generating a credential where none exists is
|
||||
/// [`ensure_gateway_credential`]'s job, not this function's.
|
||||
///
|
||||
/// **Rollback:** the replacement goes through [`write_secret`]'s atomic
|
||||
/// temp-file-plus-rename, so a failure part-way through leaves the previous
|
||||
/// credential file intact and the gateway keeps working with it. Do NOT
|
||||
/// "improve" this into a truncate-in-place or a remove-then-write — that turns
|
||||
/// a failed rotation into a gateway configured against a credential nobody
|
||||
/// holds.
|
||||
///
|
||||
/// **Self-terminating:** the value written is freshly generated and therefore
|
||||
/// not on the denylist, so the next reconcile tick detects nothing and changes
|
||||
/// nothing. Rotation happens at most once per affected node.
|
||||
pub fn rotate_compromised_gateway_credential(secrets_dir: &Path) -> Result<bool> {
|
||||
let path = secrets_dir.join(GATEWAY_HASH_SECRET_NAME);
|
||||
let Ok(current) = fs::read_to_string(&path) else {
|
||||
// Absent or unreadable: nothing to rotate. ensure_gateway_credential
|
||||
// owns materialising it.
|
||||
return Ok(false);
|
||||
};
|
||||
if !KNOWN_DEFAULT_GATEWAY_HASHES.contains(¤t.trim()) {
|
||||
return Ok(false);
|
||||
}
|
||||
write_bcrypt_pair(secrets_dir, GATEWAY_HASH_SECRET_NAME).with_context(|| {
|
||||
format!(
|
||||
"rotating compromised gateway credential at {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Write an externally computed secret value (0600, atomic). For derived
|
||||
/// secrets that aren't random generators — e.g. the btcpay internal-LND
|
||||
/// connection string assembled in `container::lnd`.
|
||||
@@ -356,195 +209,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_credential_fresh_generation_verifies_and_is_0600() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
ensure_gateway_credential(dir.path()).unwrap();
|
||||
|
||||
let hash = std::fs::read_to_string(dir.path().join(GATEWAY_HASH_SECRET_NAME)).unwrap();
|
||||
let pw = std::fs::read_to_string(dir.path().join(format!("{GATEWAY_HASH_SECRET_NAME}.pw")))
|
||||
.unwrap();
|
||||
assert!(bcrypt::verify(pw.trim(), hash.trim()).unwrap());
|
||||
|
||||
for f in [
|
||||
GATEWAY_HASH_SECRET_NAME.to_string(),
|
||||
format!("{GATEWAY_HASH_SECRET_NAME}.pw"),
|
||||
] {
|
||||
let mode = std::fs::metadata(dir.path().join(&f))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(mode, 0o600, "{f} must be 0600");
|
||||
}
|
||||
|
||||
let read_back = gateway_bcrypt_hash(dir.path()).unwrap();
|
||||
assert_eq!(read_back, hash.trim());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_credential_is_idempotent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
ensure_gateway_credential(dir.path()).unwrap();
|
||||
let first = gateway_bcrypt_hash(dir.path()).unwrap();
|
||||
ensure_gateway_credential(dir.path()).unwrap();
|
||||
let second = gateway_bcrypt_hash(dir.path()).unwrap();
|
||||
assert_eq!(first, second, "second call must not rotate the credential");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_credential_missing_is_a_named_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let err = gateway_bcrypt_hash(dir.path()).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains(GATEWAY_HASH_SECRET_NAME),
|
||||
"error must name the missing secret file: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_credential_rejects_known_default() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join(GATEWAY_HASH_SECRET_NAME),
|
||||
KNOWN_DEFAULT_GATEWAY_HASHES[0],
|
||||
)
|
||||
.unwrap();
|
||||
let err = gateway_bcrypt_hash(dir.path()).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().to_lowercase().contains("default"),
|
||||
"error must explain the denylisted value: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_credential_is_per_install_not_per_build() {
|
||||
let dir_a = tempfile::tempdir().unwrap();
|
||||
let dir_b = tempfile::tempdir().unwrap();
|
||||
ensure_gateway_credential(dir_a.path()).unwrap();
|
||||
ensure_gateway_credential(dir_b.path()).unwrap();
|
||||
let hash_a = gateway_bcrypt_hash(dir_a.path()).unwrap();
|
||||
let hash_b = gateway_bcrypt_hash(dir_b.path()).unwrap();
|
||||
assert_ne!(hash_a, hash_b, "two fresh installs must not share a hash");
|
||||
}
|
||||
|
||||
// ── FED-07 migration: rotating a shipped default off an existing node ──
|
||||
|
||||
#[test]
|
||||
fn rotates_a_denylisted_gateway_credential() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join(GATEWAY_HASH_SECRET_NAME),
|
||||
KNOWN_DEFAULT_GATEWAY_HASHES[0],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(rotate_compromised_gateway_credential(dir.path()).unwrap());
|
||||
|
||||
// The new value is readable through the normal accessor, which means
|
||||
// it is neither empty nor still denylisted.
|
||||
let rotated = gateway_bcrypt_hash(dir.path()).unwrap();
|
||||
assert!(!KNOWN_DEFAULT_GATEWAY_HASHES.contains(&rotated.as_str()));
|
||||
|
||||
// The plaintext sibling was written too and verifies against the hash,
|
||||
// so the operator can actually get back into the gateway.
|
||||
let pw = std::fs::read_to_string(dir.path().join(format!("{GATEWAY_HASH_SECRET_NAME}.pw")))
|
||||
.unwrap();
|
||||
assert!(bcrypt::verify(pw.trim(), rotated.trim()).unwrap());
|
||||
|
||||
for f in [
|
||||
GATEWAY_HASH_SECRET_NAME.to_string(),
|
||||
format!("{GATEWAY_HASH_SECRET_NAME}.pw"),
|
||||
] {
|
||||
let mode = std::fs::metadata(dir.path().join(&f))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(mode, 0o600, "{f} must stay 0600 after rotation");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_a_unique_gateway_credential_alone() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
ensure_gateway_credential(dir.path()).unwrap();
|
||||
let before = gateway_bcrypt_hash(dir.path()).unwrap();
|
||||
|
||||
assert!(!rotate_compromised_gateway_credential(dir.path()).unwrap());
|
||||
assert_eq!(before, gateway_bcrypt_hash(dir.path()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_an_unrecognised_credential_alone() {
|
||||
// The adjacency edge that matters: an operator's own hand-set value is
|
||||
// not on the denylist and must survive. Rotation is denylist-exact,
|
||||
// never "anything I did not generate".
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let operator_set = "$2y$10$operatorChosenValueThatWeMustNeverTouchAAAAAAAAAAAAAAAAAAAAA";
|
||||
std::fs::write(dir.path().join(GATEWAY_HASH_SECRET_NAME), operator_set).unwrap();
|
||||
|
||||
assert!(!rotate_compromised_gateway_credential(dir.path()).unwrap());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join(GATEWAY_HASH_SECRET_NAME)).unwrap(),
|
||||
operator_set
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_op_when_no_gateway_credential_exists() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(!rotate_compromised_gateway_credential(dir.path()).unwrap());
|
||||
assert!(!dir.path().join(GATEWAY_HASH_SECRET_NAME).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_is_idempotent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join(GATEWAY_HASH_SECRET_NAME),
|
||||
KNOWN_DEFAULT_GATEWAY_HASHES[0],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(rotate_compromised_gateway_credential(dir.path()).unwrap());
|
||||
let after_first = gateway_bcrypt_hash(dir.path()).unwrap();
|
||||
|
||||
// Second tick: nothing detected, nothing changed. This is what stops a
|
||||
// reconcile loop from recreating the gateway on every pass.
|
||||
assert!(!rotate_compromised_gateway_credential(dir.path()).unwrap());
|
||||
assert_eq!(after_first, gateway_bcrypt_hash(dir.path()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_touches_no_other_secret() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join(GATEWAY_HASH_SECRET_NAME),
|
||||
KNOWN_DEFAULT_GATEWAY_HASHES[0],
|
||||
)
|
||||
.unwrap();
|
||||
let bystanders = [
|
||||
("mempool-db-password", "mempool-value"),
|
||||
("immich-db-password", "immich-value"),
|
||||
("fmcd-password", "fmcd-value"),
|
||||
("bitcoin-rpc-password", "bitcoin-value"),
|
||||
];
|
||||
for (name, value) in bystanders {
|
||||
std::fs::write(dir.path().join(name), value).unwrap();
|
||||
}
|
||||
|
||||
assert!(rotate_compromised_gateway_credential(dir.path()).unwrap());
|
||||
|
||||
for (name, value) in bystanders {
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join(name)).unwrap(),
|
||||
value,
|
||||
"{name} must be byte-identical after a gateway rotation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_heals_unreadable_secret() {
|
||||
// Simulate the root-owned case: a present-but-unreadable file. We can't
|
||||
|
||||
@@ -18,17 +18,6 @@ fn store_path(data_dir: &Path) -> std::path::PathBuf {
|
||||
data_dir.join(CREDENTIALS_DIR).join("credentials.json")
|
||||
}
|
||||
|
||||
/// Magic prefix marking an encrypted credential store written by this version
|
||||
/// onward.
|
||||
///
|
||||
/// Historically the on-disk format was detected by sniffing the first byte for
|
||||
/// `[`/`{`. Encrypted blobs begin with a *random* 12-byte nonce, so roughly 2 in
|
||||
/// 256 (~1 in 128) saves produced a valid encrypted file whose first byte was
|
||||
/// `[` (0x5B) or `{` (0x7B); those files were misread as plaintext JSON and
|
||||
/// failed to load forever after. A fixed multi-byte marker cannot collide with a
|
||||
/// random nonce, so detection is now exact rather than probabilistic.
|
||||
const ENCRYPTED_MAGIC: &[u8] = b"ARCHYCRED1";
|
||||
|
||||
pub async fn load_credentials(data_dir: &Path) -> Result<CredentialStore> {
|
||||
ensure_dir(data_dir).await?;
|
||||
let path = store_path(data_dir);
|
||||
@@ -36,68 +25,25 @@ pub async fn load_credentials(data_dir: &Path) -> Result<CredentialStore> {
|
||||
return Ok(CredentialStore::default());
|
||||
}
|
||||
let raw = fs::read(&path).await.context("Reading credentials")?;
|
||||
decode_credentials(data_dir, &raw).await
|
||||
}
|
||||
|
||||
/// Decode any of the three on-disk credential formats that exist in the fleet.
|
||||
///
|
||||
/// Detection order, and why each step is unambiguous:
|
||||
///
|
||||
/// 1. **Current format** — `MAGIC ‖ nonce ‖ ciphertext`. The magic is a fixed
|
||||
/// 10-byte literal, so this test has no false positives and no false
|
||||
/// negatives.
|
||||
/// 2. **Legacy encrypted** (no magic) — `nonce ‖ ciphertext`. Detected by
|
||||
/// *successful AEAD decryption*, not by byte shape. ChaCha20-Poly1305 is
|
||||
/// authenticated: a successful `decrypt` means the 16-byte Poly1305 tag
|
||||
/// verified under the node key, which a non-ciphertext file passes only with
|
||||
/// probability ~2^-128. This is a cryptographic discriminator, strictly
|
||||
/// stronger than any structural sniff.
|
||||
/// 3. **Legacy plaintext JSON** (original migration path) — reached only when
|
||||
/// the bytes did not authenticate, then parsed strictly as a whole document.
|
||||
///
|
||||
/// If none match we return an error rather than a default store, so a
|
||||
/// transiently unreadable file is never silently replaced with empty
|
||||
/// credentials on the next save (CLAUDE.md: migrations never destroy data).
|
||||
async fn decode_credentials(data_dir: &Path, raw: &[u8]) -> Result<CredentialStore> {
|
||||
// 1. Current format: explicit marker.
|
||||
if let Some(body) = raw.strip_prefix(ENCRYPTED_MAGIC) {
|
||||
let key = load_encryption_key(data_dir).await?;
|
||||
let plaintext = decrypt_credentials(body, &key)?;
|
||||
return serde_json::from_slice(&plaintext).context("Parsing decrypted credentials");
|
||||
// Detect plaintext JSON (migration path) vs encrypted binary
|
||||
if raw.first().is_some_and(|b| *b == b'[' || *b == b'{') {
|
||||
let data = String::from_utf8(raw).context("UTF-8 credentials")?;
|
||||
return serde_json::from_str(&data).context("Parsing credentials");
|
||||
}
|
||||
|
||||
// 2. Legacy encrypted, unmarked. The node key may legitimately be absent on
|
||||
// a node that only ever wrote plaintext, so a key-load failure falls
|
||||
// through to the plaintext path instead of aborting.
|
||||
if let Ok(key) = load_encryption_key(data_dir).await {
|
||||
if let Ok(plaintext) = decrypt_credentials(raw, &key) {
|
||||
return serde_json::from_slice(&plaintext)
|
||||
.context("Parsing decrypted credentials (legacy unmarked)");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Legacy plaintext JSON migration path.
|
||||
serde_json::from_slice(raw).context(
|
||||
"Credentials file is not magic-prefixed encrypted data, does not authenticate \
|
||||
as a legacy encrypted blob, and is not valid plaintext JSON — refusing to \
|
||||
treat it as empty",
|
||||
)
|
||||
// Encrypted: decrypt using node key
|
||||
let key = load_encryption_key(data_dir).await?;
|
||||
let plaintext = decrypt_credentials(&raw, &key)?;
|
||||
serde_json::from_slice(&plaintext).context("Parsing decrypted credentials")
|
||||
}
|
||||
|
||||
pub async fn save_credentials(data_dir: &Path, store: &CredentialStore) -> Result<()> {
|
||||
ensure_dir(data_dir).await?;
|
||||
let path = store_path(data_dir);
|
||||
let data = serde_json::to_vec(store)?;
|
||||
// Encrypt using node key. Always written in the current, magic-prefixed
|
||||
// format — this is how legacy files are opportunistically upgraded: they are
|
||||
// read in whatever format they are on disk, and the next save re-emits them
|
||||
// marked. Nothing is ever rewritten from a read path.
|
||||
// Encrypt using node key
|
||||
let key = load_encryption_key(data_dir).await?;
|
||||
let encrypted = encrypt_credentials(&data, &key)?;
|
||||
let mut output = Vec::with_capacity(ENCRYPTED_MAGIC.len() + encrypted.len());
|
||||
output.extend_from_slice(ENCRYPTED_MAGIC);
|
||||
output.extend_from_slice(&encrypted);
|
||||
fs::write(&path, output)
|
||||
fs::write(&path, encrypted)
|
||||
.await
|
||||
.context("Writing credentials")
|
||||
}
|
||||
@@ -119,29 +65,8 @@ async fn load_encryption_key(data_dir: &Path) -> Result<[u8; 32]> {
|
||||
}
|
||||
|
||||
fn encrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
// KEY-05: the nonce names `OsRng` and is inspected before use. Nonce reuse
|
||||
// under ChaCha20-Poly1305 recovers the keystream and forges the Poly1305 tag,
|
||||
// so this draw is guarded even though it is exactly at `MIN_GUARDED_LEN`.
|
||||
// The deterministic-nonce seam below is untouched — only the *source* of the
|
||||
// random nonce changed.
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut nonce_bytes)
|
||||
.map_err(|e| anyhow::anyhow!("Refusing to encrypt with degenerate nonce entropy: {}", e))?;
|
||||
encrypt_credentials_with_nonce(data, key, nonce_bytes)
|
||||
}
|
||||
|
||||
/// Encrypt with a caller-supplied nonce, returning `nonce ‖ ciphertext` (no
|
||||
/// magic prefix — `save_credentials` adds that).
|
||||
///
|
||||
/// Split out from [`encrypt_credentials`] so tests can construct a blob whose
|
||||
/// first byte is a specific value and exercise format detection deterministically
|
||||
/// instead of waiting on a 1-in-128 random draw.
|
||||
fn encrypt_credentials_with_nonce(
|
||||
data: &[u8],
|
||||
key: &[u8; 32],
|
||||
nonce_bytes: [u8; 12],
|
||||
) -> Result<Vec<u8>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
let nonce_bytes: [u8; 12] = rand::random();
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
||||
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
|
||||
let ciphertext = cipher
|
||||
@@ -175,48 +100,8 @@ fn decrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::types::{CredentialProof, CredentialSubject, VerifiableCredential};
|
||||
use super::*;
|
||||
|
||||
/// Tempdir with a deterministic `identity/node_key` so the encryption key
|
||||
/// can be derived. Never a real key.
|
||||
fn test_dir_with_node_key() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let identity_dir = dir.path().join("identity");
|
||||
std::fs::create_dir_all(&identity_dir).unwrap();
|
||||
std::fs::write(identity_dir.join("node_key"), [0xAB; 32]).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join(CREDENTIALS_DIR)).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn sample_store(marker: &str) -> CredentialStore {
|
||||
CredentialStore {
|
||||
credentials: vec![VerifiableCredential {
|
||||
context: vec!["https://www.w3.org/ns/credentials/v2".to_string()],
|
||||
id: format!("urn:uuid:{marker}"),
|
||||
credential_type: vec![
|
||||
"VerifiableCredential".to_string(),
|
||||
"NodeOperator".to_string(),
|
||||
],
|
||||
issuer: "did:key:issuer".to_string(),
|
||||
credential_subject: CredentialSubject {
|
||||
id: "did:key:subject".to_string(),
|
||||
claims: serde_json::json!({"role": "admin"}),
|
||||
},
|
||||
issuance_date: "2026-01-01T00:00:00Z".to_string(),
|
||||
expiration_date: None,
|
||||
proof: CredentialProof {
|
||||
proof_type: "Ed25519Signature2020".to_string(),
|
||||
created: "2026-01-01T00:00:00Z".to_string(),
|
||||
verification_method: "did:key:issuer#key-1".to_string(),
|
||||
proof_purpose: "assertionMethod".to_string(),
|
||||
proof_value: "sig".to_string(),
|
||||
},
|
||||
credential_status: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_credentials_returns_empty_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -224,194 +109,4 @@ mod tests {
|
||||
assert!(store.credentials.is_empty());
|
||||
assert!(dir.path().join(CREDENTIALS_DIR).exists());
|
||||
}
|
||||
|
||||
/// Regression: an encrypted blob whose random nonce happens to start with
|
||||
/// `[` (0x5B) or `{` (0x7B) used to be misdetected as plaintext JSON, so
|
||||
/// `String::from_utf8` failed and the store became permanently unreadable.
|
||||
/// ~1 in 128 saves hit this. Both colliding bytes are exercised here
|
||||
/// deterministically via an explicit nonce.
|
||||
#[tokio::test]
|
||||
async fn test_legacy_encrypted_blob_with_json_first_byte_still_loads() {
|
||||
for (first_byte, label) in [(0x5Bu8, "open-bracket"), (0x7Bu8, "open-brace")] {
|
||||
let dir = test_dir_with_node_key();
|
||||
let key = load_encryption_key(dir.path()).await.unwrap();
|
||||
|
||||
// Force the collision: nonce[0] is exactly the byte the old sniffer
|
||||
// treated as "this file is plaintext JSON".
|
||||
let mut nonce = [0u8; 12];
|
||||
nonce[0] = first_byte;
|
||||
let plaintext = serde_json::to_vec(&sample_store(label)).unwrap();
|
||||
let blob = encrypt_credentials_with_nonce(&plaintext, &key, nonce).unwrap();
|
||||
assert_eq!(
|
||||
blob[0], first_byte,
|
||||
"test must actually trigger the collision"
|
||||
);
|
||||
|
||||
// Written WITHOUT magic: this is the legacy on-disk population.
|
||||
std::fs::write(store_path(dir.path()), &blob).unwrap();
|
||||
|
||||
let loaded = load_credentials(dir.path())
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("{label}: colliding blob failed to load: {e:#}"));
|
||||
assert_eq!(loaded.credentials.len(), 1, "{label}");
|
||||
assert_eq!(loaded.credentials[0].id, format!("urn:uuid:{label}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Same collision, but in the current magic-prefixed format.
|
||||
#[tokio::test]
|
||||
async fn test_new_format_with_colliding_nonce_loads() {
|
||||
for first_byte in [0x5Bu8, 0x7Bu8] {
|
||||
let dir = test_dir_with_node_key();
|
||||
let key = load_encryption_key(dir.path()).await.unwrap();
|
||||
let mut nonce = [0u8; 12];
|
||||
nonce[0] = first_byte;
|
||||
let plaintext = serde_json::to_vec(&sample_store("magic")).unwrap();
|
||||
let body = encrypt_credentials_with_nonce(&plaintext, &key, nonce).unwrap();
|
||||
let mut blob = ENCRYPTED_MAGIC.to_vec();
|
||||
blob.extend_from_slice(&body);
|
||||
std::fs::write(store_path(dir.path()), &blob).unwrap();
|
||||
|
||||
let loaded = load_credentials(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.credentials[0].id, "urn:uuid:magic");
|
||||
}
|
||||
}
|
||||
|
||||
/// Population 1: legacy plaintext JSON from the original migration path.
|
||||
#[tokio::test]
|
||||
async fn test_legacy_plaintext_json_still_loads() {
|
||||
let dir = test_dir_with_node_key();
|
||||
let json = serde_json::to_vec(&sample_store("plaintext")).unwrap();
|
||||
assert_eq!(json[0], b'{');
|
||||
std::fs::write(store_path(dir.path()), &json).unwrap();
|
||||
|
||||
let loaded = load_credentials(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.credentials[0].id, "urn:uuid:plaintext");
|
||||
}
|
||||
|
||||
/// Legacy plaintext must still load on a node that has no node key at all
|
||||
/// (pre-onboarding), where the encrypted path cannot even derive a key.
|
||||
#[tokio::test]
|
||||
async fn test_legacy_plaintext_json_loads_without_node_key() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join(CREDENTIALS_DIR)).unwrap();
|
||||
let json = serde_json::to_vec(&sample_store("nokey")).unwrap();
|
||||
std::fs::write(store_path(dir.path()), &json).unwrap();
|
||||
|
||||
let loaded = load_credentials(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.credentials[0].id, "urn:uuid:nokey");
|
||||
}
|
||||
|
||||
/// Population 2: legacy encrypted with an ordinary (non-colliding) nonce.
|
||||
#[tokio::test]
|
||||
async fn test_legacy_encrypted_without_magic_still_loads() {
|
||||
let dir = test_dir_with_node_key();
|
||||
let key = load_encryption_key(dir.path()).await.unwrap();
|
||||
let plaintext = serde_json::to_vec(&sample_store("legacy-enc")).unwrap();
|
||||
let blob = encrypt_credentials_with_nonce(&plaintext, &key, [0x01; 12]).unwrap();
|
||||
std::fs::write(store_path(dir.path()), &blob).unwrap();
|
||||
|
||||
let loaded = load_credentials(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.credentials[0].id, "urn:uuid:legacy-enc");
|
||||
}
|
||||
|
||||
/// Population 3: current format round-trips and is actually marked on disk.
|
||||
#[tokio::test]
|
||||
async fn test_new_format_roundtrip_and_is_magic_prefixed() {
|
||||
let dir = test_dir_with_node_key();
|
||||
save_credentials(dir.path(), &sample_store("current"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let on_disk = std::fs::read(store_path(dir.path())).unwrap();
|
||||
assert!(
|
||||
on_disk.starts_with(ENCRYPTED_MAGIC),
|
||||
"save must mark the file"
|
||||
);
|
||||
// Still genuinely encrypted, not plaintext.
|
||||
assert!(!on_disk.windows(4).any(|w| w == b"did:"));
|
||||
|
||||
let loaded = load_credentials(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.credentials[0].id, "urn:uuid:current");
|
||||
}
|
||||
|
||||
/// Opportunistic upgrade happens on write, never on read: reading a legacy
|
||||
/// file must leave it byte-identical; the next save re-emits it marked.
|
||||
#[tokio::test]
|
||||
async fn test_legacy_file_upgraded_on_write_not_on_read() {
|
||||
let dir = test_dir_with_node_key();
|
||||
let json = serde_json::to_vec(&sample_store("upgrade")).unwrap();
|
||||
std::fs::write(store_path(dir.path()), &json).unwrap();
|
||||
|
||||
let loaded = load_credentials(dir.path()).await.unwrap();
|
||||
// Read path must not have rewritten anything.
|
||||
let after_read = std::fs::read(store_path(dir.path())).unwrap();
|
||||
assert_eq!(after_read, json, "read path must not rewrite the file");
|
||||
|
||||
save_credentials(dir.path(), &loaded).await.unwrap();
|
||||
let after_write = std::fs::read(store_path(dir.path())).unwrap();
|
||||
assert!(after_write.starts_with(ENCRYPTED_MAGIC));
|
||||
|
||||
let reloaded = load_credentials(dir.path()).await.unwrap();
|
||||
assert_eq!(reloaded.credentials[0].id, "urn:uuid:upgrade");
|
||||
}
|
||||
|
||||
/// An unreadable file must surface an error, never a silent empty store —
|
||||
/// otherwise the next save would overwrite recoverable user data.
|
||||
#[tokio::test]
|
||||
async fn test_undecodable_file_errors_instead_of_returning_empty() {
|
||||
let dir = test_dir_with_node_key();
|
||||
std::fs::write(store_path(dir.path()), b"\x00\x01\x02 not json, not ours").unwrap();
|
||||
assert!(load_credentials(dir.path()).await.is_err());
|
||||
}
|
||||
|
||||
/// KEY-05 regression: a credential blob written before the entropy migration
|
||||
/// must still open after it.
|
||||
///
|
||||
/// **Hardcoded on purpose.** Every other test in this module seals and opens
|
||||
/// in the same process, which passes even if the envelope layout changed,
|
||||
/// because both halves changed together. This one pins the on-disk format
|
||||
/// `MAGIC ‖ nonce ‖ ciphertext ‖ tag` against bytes this crate did not
|
||||
/// produce: they come from an independent RFC 8439 ChaCha20-Poly1305
|
||||
/// implementation, validated first against the RFC's own §2.8.2 vector.
|
||||
///
|
||||
/// Key derivation pinned too: `SHA-256("archipelago-credential-store-v1" ‖
|
||||
/// [0xAB; 32])`, i.e. the key `test_dir_with_node_key` produces. A change to
|
||||
/// the domain separator or the derivation would fail this test, which is the
|
||||
/// point — that would strand every credential store in the fleet.
|
||||
#[tokio::test]
|
||||
async fn opens_pre_migration_ciphertext_vector() {
|
||||
const VECTOR: [u8; 56] = [
|
||||
0x41, 0x52, 0x43, 0x48, 0x59, 0x43, 0x52, 0x45, 0x44, 0x31, 0x07, 0x07, 0x07, 0x07,
|
||||
0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x75, 0x51, 0xf7, 0x32, 0x46, 0x8c,
|
||||
0xeb, 0x45, 0x1d, 0xe4, 0x20, 0x8f, 0x02, 0xaf, 0x56, 0xfe, 0x70, 0x8d, 0xc8, 0xf8,
|
||||
0x7e, 0xf2, 0xdb, 0xa2, 0x53, 0x23, 0xdb, 0x20, 0xfe, 0x15, 0x5f, 0x8e, 0x48, 0x95,
|
||||
];
|
||||
let dir = test_dir_with_node_key();
|
||||
std::fs::write(store_path(dir.path()), VECTOR).unwrap();
|
||||
|
||||
let loaded = load_credentials(dir.path())
|
||||
.await
|
||||
.expect("pre-migration credential blob must still decrypt");
|
||||
assert!(loaded.credentials.is_empty());
|
||||
|
||||
// And the vector really is the marked format, not something that fell
|
||||
// through to the plaintext path.
|
||||
assert!(VECTOR.starts_with(ENCRYPTED_MAGIC));
|
||||
}
|
||||
|
||||
/// A magic-prefixed file that fails authentication (tampered / wrong key)
|
||||
/// must error rather than fall through to another format.
|
||||
#[tokio::test]
|
||||
async fn test_tampered_magic_file_errors() {
|
||||
let dir = test_dir_with_node_key();
|
||||
save_credentials(dir.path(), &sample_store("tamper"))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut blob = std::fs::read(store_path(dir.path())).unwrap();
|
||||
*blob.last_mut().unwrap() ^= 0x01;
|
||||
std::fs::write(store_path(dir.path()), &blob).unwrap();
|
||||
|
||||
assert!(load_credentials(dir.path()).await.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,12 +61,7 @@ fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
/// replaced, so re-showing the pairing QR never piles up stale entries.
|
||||
/// Returns the plaintext token — the only time it ever exists outside the QR.
|
||||
pub async fn create(data_dir: &Path, name: &str) -> Result<String> {
|
||||
// KEY-05: a device token is a bearer credential — its unpredictability is
|
||||
// the whole of its security — so the source is named and the draw guarded.
|
||||
let mut token_bytes = [0u8; 32];
|
||||
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut token_bytes).map_err(|e| {
|
||||
anyhow::anyhow!("Refusing to mint a device token from degenerate entropy: {e}")
|
||||
})?;
|
||||
let token_bytes: [u8; 32] = rand::random();
|
||||
let token = hex::encode(token_bytes);
|
||||
|
||||
let mut tokens = load(data_dir).await;
|
||||
|
||||
@@ -1,671 +0,0 @@
|
||||
//! Entropy policy for key generation — the KEY-05 mechanism module.
|
||||
//!
|
||||
//! Three independent controls live here, each closing a different half of the
|
||||
//! same structural defect recorded as **F-10a** in
|
||||
//! `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` and classified per-site in
|
||||
//! `docs/security/KEY-05-ENTROPY-ENFORCEMENT.md`:
|
||||
//!
|
||||
//! - **The sealed allowlist** ([`KeyGenRng`], layer *a*). A key-generation seam
|
||||
//! typed `R: KeyGenRng` can only be driven by a type this module blessed. The
|
||||
//! marker's supertrait lives in a private module, so membership is unnameable
|
||||
//! — and therefore unaddable — from any other module of this crate, and from
|
||||
//! any downstream crate were this binary ever split into a library.
|
||||
//! - **The degenerate-entropy predicate** ([`is_degenerate`], [`draw_key_bytes`],
|
||||
//! layer *d*). Key material and AEAD nonces are inspected before they are
|
||||
//! used, and a draw that is all-zero, all-identical or a wrapping ±1 counter
|
||||
//! is refused outright rather than retried.
|
||||
//! - **The CSPRNG-readiness ledger** ([`record_csprng_readiness`], layer *e*).
|
||||
//! `seed::kernel_csprng_ready()` already computes whether the kernel pool was
|
||||
//! initialised at generation time; before this module that verdict was logged
|
||||
//! and discarded. It is now durable, so a node can answer the question after
|
||||
//! the fact.
|
||||
//!
|
||||
//! **Nothing here fixes a present defect.** `rand 0.8.5`'s `thread_rng()` is a
|
||||
//! fork-protected ChaCha12 CSPRNG seeded from `getrandom(2)`; every key this
|
||||
//! fleet has ever generated came from a genuine CSPRNG. What these controls
|
||||
//! remove is the *future* failure mode in which a dependency bump, feature-flag
|
||||
//! change or refactor rebinds the entropy backend with no compile error, no test
|
||||
//! failure and no diff in Archipelago's own source — the shape ("T1") that
|
||||
//! produced the 2026-07-30 COLDCARD entropy defect.
|
||||
|
||||
use rand::RngCore;
|
||||
use std::path::PathBuf;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
// ─── Layer (a): the sealed key-generation RNG allowlist ─────────────────
|
||||
|
||||
/// Private supertrait module. This is the whole sealing mechanism: `Sealed` is
|
||||
/// nameable only from inside `entropy`, so `impl KeyGenRng for MyType` cannot
|
||||
/// compile anywhere else — the required `Sealed` bound is unsatisfiable and
|
||||
/// unimplementable outside this file.
|
||||
mod sealed {
|
||||
pub trait Sealed {}
|
||||
}
|
||||
|
||||
/// The allowlist of RNGs permitted to drive key generation.
|
||||
///
|
||||
/// Deliberately **without** a `rand::CryptoRng` supertrait. `CryptoRng` is a
|
||||
/// marker with no compiler-checked content — implementing it is a promise, and
|
||||
/// a promise a caller can make about their own type is not a control. Sealed
|
||||
/// membership is checkable: the compiler enforces that the set of members is
|
||||
/// exactly the set written in this file. After KEY-05 the crate contains zero
|
||||
/// `impl rand::CryptoRng` blocks, so there is one mechanism for this claim
|
||||
/// rather than two, and the one that remains is the one the compiler verifies.
|
||||
pub(crate) trait KeyGenRng: RngCore + sealed::Sealed {
|
||||
/// Whether draws from this source are subject to [`is_degenerate`].
|
||||
///
|
||||
/// `true` for every member that exists in a production build, and not
|
||||
/// overridable outside this module because the trait is sealed.
|
||||
///
|
||||
/// The single `#[cfg(test)]` member sets it `false`, and that is not a
|
||||
/// weakening of the guard — it is what makes the guard compatible with the
|
||||
/// crate's strongest existing proof. [`testing::CountingRng`] exists to emit
|
||||
/// the published test vector `0x00, 0x01, … 0x1f`, which is *by
|
||||
/// construction* exactly the ascending-counter pattern the predicate
|
||||
/// rejects. `seed.rs`'s `mnemonic_generation_uses_injected_rng` pins the
|
||||
/// 24-word mnemonic that vector produces, and that known-answer pin is the
|
||||
/// only evidence the crate has that the RNG named at the call site is the
|
||||
/// one `bip39` actually consumes. Guarding the counter would make that pin
|
||||
/// unrepresentable and delete the proof to satisfy the guard.
|
||||
///
|
||||
/// The opt-out cannot reach a shipped binary: the only implementor that
|
||||
/// sets it `false` is itself `#[cfg(test)]`-gated and is not compiled into
|
||||
/// the `archipelago` binary at all.
|
||||
const GUARD_DRAWS: bool = true;
|
||||
}
|
||||
|
||||
impl sealed::Sealed for rand::rngs::OsRng {}
|
||||
|
||||
/// The sole production member. `OsRng` is a direct `getrandom(2)` wrapper with
|
||||
/// no userspace state, no reseeding schedule and no fork hazard — the thing a
|
||||
/// defaulted `thread_rng()` happens to be backed by today, named explicitly so
|
||||
/// that it cannot stop being so silently.
|
||||
impl KeyGenRng for rand::rngs::OsRng {}
|
||||
|
||||
// ─── Layer (d): the degenerate-entropy predicate ────────────────────────
|
||||
|
||||
/// The shortest draw the predicate is allowed to inspect.
|
||||
///
|
||||
/// Below twelve bytes the false-positive argument in
|
||||
/// `docs/security/KEY-05-ENTROPY-ENFORCEMENT.md` does not hold: on a two-byte
|
||||
/// draw, `AllIdentical` fires once in 256 on genuine CSPRNG output, which would
|
||||
/// be a far worse defect than the one being guarded. Twelve is also exactly the
|
||||
/// ChaCha20-Poly1305 nonce width, so every AEAD nonce in the crate is guardable
|
||||
/// at the floor rather than above it.
|
||||
pub(crate) const MIN_GUARDED_LEN: usize = 12;
|
||||
|
||||
/// The three — and only three — patterns [`is_degenerate`] recognises.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum DegenerateEntropy {
|
||||
/// Every byte is `0x00`.
|
||||
AllZero,
|
||||
/// Every byte equals the first byte (and the first byte is not `0x00`,
|
||||
/// which would be reported as the more specific [`Self::AllZero`]).
|
||||
AllIdentical,
|
||||
/// Every adjacent pair differs by a wrapping +1, or every adjacent pair by
|
||||
/// a wrapping −1.
|
||||
Counter,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DegenerateEntropy {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
Self::AllZero => "all bytes zero",
|
||||
Self::AllIdentical => "all bytes identical",
|
||||
Self::Counter => "wrapping ±1 counter",
|
||||
};
|
||||
f.write_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DegenerateEntropy {}
|
||||
|
||||
/// Is this buffer one of the three exactly-analysable degenerate shapes?
|
||||
///
|
||||
/// **Nothing heuristic.** No entropy estimator, no chi-squared, no
|
||||
/// "looks non-random" scoring. A predicate whose false-positive rate cannot be
|
||||
/// computed in closed form cannot be argued safe, and refusing genuine CSPRNG
|
||||
/// output on a key-generation path is strictly worse than the defect being
|
||||
/// guarded against. These three cases are what a rebound-to-broken RNG actually
|
||||
/// emits (a zeroed buffer, an uninitialised constant fill, a counter PRNG); each
|
||||
/// has a false-positive probability computable exactly for any length.
|
||||
pub(crate) fn is_degenerate(bytes: &[u8]) -> Option<DegenerateEntropy> {
|
||||
if bytes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if bytes.iter().all(|b| *b == 0) {
|
||||
return Some(DegenerateEntropy::AllZero);
|
||||
}
|
||||
|
||||
// Checked after AllZero so the reported variant is always the more specific
|
||||
// one, even though AllZero is a strict subset of AllIdentical.
|
||||
if bytes.iter().all(|b| *b == bytes[0]) {
|
||||
return Some(DegenerateEntropy::AllIdentical);
|
||||
}
|
||||
|
||||
// A single byte cannot form a counter; `windows(2)` is empty and `all`
|
||||
// would vacuously succeed, so guard the length explicitly.
|
||||
if bytes.len() >= 2 {
|
||||
let ascending = bytes.windows(2).all(|w| w[1] == w[0].wrapping_add(1));
|
||||
let descending = bytes.windows(2).all(|w| w[1] == w[0].wrapping_sub(1));
|
||||
if ascending || descending {
|
||||
return Some(DegenerateEntropy::Counter);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Fill `out` with key material from an allowlisted RNG, refusing a degenerate
|
||||
/// draw.
|
||||
///
|
||||
/// On a trip the buffer is **zeroized**, the variant and the buffer length are
|
||||
/// logged, and the error is returned. There is deliberately **no retry**: a
|
||||
/// retry would paper over a genuinely broken RNG, which is precisely the failure
|
||||
/// this layer exists to surface. The bytes themselves are never logged.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If `out.len() < MIN_GUARDED_LEN`. Calling the guard on a buffer too short for
|
||||
/// its false-positive argument to hold is a programmer error, not an input
|
||||
/// condition — a caller that legitimately needs fewer bytes must draw from
|
||||
/// `OsRng` directly and unguarded, and say so.
|
||||
pub(crate) fn draw_key_bytes<R: KeyGenRng>(
|
||||
rng: &mut R,
|
||||
out: &mut [u8],
|
||||
) -> Result<(), DegenerateEntropy> {
|
||||
assert!(
|
||||
out.len() >= MIN_GUARDED_LEN,
|
||||
"draw_key_bytes called on a {}-byte buffer; the degenerate-entropy \
|
||||
predicate's false-positive bound only holds at {} bytes or more — draw \
|
||||
unguarded from OsRng instead (KEY-05)",
|
||||
out.len(),
|
||||
MIN_GUARDED_LEN
|
||||
);
|
||||
|
||||
rng.fill_bytes(out);
|
||||
|
||||
if !R::GUARD_DRAWS {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(kind) = is_degenerate(out) {
|
||||
out.zeroize();
|
||||
tracing::error!(
|
||||
"refusing degenerate entropy draw: {} over {} bytes — the RNG backing \
|
||||
this call site is not producing usable key material (KEY-05 layer d)",
|
||||
kind,
|
||||
out.len()
|
||||
);
|
||||
return Err(kind);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Layer (e): the CSPRNG-readiness ledger ─────────────────────────────
|
||||
|
||||
/// Schema version, so a later change does not orphan lines already written on
|
||||
/// fleet nodes.
|
||||
const READINESS_SCHEMA_VERSION: u8 = 1;
|
||||
|
||||
/// One ledger line. A struct rather than `serde_json::json!` so the field order
|
||||
/// on disk is the declared order and the schema is a compile-time object rather
|
||||
/// than a literal that can drift.
|
||||
///
|
||||
/// These four fields are the whole record. There is no field for entropy, key
|
||||
/// bytes, seed material, mnemonic words or a hash of any of them — a readiness
|
||||
/// ledger that carried any of those would be a new place to steal a key from,
|
||||
/// sitting next to the identity directory.
|
||||
#[derive(serde::Serialize)]
|
||||
struct ReadinessRecord<'a> {
|
||||
v: u8,
|
||||
ts: String,
|
||||
ready: Option<bool>,
|
||||
event: &'a str,
|
||||
}
|
||||
|
||||
/// Where the ledger lives.
|
||||
///
|
||||
/// Resolved from `ARCHIPELAGO_DATA_DIR` with the `/var/lib/archipelago`
|
||||
/// fallback, matching `container/version_config.rs:36-39`, so this module needs
|
||||
/// no wiring through `bootstrap.rs` or a system handler to know its own path.
|
||||
///
|
||||
/// Deliberately **outside** `identity/`: the KEY-02 rootfs identity sweep and
|
||||
/// `backup.restore-identity` both operate on that directory wholesale, and
|
||||
/// neither should ever have to reason about a file that is not key material.
|
||||
fn readiness_ledger_path() -> PathBuf {
|
||||
let base = std::env::var("ARCHIPELAGO_DATA_DIR")
|
||||
.unwrap_or_else(|_| "/var/lib/archipelago".to_string());
|
||||
PathBuf::from(base)
|
||||
.join("security")
|
||||
.join("csprng-readiness.jsonl")
|
||||
}
|
||||
|
||||
/// Append one readiness verdict to the ledger. Best-effort by design.
|
||||
///
|
||||
/// Every failure path warns and returns. `ceremony.rs` generates a master seed
|
||||
/// **offline**, on a machine that need not have `/var/lib/archipelago` at all;
|
||||
/// a ledger write that could fail key generation would be a availability defect
|
||||
/// introduced by an audit feature, which is not a trade this is willing to make.
|
||||
///
|
||||
/// The file is created `0600` (matching `seed.rs`'s identity-blob pattern) and
|
||||
/// only ever appended to, so a node accumulates its history rather than
|
||||
/// overwriting it.
|
||||
pub(crate) fn record_csprng_readiness(ready: Option<bool>, event: &str) {
|
||||
let path = readiness_ledger_path();
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
tracing::warn!(
|
||||
"CSPRNG readiness ledger: cannot create {}: {e} — verdict not recorded",
|
||||
parent.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let record = ReadinessRecord {
|
||||
v: READINESS_SCHEMA_VERSION,
|
||||
ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
|
||||
ready,
|
||||
event,
|
||||
};
|
||||
let line = match serde_json::to_string(&record) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!("CSPRNG readiness ledger: serialisation failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.create(true).append(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
|
||||
match opts.open(&path) {
|
||||
Ok(mut f) => {
|
||||
use std::io::Write;
|
||||
if let Err(e) = writeln!(f, "{line}") {
|
||||
tracing::warn!(
|
||||
"CSPRNG readiness ledger: write to {} failed: {e}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
"CSPRNG readiness ledger: cannot open {}: {e} — verdict not recorded",
|
||||
path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test-only allowlist members ────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod testing {
|
||||
use super::{sealed, KeyGenRng};
|
||||
|
||||
/// Deterministic test-only RNG emitting `0x00, 0x01, 0x02, …`.
|
||||
///
|
||||
/// Relocated verbatim from `seed.rs` (the wrapping-add-1 `fill_bytes` and
|
||||
/// therefore the emitted byte sequence are unchanged, so the known-answer
|
||||
/// mnemonic it produces is unchanged). What did **not** move is
|
||||
/// `impl rand::CryptoRng for CountingRng`: that marker was a false promise —
|
||||
/// a counter is not a cryptographic source — and KEY-05 retires it rather
|
||||
/// than relocating it. Sealed membership replaces it, and unlike a marker it
|
||||
/// is a closed set the compiler enforces.
|
||||
pub(crate) struct CountingRng(pub u8);
|
||||
|
||||
impl rand::RngCore for CountingRng {
|
||||
fn next_u32(&mut self) -> u32 {
|
||||
let mut b = [0u8; 4];
|
||||
self.fill_bytes(&mut b);
|
||||
u32::from_le_bytes(b)
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
let mut b = [0u8; 8];
|
||||
self.fill_bytes(&mut b);
|
||||
u64::from_le_bytes(b)
|
||||
}
|
||||
|
||||
fn fill_bytes(&mut self, dest: &mut [u8]) {
|
||||
for byte in dest.iter_mut() {
|
||||
*byte = self.0;
|
||||
self.0 = self.0.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn try_fill_bytes(&mut self, dest: &mut [u8]) -> std::result::Result<(), rand::Error> {
|
||||
self.fill_bytes(dest);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl sealed::Sealed for CountingRng {}
|
||||
|
||||
impl KeyGenRng for CountingRng {
|
||||
// See `KeyGenRng::GUARD_DRAWS`. This type's entire purpose is to emit
|
||||
// the ascending counter the predicate rejects.
|
||||
const GUARD_DRAWS: bool = false;
|
||||
}
|
||||
|
||||
/// A guarded test RNG that emits a constant byte, so the guard itself can be
|
||||
/// observed tripping through `draw_key_bytes` rather than only through the
|
||||
/// pure predicate.
|
||||
pub(crate) struct ConstantRng(pub u8);
|
||||
|
||||
impl rand::RngCore for ConstantRng {
|
||||
fn next_u32(&mut self) -> u32 {
|
||||
u32::from_le_bytes([self.0; 4])
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
u64::from_le_bytes([self.0; 8])
|
||||
}
|
||||
|
||||
fn fill_bytes(&mut self, dest: &mut [u8]) {
|
||||
dest.fill(self.0);
|
||||
}
|
||||
|
||||
fn try_fill_bytes(&mut self, dest: &mut [u8]) -> std::result::Result<(), rand::Error> {
|
||||
self.fill_bytes(dest);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl sealed::Sealed for ConstantRng {}
|
||||
|
||||
// Deliberately keeps the default `GUARD_DRAWS = true`.
|
||||
impl KeyGenRng for ConstantRng {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::testing::{ConstantRng, CountingRng};
|
||||
use super::*;
|
||||
|
||||
// ─── Layer (a) ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sealed_allowlist_has_one_production_member() {
|
||||
// `OsRng` is a member and is guarded. The assertion that it is the
|
||||
// *only* production member is enforced by the compiler plus the sealing
|
||||
// — `sealed::Sealed` is unnameable outside this module, so no impl can
|
||||
// exist elsewhere — and is checked mechanically by the plan's grep
|
||||
// criterion over `impl KeyGenRng for` in this file. What is asserted
|
||||
// here is the property that must hold of every production member.
|
||||
fn assert_member<R: KeyGenRng>() -> bool {
|
||||
R::GUARD_DRAWS
|
||||
}
|
||||
assert!(
|
||||
assert_member::<rand::rngs::OsRng>(),
|
||||
"the production allowlist member must be guarded"
|
||||
);
|
||||
assert!(
|
||||
!CountingRng::GUARD_DRAWS,
|
||||
"the deterministic test vector member is the one documented opt-out"
|
||||
);
|
||||
assert!(
|
||||
ConstantRng::GUARD_DRAWS,
|
||||
"the constant test RNG must stay guarded so the guard is observable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osrng_draws_through_the_seam() {
|
||||
let mut buf = [0u8; 32];
|
||||
draw_key_bytes(&mut rand::rngs::OsRng, &mut buf).expect("OsRng draw must be accepted");
|
||||
assert!(
|
||||
buf.iter().any(|b| *b != 0),
|
||||
"draw produced an unfilled buffer"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Layer (d): the predicate ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn degenerate_rejects_all_zero() {
|
||||
assert_eq!(is_degenerate(&[0u8; 32]), Some(DegenerateEntropy::AllZero));
|
||||
assert_eq!(is_degenerate(&[0u8; 12]), Some(DegenerateEntropy::AllZero));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_rejects_all_identical() {
|
||||
assert_eq!(
|
||||
is_degenerate(&[0xABu8; 32]),
|
||||
Some(DegenerateEntropy::AllIdentical)
|
||||
);
|
||||
assert_eq!(
|
||||
is_degenerate(&[0xABu8; 12]),
|
||||
Some(DegenerateEntropy::AllIdentical)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_rejects_ascending_counter() {
|
||||
let ascending: Vec<u8> = (0u8..32).collect();
|
||||
assert_eq!(
|
||||
is_degenerate(&ascending),
|
||||
Some(DegenerateEntropy::Counter),
|
||||
"0x00..0x1f is the canonical broken-counter output"
|
||||
);
|
||||
// Wrapping, not merely ascending: 0xFE, 0xFF, 0x00, 0x01, … is the same
|
||||
// defect and must not escape through the wrap.
|
||||
let wrapping: Vec<u8> = (0..32u32).map(|i| (0xFEu8).wrapping_add(i as u8)).collect();
|
||||
assert_eq!(is_degenerate(&wrapping), Some(DegenerateEntropy::Counter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_rejects_descending_counter() {
|
||||
let descending: Vec<u8> = (0..32u32).map(|i| (0x80u8).wrapping_sub(i as u8)).collect();
|
||||
assert_eq!(is_degenerate(&descending), Some(DegenerateEntropy::Counter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_accepts_100k_osrng_draws() {
|
||||
// The false-positive claim in KEY-05-ENTROPY-ENFORCEMENT.md is a
|
||||
// calculation; this is the empirical companion to it. At 32 bytes the
|
||||
// predicted expected count over 100,000 draws is ~1e-71, so a single
|
||||
// rejection here means the predicate is wrong, not that we were unlucky.
|
||||
let mut buf = [0u8; 32];
|
||||
for i in 0..100_000u32 {
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf);
|
||||
assert_eq!(
|
||||
is_degenerate(&buf),
|
||||
None,
|
||||
"genuine OsRng draw #{i} was rejected — the predicate has a false positive"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_accepts_ordinary_material() {
|
||||
// Two bytes equal, and a run of three ascending, must not be enough.
|
||||
let sample: [u8; 16] = [
|
||||
0x9f, 0x9f, 0x01, 0x02, 0x03, 0xd4, 0x00, 0x00, 0x71, 0x8c, 0x8c, 0xff, 0x10, 0x22,
|
||||
0x35, 0xae,
|
||||
];
|
||||
assert_eq!(is_degenerate(&sample), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_key_bytes_rejects_and_zeroizes_a_degenerate_draw() {
|
||||
let mut buf = [0xFFu8; 32];
|
||||
let err = draw_key_bytes(&mut ConstantRng(0xAB), &mut buf)
|
||||
.expect_err("a constant fill must be refused");
|
||||
assert_eq!(err, DegenerateEntropy::AllIdentical);
|
||||
assert_eq!(
|
||||
buf, [0u8; 32],
|
||||
"a refused draw must leave the buffer zeroized"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_key_bytes_reports_all_zero_specifically() {
|
||||
let mut buf = [0xFFu8; 16];
|
||||
let err = draw_key_bytes(&mut ConstantRng(0x00), &mut buf).expect_err("zeros are refused");
|
||||
assert_eq!(err, DegenerateEntropy::AllZero);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "draw_key_bytes called on a 11-byte buffer")]
|
||||
fn draw_key_bytes_panics_below_min_guarded_len() {
|
||||
let mut buf = [0u8; MIN_GUARDED_LEN - 1];
|
||||
let _ = draw_key_bytes(&mut rand::rngs::OsRng, &mut buf);
|
||||
}
|
||||
|
||||
// ─── Layer (e): the ledger ──────────────────────────────────────────
|
||||
|
||||
// `ARCHIPELAGO_DATA_DIR` is process-global, so these tests must not run
|
||||
// concurrently — serialize them and give each a unique dir. Same pattern and
|
||||
// same reasoning as `container/version_config.rs:163-181` (poisoning is fine:
|
||||
// a panicking test still releases a usable guard).
|
||||
static ENV_LOCK: std::sync::Mutex<u64> = std::sync::Mutex::new(0);
|
||||
|
||||
fn with_tmp_data_dir<F: FnOnce(&std::path::Path)>(f: F) {
|
||||
let mut counter = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*counter += 1;
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"archy-entropy-test-{}-{}",
|
||||
std::process::id(),
|
||||
*counter
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::env::set_var("ARCHIPELAGO_DATA_DIR", &dir);
|
||||
f(&dir);
|
||||
std::env::remove_var("ARCHIPELAGO_DATA_DIR");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_ledger_is_0600_and_append_only() {
|
||||
with_tmp_data_dir(|dir| {
|
||||
let path = dir.join("security").join("csprng-readiness.jsonl");
|
||||
|
||||
record_csprng_readiness(Some(true), "unit-test");
|
||||
let first = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(first.lines().count(), 1, "one call must write one line");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "ledger must be owner-only");
|
||||
}
|
||||
|
||||
record_csprng_readiness(Some(false), "unit-test-2");
|
||||
let second = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(second.lines().count(), 2, "second call must append");
|
||||
assert!(
|
||||
second.starts_with(first.trim_end()),
|
||||
"append must not rewrite the first line"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_record_schema_is_exactly_four_keys() {
|
||||
with_tmp_data_dir(|dir| {
|
||||
record_csprng_readiness(None, "unit-test-schema");
|
||||
let path = dir.join("security").join("csprng-readiness.jsonl");
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let line = text.lines().next().unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(line).unwrap();
|
||||
let obj = value.as_object().unwrap();
|
||||
|
||||
let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
|
||||
keys.sort_unstable();
|
||||
assert_eq!(keys, vec!["event", "ready", "ts", "v"]);
|
||||
|
||||
assert_eq!(obj["v"], serde_json::json!(1));
|
||||
assert_eq!(obj["event"], serde_json::json!("unit-test-schema"));
|
||||
assert!(obj["ready"].is_null(), "an unknown verdict records as null");
|
||||
assert!(
|
||||
obj["ts"].as_str().unwrap().ends_with('Z'),
|
||||
"timestamp must be RFC3339 UTC"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// The fixed vocabulary a ledger line can contain: the schema keys plus the
|
||||
/// literal values the master-seed call site writes.
|
||||
///
|
||||
/// Three of these — `master`, `seed`, `ready` — are themselves BIP-39
|
||||
/// English words. A naive "no mnemonic word appears in the file" substring
|
||||
/// check would therefore fail on roughly 3% of runs purely because a random
|
||||
/// 24-word mnemonic happened to contain one of them, and would *also* false
|
||||
/// positive on substrings (`gen-era-te` contains the BIP-39 word `era`).
|
||||
/// Subtracting the fixed vocabulary and comparing whole tokens makes the
|
||||
/// assertion exact instead of flaky: any alphabetic token in the ledger that
|
||||
/// is not schema is, by construction, a leak.
|
||||
///
|
||||
/// `t` and `z` are the RFC 3339 date/time separator and the UTC designator
|
||||
/// from the `ts` value. They are single characters and every BIP-39 English
|
||||
/// word is at least three, so they cannot mask a leaked word.
|
||||
const LEDGER_FIXED_VOCABULARY: &[&str] = &[
|
||||
"v", "ts", "ready", "event", "master", "seed", "generate", "true", "false", "null", "t",
|
||||
"z",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn readiness_record_contains_no_mnemonic_words() {
|
||||
with_tmp_data_dir(|dir| {
|
||||
let (mnemonic, _seed) = crate::seed::MasterSeed::generate().unwrap();
|
||||
let path = dir.join("security").join("csprng-readiness.jsonl");
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.expect("MasterSeed::generate must have written a readiness line");
|
||||
|
||||
let unexpected: Vec<String> = text
|
||||
.split(|c: char| !c.is_ascii_alphabetic())
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(|t| t.to_ascii_lowercase())
|
||||
.filter(|t| !LEDGER_FIXED_VOCABULARY.contains(&t.as_str()))
|
||||
.collect();
|
||||
assert!(
|
||||
unexpected.is_empty(),
|
||||
"ledger contains tokens outside the fixed schema vocabulary: {unexpected:?}"
|
||||
);
|
||||
|
||||
for word in mnemonic.to_string().split_whitespace() {
|
||||
assert!(
|
||||
!unexpected.iter().any(|t| t == word),
|
||||
"mnemonic word {word:?} leaked into the readiness ledger"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_record_survives_unwritable_data_dir() {
|
||||
let mut counter = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*counter += 1;
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"archy-entropy-unwritable-{}-{}",
|
||||
std::process::id(),
|
||||
*counter
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// A *file* where the data dir should be, so `create_dir_all` of the
|
||||
// `security/` child cannot succeed.
|
||||
let blocker = dir.join("not-a-directory");
|
||||
std::fs::write(&blocker, b"x").unwrap();
|
||||
std::env::set_var("ARCHIPELAGO_DATA_DIR", &blocker);
|
||||
|
||||
// The contract is that this returns normally. A panic or an unwind here
|
||||
// fails the test, which is the whole assertion: a ledger write must
|
||||
// never be able to fail key generation on the offline ceremony path.
|
||||
record_csprng_readiness(Some(true), "unit-test-unwritable");
|
||||
|
||||
std::env::remove_var("ARCHIPELAGO_DATA_DIR");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -36,15 +36,10 @@ pub async fn create_invite(
|
||||
trust_level: TrustLevel,
|
||||
) -> Result<String> {
|
||||
use base64::Engine;
|
||||
use rand::Rng;
|
||||
|
||||
// KEY-05: a federation invite token is unguessable-by-design — it is the
|
||||
// whole authorisation for a peer join — so the source is named and the
|
||||
// 16-byte draw is guarded. The `rand::Rng` import that brought `fill` into
|
||||
// scope is gone with the call that needed it.
|
||||
let mut token_bytes = [0u8; 16];
|
||||
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut token_bytes).map_err(|e| {
|
||||
anyhow::anyhow!("Refusing to mint an invite token from degenerate entropy: {e}")
|
||||
})?;
|
||||
rand::thread_rng().fill(&mut token_bytes);
|
||||
let token = hex::encode(token_bytes);
|
||||
|
||||
let mut payload = serde_json::json!({
|
||||
@@ -204,8 +199,6 @@ pub async fn accept_invite(
|
||||
fips_npub: fips_npub.clone(),
|
||||
last_transport: None,
|
||||
last_transport_at: None,
|
||||
last_sync_error: None,
|
||||
last_sync_error_at: None,
|
||||
};
|
||||
|
||||
add_node(data_dir, node.clone()).await?;
|
||||
|
||||
@@ -21,7 +21,7 @@ pub(crate) use storage::load_invites;
|
||||
#[allow(unused_imports)]
|
||||
pub use storage::{
|
||||
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
|
||||
record_sync_result, remove_node, save_nodes, set_trust_level, update_node,
|
||||
remove_node, save_nodes, set_trust_level, update_node,
|
||||
};
|
||||
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer, sync_with_peer_by_did};
|
||||
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
|
||||
|
||||
@@ -150,8 +150,7 @@ pub async fn record_peer_transport(
|
||||
onion: Option<&str>,
|
||||
transport: &str,
|
||||
) -> Result<()> {
|
||||
let _guard = FEDERATION_STORE_LOCK.lock().await;
|
||||
let mut nodes = load_nodes_inner(data_dir).await?;
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let onion_target = onion.map(|o| o.trim_end_matches(".onion"));
|
||||
|
||||
@@ -169,70 +168,7 @@ pub async fn record_peer_transport(
|
||||
}
|
||||
|
||||
if modified {
|
||||
save_nodes_inner(data_dir, &nodes).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upper bound on a persisted `last_sync_error` message, in characters.
|
||||
///
|
||||
/// T-01-18: the periodic loop records an outcome for every peer on every
|
||||
/// pass, so an unbounded error string (a peer echoing a huge body, a deep
|
||||
/// `anyhow` chain) would be rewritten into `nodes.json` every 90 seconds.
|
||||
/// Counted in `char`s, not bytes, so truncation can never split a UTF-8
|
||||
/// sequence and produce a file that fails to deserialize.
|
||||
pub(crate) const MAX_SYNC_ERROR_CHARS: usize = 256;
|
||||
|
||||
/// Record the outcome of the most recent federation sync attempt with a peer.
|
||||
///
|
||||
/// `Err(msg)` stores the message (truncated to `MAX_SYNC_ERROR_CHARS`) plus
|
||||
/// the current time; `Ok(())` clears both fields so a recovered peer stops
|
||||
/// showing a stale error badge. Only the named DID is touched.
|
||||
///
|
||||
/// Why this exists: both periodic sync loops previously logged failures at
|
||||
/// `debug!` and nothing else, so a peer that had not synced in days looked
|
||||
/// identical in the UI to one that synced a minute ago (FED-02).
|
||||
///
|
||||
/// A DID that isn't in the node list is a silent `Ok` and writes nothing —
|
||||
/// a peer the operator removed while a sync was in flight must not be
|
||||
/// resurrected by that sync's error write. This function never creates a
|
||||
/// node entry.
|
||||
pub async fn record_sync_result(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
outcome: Result<(), String>,
|
||||
) -> Result<()> {
|
||||
let _guard = FEDERATION_STORE_LOCK.lock().await;
|
||||
let mut nodes = load_nodes_inner(data_dir).await?;
|
||||
|
||||
let Some(node) = nodes.iter_mut().find(|n| n.did == did) else {
|
||||
// Unknown/removed peer: nothing to record against. Not an error.
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let changed = match outcome {
|
||||
Err(msg) => {
|
||||
let truncated: String = msg.chars().take(MAX_SYNC_ERROR_CHARS).collect();
|
||||
node.last_sync_error = Some(truncated);
|
||||
node.last_sync_error_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
true
|
||||
}
|
||||
Ok(()) => {
|
||||
// Peer recovered — drop the badge rather than leaving a stale one.
|
||||
let had_error = node.last_sync_error.is_some() || node.last_sync_error_at.is_some();
|
||||
node.last_sync_error = None;
|
||||
node.last_sync_error_at = None;
|
||||
had_error
|
||||
}
|
||||
};
|
||||
|
||||
// A healthy peer stays healthy on most passes, and the surviving loop
|
||||
// calls this for every peer every 90s. Skipping the write when nothing
|
||||
// actually changed keeps the steady state read-only, so this failure
|
||||
// surfacing doesn't add a rewrite of nodes.json (and lock contention
|
||||
// with `federation.remove-node`) every single pass.
|
||||
if changed {
|
||||
save_nodes_inner(data_dir, &nodes).await?;
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -410,8 +346,7 @@ pub async fn set_trust_level(
|
||||
|
||||
/// Update a federated node's metadata (onion, pubkey, name, last_seen).
|
||||
pub async fn update_node(data_dir: &Path, updated: &FederatedNode) -> Result<()> {
|
||||
let _guard = FEDERATION_STORE_LOCK.lock().await;
|
||||
let mut nodes = load_nodes_inner(data_dir).await?;
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
if let Some(node) = nodes.iter_mut().find(|n| n.did == updated.did) {
|
||||
if !updated.onion.is_empty() {
|
||||
node.onion = updated.onion.clone();
|
||||
@@ -425,7 +360,7 @@ pub async fn update_node(data_dir: &Path, updated: &FederatedNode) -> Result<()>
|
||||
if updated.last_seen.is_some() {
|
||||
node.last_seen = updated.last_seen.clone();
|
||||
}
|
||||
save_nodes_inner(data_dir, &nodes).await?;
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -498,8 +433,6 @@ mod tests {
|
||||
fips_npub: None,
|
||||
last_transport: None,
|
||||
last_transport_at: None,
|
||||
last_sync_error: None,
|
||||
last_sync_error_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,38 +525,6 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
/// FED-01 failure-surfacing edge: a removal whose tombstone write fails
|
||||
/// must return `Err` to the caller instead of reporting success, and
|
||||
/// must NOT half-apply — the node list must be left untouched. Forces
|
||||
/// the failure by pre-creating the removed-nodes path as a directory: a
|
||||
/// directory can't be replaced by `fs::write`, so `tombstone_did_inner`
|
||||
/// errors before `remove_node`'s node-list save ever runs (tombstone is
|
||||
/// written first, per `remove_node`'s documented ordering).
|
||||
#[tokio::test]
|
||||
async fn test_remove_errors_when_tombstone_write_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let federation_dir = dir.path().join(FEDERATION_DIR);
|
||||
std::fs::create_dir_all(&federation_dir).unwrap();
|
||||
std::fs::create_dir_all(federation_dir.join(REMOVED_FILE)).unwrap();
|
||||
|
||||
let result = remove_node(dir.path(), "did:key:z1").await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a failed tombstone write must surface as an error, not a silent no-op"
|
||||
);
|
||||
|
||||
let nodes = load_nodes(dir.path()).await.unwrap();
|
||||
assert!(
|
||||
nodes.iter().any(|n| n.did == "did:key:z1"),
|
||||
"a removal whose tombstone never landed must not half-apply — \
|
||||
the node list must remain untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_tombstones_and_readd_clears_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -815,130 +716,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// FED-02: a failed sync must leave a durable, per-peer record instead of
|
||||
/// only a `debug!` line, so the operator can tell a peer that hasn't
|
||||
/// synced in days from one that synced a minute ago.
|
||||
#[tokio::test]
|
||||
async fn test_record_sync_result_persists_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z2", "b.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
record_sync_result(
|
||||
dir.path(),
|
||||
"did:key:z1",
|
||||
Err("peer unreachable".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let nodes = load_nodes(dir.path()).await.unwrap();
|
||||
let n1 = nodes.iter().find(|n| n.did == "did:key:z1").unwrap();
|
||||
assert_eq!(n1.last_sync_error.as_deref(), Some("peer unreachable"));
|
||||
assert!(
|
||||
n1.last_sync_error_at.is_some(),
|
||||
"an error must carry the time it happened"
|
||||
);
|
||||
|
||||
let n2 = nodes.iter().find(|n| n.did == "did:key:z2").unwrap();
|
||||
assert!(
|
||||
n2.last_sync_error.is_none(),
|
||||
"only the failing peer may be marked"
|
||||
);
|
||||
}
|
||||
|
||||
/// FED-02 adjacency edge: the badge must not outlive the failure. A
|
||||
/// successful sync clears the previously recorded error for that peer.
|
||||
#[tokio::test]
|
||||
async fn test_record_sync_result_success_clears_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
record_sync_result(dir.path(), "did:key:z1", Err("timed out".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(load_nodes(dir.path()).await.unwrap()[0]
|
||||
.last_sync_error
|
||||
.is_some());
|
||||
|
||||
record_sync_result(dir.path(), "did:key:z1", Ok(()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let nodes = load_nodes(dir.path()).await.unwrap();
|
||||
assert!(
|
||||
nodes[0].last_sync_error.is_none(),
|
||||
"a recovered peer must not keep its stale error badge"
|
||||
);
|
||||
assert!(
|
||||
nodes[0].last_sync_error_at.is_none(),
|
||||
"the error timestamp must clear with the error"
|
||||
);
|
||||
}
|
||||
|
||||
/// FED-02: a peer removed mid-pass must not be resurrected by the
|
||||
/// in-flight sync attempt's error write. Missing DID is a silent Ok.
|
||||
#[tokio::test]
|
||||
async fn test_record_sync_result_missing_did_is_noop() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
record_sync_result(dir.path(), "did:key:zGONE", Err("unreachable".to_string()))
|
||||
.await
|
||||
.expect("recording against an unknown DID must be a silent Ok, not an error");
|
||||
|
||||
let nodes = load_nodes(dir.path()).await.unwrap();
|
||||
assert_eq!(nodes.len(), 1, "a removed peer must not be resurrected");
|
||||
assert_eq!(nodes[0].did, "did:key:z1");
|
||||
assert!(nodes[0].last_sync_error.is_none());
|
||||
}
|
||||
|
||||
/// FED-02 empty edge: recording against an empty node store writes
|
||||
/// nothing and errors on nobody (the zero-federated-node sync pass).
|
||||
#[tokio::test]
|
||||
async fn test_record_sync_result_on_empty_store_is_noop() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
record_sync_result(dir.path(), "did:key:zAny", Err("boom".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(load_nodes(dir.path()).await.unwrap().is_empty());
|
||||
assert!(
|
||||
!dir.path().join(FEDERATION_DIR).join(NODES_FILE).exists(),
|
||||
"a no-op must not create the node file"
|
||||
);
|
||||
}
|
||||
|
||||
/// T-01-18: an unbounded error string must not bloat nodes.json on every
|
||||
/// failed pass. The recorded message is truncated before persistence.
|
||||
#[tokio::test]
|
||||
async fn test_record_sync_result_truncates_long_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let huge = "x".repeat(5000);
|
||||
record_sync_result(dir.path(), "did:key:z1", Err(huge))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let nodes = load_nodes(dir.path()).await.unwrap();
|
||||
let msg = nodes[0].last_sync_error.as_deref().unwrap();
|
||||
assert!(
|
||||
msg.chars().count() <= MAX_SYNC_ERROR_CHARS,
|
||||
"recorded error must be truncated to {MAX_SYNC_ERROR_CHARS} chars, got {}",
|
||||
msg.chars().count()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_node_state() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -187,8 +187,6 @@ async fn merge_transitive_peers(
|
||||
fips_npub: hint.fips_npub.clone(),
|
||||
last_transport: None,
|
||||
last_transport_at: None,
|
||||
last_sync_error: None,
|
||||
last_sync_error_at: None,
|
||||
});
|
||||
added += 1;
|
||||
}
|
||||
@@ -377,8 +375,6 @@ mod tests {
|
||||
fips_npub: Some("npub1a".into()),
|
||||
last_transport: None,
|
||||
last_transport_at: None,
|
||||
last_sync_error: None,
|
||||
last_sync_error_at: None,
|
||||
},
|
||||
FederatedNode {
|
||||
did: "did:key:zObserver".into(),
|
||||
@@ -392,8 +388,6 @@ mod tests {
|
||||
fips_npub: Some("npub1b".into()),
|
||||
last_transport: None,
|
||||
last_transport_at: None,
|
||||
last_sync_error: None,
|
||||
last_sync_error_at: None,
|
||||
},
|
||||
FederatedNode {
|
||||
did: "did:key:zUntrusted".into(),
|
||||
@@ -407,8 +401,6 @@ mod tests {
|
||||
fips_npub: None,
|
||||
last_transport: None,
|
||||
last_transport_at: None,
|
||||
last_sync_error: None,
|
||||
last_sync_error_at: None,
|
||||
},
|
||||
];
|
||||
let state = build_local_state(
|
||||
@@ -451,8 +443,6 @@ mod tests {
|
||||
fips_npub: None,
|
||||
last_transport: None,
|
||||
last_transport_at: None,
|
||||
last_sync_error: None,
|
||||
last_sync_error_at: None,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -77,23 +77,6 @@ pub struct FederatedNode {
|
||||
/// RFC 3339 timestamp of the last_transport value.
|
||||
#[serde(default)]
|
||||
pub last_transport_at: Option<String>,
|
||||
/// Error from the most recent federation sync attempt with this peer,
|
||||
/// `None` when that attempt succeeded. Written back after every attempt
|
||||
/// (same shape as `last_transport`, for the failure side) so the
|
||||
/// operator can tell a peer that hasn't synced in days from one that
|
||||
/// synced a minute ago — previously a failed sync existed only as a
|
||||
/// `debug!` log line, making the two indistinguishable in the UI.
|
||||
///
|
||||
/// Truncated to `storage::MAX_SYNC_ERROR_CHARS` before it is persisted
|
||||
/// so a pathological error can't bloat `nodes.json` on every pass, and
|
||||
/// carries only the error's own display string — never credential
|
||||
/// material (see FED-02's privacy prohibition).
|
||||
#[serde(default)]
|
||||
pub last_sync_error: Option<String>,
|
||||
/// RFC 3339 timestamp of the last_sync_error value. Cleared together
|
||||
/// with `last_sync_error` when the peer recovers.
|
||||
#[serde(default)]
|
||||
pub last_sync_error_at: Option<String>,
|
||||
}
|
||||
|
||||
/// State snapshot received from a federated peer during sync.
|
||||
@@ -221,8 +204,6 @@ mod tests {
|
||||
fips_npub: None,
|
||||
last_transport: None,
|
||||
last_transport_at: None,
|
||||
last_sync_error: None,
|
||||
last_sync_error_at: None,
|
||||
};
|
||||
let json = serde_json::to_string(&node).unwrap();
|
||||
let parsed: FederatedNode = serde_json::from_str(&json).unwrap();
|
||||
|
||||
@@ -72,10 +72,7 @@ pub async fn resolve(npub: &str) -> Result<Ipv6Addr> {
|
||||
.await
|
||||
.context("connect to FIPS DNS")?;
|
||||
|
||||
// KEY-05: source named. A 2-byte DNS transaction id, not key material, so it
|
||||
// is drawn unguarded — an "all bytes identical" predicate on two bytes
|
||||
// false-positives once in 256, which would be worse than the defect.
|
||||
let id: u16 = rand::RngCore::next_u32(&mut rand::rngs::OsRng) as u16;
|
||||
let id: u16 = rand::random();
|
||||
let query = encode_query(id, npub)?;
|
||||
tokio::time::timeout(DNS_TIMEOUT, sock.send(&query))
|
||||
.await
|
||||
|
||||
@@ -48,7 +48,6 @@ mod data_model;
|
||||
mod device_tokens;
|
||||
mod disk_monitor;
|
||||
mod electrs_status;
|
||||
mod entropy;
|
||||
mod federation;
|
||||
mod fips;
|
||||
mod health_monitor;
|
||||
|
||||
@@ -345,8 +345,6 @@ pub(super) async fn resolve_peer(state: &Arc<MeshState>, sender_prefix: &str) ->
|
||||
pkc_capable: false,
|
||||
lat: None,
|
||||
lon: None,
|
||||
// Set only by an explicit LightningInfo advert, never inferred.
|
||||
lightning_uri: None,
|
||||
};
|
||||
let is_new = {
|
||||
let mut peers = state.peers.write().await;
|
||||
@@ -604,8 +602,6 @@ pub(super) async fn handle_identity_received(
|
||||
pkc_capable: false,
|
||||
lat: None,
|
||||
lon: None,
|
||||
// Set only by an explicit LightningInfo advert, never inferred.
|
||||
lightning_uri: None,
|
||||
};
|
||||
|
||||
let is_new = {
|
||||
@@ -628,14 +624,6 @@ pub(super) async fn handle_identity_received(
|
||||
peer.lat = existing.lat;
|
||||
peer.lon = existing.lon;
|
||||
}
|
||||
// Same hazard as the name and position above: an identity advert
|
||||
// carries no Lightning datum, and Reticulum re-emits one every
|
||||
// announce tick, so a wholesale insert would drop a peer out of the
|
||||
// channel-open picker about once a minute. The URI comes only from
|
||||
// an explicit LightningInfo advert — preserve it.
|
||||
if peer.lightning_uri.is_none() {
|
||||
peer.lightning_uri = existing.lightning_uri.clone();
|
||||
}
|
||||
}
|
||||
peers.insert(contact_id, peer.clone());
|
||||
is_new
|
||||
|
||||
@@ -485,41 +485,6 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
}
|
||||
}
|
||||
|
||||
Some(MeshMessageType::LightningInfo) => {
|
||||
match message_types::decode_payload::<message_types::LightningInfoPayload>(&envelope.v)
|
||||
{
|
||||
Ok(info) => {
|
||||
// Validate BEFORE touching stored state. This arrives over
|
||||
// unauthenticated RF, and a peer that has already given us a
|
||||
// good URI must not lose it to a later malformed one —
|
||||
// otherwise anyone in range could blank out a real peer's
|
||||
// entry in the channel-open picker (T-01-12).
|
||||
if !message_types::is_valid_lightning_uri(&info.uri) {
|
||||
warn!(
|
||||
"Rejecting malformed lightning_info URI from contact {} — \
|
||||
keeping any previously stored URI",
|
||||
sender_contact_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
let mut peers = state.peers.write().await;
|
||||
if let Some(peer) = peers.get_mut(&sender_contact_id) {
|
||||
// Newest advertisement wins: a node that moves host or
|
||||
// rotates its port re-advertises, and the stale entry
|
||||
// would just fail to dial. Overwrite, never accumulate.
|
||||
peer.lightning_uri = Some(info.uri);
|
||||
} else {
|
||||
warn!(
|
||||
"lightning_info from unknown contact {} — dropped (a peer record is \
|
||||
created by identity/contact discovery, not by this advertisement)",
|
||||
sender_contact_id
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to decode lightning_info payload: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
Some(MeshMessageType::ChannelInvite) => {
|
||||
match message_types::decode_payload::<message_types::ChannelInvitePayload>(&envelope.v)
|
||||
{
|
||||
|
||||
@@ -828,11 +828,6 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
|
||||
// it just because a refresh's snapshot didn't carry one.
|
||||
lat: contact.lat.or_else(|| existing.and_then(|p| p.lat)),
|
||||
lon: contact.lon.or_else(|| existing.and_then(|p| p.lon)),
|
||||
// A contact refresh carries no Lightning datum — it comes
|
||||
// only from an explicit LightningInfo advert. Preserve what
|
||||
// was advertised, or a routine refresh would silently empty
|
||||
// the channel-open picker.
|
||||
lightning_uri: existing.and_then(|p| p.lightning_uri.clone()),
|
||||
};
|
||||
peers.insert(contact_id, peer);
|
||||
}
|
||||
|
||||
@@ -76,16 +76,6 @@ pub enum MeshMessageType {
|
||||
/// Reply to an AssistQuery — a chunk of the LLM's answer, addressed back to
|
||||
/// the asker by `req_id`. Long answers span multiple chunks (`seq`/`done`).
|
||||
AssistResponse = 25,
|
||||
/// "I run Lightning, and this is how to reach me" — advertises the sender's
|
||||
/// LND connection URI so the recipient can offer it as a channel-open
|
||||
/// target.
|
||||
///
|
||||
/// Only ever sent on an explicit operator action against a chosen peer
|
||||
/// (`mesh.send-lightning-info`). It is never auto-broadcast to contacts in
|
||||
/// range, and a received advertisement is never re-advertised onward — the
|
||||
/// URI is this node's payment endpoint, and who learns it is the operator's
|
||||
/// choice (T-01-13).
|
||||
LightningInfo = 26,
|
||||
}
|
||||
|
||||
impl MeshMessageType {
|
||||
@@ -117,7 +107,6 @@ impl MeshMessageType {
|
||||
23 => Some(Self::ContentInline),
|
||||
24 => Some(Self::AssistQuery),
|
||||
25 => Some(Self::AssistResponse),
|
||||
26 => Some(Self::LightningInfo),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -153,7 +142,6 @@ impl MeshMessageType {
|
||||
"content_inline" => Some(Self::ContentInline),
|
||||
"assist_query" => Some(Self::AssistQuery),
|
||||
"assist_response" => Some(Self::AssistResponse),
|
||||
"lightning_info" => Some(Self::LightningInfo),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -186,7 +174,6 @@ impl MeshMessageType {
|
||||
Self::ContentInline => "content_inline",
|
||||
Self::AssistQuery => "assist_query",
|
||||
Self::AssistResponse => "assist_response",
|
||||
Self::LightningInfo => "lightning_info",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -742,57 +729,6 @@ pub struct PresencePayload {
|
||||
pub last_active: u32,
|
||||
}
|
||||
|
||||
/// LightningInfo — the sender's LND connection URI, so the recipient can offer
|
||||
/// it as a channel-open target.
|
||||
///
|
||||
/// `uri` is the standard `pubkey@host:port` form (the `:port` suffix is
|
||||
/// optional). `alias` is the node's human-readable name, carried so the picker
|
||||
/// can label the entry without a second round trip; it is advisory and
|
||||
/// unverified, so it must never be used as an identity.
|
||||
///
|
||||
/// Kept to two fields on purpose: this rides LoRa, where every byte is paid for
|
||||
/// on air.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LightningInfoPayload {
|
||||
pub uri: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub alias: Option<String>,
|
||||
}
|
||||
|
||||
/// Is this a well-formed Lightning connection URI (`pubkey@host` or
|
||||
/// `pubkey@host:port`)?
|
||||
///
|
||||
/// Validated before anything is stored, because this arrives over unauthenticated
|
||||
/// RF: the pubkey part must be 66 hexadecimal characters (a compressed secp256k1
|
||||
/// key, the same rule `lnd.openchannel` enforces) and the host part must be
|
||||
/// non-empty. Deliberately does NOT resolve or dial the host — that would turn a
|
||||
/// received advertisement into an outbound connection an attacker chose.
|
||||
pub fn is_valid_lightning_uri(uri: &str) -> bool {
|
||||
// Exactly one '@': "a@b@c" must not pass by splitting on the first one.
|
||||
let mut parts = uri.split('@');
|
||||
let (Some(pubkey), Some(host), None) = (parts.next(), parts.next(), parts.next()) else {
|
||||
return false;
|
||||
};
|
||||
if pubkey.len() != 66 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return false;
|
||||
}
|
||||
// Strip an optional :port and require the remaining host to be non-empty.
|
||||
// rsplit_once so IPv6-ish hosts don't lose their body to the first colon.
|
||||
let host_only = match host.rsplit_once(':') {
|
||||
Some((h, port)) => {
|
||||
if port.is_empty() || !port.chars().all(|c| c.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
if port.parse::<u16>().is_err() {
|
||||
return false;
|
||||
}
|
||||
h
|
||||
}
|
||||
None => host,
|
||||
};
|
||||
!host_only.is_empty() && !host_only.contains(char::is_whitespace)
|
||||
}
|
||||
|
||||
/// ChannelInvite — advertise/invite a peer to join a channel. `key` is an
|
||||
/// optional base64 pre-shared secret; absent `key` means public.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -1023,93 +959,4 @@ mod tests {
|
||||
let decoded: BlockHeaderPayload = decode_payload(&encoded).unwrap();
|
||||
assert_eq!(decoded.height, 890412);
|
||||
}
|
||||
|
||||
const LN_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
|
||||
|
||||
#[test]
|
||||
fn lightning_info_round_trips_through_u8_label_and_back() {
|
||||
assert_eq!(
|
||||
MeshMessageType::from_u8(26),
|
||||
Some(MeshMessageType::LightningInfo)
|
||||
);
|
||||
assert_eq!(MeshMessageType::LightningInfo.label(), "lightning_info");
|
||||
assert_eq!(
|
||||
MeshMessageType::from_label("lightning_info"),
|
||||
Some(MeshMessageType::LightningInfo)
|
||||
);
|
||||
// The discriminant is additive: 26 was unused before, so a deployed peer
|
||||
// that predates this simply fails to decode it rather than mis-decoding
|
||||
// it as some other type.
|
||||
assert_eq!(MeshMessageType::LightningInfo as u8, 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_info_payload_round_trips_over_the_wire() {
|
||||
let payload = LightningInfoPayload {
|
||||
uri: format!("{LN_PUBKEY}@1.2.3.4:9735"),
|
||||
alias: Some("archy".into()),
|
||||
};
|
||||
let encoded = encode_payload(&payload).unwrap();
|
||||
let decoded: LightningInfoPayload = decode_payload(&encoded).unwrap();
|
||||
assert_eq!(decoded.uri, payload.uri);
|
||||
assert_eq!(decoded.alias.as_deref(), Some("archy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_info_payload_alias_is_optional_and_omitted_on_the_wire() {
|
||||
let payload = LightningInfoPayload {
|
||||
uri: format!("{LN_PUBKEY}@1.2.3.4:9735"),
|
||||
alias: None,
|
||||
};
|
||||
let encoded = encode_payload(&payload).unwrap();
|
||||
let decoded: LightningInfoPayload = decode_payload(&encoded).unwrap();
|
||||
assert!(decoded.alias.is_none());
|
||||
// skip_serializing_if keeps the absent alias off the air entirely.
|
||||
let with_alias = encode_payload(&LightningInfoPayload {
|
||||
uri: format!("{LN_PUBKEY}@1.2.3.4:9735"),
|
||||
alias: Some("archy".into()),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
encoded.len() < with_alias.len(),
|
||||
"an absent alias must cost fewer bytes on air, not the same"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_lightning_uris_are_accepted() {
|
||||
assert!(is_valid_lightning_uri(&format!("{LN_PUBKEY}@1.2.3.4:9735")));
|
||||
assert!(is_valid_lightning_uri(&format!(
|
||||
"{LN_PUBKEY}@example.com:9735"
|
||||
)));
|
||||
// The :port suffix is optional.
|
||||
assert!(is_valid_lightning_uri(&format!("{LN_PUBKEY}@1.2.3.4")));
|
||||
assert!(is_valid_lightning_uri(&format!(
|
||||
"{LN_PUBKEY}@abcdefghij.onion:9735"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_lightning_uris_are_rejected() {
|
||||
let bad = [
|
||||
"".to_string(),
|
||||
"no-at-sign".to_string(),
|
||||
LN_PUBKEY.to_string(), // no host at all
|
||||
format!("{LN_PUBKEY}@"), // empty host
|
||||
"@1.2.3.4:9735".to_string(), // no pubkey
|
||||
format!("{}@1.2.3.4:9735", &LN_PUBKEY[..65]), // pubkey too short
|
||||
format!("{}z@1.2.3.4:9735", &LN_PUBKEY[..65]), // pubkey not hex
|
||||
format!("{LN_PUBKEY}@1.2.3.4:99999"), // port out of u16 range
|
||||
format!("{LN_PUBKEY}@1.2.3.4:"), // empty port
|
||||
format!("{LN_PUBKEY}@1.2.3.4:http"), // non-numeric port
|
||||
format!("{LN_PUBKEY}@host with spaces:9735"),
|
||||
format!("{LN_PUBKEY}@a@b:9735"), // two '@'
|
||||
];
|
||||
for uri in bad {
|
||||
assert!(
|
||||
!is_valid_lightning_uri(&uri),
|
||||
"must reject malformed URI {uri:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,9 +260,6 @@ pub(crate) async fn upsert_federation_peer(
|
||||
pkc_capable: existing.as_ref().map(|p| p.pkc_capable).unwrap_or(false),
|
||||
lat: existing.as_ref().and_then(|p| p.lat),
|
||||
lon: existing.as_ref().and_then(|p| p.lon),
|
||||
// Federation seeding carries no Lightning datum today; keep whatever an
|
||||
// explicit advert already established for this peer.
|
||||
lightning_uri: existing.as_ref().and_then(|p| p.lightning_uri.clone()),
|
||||
};
|
||||
peers.insert(contact_id, peer);
|
||||
// A radio twin of this node (same advert_name, no arch identity yet) can now
|
||||
@@ -2419,7 +2416,6 @@ mod tests {
|
||||
pkc_capable: false,
|
||||
lat: None,
|
||||
lon: None,
|
||||
lightning_uri: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,20 +111,6 @@ pub struct MeshPeer {
|
||||
pub lat: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub lon: Option<f64>,
|
||||
/// This peer's advertised Lightning connection URI (`pubkey@host:port`).
|
||||
///
|
||||
/// Set ONLY from a received `LightningInfo` advertisement (or federation
|
||||
/// seeding in a later plan) — never inferred, never defaulted. `None` means
|
||||
/// "this peer has not told us it runs Lightning", which is what the
|
||||
/// channel-open picker uses to decide whether to offer it as a request
|
||||
/// target at all.
|
||||
///
|
||||
/// The advertisement is unauthenticated RF input, so this is a *request*
|
||||
/// target the operator chooses to act on, not a trusted identity. It is
|
||||
/// stored against the peer's authenticating key (`identity_pubkey_hex()`),
|
||||
/// never the firmware routing key (T-01-11).
|
||||
#[serde(default)]
|
||||
pub lightning_uri: Option<String>,
|
||||
}
|
||||
|
||||
impl MeshPeer {
|
||||
@@ -308,7 +294,6 @@ mod tests {
|
||||
pkc_capable: false,
|
||||
lat: None,
|
||||
lon: None,
|
||||
lightning_uri: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,12 +97,7 @@ pub fn generate_prekey_bundle(
|
||||
|
||||
// Generate signed prekey
|
||||
let (spk_secret, spk_public) = crypto::generate_x25519_ephemeral();
|
||||
// KEY-05: source named. This is a 4-byte prekey *identifier*, not key
|
||||
// material — the X25519 secret is the line above — so it is drawn unguarded:
|
||||
// the degenerate predicate's false-positive bound does not hold below 12
|
||||
// bytes. See the classification table in
|
||||
// docs/security/KEY-05-ENTROPY-ENFORCEMENT.md.
|
||||
let spk_id: u32 = rand::RngCore::next_u32(&mut rand::rngs::OsRng);
|
||||
let spk_id: u32 = rand::random();
|
||||
let signature = identity_signing_key.sign(&spk_public);
|
||||
|
||||
let signed_prekey = SignedPrekey {
|
||||
@@ -116,8 +111,7 @@ pub fn generate_prekey_bundle(
|
||||
let mut one_time_secrets = Vec::with_capacity(num_one_time_prekeys as usize);
|
||||
for _ in 0..num_one_time_prekeys {
|
||||
let (otk_secret, otk_public) = crypto::generate_x25519_ephemeral();
|
||||
// KEY-05: source named; unguarded for the same reason as `spk_id` above.
|
||||
let otk_id: u32 = rand::RngCore::next_u32(&mut rand::rngs::OsRng);
|
||||
let otk_id: u32 = rand::random();
|
||||
one_time_prekeys.push(OneTimePrekey {
|
||||
id: otk_id,
|
||||
public: otk_public,
|
||||
@@ -390,70 +384,4 @@ mod tests {
|
||||
|
||||
assert!(verify_bundle(&bundle).is_err());
|
||||
}
|
||||
|
||||
/// KEY-05: the prekey bundle crosses the wire to other nodes, so the entropy
|
||||
/// migration must be provably source-only. This pins the serialised field
|
||||
/// **set, types and ordering** — a later refactor that reshapes the bundle
|
||||
/// while "just" touching the RNG fails here rather than silently breaking
|
||||
/// every peer that already holds the old shape.
|
||||
#[test]
|
||||
fn prekey_bundle_wire_shape_unchanged() {
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let (bundle, _secrets) = generate_prekey_bundle(&signing_key, 2).unwrap();
|
||||
let json = serde_json::to_string(&bundle).unwrap();
|
||||
|
||||
// Ordering. Checked against the emitted *string*, not a parsed
|
||||
// `serde_json::Value`: `Value`'s map is a `BTreeMap` unless the
|
||||
// `preserve_order` feature happens to be unified on, so a `Value` would
|
||||
// silently assert alphabetical order instead of declaration order. The
|
||||
// serialised text is what actually goes on the wire.
|
||||
let pos = |k: &str| json.find(k).unwrap_or_else(|| panic!("missing field {k}"));
|
||||
assert!(pos("\"identity_key\"") < pos("\"x25519_identity\""));
|
||||
assert!(pos("\"x25519_identity\"") < pos("\"signed_prekey\""));
|
||||
assert!(pos("\"signed_prekey\"") < pos("\"one_time_prekeys\""));
|
||||
|
||||
// Field set and types.
|
||||
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
let obj = value.as_object().expect("bundle serialises as an object");
|
||||
let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
|
||||
keys.sort_unstable();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![
|
||||
"identity_key",
|
||||
"one_time_prekeys",
|
||||
"signed_prekey",
|
||||
"x25519_identity"
|
||||
]
|
||||
);
|
||||
|
||||
// Both identity fields stay 32-byte values hex-encoded to 64 chars.
|
||||
assert_eq!(obj["identity_key"].as_str().unwrap().len(), 64);
|
||||
assert_eq!(obj["x25519_identity"].as_str().unwrap().len(), 64);
|
||||
|
||||
let spk = obj["signed_prekey"].as_object().unwrap();
|
||||
let mut spk_keys: Vec<&str> = spk.keys().map(String::as_str).collect();
|
||||
spk_keys.sort_unstable();
|
||||
assert_eq!(spk_keys, vec!["id", "public", "signature"]);
|
||||
assert!(spk["id"].is_u64(), "prekey id must remain an unsigned int");
|
||||
assert!(
|
||||
u32::try_from(spk["id"].as_u64().unwrap()).is_ok(),
|
||||
"prekey id must still fit u32"
|
||||
);
|
||||
assert_eq!(spk["public"].as_str().unwrap().len(), 64);
|
||||
|
||||
let otks = obj["one_time_prekeys"].as_array().unwrap();
|
||||
assert_eq!(otks.len(), 2);
|
||||
let mut otk_keys: Vec<&str> = otks[0]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
otk_keys.sort_unstable();
|
||||
assert_eq!(otk_keys, vec!["id", "public"]);
|
||||
assert!(otks[0]["id"].is_u64());
|
||||
assert!(u32::try_from(otks[0]["id"].as_u64().unwrap()).is_ok());
|
||||
assert_eq!(otks[0]["public"].as_str().unwrap().len(), 64);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,37 +101,6 @@ impl EndpointRateLimiter {
|
||||
// DID rotation: sensitive identity operation
|
||||
limits.insert("node.rotate-did".to_string(), (3, 600));
|
||||
|
||||
// ── Unauthenticated onboarding mutators (F-01 / KEY-01) ─────────────
|
||||
//
|
||||
// These are in UNAUTHENTICATED_METHODS and can write node key
|
||||
// material, so they are rate-limited as defence in depth behind
|
||||
// `api::rpc::onboarding_gate`. The numbers are deliberately GENEROUS
|
||||
// rather than minimal, because a 429 here is a hard, user-visible
|
||||
// failure at the DID-creation screen — exactly the failure the
|
||||
// in-memory generate lock (`seed_rpc.rs:97-116`) was written to
|
||||
// prevent. A 429 comes back as `{"error":{"code":429,...}}` with
|
||||
// "Rate limit exceeded. Try again later." (`api/rpc/mod.rs:506-519`)
|
||||
// over HTTP 429, and neither the onboarding view's transient-error
|
||||
// regex (`OnboardingSeedGenerate.vue:243`) nor `rpc-client.ts`'s
|
||||
// retryable check (502/503 only) matches it — so a too-tight limit
|
||||
// surfaces to the user as "onboarding is broken".
|
||||
//
|
||||
// seed.generate — derivation: the view's 4s silent retry loop
|
||||
// (`OnboardingSeedGenerate.vue:265-268`) only fires on transient /
|
||||
// network errors, i.e. when the daemon is not answering at all, so the
|
||||
// limiter never sees those. What DOES reach the limiter is the
|
||||
// 30s-timeout aborts plus rpc-client's internal retries — roughly one
|
||||
// user-visible attempt per 30s, i.e. ~10 per 300s worst case. 20/300s
|
||||
// is ~6x the realistic budget and ~2x the pathological one.
|
||||
limits.insert("seed.generate".to_string(), (20, 300));
|
||||
// seed.restore — the audit suggests matching auth.changePassword at
|
||||
// 3/300s. REJECTED with cause: `rpc-client.ts:196-215` retries a single
|
||||
// call up to 3 times, so 3/300s would burn a user's entire budget on
|
||||
// one submit of a mistyped seed phrase and lock them out of the retry.
|
||||
limits.insert("seed.restore".to_string(), (10, 300));
|
||||
limits.insert("seed.save-encrypted".to_string(), (10, 300));
|
||||
limits.insert("backup.restore-identity".to_string(), (10, 300));
|
||||
|
||||
Self {
|
||||
requests: Arc::new(RwLock::new(HashMap::new())),
|
||||
limits: Arc::new(limits),
|
||||
@@ -226,66 +195,4 @@ mod tests {
|
||||
// ip2 should still be allowed
|
||||
assert!(limiter.check(ip2).await);
|
||||
}
|
||||
|
||||
fn test_ip() -> IpAddr {
|
||||
IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
|
||||
}
|
||||
|
||||
/// seed.generate is rate-limited, but not below the real client retry
|
||||
/// budget: 20 attempts in the window are allowed, the 21st is refused.
|
||||
#[tokio::test]
|
||||
async fn seed_generate_allows_twenty_then_limits() {
|
||||
let limiter = EndpointRateLimiter::new();
|
||||
let ip = test_ip();
|
||||
|
||||
for i in 0..20 {
|
||||
assert!(
|
||||
limiter.check("seed.generate", ip).await,
|
||||
"attempt {i} must be allowed — a 429 here is a hard failure at \
|
||||
the DID-creation screen"
|
||||
);
|
||||
limiter.record("seed.generate", ip).await;
|
||||
}
|
||||
|
||||
assert!(
|
||||
!limiter.check("seed.generate", ip).await,
|
||||
"the 21st attempt in the window must be refused"
|
||||
);
|
||||
}
|
||||
|
||||
/// One user submit of a seed phrase costs up to 1 + 3 internal retries
|
||||
/// (`rpc-client.ts:196-215`). The limit must clear that comfortably, which
|
||||
/// is why the audit's suggested 3/300s was rejected.
|
||||
#[tokio::test]
|
||||
async fn seed_restore_allows_a_full_submit_with_its_retries() {
|
||||
let limiter = EndpointRateLimiter::new();
|
||||
let ip = test_ip();
|
||||
|
||||
for i in 0..4 {
|
||||
assert!(
|
||||
limiter.check("seed.restore", ip).await,
|
||||
"call {i} of one user submit + its internal retries must be allowed"
|
||||
);
|
||||
limiter.record("seed.restore", ip).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// All four onboarding mutators are actually registered — a typo'd key
|
||||
/// silently means "not rate-limited at all" (`check` returns true for
|
||||
/// unknown methods).
|
||||
#[tokio::test]
|
||||
async fn onboarding_mutators_are_registered() {
|
||||
let limiter = EndpointRateLimiter::new();
|
||||
for method in [
|
||||
"seed.generate",
|
||||
"seed.restore",
|
||||
"seed.save-encrypted",
|
||||
"backup.restore-identity",
|
||||
] {
|
||||
assert!(
|
||||
limiter.limits.contains_key(method),
|
||||
"{method} has no rate limit entry"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,77 +79,18 @@ fn kernel_csprng_ready() -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Generate a 24-word English BIP-39 mnemonic from an **explicitly supplied** CSPRNG.
|
||||
///
|
||||
/// The entropy source is an argument here, never a transitive-dependency default.
|
||||
/// See `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` finding F-02 / `[ARCHY-1]`:
|
||||
/// a bare `bip39::Mnemonic::generate(24)` resolves through `Mnemonic::generate_in`
|
||||
/// to `&mut rand::thread_rng()` *inside* the `bip39` crate, so the RNG backing every
|
||||
/// Archipelago key — including the fleet release-root signing key — would be chosen
|
||||
/// by a dependency's default rather than stated at this call site.
|
||||
///
|
||||
/// That is precisely the structural shape of the 2026-07-30 COLDCARD entropy defect
|
||||
/// ("T1"), where a refactor silently rebound seed generation to a non-cryptographic
|
||||
/// PRNG with no compile error and no test failure. Naming the source here means a
|
||||
/// future `rand` or `bip39` bump cannot rebind it silently, and it creates the seam
|
||||
/// that `mnemonic_generation_uses_injected_rng` needs to prove the passed RNG is the
|
||||
/// one actually consumed.
|
||||
///
|
||||
/// **KEY-05 (2026-08-02) generalises that in two ways.**
|
||||
///
|
||||
/// The bound is no longer `rand::CryptoRng + rand::RngCore`. `CryptoRng` is a
|
||||
/// marker with no compiler-checked content: any caller could implement it for any
|
||||
/// type and satisfy this signature while supplying a counter. The bound is now
|
||||
/// [`crate::entropy::KeyGenRng`], a **sealed** allowlist whose supertrait lives in a
|
||||
/// private module of `entropy`, so the set of RNGs that can drive the master key
|
||||
/// hierarchy is exactly the set written in that one file and the compiler enforces
|
||||
/// it. Documentation became a constraint.
|
||||
///
|
||||
/// The entropy is also now **inspectable at this seam**: it is drawn into a local
|
||||
/// buffer through [`crate::entropy::draw_key_bytes`], which refuses an all-zero,
|
||||
/// all-identical or wrapping-counter draw before it can become a seed, and the
|
||||
/// mnemonic is built with `from_entropy` instead of `generate_in_with`. Those two
|
||||
/// are the same function for the same RNG output — `mnemonic_generation_uses_injected_rng`
|
||||
/// below has asserted exactly that equivalence since F-02, and still does. The
|
||||
/// buffer is zeroized before this function returns on every path.
|
||||
fn generate_mnemonic_with<R: crate::entropy::KeyGenRng>(rng: &mut R) -> Result<bip39::Mnemonic> {
|
||||
let mut entropy = [0u8; 32];
|
||||
let result = crate::entropy::draw_key_bytes(rng, &mut entropy)
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Refusing to build a mnemonic from degenerate entropy: {}",
|
||||
e
|
||||
)
|
||||
})
|
||||
.and_then(|()| {
|
||||
bip39::Mnemonic::from_entropy(&entropy)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to generate mnemonic: {}", e))
|
||||
});
|
||||
entropy.zeroize();
|
||||
result
|
||||
}
|
||||
|
||||
impl MasterSeed {
|
||||
/// Generate a new 24-word BIP-39 mnemonic and derive the master seed.
|
||||
pub fn generate() -> Result<(bip39::Mnemonic, Self)> {
|
||||
let ready = kernel_csprng_ready();
|
||||
match ready {
|
||||
match kernel_csprng_ready() {
|
||||
Some(true) => tracing::info!("kernel CSPRNG initialized; generating master seed"),
|
||||
Some(false) => tracing::warn!(
|
||||
"kernel CSPRNG not yet initialized; getrandom() will block until the pool is seeded"
|
||||
),
|
||||
None => {}
|
||||
}
|
||||
// Until KEY-05 layer (e) this verdict was computed, logged and thrown
|
||||
// away, so a node could never answer after the fact whether its keys were
|
||||
// born from a seeded pool (backlog R-09). Now it is durable. Best-effort:
|
||||
// `ceremony.rs` runs this offline where no data directory need exist, and
|
||||
// an audit record must never be able to fail key generation.
|
||||
crate::entropy::record_csprng_readiness(ready, "master-seed-generate");
|
||||
// OsRng is passed explicitly: a direct getrandom(2) wrapper with no
|
||||
// userspace state, chosen here rather than inherited. See
|
||||
// `generate_mnemonic_with` for why this is stated and not defaulted.
|
||||
let mnemonic = generate_mnemonic_with(&mut rand::rngs::OsRng)?;
|
||||
let mnemonic = bip39::Mnemonic::generate(24)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to generate mnemonic: {}", e))?;
|
||||
let seed = Self::from_mnemonic(&mnemonic);
|
||||
Ok((mnemonic, seed))
|
||||
}
|
||||
@@ -261,18 +202,8 @@ pub fn derive_nostr_identity_key(seed: &MasterSeed, index: u32) -> Result<nostr_
|
||||
|
||||
// ─── Bitcoin / LND Derivation ───────────────────────────────────────────
|
||||
|
||||
/// Derive the BIP-84 account-level extended private key.
|
||||
/// Derive the BIP-84 account-level extended private key for Bitcoin Core.
|
||||
/// Path: m/84'/0'/0' (native segwit, mainnet).
|
||||
///
|
||||
/// **Retained deliberately with no production caller (Phase 10, D-07c).** Its only
|
||||
/// non-test caller was the Bitcoin Core wallet-init RPC handler, deleted under D-07b
|
||||
/// because it imported this xprv into Bitcoin Core's `wallet.dat` (audit finding
|
||||
/// F-13; see `docs/security/KEY-03-SIGNING-POSTURE.md`). This function is *not*
|
||||
/// cruft: it is covered by existing tests below, and
|
||||
/// it is the derivation D-07c's deferred BDK cold vault (ElectrumX-backed, daemon
|
||||
/// side) will need. Do not delete it as dead code; if D-07c is abandoned, remove
|
||||
/// the decision and the function together.
|
||||
#[allow(dead_code)]
|
||||
pub fn derive_bitcoin_xprv(seed: &MasterSeed) -> Result<bitcoin::bip32::Xpriv> {
|
||||
use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv};
|
||||
use bitcoin::Network;
|
||||
@@ -663,78 +594,6 @@ mod tests {
|
||||
assert_eq!(mnemonic.word_count(), 24);
|
||||
}
|
||||
|
||||
/// The deterministic counter RNG that drives the seam below now lives in
|
||||
/// `crate::entropy::testing` (KEY-05). Its `fill_bytes` behaviour and
|
||||
/// therefore its emitted byte sequence are unchanged, so the known-answer
|
||||
/// mnemonic pinned below is unchanged.
|
||||
///
|
||||
/// Its `impl rand::CryptoRng` did **not** move: that marker was a false
|
||||
/// promise — a counter is not a cryptographic source — and KEY-05 retires it
|
||||
/// rather than relocating it. Membership of the sealed `entropy::KeyGenRng`
|
||||
/// allowlist replaces it, and unlike a marker anyone can implement, that set
|
||||
/// is closed and compiler-enforced. The crate now contains zero
|
||||
/// `impl rand::CryptoRng` blocks.
|
||||
use crate::entropy::testing::CountingRng;
|
||||
|
||||
#[test]
|
||||
fn mnemonic_generation_uses_injected_rng() {
|
||||
// Regression guard for [ARCHY-1] / F-02 in
|
||||
// docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md. This test cannot exist
|
||||
// against a bare `bip39::Mnemonic::generate(24)` call: there is no seam
|
||||
// to inject through, so there is no way to observe *which* RNG was used.
|
||||
let mut rng = CountingRng(0);
|
||||
let mnemonic = generate_mnemonic_with(&mut rng).unwrap();
|
||||
|
||||
assert_eq!(mnemonic.word_count(), 24, "must remain 256-bit / 24 words");
|
||||
|
||||
// The injected RNG drew exactly 32 bytes and they became the mnemonic's
|
||||
// entropy verbatim — proof that the RNG passed at the call site, not
|
||||
// bip39's transitive `rand::thread_rng()` default, is the one consumed.
|
||||
let expected_entropy: Vec<u8> = (0u8..32).collect();
|
||||
let from_entropy = bip39::Mnemonic::from_entropy(&expected_entropy).unwrap();
|
||||
assert_eq!(
|
||||
mnemonic.to_string(),
|
||||
from_entropy.to_string(),
|
||||
"generated mnemonic must be exactly from_entropy(injected RNG output)"
|
||||
);
|
||||
|
||||
// Known-answer pin, so a silent rebinding of the entropy source (a rand
|
||||
// or bip39 bump, a feature-flag change) fails loudly rather than quietly.
|
||||
//
|
||||
// These words are a public test vector derived from entropy 0x00..=0x1f —
|
||||
// a deliberately weak, published value. It is not, and must never be, a
|
||||
// real seed.
|
||||
assert_eq!(
|
||||
mnemonic.to_string(),
|
||||
"abandon amount liar amount expire adjust cage candy arch gather drum \
|
||||
bullet absurd math era live bid rhythm alien crouch range attend \
|
||||
journey unaware",
|
||||
"injected-RNG known-answer mnemonic"
|
||||
);
|
||||
|
||||
// Same RNG state in, same mnemonic out.
|
||||
let mut rng2 = CountingRng(0);
|
||||
assert_eq!(
|
||||
generate_mnemonic_with(&mut rng2).unwrap().to_string(),
|
||||
mnemonic.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnemonic_generation_is_256_bit() {
|
||||
// Production path: OsRng, explicitly passed. 24 words = 256 bits, and two
|
||||
// successive productions from the real entropy source must differ.
|
||||
let (a, _) = MasterSeed::generate().unwrap();
|
||||
let (b, _) = MasterSeed::generate().unwrap();
|
||||
assert_eq!(a.word_count(), 24);
|
||||
assert_eq!(b.word_count(), 24);
|
||||
assert_ne!(
|
||||
a.to_string(),
|
||||
b.to_string(),
|
||||
"real entropy must not repeat across calls"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_nondeterministic() {
|
||||
// Regression guard against a fixed/seeded RNG ever being wired into
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user