Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adcd0f7ce8 |
@@ -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."
|
||||
}
|
||||
+16
-16
@@ -15,20 +15,20 @@ 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)
|
||||
- [ ] **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
|
||||
- [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)
|
||||
- [ ] **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,17 +109,17 @@ 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-02 | Phase 1 | Pending |
|
||||
| UIFIX-03 | Phase 1 | Pending |
|
||||
| UIFIX-04 | Phase 1 | Pending |
|
||||
| UIFIX-05 | Phase 1 | Complete |
|
||||
| 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 |
|
||||
|
||||
+13
-57
@@ -53,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)
|
||||
@@ -72,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)*
|
||||
@@ -235,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 | - |
|
||||
@@ -244,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
|
||||
|
||||
@@ -263,45 +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 across 15 files** (`session.rs` 16, `pine_ha.rs` 6, `wallet/bdhke.rs` 4 — *ecash key material*, `mesh/x3dh.rs` 2 — *key-agreement material*, `storage_crypto.rs` 1 — *AEAD nonce*, +10 more; full table in the audit's §F-10a). 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:** 5 plans + KEY-05 unplanned (needs a 6th plan)
|
||||
|
||||
> **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 not yet planned.** The 5 plans below predate it; a 6th plan (or a re-plan) is
|
||||
> required before this phase can be considered fully covered.
|
||||
|
||||
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
|
||||
|
||||
### 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)
|
||||
|
||||
+10
-14
@@ -5,16 +5,16 @@ milestone_name: milestone
|
||||
current_phase: 09
|
||||
current_phase_name: BotFights Platform Upgrade
|
||||
status: planning
|
||||
stopped_at: Phase 10 planned (5 plans, 2 waves) — ready to execute; F-01 verified but NOT yet fixed
|
||||
last_updated: "2026-08-02T10:15:10.221Z"
|
||||
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: 11
|
||||
total_phases: 9
|
||||
completed_phases: 1
|
||||
total_plans: 44
|
||||
completed_plans: 27
|
||||
percent: 9
|
||||
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,7 +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 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
|
||||
|
||||
@@ -120,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
|
||||
|
||||
@@ -165,6 +161,6 @@ Decisions are logged in PROJECT.md (10 locked ADRs in the `<decisions>` block +
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-08-02T10:15:10.179Z
|
||||
Stopped at: Phase 10 planned (5 plans, 2 waves) — ready to execute; F-01 verified but NOT yet fixed
|
||||
Resume file: .planning/phases/10-key-material-hardening/10-01-PLAN.md
|
||||
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
|
||||
|
||||
@@ -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,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,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,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,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,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,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,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 +0,0 @@
|
||||
|
||||
-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,93 +0,0 @@
|
||||
Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 47
|
||||
versionName = "0.5.27"
|
||||
versionCode = 45
|
||||
versionName = "0.5.25"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -1,40 +1,5 @@
|
||||
package com.archipelago.app
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import android.webkit.WebView
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.fips.FipsNative
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ArchipelagoApp : Application() {
|
||||
|
||||
private val warmupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Warmups that otherwise land inside the first frame:
|
||||
// - FipsNative.available dlopens the 7 MB Rust core; referenced from
|
||||
// composition (NESMenu, mesh auto-start), it blocked the UI thread.
|
||||
// - The first DataStore read gates the nav graph's start destination;
|
||||
// parsing it here means the launch gate resolves in the first
|
||||
// emission instead of waiting on cold disk IO.
|
||||
warmupScope.launch {
|
||||
FipsNative.available
|
||||
runCatching { ServerPreferences(this@ArchipelagoApp).launchState.first() }
|
||||
}
|
||||
|
||||
// First WebView construction pays Chromium provider load (~150-400 ms
|
||||
// cold). Absorb it while the main thread is idle before the kiosk
|
||||
// needs it, instead of serially after the connection probe.
|
||||
Looper.getMainLooper().queue.addIdleHandler {
|
||||
runCatching { WebView(this).destroy() }
|
||||
false // one-shot
|
||||
}
|
||||
}
|
||||
}
|
||||
class ArchipelagoApp : Application()
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.archipelago.app.ui.navigation.AppNavHost
|
||||
import com.archipelago.app.ui.screens.releaseKioskWebView
|
||||
import com.archipelago.app.ui.theme.ArchipelagoTheme
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@@ -20,13 +19,7 @@ class MainActivity : ComponentActivity() {
|
||||
private val pendingPairUri = MutableStateFlow<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Hold the branded system splash until the nav graph has its launch
|
||||
// state — without this the splash dropped at the first composed frame,
|
||||
// which was EMPTY (the DataStore read hadn't landed): splash → black
|
||||
// flash → UI on every launch.
|
||||
var navReady = false
|
||||
val splash = installSplashScreen()
|
||||
splash.setKeepOnScreenCondition { !navReady }
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
pendingPairUri.value = intent?.dataString
|
||||
@@ -36,7 +29,6 @@ class MainActivity : ComponentActivity() {
|
||||
AppNavHost(
|
||||
pairUri = pairUri,
|
||||
onPairUriConsumed = { pendingPairUri.value = null },
|
||||
onReady = { navReady = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -46,14 +38,4 @@ class MainActivity : ComponentActivity() {
|
||||
super.onNewIntent(intent)
|
||||
pendingPairUri.value = intent.dataString
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
// Swiped out of recents (or otherwise finished) — let go of the
|
||||
// retained kiosk WebView so the next launch starts clean. Without
|
||||
// this the FIPS service keeps the process (and the static WebView)
|
||||
// alive, and "close the app" no longer restarted it. isFinishing
|
||||
// keeps config changes (rotation) on the fast reattach path.
|
||||
if (isFinishing) releaseKioskWebView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "server_prefs")
|
||||
@@ -30,18 +29,6 @@ data class ServerEntry(
|
||||
/** Label to show in lists — the user-given name, or the address if unnamed. */
|
||||
fun displayName(): String = name.ifBlank { address }
|
||||
|
||||
/**
|
||||
* Is this node reachable over the Archipelago FIPS mesh?
|
||||
*
|
||||
* A node that advertised either identity (npub) or a mesh address (ULA)
|
||||
* came from a FIPS-capable pairing QR. Anything else — a hand-entered LAN
|
||||
* box, someone else's server behind their own VPN — is a plain HTTP
|
||||
* target, and the companion must NOT raise its own tunnel for it: Android
|
||||
* allows exactly one VPN at a time, so doing so would silently take the
|
||||
* tunnel away from whatever the user actually uses to reach that node.
|
||||
*/
|
||||
fun isFipsNode(): Boolean = npub.isNotBlank() || meshIp.isNotBlank()
|
||||
|
||||
/** Bracket bare IPv6 literals (the mesh ULA) so they form valid URLs. */
|
||||
private fun urlHost(host: String): String =
|
||||
if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
|
||||
@@ -102,9 +89,9 @@ class ServerPreferences(private val context: Context) {
|
||||
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
||||
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
|
||||
|
||||
private fun activeServerFrom(prefs: Preferences): ServerEntry? {
|
||||
val address = prefs[activeAddressKey] ?: return null
|
||||
return ServerEntry(
|
||||
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
|
||||
val address = prefs[activeAddressKey] ?: return@map null
|
||||
ServerEntry(
|
||||
address = address,
|
||||
useHttps = prefs[activeHttpsKey] ?: false,
|
||||
port = prefs[activePortKey] ?: "",
|
||||
@@ -115,52 +102,19 @@ class ServerPreferences(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
// distinctUntilChanged on every flow: DataStore emits on EVERY write to the
|
||||
// file regardless of key, and each spurious emission recomposed whatever
|
||||
// screen collected it (the kiosk recomposed on gesture-hint writes).
|
||||
|
||||
val activeServer: Flow<ServerEntry?> = context.dataStore.data
|
||||
.map { prefs -> activeServerFrom(prefs) }
|
||||
.distinctUntilChanged()
|
||||
|
||||
val savedServers: Flow<List<ServerEntry>> = context.dataStore.data.map { prefs ->
|
||||
val raw = prefs[savedServersKey] ?: emptySet()
|
||||
// Sorted so set-iteration order can't produce a structurally different
|
||||
// list for the same servers (which defeats distinctUntilChanged).
|
||||
raw.mapNotNull { ServerEntry.deserialize(it) }.sortedBy { it.displayName() }
|
||||
}.distinctUntilChanged()
|
||||
raw.mapNotNull { ServerEntry.deserialize(it) }
|
||||
}
|
||||
|
||||
val introSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||
prefs[introSeenKey] ?: false
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
/** One-shot flag for the three-finger-hold teaching overlay. */
|
||||
val gestureHintSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||
prefs[gestureHintSeenKey] ?: false
|
||||
}.distinctUntilChanged()
|
||||
|
||||
/** Everything the nav graph needs to pick a start destination, derived
|
||||
* from ONE DataStore emission. Collecting introSeen and activeServer as
|
||||
* two separate flows let them land in different frames — the intro flag
|
||||
* could resolve first and flash the Connect screen at a paired user
|
||||
* before the active server arrived. */
|
||||
data class LaunchState(
|
||||
val introSeen: Boolean,
|
||||
val activeServer: ServerEntry?,
|
||||
/** Every saved node — the launch gate needs the COUNT to decide
|
||||
* whether to ask which one to connect to. */
|
||||
val savedServers: List<ServerEntry>,
|
||||
)
|
||||
|
||||
val launchState: Flow<LaunchState> = context.dataStore.data.map { prefs ->
|
||||
LaunchState(
|
||||
introSeen = prefs[introSeenKey] ?: false,
|
||||
activeServer = activeServerFrom(prefs),
|
||||
savedServers = (prefs[savedServersKey] ?: emptySet())
|
||||
.mapNotNull { ServerEntry.deserialize(it) }
|
||||
.sortedBy { it.displayName() },
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
suspend fun setActiveServer(server: ServerEntry) {
|
||||
context.dataStore.edit { prefs ->
|
||||
|
||||
@@ -37,7 +37,6 @@ class ArchyVpnService : VpnService() {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var warmerJob: Job? = null
|
||||
private var handoffKickJob: Job? = null
|
||||
|
||||
// Seamless transport handoff (Wi-Fi ⇄ 5G ⇄ future BLE). Without this the
|
||||
// tunnel's underlying network stays pinned to the interface that was
|
||||
@@ -205,20 +204,14 @@ class ArchyVpnService : VpnService() {
|
||||
|
||||
/**
|
||||
* Track the phone's default network and hand the mesh over to it as the
|
||||
* phone roams (Wi-Fi ⇄ 5G). Two actions per change:
|
||||
* phone roams (Wi-Fi ⇄ 5G, and later BLE). Two actions per change:
|
||||
* 1. setUnderlyingNetworks(new) — the tunnel's packets follow the live
|
||||
* network instead of dying on the one it launched with.
|
||||
* 2. re-home the mesh — kick the session warmer so discovery + sessions
|
||||
* rebuild on the new path; the node's own fast-reconnect (1s) redials
|
||||
* peers over the new route.
|
||||
* rebuild on the new path immediately; the node's own fast-reconnect
|
||||
* (1s) redials peers over the new route.
|
||||
* onAvailable also fires for the FIRST network, which is how the initial
|
||||
* underlying network gets set.
|
||||
*
|
||||
* requestNetwork, NOT registerDefaultNetworkCallback: this app is routed
|
||||
* through its own TUN, so its "default network" IS the VPN — a default
|
||||
* callback fires once with our own tunnel and never again on Wi-Fi ⇄ 5G.
|
||||
* A NetworkRequest's default capabilities include NOT_VPN, so requestNetwork
|
||||
* tracks the best real transport underneath instead.
|
||||
*/
|
||||
private fun registerNetworkHandoff() {
|
||||
if (networkCallback != null) return
|
||||
@@ -243,6 +236,10 @@ class ArchyVpnService : VpnService() {
|
||||
}
|
||||
}
|
||||
networkCallback = cb
|
||||
// requestNetwork tracks the BEST network of the request; when the
|
||||
// phone moves Wi-Fi→5G the callback re-fires onAvailable with the new
|
||||
// one. (registerDefaultNetworkCallback would also work; requestNetwork
|
||||
// lets us extend to BLE-capable transports later.)
|
||||
runCatching { cm.requestNetwork(request, cb) }
|
||||
}
|
||||
|
||||
@@ -254,22 +251,13 @@ class ArchyVpnService : VpnService() {
|
||||
runCatching { setUnderlyingNetworks(arrayOf(network)) }
|
||||
if (changed && FipsNative.isRunning()) {
|
||||
Log.i(TAG, "network handoff → re-homing mesh on new default network")
|
||||
// Coalesced, not immediate: marginal Wi-Fi flaps the default
|
||||
// Wi-Fi ⇄ cell in bursts, and an aggressive warmer pass per flip
|
||||
// meant near-constant session churn — the "reconnects a lot"
|
||||
// report. The re-pin above still happens on every change; only
|
||||
// the rediscovery kick waits for the network to hold still.
|
||||
handoffKickJob?.cancel()
|
||||
handoffKickJob = scope.launch {
|
||||
delay(2_000)
|
||||
if (FipsNative.isRunning()) startSessionWarmer()
|
||||
}
|
||||
// Fresh warmer pass drives immediate rediscovery/session rebuild
|
||||
// on the new path instead of waiting out dead-link timeouts.
|
||||
startSessionWarmer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterNetworkHandoff() {
|
||||
handoffKickJob?.cancel()
|
||||
handoffKickJob = null
|
||||
val cm = connectivityManager
|
||||
val cb = networkCallback
|
||||
if (cm != null && cb != null) {
|
||||
|
||||
@@ -3,10 +3,8 @@ package com.archipelago.app.fips
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Glue between pairing and the mesh: persists the node peer from a scanned
|
||||
@@ -38,27 +36,20 @@ object FipsManager {
|
||||
* No-op on devices without the native lib (non-arm64).
|
||||
*/
|
||||
suspend fun registerNode(context: Context, info: FipsPairInfo?, alias: String) {
|
||||
if (info == null) return
|
||||
// Every caller reaches this from a Compose scope — i.e. the MAIN
|
||||
// thread — the instant a pairing QR decodes. Everything below is
|
||||
// main-hostile: touching FipsNative dlopens the 7 MB mesh core,
|
||||
// ensureIdentity runs native ed25519 keygen, and VpnService.prepare
|
||||
// is a binder round-trip. Left on the UI thread it froze the frame
|
||||
// right after the camera got the code, which reads as "the scanner
|
||||
// is slow" when the scan itself already succeeded.
|
||||
val consent = withContext(Dispatchers.IO) {
|
||||
if (!FipsNative.available) return@withContext null
|
||||
val prefs = FipsPreferences(context)
|
||||
ensureIdentity(prefs)
|
||||
prefs.upsertNodePeer(info, alias)
|
||||
peersDirty = true
|
||||
// Restart the mesh with the new peer RIGHT NOW when consent already
|
||||
// exists — relying on the consentNeeded collector left a running
|
||||
// mesh on the OLD peer list whenever the collector wasn't active
|
||||
// (fresh pairings looked dead until a full app restart).
|
||||
VpnService.prepare(context) == null
|
||||
} ?: return
|
||||
if (consent) startService(context) else _consentNeeded.value = true
|
||||
if (info == null || !FipsNative.available) return
|
||||
val prefs = FipsPreferences(context)
|
||||
ensureIdentity(prefs)
|
||||
prefs.upsertNodePeer(info, alias)
|
||||
peersDirty = true
|
||||
// Restart the mesh with the new peer RIGHT NOW when consent already
|
||||
// exists — relying on the consentNeeded collector left a running
|
||||
// mesh on the OLD peer list whenever the collector wasn't active
|
||||
// (fresh pairings looked dead until a full app restart).
|
||||
if (VpnService.prepare(context) == null) {
|
||||
startService(context)
|
||||
} else {
|
||||
_consentNeeded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate-once mesh identity. Returns null only if the RNG/native fails. */
|
||||
@@ -76,17 +67,11 @@ object FipsManager {
|
||||
* through AppNavHost instead.
|
||||
*/
|
||||
suspend fun autoStartIfReady(context: Context) {
|
||||
// Self-dispatching for the same reason as registerNode: callers reach
|
||||
// this from Compose scopes, and dlopen + binder must not ride the UI
|
||||
// thread (the connect path calls it while the scanner is still up).
|
||||
val ready = withContext(Dispatchers.IO) {
|
||||
if (!FipsNative.available) return@withContext false
|
||||
val prefs = FipsPreferences(context)
|
||||
if (prefs.identity() == null || !prefs.hasPeers()) return@withContext false
|
||||
// consent missing — don't prompt here
|
||||
VpnService.prepare(context) == null
|
||||
}
|
||||
if (ready) startService(context)
|
||||
if (!FipsNative.available) return
|
||||
val prefs = FipsPreferences(context)
|
||||
if (prefs.identity() == null || !prefs.hasPeers()) return
|
||||
if (VpnService.prepare(context) != null) return // consent missing — don't prompt here
|
||||
startService(context)
|
||||
}
|
||||
|
||||
fun startService(context: Context) {
|
||||
|
||||
@@ -1,46 +1,37 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.ui.screens.PixelArtLogo
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
|
||||
/**
|
||||
* Full-screen loader shown while the app is dialing a node.
|
||||
*
|
||||
* Two faces, because they are two different promises:
|
||||
* - [mesh] `true` — a FIPS node: the branded "F*CK IPs" screen, because what
|
||||
* is loading really is a connection to a cryptographic identity, not an IP.
|
||||
* - [mesh] `false` — a plain node reached over the network like anything
|
||||
* else. No mesh branding at all: claiming the mesh is carrying a connection
|
||||
* it isn't is worse than an anonymous spinner.
|
||||
* The branded "F*CK IPs" full-screen loader — shown whenever the app is
|
||||
* dialing the node over the mesh (relaunch race, post-scan first connect),
|
||||
* instead of an anonymous spinner. The point of the brand: what's loading
|
||||
* is a connection to a cryptographic identity, not an IP.
|
||||
*/
|
||||
@Composable
|
||||
fun MeshLoadingScreen(
|
||||
mesh: Boolean = true,
|
||||
nodeName: String = "",
|
||||
done: Boolean = false,
|
||||
) {
|
||||
fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs harmed") {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -48,39 +39,39 @@ fun MeshLoadingScreen(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// The app's own badge — the same ringed mark as the launcher icon
|
||||
// and the system splash, so launch → splash → this screen is one
|
||||
// continuous identity.
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(112.dp),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
// The brand's circle-container logo (as on the connect screen /
|
||||
// web login): pixel-art "a" centered in a black disc.
|
||||
Box(
|
||||
Modifier
|
||||
.size(120.dp)
|
||||
.clip(androidx.compose.foundation.shape.CircleShape)
|
||||
.background(Color.Black)
|
||||
.border(
|
||||
1.dp,
|
||||
Color.White.copy(alpha = 0.14f),
|
||||
androidx.compose.foundation.shape.CircleShape,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
PixelArtLogo(Modifier.size(64.dp))
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
text = if (mesh) "F*CK IPS MESH" else "CONNECTING",
|
||||
text = "F*CK IPs MESH",
|
||||
color = BitcoinOrange,
|
||||
fontSize = 16.sp,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 4.sp,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = when {
|
||||
mesh -> "Dialing your node by its key — no IPs harmed"
|
||||
nodeName.isNotBlank() -> "Reaching $nodeName"
|
||||
else -> "Reaching your node"
|
||||
},
|
||||
color = if (done) TextPrimary else TextMuted,
|
||||
text = message,
|
||||
color = TextMuted,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
SlidingLoader(
|
||||
modifier = Modifier.width(220.dp),
|
||||
done = done,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
CircularProgressIndicator(color = BitcoinOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.RestartAlt
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -71,7 +70,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.ui.screens.restartCompanionApp
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SurfaceDark
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
@@ -223,11 +221,7 @@ private fun MenuPanel(
|
||||
HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") {
|
||||
page = HubPage.NODES
|
||||
}
|
||||
// Mesh oversight only when this session is actually on the
|
||||
// mesh. Offering "FIPS Mesh" while connected to a plain node
|
||||
// (whose traffic is going nowhere near the tunnel) advertises
|
||||
// a connection the user doesn't have.
|
||||
if (FipsNative.available && activeServer?.isFipsNode() == true) {
|
||||
if (FipsNative.available) {
|
||||
HubCard(Icons.Default.Bolt, "FIPS Mesh", "Mesh identity & status") { page = HubPage.FIPS }
|
||||
}
|
||||
if (onMeshParty != null) {
|
||||
@@ -235,36 +229,6 @@ private fun MenuPanel(
|
||||
}
|
||||
// Dark/Classic style lives on the remote/keyboard screen next to
|
||||
// the settings button — not here.
|
||||
|
||||
// Small version chip at the hub's foot — the one place a
|
||||
// connected user can always check what build they're on.
|
||||
val hubContext = LocalContext.current
|
||||
|
||||
// Restart: the dashboard WebView is retained across
|
||||
// remote ⇄ dashboard (that's the point), which also means a
|
||||
// wedged page can't be cleared by leaving the screen. This
|
||||
// throws the page away and relaunches the app clean — the mesh
|
||||
// service keeps running.
|
||||
HubCard(Icons.Default.RestartAlt, "Restart", "Reload the app from scratch") {
|
||||
onDismiss()
|
||||
restartCompanionApp(hubContext)
|
||||
}
|
||||
val versionLabel = remember {
|
||||
runCatching {
|
||||
hubContext.packageManager
|
||||
.getPackageInfo(hubContext.packageName, 0).versionName
|
||||
}.getOrNull()?.let { "Companion v$it" } ?: ""
|
||||
}
|
||||
if (versionLabel.isNotEmpty()) {
|
||||
Text(
|
||||
versionLabel,
|
||||
color = TextMuted.copy(alpha = 0.6f),
|
||||
fontSize = 11.sp,
|
||||
letterSpacing = 1.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HubPage.NODES -> {
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraManager
|
||||
import android.hardware.camera2.CameraMetadata
|
||||
import android.hardware.camera2.CaptureRequest
|
||||
import android.os.Process
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.camera2.interop.Camera2Interop
|
||||
import androidx.camera.camera2.interop.ExperimentalCamera2Interop
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.FocusMeteringAction
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.core.SurfaceOrientedMeteringPointFactory
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -26,26 +16,22 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.FlashOff
|
||||
import androidx.compose.material.icons.filled.FlashOn
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -60,15 +46,10 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
@@ -78,26 +59,23 @@ import com.archipelago.app.data.PairResult
|
||||
import com.archipelago.app.data.ServerQrParser
|
||||
import com.archipelago.app.ui.screens.GlassButton
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.DecodeHintType
|
||||
import com.google.zxing.MultiFormatReader
|
||||
import com.google.zxing.NotFoundException
|
||||
import com.google.zxing.PlanarYUVLuminanceSource
|
||||
import com.google.zxing.common.GlobalHistogramBinarizer
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
import com.google.zxing.qrcode.QRCodeReader
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Scans the node pairing QR (docs/companion-pairing-qr.md) and reports the
|
||||
* decoded server entry. Handles the camera permission itself; foreign/invalid
|
||||
* codes show a hint in the status strip and scanning continues.
|
||||
*
|
||||
* Visually this is the SAME glass modal the web wallet uses (neode-ui's
|
||||
* WalletScanModal) — scrim, glass card, square preview, orange viewfinder,
|
||||
* status strip — so pairing from the app and scanning from the web UI look
|
||||
* like one product rather than two different scanners.
|
||||
* Full-screen camera overlay that scans the node pairing QR
|
||||
* (docs/companion-pairing-qr.md) and reports the decoded server entry.
|
||||
* Handles the camera permission itself; foreign/invalid codes show a hint
|
||||
* and scanning continues.
|
||||
*/
|
||||
@Composable
|
||||
fun QrScannerOverlay(
|
||||
@@ -105,14 +83,28 @@ fun QrScannerOverlay(
|
||||
onDismiss: () -> Unit,
|
||||
onServerScanned: (PairResult.Success) -> Unit,
|
||||
) {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
var hintRes by remember { mutableStateOf<Int?>(null) }
|
||||
var handled by remember { mutableStateOf(false) }
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> hasPermission = granted }
|
||||
|
||||
LaunchedEffect(visible) {
|
||||
if (visible) {
|
||||
handled = false
|
||||
hintRes = null
|
||||
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission = granted
|
||||
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,331 +116,125 @@ fun QrScannerOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
QrGlassModal(
|
||||
visible = visible,
|
||||
title = stringResource(R.string.scan_node_qr),
|
||||
status = hintRes?.let { stringResource(it) to true },
|
||||
idleHint = stringResource(R.string.scan_qr_hint),
|
||||
permissionRationale = stringResource(R.string.camera_permission_needed),
|
||||
onDismiss = onDismiss,
|
||||
onDecoded = { text ->
|
||||
if (!handled) {
|
||||
when (val result = ServerQrParser.parse(text)) {
|
||||
is PairResult.Success -> {
|
||||
handled = true
|
||||
// Confirm the hit in the hand — the eye is still on the
|
||||
// code, not on the screen.
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onServerScanned(result)
|
||||
}
|
||||
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
|
||||
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared native scanner shell — one visual contract for every camera the
|
||||
* app opens (pairing, wallet), mirroring neode-ui's WalletScanModal so the
|
||||
* native and web scanners are indistinguishable:
|
||||
* - black/60 scrim, dismiss on tap-outside
|
||||
* - glass card (rounded 24, white/10 hairline) capped at 420dp
|
||||
* - square preview with the 62% orange viewfinder and a darkened surround
|
||||
* - a status strip that carries hints and errors
|
||||
* - an optional footer (the wallet's "Upload image")
|
||||
*/
|
||||
@Composable
|
||||
internal fun QrGlassModal(
|
||||
visible: Boolean,
|
||||
title: String,
|
||||
// message + isError; null falls back to [idleHint].
|
||||
status: Pair<String, Boolean>?,
|
||||
idleHint: String,
|
||||
permissionRationale: String,
|
||||
onDismiss: () -> Unit,
|
||||
onDecoded: (String) -> Unit,
|
||||
footer: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
var torchOn by remember { mutableStateOf(false) }
|
||||
var hasTorch by remember { mutableStateOf(false) }
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> hasPermission = granted }
|
||||
|
||||
LaunchedEffect(visible) {
|
||||
if (visible) {
|
||||
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission = granted
|
||||
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
} else {
|
||||
torchOn = false
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||
BackHandler { onDismiss() }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onDismiss,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
.background(Color.Black),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(16.dp)
|
||||
.widthIn(max = 420.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF212151C))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(24.dp))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = {}, // swallow — only the scrim dismisses
|
||||
)
|
||||
.padding(24.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
stringResource(R.string.close),
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.Black.copy(alpha = 0.4f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasPermission) {
|
||||
CameraQrPreview(
|
||||
onDecoded = onDecoded,
|
||||
torchOn = torchOn,
|
||||
onTorchAvailable = { hasTorch = it },
|
||||
)
|
||||
// Viewfinder — 62% of the preview, matching the web
|
||||
// modal's .scan-viewfinder, and matching the ROI the
|
||||
// decoder actually reads (QR_ROI_FRACTION).
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize(QR_ROI_FRACTION)
|
||||
.border(
|
||||
2.dp,
|
||||
BitcoinOrange.copy(alpha = 0.85f),
|
||||
RoundedCornerShape(16.dp),
|
||||
),
|
||||
)
|
||||
if (hasTorch) {
|
||||
IconButton(
|
||||
onClick = { torchOn = !torchOn },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(6.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.Black.copy(alpha = 0.45f)),
|
||||
) {
|
||||
Icon(
|
||||
if (torchOn) Icons.Default.FlashOn else Icons.Default.FlashOff,
|
||||
stringResource(
|
||||
if (torchOn) R.string.torch_off else R.string.torch_on,
|
||||
),
|
||||
tint = if (torchOn) BitcoinOrange else Color.White.copy(alpha = 0.85f),
|
||||
)
|
||||
if (hasPermission) {
|
||||
CameraQrPreview(
|
||||
onDecoded = { text ->
|
||||
if (!handled) {
|
||||
when (val result = ServerQrParser.parse(text)) {
|
||||
is PairResult.Success -> {
|
||||
handled = true
|
||||
onServerScanned(result)
|
||||
}
|
||||
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
|
||||
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = permissionRationale,
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
GlassButton(
|
||||
text = stringResource(R.string.grant_camera_access),
|
||||
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
},
|
||||
)
|
||||
// Aim frame
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.White.copy(alpha = 0.05f))
|
||||
.padding(12.dp)
|
||||
.defaultMinSize(minHeight = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
.align(Alignment.Center)
|
||||
.size(260.dp)
|
||||
.border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = status?.first?.takeIf { it.isNotBlank() } ?: idleHint,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (status?.second == true) {
|
||||
Color(0xFFF87171)
|
||||
} else {
|
||||
Color.White.copy(alpha = 0.6f)
|
||||
},
|
||||
text = stringResource(R.string.camera_permission_needed),
|
||||
color = TextPrimary,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
GlassButton(
|
||||
text = stringResource(R.string.grant_camera_access),
|
||||
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Top bar: title + close
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_node_qr),
|
||||
color = TextPrimary,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom hints
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(horizontal = 32.dp, vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
hintRes?.let { res ->
|
||||
Text(
|
||||
text = stringResource(res),
|
||||
color = BitcoinOrange,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
if (hasPermission) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_qr_hint),
|
||||
color = TextMuted,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
if (footer != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
footer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm the CameraX provider and the ZXing decode path before the user ever
|
||||
* asks for a scan, so opening the scanner doesn't pay provider init + class
|
||||
* loading on the critical path. Does NOT open the camera: no permission is
|
||||
* needed, no LED lights up, nothing is recorded — [ProcessCameraProvider]
|
||||
* init is process-wide and cached, and the synthetic decode below just walks
|
||||
* a blank 32x32 frame to class-load the binarizer/detector.
|
||||
*
|
||||
* Called once per process from the kiosk WebView (first page load) and by the
|
||||
* page via `ArchipelagoQr.prewarm()`.
|
||||
*/
|
||||
internal fun prewarmQrScanner(context: Context) {
|
||||
if (!qrPrewarmed.compareAndSet(false, true)) return
|
||||
val app = context.applicationContext
|
||||
runCatching { ProcessCameraProvider.getInstance(app) }
|
||||
// Off the UI thread: the first decode attempt loads a dozen ZXing classes.
|
||||
Executors.newSingleThreadExecutor().let { exec ->
|
||||
exec.execute {
|
||||
runCatching {
|
||||
val blank = ByteArray(32 * 32)
|
||||
val reader = MultiFormatReader().apply {
|
||||
setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE)))
|
||||
}
|
||||
val source = PlanarYUVLuminanceSource(blank, 32, 32, 0, 0, 32, 32, false)
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
|
||||
}
|
||||
}
|
||||
exec.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
private val qrPrewarmed = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* Fraction of the preview's shorter edge that both the on-screen viewfinder
|
||||
* and the decoder's region of interest use. Keeping them identical is the
|
||||
* point: the user aims at the box, and the box is exactly what gets decoded.
|
||||
*/
|
||||
internal const val QR_ROI_FRACTION = 0.62f
|
||||
|
||||
/**
|
||||
* Shared by the pairing scanner and the wallet scan modal.
|
||||
*
|
||||
* [torchOn] drives the flash; [onTorchAvailable] reports whether this camera
|
||||
* has one at all (the caller only draws its toggle when it does).
|
||||
*
|
||||
* ## Why this looks the way it does
|
||||
*
|
||||
* The previous version hunted: a scheduled tick alternated the optical zoom
|
||||
* between 1x and 1.5x and re-fired `startFocusAndMetering(...disableAutoCancel())`
|
||||
* every 2 seconds. Both are camera-hostile:
|
||||
*
|
||||
* - Every zoom step restarts AE/AF convergence, so the sensor spends the
|
||||
* seconds right after it delivering soft frames — precisely the frames the
|
||||
* decoder needs to be sharp. The visible symptom is the "zooms in and out
|
||||
* and takes ages" report.
|
||||
* - `disableAutoCancel()` leaves AF **locked** at whatever it converged on
|
||||
* instead of handing the lens back to continuous AF, so a re-aim never
|
||||
* refocused on its own; the next timer tick then kicked off another full
|
||||
* sweep from a locked position — a lens that hunts forever.
|
||||
*
|
||||
* A stock camera app does neither. It leaves CameraX's continuous AF alone,
|
||||
* refocuses on tap, and never touches zoom. This does the same, with one
|
||||
* concession to the "hand-held QR is a static scene" case: if nothing has
|
||||
* decoded for a few seconds, ONE auto-cancelling focus nudge is issued (and
|
||||
* then not again for a while), which re-arms continuous AF instead of
|
||||
* fighting it.
|
||||
*/
|
||||
/** Shared by the pairing scanner and the wallet scan modal. */
|
||||
@Composable
|
||||
internal fun CameraQrPreview(
|
||||
onDecoded: (String) -> Unit,
|
||||
torchOn: Boolean = false,
|
||||
onTorchAvailable: (Boolean) -> Unit = {},
|
||||
) {
|
||||
internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val currentOnDecoded by rememberUpdatedState(onDecoded)
|
||||
val currentOnTorchAvailable by rememberUpdatedState(onTorchAvailable)
|
||||
var camera by remember { mutableStateOf<androidx.camera.core.Camera?>(null) }
|
||||
val previewView = remember {
|
||||
PreviewView(context).apply {
|
||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
// TextureView, not the SurfaceView default: SurfaceView punches a
|
||||
// hole in the window, which black-flashes inside Compose fades and
|
||||
// ignores rounded-corner clipping (the glass modal).
|
||||
// ignores rounded-corner clipping (wallet modal).
|
||||
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||
}
|
||||
}
|
||||
// Set by the analyzer on every decode; the focus nudge below reads it to
|
||||
// tell "nothing in view" from "reading fine, leave the camera alone".
|
||||
val lastDecodeAt = remember { java.util.concurrent.atomic.AtomicLong(0L) }
|
||||
// A tap-to-focus wins over the periodic centre AF for a few seconds.
|
||||
val lastTapFocusAt = remember { java.util.concurrent.atomic.AtomicLong(0L) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
// Analysis runs at display priority: the decode thread competes with
|
||||
// the FIPS mesh service's native workers in this same process, and a
|
||||
// background-priority analyzer is exactly how a sharp, well-framed
|
||||
// code still takes seconds to land.
|
||||
val analysisExecutor = Executors.newSingleThreadExecutor { r ->
|
||||
Thread {
|
||||
Process.setThreadPriority(Process.THREAD_PRIORITY_DISPLAY)
|
||||
r.run()
|
||||
}.apply { name = "qr-analyzer" }
|
||||
}
|
||||
val analysisExecutor = Executors.newSingleThreadExecutor()
|
||||
val mainExecutor = ContextCompat.getMainExecutor(context)
|
||||
val providerFuture = ProcessCameraProvider.getInstance(context)
|
||||
var provider: ProcessCameraProvider? = null
|
||||
@@ -457,18 +243,15 @@ internal fun CameraQrPreview(
|
||||
providerFuture.addListener({
|
||||
val p = providerFuture.get()
|
||||
provider = p
|
||||
val previewBuilder = Preview.Builder()
|
||||
tuneForBarcodes(previewBuilder, context)
|
||||
val preview = previewBuilder.build().also {
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
// Dense Lightning-invoice QRs need BOTH enough pixels per module and
|
||||
// sharp focus. 1280x720 left dense invoices undecodable while sparse
|
||||
// address QRs still read — the "scanner doesn't pick up invoices"
|
||||
// report. 1920x1080 roughly doubles module resolution. The analyzer
|
||||
// never binarizes the full 2 MP: it reads the centre ROI at this
|
||||
// resolution (for dense codes) and the whole frame at half of it
|
||||
// (for coverage), so the big frame costs little.
|
||||
// sharp focus. 1280x720 + a far-focused camera (e.g. Pixel 9a's main
|
||||
// lens, which won't focus close) left dense invoices undecodable
|
||||
// while sparse address QRs still read — the "scanner doesn't pick up
|
||||
// invoices" report. 1920x1080 roughly doubles module resolution so a
|
||||
// QR held at the camera's actual focus distance still resolves.
|
||||
@Suppress("DEPRECATION")
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setTargetResolution(android.util.Size(1920, 1080))
|
||||
@@ -477,47 +260,25 @@ internal fun CameraQrPreview(
|
||||
.also {
|
||||
it.setAnalyzer(
|
||||
analysisExecutor,
|
||||
QrCodeAnalyzer { text ->
|
||||
lastDecodeAt.set(System.currentTimeMillis())
|
||||
mainExecutor.execute { currentOnDecoded(text) }
|
||||
},
|
||||
QrCodeAnalyzer { text -> mainExecutor.execute { currentOnDecoded(text) } },
|
||||
)
|
||||
}
|
||||
try {
|
||||
p.unbindAll()
|
||||
val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
||||
camera = cam
|
||||
currentOnTorchAvailable(cam.cameraInfo.hasFlashUnit())
|
||||
// Start the clock at bind time so the nudge below waits for the
|
||||
// user to actually aim before it does anything.
|
||||
lastDecodeAt.set(System.currentTimeMillis())
|
||||
// Centre point, normalized — valid before the view is measured.
|
||||
val point = SurfaceOrientedMeteringPointFactory(1f, 1f).createPoint(0.5f, 0.5f)
|
||||
// A one-shot AF action puts the lens in AUTO — i.e. LOCKED —
|
||||
// until it auto-cancels. The default 5s lock is far too long
|
||||
// here: it spans exactly the window where the user is swinging
|
||||
// the phone towards the code, and a locked lens cannot follow
|
||||
// them. Hand control back after 1s so CONTINUOUS_PICTURE (set
|
||||
// explicitly in tuneForBarcodes) does the real work, which is
|
||||
// what actually tracks a moving aim.
|
||||
val focusAction = FocusMeteringAction.Builder(point, FocusMeteringAction.FLAG_AF)
|
||||
.setAutoCancelDuration(1, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
var lastNudgeAt = 0L
|
||||
// Force a centre autofocus on a repeating tick. A hand-held QR is
|
||||
// a static scene, so continuous-AF often never retriggers and the
|
||||
// lens sits at its resting (far) focus — fatal for dense codes.
|
||||
// A normalized centre point works before the view is measured.
|
||||
val point = androidx.camera.core.SurfaceOrientedMeteringPointFactory(1f, 1f)
|
||||
.createPoint(0.5f, 0.5f)
|
||||
val focusAction = androidx.camera.core.FocusMeteringAction.Builder(
|
||||
point,
|
||||
androidx.camera.core.FocusMeteringAction.FLAG_AF,
|
||||
).disableAutoCancel().build()
|
||||
focusScheduler.scheduleWithFixedDelay({
|
||||
val now = System.currentTimeMillis()
|
||||
// The nudge only exists for the one case continuous AF
|
||||
// genuinely misses: the phone held perfectly still on a
|
||||
// code while the lens sits at its resting focus, with no
|
||||
// scene change to trigger a sweep.
|
||||
if (now - lastDecodeAt.get() > 2_000 &&
|
||||
now - lastNudgeAt > 3_000 &&
|
||||
now - lastTapFocusAt.get() > 3_000
|
||||
) {
|
||||
lastNudgeAt = now
|
||||
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
|
||||
}
|
||||
}, 1, 1, java.util.concurrent.TimeUnit.SECONDS)
|
||||
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
|
||||
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS)
|
||||
} catch (_: Exception) {
|
||||
// Camera unavailable — the user can dismiss and enter details manually.
|
||||
}
|
||||
@@ -525,251 +286,66 @@ internal fun CameraQrPreview(
|
||||
|
||||
onDispose {
|
||||
focusScheduler.shutdownNow()
|
||||
runCatching { camera?.cameraControl?.enableTorch(false) }
|
||||
camera = null
|
||||
provider?.unbindAll()
|
||||
analysisExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
// Torch follows the caller's state (and switches off when the view goes).
|
||||
LaunchedEffect(camera, torchOn) {
|
||||
runCatching { camera?.cameraControl?.enableTorch(torchOn) }
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
factory = { previewView },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Tap-to-focus: the ROI assumes the code is centred; a tap lets the
|
||||
// user point at one that isn't, or re-trigger AF the instant
|
||||
// they've framed it.
|
||||
.pointerInput(camera) {
|
||||
detectTapGestures { offset ->
|
||||
val cam = camera ?: return@detectTapGestures
|
||||
val factory = previewView.meteringPointFactory
|
||||
val action = FocusMeteringAction.Builder(
|
||||
factory.createPoint(offset.x, offset.y),
|
||||
FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE,
|
||||
).build()
|
||||
lastTapFocusAt.set(System.currentTimeMillis())
|
||||
runCatching { cam.cameraControl.startFocusAndMetering(action) }
|
||||
}
|
||||
},
|
||||
)
|
||||
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the capture session the way a dedicated barcode scanner does,
|
||||
* rather than the way a photo app does.
|
||||
*
|
||||
* The single most valuable knob is **CONTROL_AE_TARGET_FPS_RANGE**. Left
|
||||
* alone, auto-exposure indoors happily drops the sensor to 10–15 fps and
|
||||
* takes 60–100 ms exposures — every hand-held frame is then motion-blurred,
|
||||
* and a blurred QR is not a slow decode, it is *no* decode. The user waves
|
||||
* the phone about waiting for a lock that cannot happen. Pinning the lower
|
||||
* bound of the AE range as high as the device allows caps exposure time
|
||||
* (~33 ms at 30 fps), so frames come out sharp; AE compensates with gain
|
||||
* instead, and ZXing tolerates noise far better than it tolerates blur.
|
||||
* (Dark rooms get grainier as a result — that is what the torch button is
|
||||
* for, and grainy-but-sharp still decodes where smooth-but-smeared never
|
||||
* does.)
|
||||
*
|
||||
* CONTINUOUS_PICTURE is set explicitly so that when a tap-to-focus action
|
||||
* expires, CameraX restores continuous AF rather than whatever the device
|
||||
* defaults to; FAST noise/edge processing shaves ISP latency per frame.
|
||||
*
|
||||
* All of it is best-effort — an OEM that rejects a key just keeps its default.
|
||||
*/
|
||||
@androidx.annotation.OptIn(ExperimentalCamera2Interop::class)
|
||||
private fun tuneForBarcodes(builder: Preview.Builder, context: Context) {
|
||||
runCatching {
|
||||
val ext = Camera2Interop.Extender(builder)
|
||||
ext.setCaptureRequestOption(
|
||||
CaptureRequest.CONTROL_AF_MODE,
|
||||
CameraMetadata.CONTROL_AF_MODE_CONTINUOUS_PICTURE,
|
||||
)
|
||||
ext.setCaptureRequestOption(
|
||||
CaptureRequest.NOISE_REDUCTION_MODE,
|
||||
CameraMetadata.NOISE_REDUCTION_MODE_FAST,
|
||||
)
|
||||
ext.setCaptureRequestOption(
|
||||
CaptureRequest.EDGE_MODE,
|
||||
CameraMetadata.EDGE_MODE_FAST,
|
||||
)
|
||||
highestSteadyFpsRange(context)?.let {
|
||||
ext.setCaptureRequestOption(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The back camera's AE range with the highest floor, ignoring anything that
|
||||
* runs past 30 fps (those are the high-speed/slow-motion modes, which cost
|
||||
* light for frames we do not need).
|
||||
*/
|
||||
private fun highestSteadyFpsRange(context: Context): android.util.Range<Int>? = runCatching {
|
||||
val manager = context.getSystemService(CameraManager::class.java) ?: return@runCatching null
|
||||
val backId = manager.cameraIdList.firstOrNull { id ->
|
||||
manager.getCameraCharacteristics(id)
|
||||
.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK
|
||||
} ?: return@runCatching null
|
||||
manager.getCameraCharacteristics(backId)
|
||||
.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES)
|
||||
?.filter { it.upper <= 30 }
|
||||
?.maxWithOrNull(compareBy({ it.lower }, { it.upper }))
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* ZXing decoder over the camera's Y (luminance) plane.
|
||||
*
|
||||
* ## The rule this class exists to obey
|
||||
*
|
||||
* **Every frame costs the same, and every frame sees the whole scene.**
|
||||
*
|
||||
* That sounds obvious; the previous version violated both halves and produced
|
||||
* a scanner with a very specific failure: it locked on instantly if the code
|
||||
* was already in view when the camera opened, but crawled if you opened it
|
||||
* and then moved to the code. The cause was an escalation ladder — each frame
|
||||
* that failed to decode unlocked progressively more expensive searches, up to
|
||||
* a TRY_HARDER pass over the full 2 MP frame plus an inverted retry, easily
|
||||
* 150–300 ms of work.
|
||||
*
|
||||
* So the moment the user began hunting for the code, the analyzer dropped from
|
||||
* ~30 attempts per second to ~4, each one on a motion-blurred frame. By the
|
||||
* time they framed the code and held still, the pipeline was busy grinding
|
||||
* through an exhaustive search of an old, blurry frame. Escalating on failure
|
||||
* is exactly backwards: failure means the user is still aiming, which is when
|
||||
* the scanner must be at its *fastest*, not its most thorough.
|
||||
*
|
||||
* ## What runs now, on every single frame
|
||||
*
|
||||
* 1. **Centre ROI at full resolution** ([QR_ROI_FRACTION], ~0.45 MP). Full
|
||||
* sensor detail, so dense Lightning invoices keep their pixels-per-module.
|
||||
* 2. **The whole frame at half resolution** (~0.5 MP). This is what fixes the
|
||||
* "move to the code" case: coverage is no longer limited to the viewfinder
|
||||
* box on the fast path, so a code that is merely *near* the middle decodes
|
||||
* immediately instead of waiting for a slow tier to come around. A code
|
||||
* big enough to be off-centre is big enough to survive the 2x downscale.
|
||||
* 3. **One alternating second binarizer** — GlobalHistogram over the ROI on
|
||||
* even frames, over the half-frame on odd ones. Hybrid is tuned for
|
||||
* shadowed paper; most codes this app scans are on a *screen* (the node's
|
||||
* pairing popup, another phone's wallet) where a global threshold is both
|
||||
* cheaper and more reliable. Alternating keeps the per-frame budget flat.
|
||||
*
|
||||
* Two rare extras, both bounded so they can never dent the loop above: an
|
||||
* inverted ROI pass every 8th frame (light-on-dark codes), and one TRY_HARDER
|
||||
* pass over the half-frame at most once a second (skewed/damaged codes).
|
||||
*
|
||||
* Steady-state that is ~35 ms per frame — around 27 attempts per second, and
|
||||
* it does not degrade the longer the user hunts.
|
||||
*
|
||||
* Buffers are allocated once and reused: the original path allocated a fresh
|
||||
* ~2 MB array per frame, 60 MB/s of garbage at 30 fps, with GC pauses landing
|
||||
* mid-decode.
|
||||
*/
|
||||
/** ZXing-based QR decoder over the camera's Y (luminance) plane. */
|
||||
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
|
||||
// QRCodeReader directly rather than MultiFormatReader: with a single
|
||||
// format in play the dispatch and per-call state reset are pure overhead.
|
||||
private val reader = QRCodeReader()
|
||||
private val plainHints = mapOf<DecodeHintType, Any>(
|
||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||
)
|
||||
private val hardHints = mapOf<DecodeHintType, Any>(
|
||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||
DecodeHintType.TRY_HARDER to true,
|
||||
)
|
||||
|
||||
private var roiBuffer = ByteArray(0)
|
||||
private var halfBuffer = ByteArray(0)
|
||||
private var frame = 0L
|
||||
private var lastHardAt = 0L
|
||||
|
||||
private fun read(
|
||||
source: PlanarYUVLuminanceSource,
|
||||
global: Boolean = false,
|
||||
hard: Boolean = false,
|
||||
inverted: Boolean = false,
|
||||
): String? {
|
||||
val src = if (inverted) source.invert() else source
|
||||
val bitmap = BinaryBitmap(
|
||||
if (global) GlobalHistogramBinarizer(src) else HybridBinarizer(src),
|
||||
private val reader = MultiFormatReader().apply {
|
||||
setHints(
|
||||
mapOf(
|
||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||
// Screen-displayed QRs come with moiré, glare, and soft focus at
|
||||
// close range — the exhaustive search is worth the milliseconds.
|
||||
DecodeHintType.TRY_HARDER to true,
|
||||
)
|
||||
)
|
||||
return runCatching {
|
||||
reader.decode(bitmap, if (hard) hardHints else plainHints).text
|
||||
}.getOrNull().also { reader.reset() }
|
||||
}
|
||||
|
||||
private var lastAttempt = 0L
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
// Decode ~7x/s, not on every frame: TRY_HARDER (plus the inverted
|
||||
// retry) pegs a core when run at camera rate, and that CPU contention
|
||||
// is what made the preview itself stutter. KEEP_ONLY_LATEST means the
|
||||
// frames skipped here are simply dropped, so decodes stay current.
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastAttempt < 140) {
|
||||
image.close()
|
||||
return
|
||||
}
|
||||
lastAttempt = now
|
||||
try {
|
||||
val plane = image.planes[0]
|
||||
val buffer = plane.buffer
|
||||
val stride = plane.rowStride
|
||||
// YUV_420_888 permits an interleaved Y plane. Rare, but a device
|
||||
// that does it would otherwise hand the decoder pure noise.
|
||||
val pixelStride = plane.pixelStride
|
||||
val width = image.width
|
||||
val height = image.height
|
||||
frame++
|
||||
|
||||
buffer.rewind()
|
||||
val available = buffer.remaining()
|
||||
|
||||
// ── 1. Centre ROI, full resolution ──────────────────────────────
|
||||
val side = (minOf(width, height) * QR_ROI_FRACTION).toInt().coerceAtLeast(1)
|
||||
val left = (width - side) / 2
|
||||
val top = (height - side) / 2
|
||||
if (roiBuffer.size != side * side) roiBuffer = ByteArray(side * side)
|
||||
for (row in 0 until side) {
|
||||
val srcPos = (top + row) * stride + left * pixelStride
|
||||
if (srcPos + side * pixelStride > available) break
|
||||
if (pixelStride == 1) {
|
||||
buffer.position(srcPos)
|
||||
buffer.get(roiBuffer, row * side, side)
|
||||
} else {
|
||||
val dst = row * side
|
||||
for (col in 0 until side) {
|
||||
roiBuffer[dst + col] = buffer.get(srcPos + col * pixelStride)
|
||||
}
|
||||
}
|
||||
}
|
||||
val roi = PlanarYUVLuminanceSource(roiBuffer, side, side, 0, 0, side, side, false)
|
||||
read(roi)?.let { onDecoded(it); return }
|
||||
|
||||
// ── 2. Whole frame, half resolution ─────────────────────────────
|
||||
val hw = width / 2
|
||||
val hh = height / 2
|
||||
if (halfBuffer.size != hw * hh) halfBuffer = ByteArray(hw * hh)
|
||||
var truncated = false
|
||||
for (row in 0 until hh) {
|
||||
val srcRow = row * 2 * stride
|
||||
val dst = row * hw
|
||||
for (col in 0 until hw) {
|
||||
val srcPos = srcRow + col * 2 * pixelStride
|
||||
if (srcPos >= available) { truncated = true; break }
|
||||
halfBuffer[dst + col] = buffer.get(srcPos)
|
||||
}
|
||||
if (truncated) break
|
||||
}
|
||||
val half = PlanarYUVLuminanceSource(halfBuffer, hw, hh, 0, 0, hw, hh, false)
|
||||
read(half)?.let { onDecoded(it); return }
|
||||
|
||||
// ── 3. Alternating second binarizer ─────────────────────────────
|
||||
val second = if (frame % 2 == 0L) roi else half
|
||||
read(second, global = true)?.let { onDecoded(it); return }
|
||||
|
||||
// ── Bounded extras ──────────────────────────────────────────────
|
||||
if (frame % 8 == 0L) {
|
||||
read(roi, inverted = true)?.let { onDecoded(it); return }
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastHardAt >= 1_000) {
|
||||
lastHardAt = now
|
||||
read(half, hard = true)?.let { onDecoded(it); return }
|
||||
// Copy into a rowStride-wide array; the last row of the plane buffer
|
||||
// may be short of the full stride, so the tail stays zero-padded.
|
||||
val data = ByteArray(plane.rowStride * image.height)
|
||||
buffer.get(data, 0, minOf(buffer.remaining(), data.size))
|
||||
val source = PlanarYUVLuminanceSource(
|
||||
data, plane.rowStride, image.height,
|
||||
0, 0, image.width, image.height,
|
||||
false,
|
||||
)
|
||||
val result = try {
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
|
||||
} catch (_: NotFoundException) {
|
||||
// Dark-themed pages can render light-on-dark QRs — retry inverted.
|
||||
reader.reset()
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert())))
|
||||
}
|
||||
onDecoded(result.text)
|
||||
} catch (_: NotFoundException) {
|
||||
// No QR in this frame — keep scanning.
|
||||
} catch (_: Exception) {
|
||||
// Malformed frame; skip it.
|
||||
} finally {
|
||||
reader.reset()
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.keyframes
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
|
||||
/** green-400 — the same "done" colour the web install overlay lands on. */
|
||||
private val DoneGreen = Color(0xFF4ADE80)
|
||||
|
||||
/**
|
||||
* The Archipelago loading bar: a stripe that runs side to side inside a dim
|
||||
* track and lands as a solid green bar when the work completes.
|
||||
*
|
||||
* This is a direct port of the platform's install-progress overlay
|
||||
* (neode-ui SystemUpdate.vue `.install-overlay-bar-anim`): a third-width
|
||||
* orange stripe on a white/10 track, 1.8s ease-in-out, going full green on
|
||||
* success. Using the same loader natively is what makes the companion feel
|
||||
* like the same product as the node UI rather than a stock Android app.
|
||||
*
|
||||
* @param done finished successfully — the bar fills solid green.
|
||||
* @param stalled waiting on the user / something external — the bar parks
|
||||
* half-full in a dimmed orange instead of animating, so it
|
||||
* reads as "this needs you", not "still working".
|
||||
*/
|
||||
@Composable
|
||||
fun SlidingLoader(
|
||||
modifier: Modifier = Modifier,
|
||||
done: Boolean = false,
|
||||
stalled: Boolean = false,
|
||||
height: Dp = 8.dp,
|
||||
) {
|
||||
val doneProgress by animateFloatAsState(
|
||||
targetValue = if (done) 1f else 0f,
|
||||
animationSpec = tween(320),
|
||||
label = "loaderDone",
|
||||
)
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.clip(RoundedCornerShape(percent = 50))
|
||||
.background(Color.White.copy(alpha = 0.10f)),
|
||||
) {
|
||||
val trackWidth = maxWidth
|
||||
val stripeWidth = trackWidth / 3
|
||||
val stripePx = with(LocalDensity.current) { stripeWidth.toPx() }
|
||||
|
||||
if (doneProgress < 1f) {
|
||||
if (stalled) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(0.5f)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(percent = 50))
|
||||
.background(BitcoinOrange.copy(alpha = 0.6f)),
|
||||
)
|
||||
} else {
|
||||
// Keyframes copied from the web overlay: -100% → 120% → 300%
|
||||
// of the STRIPE's own width, which is what gives the bar its
|
||||
// fast sweep out and lazy re-entry.
|
||||
val transition = rememberInfiniteTransition(label = "loaderSlide")
|
||||
val offset by transition.animateFloat(
|
||||
initialValue = -1f,
|
||||
targetValue = 3f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = keyframes {
|
||||
durationMillis = 1800
|
||||
(-1f) at 0
|
||||
1.2f at 900
|
||||
3f at 1800
|
||||
},
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "loaderOffset",
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(1f / 3f)
|
||||
.fillMaxHeight()
|
||||
.graphicsLayer { translationX = offset * stripePx }
|
||||
.clip(RoundedCornerShape(percent = 50))
|
||||
.background(BitcoinOrange),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (doneProgress > 0f) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.graphicsLayer { alpha = doneProgress }
|
||||
.background(DoneGreen),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+189
-44
@@ -1,26 +1,58 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.ui.screens.GlassButton
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.DecodeHintType
|
||||
@@ -30,10 +62,10 @@ import com.google.zxing.RGBLuminanceSource
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
|
||||
/**
|
||||
* Native replacement for the web wallet's scan pane — the shared [QrGlassModal]
|
||||
* shell (same visual design as neode-ui's WalletScanModal) with the camera and
|
||||
* decoding running natively, so the preview doesn't lag the way getUserMedia
|
||||
* does inside a WebView.
|
||||
* Native replacement for the web wallet's scan pane — same visual design as
|
||||
* neode-ui's WalletScanModal (dark glass card, square preview, orange
|
||||
* viewfinder, status strip) but the camera and decoding run natively, so the
|
||||
* preview doesn't lag the way getUserMedia does inside a WebView.
|
||||
*
|
||||
* Decoded text is handed back to the page ([onDecoded]) which does all the
|
||||
* detection/spend logic; the page in turn streams status lines (animated-QR
|
||||
@@ -48,7 +80,15 @@ fun WalletQrScannerModal(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val haptics = LocalHapticFeedback.current
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> hasPermission = granted }
|
||||
|
||||
// Local error from a failed image upload; a fresh web status replaces it.
|
||||
var uploadError by remember { mutableStateOf<String?>(null) }
|
||||
@@ -67,50 +107,155 @@ fun WalletQrScannerModal(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(visible) { if (visible) uploadError = null }
|
||||
LaunchedEffect(status) { if (status != null) uploadError = null }
|
||||
|
||||
// Throttle repeat frames: a static QR decodes many times a second but the
|
||||
// page only needs one; animated QRs still stream because each frame's
|
||||
// text differs.
|
||||
var lastText by remember { mutableStateOf("") }
|
||||
var lastSentAt by remember { mutableStateOf(0L) }
|
||||
LaunchedEffect(visible) {
|
||||
if (visible) {
|
||||
lastText = ""
|
||||
lastSentAt = 0L
|
||||
uploadError = null
|
||||
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission = granted
|
||||
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(status) { if (status != null) uploadError = null }
|
||||
|
||||
QrGlassModal(
|
||||
visible = visible,
|
||||
title = stringResource(R.string.scan_to_send),
|
||||
status = uploadError?.let { it to true } ?: status,
|
||||
idleHint = stringResource(R.string.scan_wallet_hint),
|
||||
permissionRationale = stringResource(R.string.camera_permission_needed),
|
||||
onDismiss = onDismiss,
|
||||
onDecoded = { text ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (text != lastText || now - lastSentAt > 250) {
|
||||
// Buzz on the FIRST hit only: an animated QR streams a new
|
||||
// frame every few ms, and one buzz each would be a drill in
|
||||
// the hand.
|
||||
if (lastText.isEmpty()) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||
BackHandler { onDismiss() }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onDismiss,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(16.dp)
|
||||
.widthIn(max = 420.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF212151C))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(24.dp))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = {}, // swallow — only the scrim dismisses
|
||||
)
|
||||
.padding(24.dp),
|
||||
) {
|
||||
// Header — mirrors the web modal's title row
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_to_send),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
stringResource(R.string.close),
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
lastText = text
|
||||
lastSentAt = now
|
||||
onDecoded(text)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Square camera preview with the orange viewfinder
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.Black.copy(alpha = 0.4f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasPermission) {
|
||||
// Throttle repeat frames: a static QR decodes ~20x/s but
|
||||
// the page only needs one; animated QRs still stream
|
||||
// because each frame's text differs.
|
||||
var lastText by remember { mutableStateOf("") }
|
||||
var lastSentAt by remember { mutableStateOf(0L) }
|
||||
CameraQrPreview(onDecoded = { text ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (text != lastText || now - lastSentAt > 250) {
|
||||
lastText = text
|
||||
lastSentAt = now
|
||||
onDecoded(text)
|
||||
}
|
||||
})
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize(0.62f)
|
||||
.border(
|
||||
2.dp,
|
||||
BitcoinOrange.copy(alpha = 0.85f),
|
||||
RoundedCornerShape(16.dp),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.camera_permission_needed),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
GlassButton(
|
||||
text = stringResource(R.string.grant_camera_access),
|
||||
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Status strip — same slot the web modal uses for hints/errors
|
||||
val message = uploadError ?: status?.first
|
||||
val isError = uploadError != null || status?.second == true
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.White.copy(alpha = 0.05f))
|
||||
.padding(12.dp)
|
||||
.defaultMinSize(minHeight = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = message?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.scan_wallet_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (isError) Color(0xFFF87171) else Color.White.copy(alpha = 0.6f),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
GlassButton(
|
||||
text = stringResource(R.string.upload_qr_image),
|
||||
onClick = { imagePicker.launch("image/*") },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
footer = {
|
||||
GlassButton(
|
||||
text = stringResource(R.string.upload_qr_image),
|
||||
onClick = { imagePicker.launch("image/*") },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode a QR from a picked image, downsampled so huge photos stay cheap. */
|
||||
|
||||
@@ -24,18 +24,14 @@ import com.archipelago.app.data.ServerQrParser
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.ui.screens.FlareScreen
|
||||
import com.archipelago.app.ui.screens.IntroScreen
|
||||
import com.archipelago.app.ui.screens.NodePickerScreen
|
||||
import com.archipelago.app.ui.screens.PartyScreen
|
||||
import com.archipelago.app.ui.screens.RemoteInputScreen
|
||||
import com.archipelago.app.ui.screens.ServerConnectScreen
|
||||
import com.archipelago.app.ui.screens.WebViewScreen
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object Routes {
|
||||
const val INTRO = "intro"
|
||||
const val NODE_PICKER = "node_picker"
|
||||
const val SERVER_CONNECT = "server_connect"
|
||||
const val WEB_VIEW = "web_view"
|
||||
const val REMOTE_INPUT = "remote_input"
|
||||
@@ -43,38 +39,18 @@ object Routes {
|
||||
const val FLARE = "flare"
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-scoped "have we already asked which node?" flag.
|
||||
*
|
||||
* The picker is a COLD-START question: opening the app fresh (or after the
|
||||
* mesh service and its process were killed) is exactly when the user may want
|
||||
* a different node than last time. An Activity recreation inside a live
|
||||
* process — rotation, theme change — must not re-ask, and neither must a
|
||||
* simple return from the background, so the flag lives with the process
|
||||
* rather than in saved state.
|
||||
*/
|
||||
private object LaunchGate {
|
||||
@Volatile
|
||||
var nodeChoiceMade: Boolean = false
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppNavHost(
|
||||
pairUri: String? = null,
|
||||
onPairUriConsumed: () -> Unit = {},
|
||||
onReady: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { ServerPreferences(context) }
|
||||
val navController = rememberNavController()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// One combined emission — introSeen and activeServer resolving in separate
|
||||
// frames used to flash the Connect screen at paired users on launch.
|
||||
val launchState by prefs.launchState.collectAsState(initial = null)
|
||||
val introSeen = launchState?.introSeen
|
||||
val activeServer = launchState?.activeServer
|
||||
val savedServers = launchState?.savedServers ?: emptyList()
|
||||
val introSeen by prefs.introSeen.collectAsState(initial = null)
|
||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
||||
|
||||
// Pairing entry from a deep link that carried no password — prefills the
|
||||
// connect form so the user lands on the password prompt for that server.
|
||||
@@ -103,30 +79,12 @@ fun AppNavHost(
|
||||
}
|
||||
}
|
||||
|
||||
if (introSeen == null) return
|
||||
|
||||
// Ask which node when the user keeps more than one and this is a cold
|
||||
// start. Anything else (single node, mid-process Activity recreation,
|
||||
// a pairing deep link) goes straight through as before.
|
||||
val needsNodeChoice = introSeen == true &&
|
||||
!LaunchGate.nodeChoiceMade &&
|
||||
savedServers.size > 1
|
||||
|
||||
// Paired + previously consented → the mesh comes back silently on launch,
|
||||
// but ONLY once the session's node is known to be a FIPS node. Bringing
|
||||
// the tunnel up before that took Android's single VPN slot away from
|
||||
// whatever the user uses to reach a non-mesh node. Off the main
|
||||
// dispatcher: this path dlopens the 7 MB fips core and does a binder
|
||||
// round-trip (VpnService.prepare).
|
||||
LaunchedEffect(needsNodeChoice, activeServer?.npub, activeServer?.meshIp) {
|
||||
if (needsNodeChoice) return@LaunchedEffect
|
||||
if (activeServer?.isFipsNode() != true) return@LaunchedEffect
|
||||
withContext(Dispatchers.IO) { FipsManager.autoStartIfReady(context) }
|
||||
// Paired + previously consented → the mesh comes back silently on launch.
|
||||
LaunchedEffect(Unit) {
|
||||
FipsManager.autoStartIfReady(context)
|
||||
}
|
||||
|
||||
// Launch state resolved — MainActivity holds the system splash until now,
|
||||
// so the first visible frame is the real UI, never a black gap.
|
||||
LaunchedEffect(Unit) { onReady() }
|
||||
if (introSeen == null) return
|
||||
|
||||
// Declared after the introSeen gate so it can't fire before the NavHost
|
||||
// below has set the nav graph; pairUri stays pending until consumed here.
|
||||
@@ -160,7 +118,6 @@ fun AppNavHost(
|
||||
|
||||
val startDestination = when {
|
||||
introSeen == false -> Routes.INTRO
|
||||
needsNodeChoice -> Routes.NODE_PICKER
|
||||
activeServer != null -> Routes.WEB_VIEW
|
||||
else -> Routes.SERVER_CONNECT
|
||||
}
|
||||
@@ -169,37 +126,6 @@ fun AppNavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
) {
|
||||
composable(Routes.NODE_PICKER) {
|
||||
NodePickerScreen(
|
||||
servers = savedServers,
|
||||
lastActive = activeServer,
|
||||
onPick = { server ->
|
||||
LaunchGate.nodeChoiceMade = true
|
||||
scope.launch {
|
||||
prefs.setActiveServer(server)
|
||||
// The mesh follows the choice, and ONLY the choice.
|
||||
// A non-mesh node gets the tunnel taken down: Android
|
||||
// hands out one VPN slot, and holding it hostage is
|
||||
// what broke reaching nodes behind a different VPN.
|
||||
withContext(Dispatchers.IO) {
|
||||
if (server.isFipsNode()) {
|
||||
FipsManager.autoStartIfReady(context)
|
||||
} else {
|
||||
FipsManager.stopService(context)
|
||||
}
|
||||
}
|
||||
navController.navigate(Routes.WEB_VIEW) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
},
|
||||
onAddNode = {
|
||||
LaunchGate.nodeChoiceMade = true
|
||||
navController.navigate(Routes.SERVER_CONNECT)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.INTRO) {
|
||||
IntroScreen(
|
||||
onMeshParty = {
|
||||
|
||||
@@ -107,11 +107,7 @@ fun FlareScreen(onBack: () -> Unit) {
|
||||
}
|
||||
|
||||
val peer = peers.firstOrNull { it.npub == selectedNpub }
|
||||
// derivedStateOf: filtering inline re-ran over the whole store on every
|
||||
// recomposition — including one per keystroke in the composer.
|
||||
val messages by remember(selectedNpub) {
|
||||
androidx.compose.runtime.derivedStateOf { allMessages.filter { it.peerNpub == selectedNpub } }
|
||||
}
|
||||
val messages = allMessages.filter { it.peerNpub == selectedNpub }
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
|
||||
@@ -309,13 +305,7 @@ private fun MessageBubble(msg: FlareMessage) {
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
if (msg.photoPath.isNotBlank()) {
|
||||
// Decoded off-main and downsampled to the bubble width —
|
||||
// full-size decode in remember{} ran on the UI thread mid-
|
||||
// scroll and held ~8 MB per visible photo (OOM territory).
|
||||
var bmp by remember(msg.photoPath) { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
LaunchedEffect(msg.photoPath) {
|
||||
bmp = withContext(Dispatchers.IO) { decodeSampledPhoto(msg.photoPath, 600) }
|
||||
}
|
||||
val bmp = remember(msg.photoPath) { BitmapFactory.decodeFile(msg.photoPath) }
|
||||
bmp?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
@@ -346,19 +336,6 @@ private fun MessageBubble(msg: FlareMessage) {
|
||||
}
|
||||
|
||||
/** Decode, downscale (≤1600px) and JPEG-compress a picked photo off-main. */
|
||||
/** Decode a stored beamed photo at roughly [maxPx] on the long edge — the
|
||||
* bubble renders at ~300 dp, so the stored 1600 px original is 25× the
|
||||
* pixels needed. Blocking — call on IO. */
|
||||
private fun decodeSampledPhoto(path: String, maxPx: Int): android.graphics.Bitmap? = try {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(path, bounds)
|
||||
var sample = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= maxPx) sample *= 2
|
||||
BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample })
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun compressPhoto(context: android.content.Context, uri: Uri): ByteArray? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
|
||||
@@ -37,7 +37,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
@@ -66,10 +65,9 @@ fun IntroScreen(
|
||||
var showContent by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Content fades in WITH the logo, not after it — the serial
|
||||
// 800ms + 300ms sequence held "Get Started" off-screen for 1.1s.
|
||||
logoAlpha.animateTo(1f, animationSpec = tween(800))
|
||||
delay(300)
|
||||
showContent = true
|
||||
logoAlpha.animateTo(1f, animationSpec = tween(450))
|
||||
}
|
||||
|
||||
Box(
|
||||
@@ -113,9 +111,7 @@ fun IntroScreen(
|
||||
contentDescription = "Archipelago",
|
||||
modifier = Modifier
|
||||
.size(160.dp)
|
||||
// graphicsLayer defers the alpha read to the draw phase —
|
||||
// .alpha(value) recomposed the whole screen per frame.
|
||||
.graphicsLayer { alpha = logoAlpha.value },
|
||||
.alpha(logoAlpha.value),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SuccessGreen
|
||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
|
||||
/**
|
||||
* "Which node?" — shown at launch when more than one node is saved.
|
||||
*
|
||||
* The companion used to dive straight back into whichever node was last
|
||||
* active, which is wrong the moment a user keeps more than one: they arrive
|
||||
* somewhere they didn't choose, and (worse) the FIPS tunnel came up before
|
||||
* anyone said which network this session belongs to. Picking first makes the
|
||||
* choice explicit and lets the mesh stay down for nodes that aren't on it.
|
||||
*
|
||||
* [onPick] carries the entry; the caller decides what the mesh does about it.
|
||||
*/
|
||||
@Composable
|
||||
fun NodePickerScreen(
|
||||
servers: List<ServerEntry>,
|
||||
lastActive: ServerEntry?,
|
||||
onPick: (ServerEntry) -> Unit,
|
||||
onAddNode: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.bg_synthwave),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.65f),
|
||||
Color.Black.copy(alpha = 0.5f),
|
||||
Color.Black.copy(alpha = 0.85f),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 48.dp, bottom = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_logo),
|
||||
contentDescription = "Archipelago",
|
||||
modifier = Modifier.size(88.dp),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.pick_node_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = TextPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.pick_node_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextMuted,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
servers.forEach { server ->
|
||||
NodeCard(
|
||||
server = server,
|
||||
isLast = lastActive?.sameNode(server) == true,
|
||||
onClick = { onPick(server) },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
GlassButton(
|
||||
text = stringResource(R.string.pick_node_add),
|
||||
onClick = onAddNode,
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodeCard(
|
||||
server: ServerEntry,
|
||||
isLast: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = 0.08f),
|
||||
Color.White.copy(alpha = 0.02f),
|
||||
),
|
||||
)
|
||||
)
|
||||
.border(
|
||||
1.dp,
|
||||
if (isLast) BitcoinOrange.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.1f),
|
||||
RoundedCornerShape(14.dp),
|
||||
)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (server.useHttps) Icons.Default.Lock else Icons.Default.LockOpen,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (server.useHttps) SuccessGreen else BitcoinOrange,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = server.displayName(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val secondary = buildString {
|
||||
if (server.name.isNotBlank()) append(server.address)
|
||||
if (server.port.isNotBlank()) {
|
||||
if (isNotEmpty()) append(":${server.port}") else append("Port ${server.port}")
|
||||
}
|
||||
}
|
||||
if (secondary.isNotBlank()) {
|
||||
Text(
|
||||
text = secondary,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = TextMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
// The one thing that actually changes behaviour on this screen: a mesh
|
||||
// node brings the FIPS tunnel up, a plain one deliberately does not.
|
||||
if (server.isFipsNode()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = BitcoinOrange,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "FIPS",
|
||||
color = BitcoinOrange,
|
||||
fontSize = 11.sp,
|
||||
letterSpacing = 1.sp,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,12 +123,9 @@ fun PartyScreen(
|
||||
name = prefs.partyName()
|
||||
// The hotspot/WiFi address can change while this screen is open
|
||||
// (e.g. the user flips the hotspot on mid-demo) — keep it fresh.
|
||||
// Tight only at first (the hotspot-flip window); interface walks
|
||||
// allocate, so back off once the screen has been open a while.
|
||||
var round = 0
|
||||
while (true) {
|
||||
localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() }
|
||||
delay(if (round++ < 10) 3_000 else 30_000)
|
||||
delay(3_000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,16 +138,7 @@ fun PartyScreen(
|
||||
port = PartyQr.PARTY_UDP_PORT,
|
||||
)
|
||||
}
|
||||
// QR encode + bitmap fill off the composition: done in remember{} it ran
|
||||
// on the UI thread PER KEYSTROKE of the name field (the payload embeds the
|
||||
// name) — a ZXing encode plus a megabyte-plus allocation per character.
|
||||
// The 250 ms delay is a free debounce via coroutine cancellation.
|
||||
var qrBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
LaunchedEffect(qrPayload) {
|
||||
if (qrPayload == null) { qrBitmap = null; return@LaunchedEffect }
|
||||
if (qrBitmap != null) delay(250)
|
||||
qrBitmap = withContext(Dispatchers.Default) { renderQr(qrPayload) }
|
||||
}
|
||||
val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } }
|
||||
|
||||
BackHandler {
|
||||
when {
|
||||
@@ -349,12 +337,7 @@ fun PartyScreen(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// Encoded off-main; done in remember{} it dropped the
|
||||
// overlay's first fade-in frame.
|
||||
var dlQr by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
LaunchedEffect(Unit) {
|
||||
dlQr = withContext(Dispatchers.Default) { renderQr(APP_DOWNLOAD_URL) }
|
||||
}
|
||||
val dlQr = remember { renderQr(APP_DOWNLOAD_URL) }
|
||||
dlQr?.let { bmp ->
|
||||
Box(
|
||||
Modifier
|
||||
@@ -381,7 +364,7 @@ fun PartyScreen(
|
||||
"…or send the APK file directly",
|
||||
color = BitcoinOrange,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.clickable { scope.launch { shareCompanionApk(context) } }.padding(8.dp),
|
||||
modifier = Modifier.clickable { shareCompanionApk(context) }.padding(8.dp),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("Close", color = TextMuted, fontSize = 14.sp, modifier = Modifier.clickable { showShareQr = false }.padding(8.dp))
|
||||
@@ -490,9 +473,8 @@ fun PartyScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Render a QR payload as a bitmap (dark modules on white). 512 px covers the
|
||||
* 240.dp display size at any density; 640 was a third more pixels for nothing. */
|
||||
private fun renderQr(payload: String, size: Int = 512): Bitmap? = try {
|
||||
/** Render a QR payload as a bitmap (dark modules on white). */
|
||||
private fun renderQr(payload: String, size: Int = 640): Bitmap? = try {
|
||||
val matrix = QRCodeWriter().encode(
|
||||
payload,
|
||||
BarcodeFormat.QR_CODE,
|
||||
@@ -512,23 +494,16 @@ private fun renderQr(payload: String, size: Int = 512): Bitmap? = try {
|
||||
}
|
||||
|
||||
/** Share this install's own APK via the system share sheet — a nearby friend
|
||||
* gets the companion with no internet at all (Quick Share / Bluetooth).
|
||||
* The ~27 MB copy runs on IO — inline in the click handler it froze the UI
|
||||
* for seconds (ANR territory on slow flash). Copied once per install; the
|
||||
* cached file is reused while its size still matches the source. */
|
||||
private suspend fun shareCompanionApk(context: android.content.Context) {
|
||||
* gets the companion with no internet at all (Quick Share / Bluetooth). */
|
||||
private fun shareCompanionApk(context: android.content.Context) {
|
||||
try {
|
||||
val uri = withContext(Dispatchers.IO) {
|
||||
val src = java.io.File(context.applicationInfo.sourceDir)
|
||||
val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
|
||||
val out = java.io.File(dir, "archipelago-companion.apk")
|
||||
if (!out.exists() || out.length() != src.length()) {
|
||||
src.copyTo(out, overwrite = true)
|
||||
}
|
||||
androidx.core.content.FileProvider.getUriForFile(
|
||||
context, "${context.packageName}.fileprovider", out,
|
||||
)
|
||||
}
|
||||
val src = java.io.File(context.applicationInfo.sourceDir)
|
||||
val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
|
||||
val out = java.io.File(dir, "archipelago-companion.apk")
|
||||
src.copyTo(out, overwrite = true)
|
||||
val uri = androidx.core.content.FileProvider.getUriForFile(
|
||||
context, "${context.packageName}.fileprovider", out,
|
||||
)
|
||||
val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "application/vnd.android.package-archive"
|
||||
putExtra(android.content.Intent.EXTRA_STREAM, uri)
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -75,7 +76,6 @@ import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.ui.components.MeshLoadingScreen
|
||||
import com.archipelago.app.ui.components.SlidingLoader
|
||||
import com.archipelago.app.ui.components.QrScannerOverlay
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ErrorRed
|
||||
@@ -86,7 +86,6 @@ import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import com.archipelago.app.ui.theme.TextSecondary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -109,20 +108,6 @@ fun ServerConnectScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||
|
||||
// Warm the mesh tunnel the moment the screen appears — starting it only
|
||||
// after the LAN probe failed put full tunnel bring-up + session discovery
|
||||
// inside the user's wait. By connect-tap time it's usually already up.
|
||||
//
|
||||
// Only when there is actually a mesh node to warm for, though: raising the
|
||||
// tunnel on a phone whose saved nodes are all plain HTTP boxes takes
|
||||
// Android's single VPN slot for nothing.
|
||||
LaunchedEffect(savedServers.any { it.isFipsNode() }) {
|
||||
if (savedServers.none { it.isFipsNode() }) return@LaunchedEffect
|
||||
withContext(Dispatchers.IO) { FipsManager.autoStartIfReady(context) }
|
||||
}
|
||||
|
||||
var name by remember { mutableStateOf("") }
|
||||
var address by remember { mutableStateOf("") }
|
||||
var port by remember { mutableStateOf("") }
|
||||
@@ -136,13 +121,8 @@ fun ServerConnectScreen(
|
||||
// Landing shows Scan/Manual choice; the form appears in manual mode or while editing.
|
||||
var manualMode by remember { mutableStateOf(false) }
|
||||
var showScanner by remember { mutableStateOf(false) }
|
||||
// Is the connect currently running aimed at a mesh node? Drives whether
|
||||
// the loader wears the FIPS brand — see MeshLoadingScreen.
|
||||
var connectingOverMesh by remember { mutableStateOf(false) }
|
||||
var connectingName by remember { mutableStateOf("") }
|
||||
// Brief green landing on the loader before the kiosk takes over, matching
|
||||
// the platform's install overlay.
|
||||
var connectSucceeded by remember { mutableStateOf(false) }
|
||||
|
||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||
|
||||
fun clearForm() {
|
||||
name = ""
|
||||
@@ -191,60 +171,40 @@ fun ServerConnectScreen(
|
||||
}
|
||||
isConnecting = true
|
||||
errorMessage = null
|
||||
connectingOverMesh = server.isFipsNode()
|
||||
connectingName = server.displayName()
|
||||
connectSucceeded = false
|
||||
|
||||
scope.launch {
|
||||
// LAN and mesh race IN PARALLEL — the serial LAN-then-mesh chain
|
||||
// burned a guaranteed-dead 5 s LAN probe before the mesh path even
|
||||
// started (the off-LAN QR-pairing case, exactly where speed shows).
|
||||
// The scanned IP was only ever a dial hint; the node's real
|
||||
var reachable = testConnection(server)
|
||||
|
||||
// LAN address didn't answer — phone off-LAN (5G) or DHCP moved the
|
||||
// node. The scanned IP was only ever a dial hint; the node's real
|
||||
// identity is its npub and its ULA is reachable from anywhere over
|
||||
// the mesh. Mesh discovery + first session can take 15s+ through
|
||||
// the public tree (HANDOFF-2026-07-23 node diagnosis), and on a
|
||||
// first-ever pairing the VPN consent dialog is on screen at the
|
||||
// same time — so the mesh side keeps probing inside its budget
|
||||
// while the tunnel (already started at screen entry, and kicked
|
||||
// again here) warms up underneath.
|
||||
val meshServer = server.meshIp.takeIf { it.isNotBlank() }?.let {
|
||||
// the mesh. Bring the tunnel up and probe the ULA before failing.
|
||||
if (!reachable && server.meshIp.isNotBlank()) {
|
||||
FipsManager.autoStartIfReady(context)
|
||||
server.copy(address = it, useHttps = false, port = "")
|
||||
}
|
||||
val reachable = kotlinx.coroutines.coroutineScope {
|
||||
val lan = async { testConnection(server, timeoutMs = 4_000) }
|
||||
val mesh = async {
|
||||
if (meshServer == null) return@async false
|
||||
val deadline = System.currentTimeMillis() + 45_000
|
||||
var ok = false
|
||||
while (!ok && System.currentTimeMillis() < deadline) {
|
||||
ok = testConnection(meshServer, timeoutMs = 8_000)
|
||||
if (!ok) delay(2000)
|
||||
}
|
||||
ok
|
||||
}
|
||||
val first = kotlinx.coroutines.selects.select<Boolean> {
|
||||
lan.onAwait { it }
|
||||
mesh.onAwait { it }
|
||||
}
|
||||
if (first) {
|
||||
lan.cancel(); mesh.cancel()
|
||||
true
|
||||
} else {
|
||||
// One side gave up — the verdict is whatever the other says.
|
||||
if (lan.isCompleted) mesh.await() else lan.await()
|
||||
val meshServer = server.copy(
|
||||
address = server.meshIp,
|
||||
useHttps = false,
|
||||
port = "",
|
||||
)
|
||||
// Mesh discovery + first session can take 15s+ through the
|
||||
// public tree (HANDOFF-2026-07-23 node diagnosis), and on a
|
||||
// first-ever pairing the VPN consent dialog is on screen at
|
||||
// the same time — so probe patiently inside a 60s budget with
|
||||
// per-attempt timeouts wide enough to ride out TCP
|
||||
// retransmit backoff. The VPN service pre-warms the session
|
||||
// in parallel (ArchyVpnService.startSessionWarmer).
|
||||
val deadline = System.currentTimeMillis() + 60_000
|
||||
while (!reachable && System.currentTimeMillis() < deadline) {
|
||||
reachable = testConnection(meshServer, timeoutMs = 15_000)
|
||||
if (!reachable) delay(3000)
|
||||
}
|
||||
}
|
||||
isConnecting = false
|
||||
|
||||
if (reachable) {
|
||||
// Land the loader green before handing over, so the last thing
|
||||
// seen is "done", not a bar cut mid-sweep.
|
||||
connectSucceeded = true
|
||||
prefs.setActiveServer(server)
|
||||
delay(320)
|
||||
isConnecting = false
|
||||
onConnected(server.toUrl())
|
||||
} else {
|
||||
isConnecting = false
|
||||
errorMessage = context.getString(R.string.connection_failed)
|
||||
}
|
||||
}
|
||||
@@ -333,7 +293,7 @@ fun ServerConnectScreen(
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = if (editingServer != null) stringResource(R.string.edit_server_title) else stringResource(R.string.connect_to_node),
|
||||
text = if (editingServer != null) stringResource(R.string.edit_server_title) else "Connect to Server",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = TextPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -617,9 +577,10 @@ fun ServerConnectScreen(
|
||||
}
|
||||
|
||||
if (isConnecting) {
|
||||
SlidingLoader(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
done = connectSucceeded,
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -656,11 +617,7 @@ fun ServerConnectScreen(
|
||||
// establishing (LAN probe → tunnel up → ULA probe can take a while).
|
||||
// The small inline spinner stays for context; this owns the screen.
|
||||
if (isConnecting) {
|
||||
MeshLoadingScreen(
|
||||
mesh = connectingOverMesh,
|
||||
nodeName = connectingName,
|
||||
done = connectSucceeded,
|
||||
)
|
||||
MeshLoadingScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -729,17 +686,6 @@ private fun sanitizeAddress(input: String): String {
|
||||
.trimEnd('/')
|
||||
}
|
||||
|
||||
// Built once — the connect loop probed up to 20 times, and each attempt was
|
||||
// paying a fresh SSLContext + SecureRandom init.
|
||||
private val trustAllSslFactory: javax.net.ssl.SSLSocketFactory by lazy {
|
||||
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
|
||||
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
|
||||
})
|
||||
SSLContext.getInstance("TLS").apply { init(null, trustAll, java.security.SecureRandom()) }.socketFactory
|
||||
}
|
||||
|
||||
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers.
|
||||
* [timeoutMs] is per-phase (connect / read) — mesh probes need far more
|
||||
* patience than LAN ones (first session through the tree can take 15s+). */
|
||||
@@ -751,7 +697,14 @@ private suspend fun testConnection(server: ServerEntry, timeoutMs: Int = 5000):
|
||||
|
||||
// Trust self-signed certs for local HTTPS (Archipelago nodes rarely have CA certs)
|
||||
if (connection is HttpsURLConnection) {
|
||||
connection.sslSocketFactory = trustAllSslFactory
|
||||
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
|
||||
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
|
||||
})
|
||||
val sc = SSLContext.getInstance("TLS")
|
||||
sc.init(null, trustAll, java.security.SecureRandom())
|
||||
connection.sslSocketFactory = sc.socketFactory
|
||||
connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,95 +2,56 @@ package com.archipelago.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
|
||||
/**
|
||||
* The platform's brand face. neode-ui sets `font-archipelago: Montserrat` and
|
||||
* uses it for every heading, title and button label, with body copy left to
|
||||
* `Avenir Next, system-ui` — which on Android resolves to the system sans
|
||||
* anyway. Mirroring that split exactly is what makes companion text read as
|
||||
* the same product as the node UI.
|
||||
*
|
||||
* Montserrat is SIL OFL 1.1 (see Android/MONTSERRAT-OFL.txt); the files are
|
||||
* the ones already vendored for the web UI, so both halves ship the same
|
||||
* outlines.
|
||||
*/
|
||||
val Montserrat = FontFamily(
|
||||
Font(R.font.montserrat_medium, FontWeight.Medium),
|
||||
Font(R.font.montserrat_semibold, FontWeight.SemiBold),
|
||||
Font(R.font.montserrat_bold, FontWeight.Bold),
|
||||
Font(R.font.montserrat_extrabold, FontWeight.ExtraBold),
|
||||
)
|
||||
|
||||
val Typography = Typography(
|
||||
// ── Display / headings: Montserrat, tight and heavy like the web hero
|
||||
// copy (the platform sets tracking negative on its big type).
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = (-0.8).sp,
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
letterSpacing = (-0.5).sp,
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp,
|
||||
letterSpacing = (-0.4).sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = (-0.2).sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp,
|
||||
),
|
||||
// ── Body: system sans, exactly as the web falls back to.
|
||||
bodyLarge = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.2.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
letterSpacing = 0.25.sp,
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 18.sp,
|
||||
),
|
||||
// ── Buttons / labels: Montserrat again, matching .glass-button.
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
|
||||
@@ -1,52 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- System splash icon — deliberately the SAME mark as the adaptive launcher
|
||||
icon (ic_launcher_background.xml): dark disc + metallic ring + white
|
||||
Archipelago grid. Tapping the icon and watching the splash should show
|
||||
one badge, not two different logos.
|
||||
|
||||
Geometry is copied from the launcher: the Android 12 splash draws its icon
|
||||
on a 288dp canvas whose inner 2/3 is the safe area — the same 0.667 ratio
|
||||
the adaptive-icon mask uses — so the launcher's 0.65 (ring) / 0.55 (grid)
|
||||
group scales land identically here. -->
|
||||
<!-- Archipelago pixel-art "A" for splash screen -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="288dp"
|
||||
android:height="288dp"
|
||||
android:viewportWidth="752"
|
||||
android:viewportHeight="752">
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
|
||||
<!-- Dark disc + gradient ring (#000 -> #666), matching logo.svg -->
|
||||
<group
|
||||
android:pivotX="376"
|
||||
android:pivotY="376"
|
||||
android:scaleX="0.65"
|
||||
android:scaleY="0.65">
|
||||
<path
|
||||
android:fillColor="#0A0A0A"
|
||||
android:strokeWidth="22.8834"
|
||||
android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="751.337"
|
||||
android:startY="751.338"
|
||||
android:endX="0"
|
||||
android:endY="0.000976562">
|
||||
<item android:offset="0" android:color="#FF000000" />
|
||||
<item android:offset="1" android:color="#FF666666" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</group>
|
||||
|
||||
<!-- White Archipelago grid -->
|
||||
<group
|
||||
android:pivotX="376"
|
||||
android:pivotY="376"
|
||||
android:pivotX="512"
|
||||
android:pivotY="512"
|
||||
android:scaleX="0.55"
|
||||
android:scaleY="0.55">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" />
|
||||
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,318h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,318h72.082v70.936h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,318h72.082v70.936h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,318h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,396.46h71.007v72.011h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,396.46h72.083v72.011h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M278,475.994h72.083v72.012h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,475.994h71.007v72.012h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,475.994h72.082v72.012h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,475.994h72.082v72.012h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,475.994h71.007v72.012h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,475.994h72.083v72.012h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M278,555.529h72.083v70.936h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,555.529h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,555.529h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,555.529h72.083v70.936h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,633.989h71.007v72.011h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,633.989h72.082v72.011h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,633.989h72.082v72.011h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,633.989h71.007v72.011h-71.007z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -49,12 +49,4 @@
|
||||
<string name="scan_wallet_hint">Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code</string>
|
||||
<string name="upload_qr_image">Upload image</string>
|
||||
<string name="no_qr_in_image">No QR code found in that image — try another, closer and well-lit</string>
|
||||
<string name="torch_on">Turn on the torch</string>
|
||||
<string name="torch_off">Turn off the torch</string>
|
||||
|
||||
<!-- Launch node picker (more than one node saved) -->
|
||||
<string name="pick_node_title">Which node?</string>
|
||||
<string name="pick_node_hint">Choose the Archipelago this session connects to. The FIPS mesh only comes up for mesh nodes.</string>
|
||||
<string name="pick_node_add">Add another node</string>
|
||||
<string name="connect_to_node">Connect to your node</string>
|
||||
</resources>
|
||||
|
||||
@@ -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" {
|
||||
@@ -1426,96 +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;
|
||||
let salt_bytes: [u8; 16] = rand::random();
|
||||
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<()> {
|
||||
@@ -2610,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
|
||||
@@ -2709,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
|
||||
)),
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -577,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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -2049,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!(
|
||||
@@ -3270,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).
|
||||
@@ -5159,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());
|
||||
|
||||
@@ -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 {
|
||||
@@ -113,123 +102,6 @@ fn random_base64(bytes: usize) -> String {
|
||||
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`.
|
||||
@@ -337,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
|
||||
|
||||
@@ -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,7 +168,7 @@ pub async fn record_peer_transport(
|
||||
}
|
||||
|
||||
if modified {
|
||||
save_nodes_inner(data_dir, &nodes).await?;
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -347,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();
|
||||
@@ -362,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(())
|
||||
}
|
||||
@@ -527,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();
|
||||
|
||||
@@ -79,28 +79,6 @@ 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.
|
||||
fn generate_mnemonic_with<R: rand::CryptoRng + rand::RngCore>(
|
||||
rng: &mut R,
|
||||
) -> Result<bip39::Mnemonic> {
|
||||
bip39::Mnemonic::generate_in_with(rng, bip39::Language::English, 24)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to generate mnemonic: {}", e))
|
||||
}
|
||||
|
||||
impl MasterSeed {
|
||||
/// Generate a new 24-word BIP-39 mnemonic and derive the master seed.
|
||||
pub fn generate() -> Result<(bip39::Mnemonic, Self)> {
|
||||
@@ -111,10 +89,8 @@ impl MasterSeed {
|
||||
),
|
||||
None => {}
|
||||
}
|
||||
// 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))
|
||||
}
|
||||
@@ -618,102 +594,6 @@ mod tests {
|
||||
assert_eq!(mnemonic.word_count(), 24);
|
||||
}
|
||||
|
||||
/// Deterministic test-only RNG emitting 0x00, 0x01, 0x02, … so a mnemonic
|
||||
/// generated through the injection seam is fully predictable.
|
||||
///
|
||||
/// `CryptoRng` is a marker trait — implementing it is a promise that the
|
||||
/// source is suitable for cryptographic use. That promise is false here and
|
||||
/// deliberately so: this type exists only to stand in at the seam under
|
||||
/// `cfg(test)` and must never be reachable from production code.
|
||||
struct CountingRng(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 rand::CryptoRng for 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
|
||||
|
||||
@@ -62,103 +62,8 @@ those are marked ✅ below with the commit that did it, so we stop re-litigating
|
||||
installed on any of .116/.198/.228 to check directly; low priority until someone
|
||||
actually needs it installed.
|
||||
|
||||
- [ ] **Add `cargo audit` / `cargo deny` to CI, failing on duplicate `rand` majors**
|
||||
(entropy audit R-05, finding F-07 —
|
||||
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`). `cargo-audit` is not installed
|
||||
anywhere, so no RustSec check has ever run against this tree. Separately,
|
||||
`cargo tree` shows **both** `rand 0.8.5` (direct, all first-party key generation)
|
||||
and `rand 0.9.2` (transitive via `totp-rs` and `tungstenite 0.26.2`) resolved into
|
||||
one binary. `rand 0.9.0` removed `ThreadRng` fork protection and the orchestrator
|
||||
forks constantly, so a future bump must be visible rather than silent — add a
|
||||
`bans` rule so the duplicate majors show up in CI, not in an incident.
|
||||
|
||||
- [ ] **Harden the release signing ceremony's mnemonic input** (entropy audit R-08,
|
||||
finding F-06). `ceremony gen` prints the release master mnemonic to **stdout**
|
||||
(`core/archipelago/src/ceremony.rs:71-77`) and `load_release_root_key` prefers the
|
||||
`RELEASE_MASTER_MNEMONIC` **environment variable** over stdin (`:157-160`) — both
|
||||
leak into shell history, `/proc/<pid>/environ`, tmux scrollback and terminal
|
||||
recordings. This is the seed that derives the fleet release-root signing key, so a
|
||||
leak means forged signed manifests fleet-wide. Make stdin/TTY the only supported
|
||||
input for `sign`/`pubkey`; write `gen`'s output to a `0600` file rather than the
|
||||
terminal. Small change, but schedule it deliberately — it is the signing ceremony.
|
||||
|
||||
- [ ] **Small entropy-audit hygiene batch** (entropy audit R-09 – R-12, R-14 —
|
||||
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`). Five independent one-liners,
|
||||
each closing a Low/Informational finding:
|
||||
- Persist the CSPRNG-readiness verdict (`seed.rs:85-91`) as a durable structured
|
||||
event, so any node can answer post-hoc "was the entropy pool ready when this seed
|
||||
was born?" — the question Coldcard owners cannot answer today.
|
||||
- Add a test asserting the `getrandom` crate uses the **blocking** syscall, making
|
||||
`seed.rs:52-57`'s invariant mechanical instead of a comment.
|
||||
- Clear `_seed_words` from `sessionStorage` on route-leave from onboarding, not only
|
||||
on successful verify (`OnboardingSeedVerify.vue:251`), plus a wall-clock expiry
|
||||
mirroring the server's 10-minute `MNEMONIC_TTL`.
|
||||
- Replace `% charset.len()` in `totp.rs:305` with `SliceRandom::choose(&mut OsRng)`.
|
||||
(No bias today — 32 divides 256 — but any future charset edit introduces one
|
||||
silently. The audit refutes the research's claim that this is currently biased.)
|
||||
- Comment `pickRandomIndices` (`OnboardingSeedVerify.vue:157`) to record that its
|
||||
`Math.random()` picks a UX challenge, not key material, so the next auditor does
|
||||
not re-derive that it is benign.
|
||||
|
||||
- [ ] ~~**Swap container `generated_secrets` to explicit `OsRng`** (entropy audit R-13,
|
||||
finding F-10) — two-line change in `container/secrets.rs:90-102`~~
|
||||
**SUPERSEDED 2026-08-02 by R-16 / KEY-05.** The audit scoped this at 2 call sites; the
|
||||
real surface is **41 across 15 files** — see the audit's new §F-10a. `secrets.rs` is 2
|
||||
of them, and a two-line fix there while 39 other sites inherit the same dependency
|
||||
default is not a fix.
|
||||
|
||||
- [ ] **Crate-wide CSPRNG enforcement — a defaulted RNG cannot be inherited anywhere**
|
||||
(entropy audit **R-16 / F-10a**, Medium) — tracked as **KEY-05 in Phase 10**, so plan
|
||||
and execute it there rather than as a standalone item. `session.rs` (16 sites),
|
||||
`pine_ha.rs` (6), `wallet/bdhke.rs` (4 — **ecash key material**), `mesh/x3dh.rs` (2 —
|
||||
**key-agreement material**), `storage_crypto.rs` (1 — **AEAD nonce**), +10 more files.
|
||||
Nothing is broken today (`rand::random()`/`thread_rng()` are ChaCha12 from
|
||||
`getrandom(2)`), but it is the T1 shape that produced the COLDCARD defect, now with key
|
||||
material in the blast radius. Five layers: sealed allowlist trait at key-gen seams;
|
||||
`clippy.toml` `disallowed-methods` ban (compile-time, CI-enforced — no `clippy.toml`
|
||||
exists yet); `cargo-deny` on duplicate `rand` majors (absorbs R-05); degenerate-entropy
|
||||
runtime check; persist the CSPRNG-readiness verdict (absorbs R-09). Also retires the
|
||||
`impl rand::CryptoRng for CountingRng` false promise at `seed.rs:656`.
|
||||
**Gated: do not start until the concurrent Phase 1 agent is done and synced.**
|
||||
|
||||
## Tier 1 — Medium effort, unblocked
|
||||
|
||||
- [ ] **Fix the fail-open first-boot secret regeneration in the ISO** (entropy audit
|
||||
R-02 + R-03, finding F-03 — `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`).
|
||||
The installed rootfs is a **cached container export shared by every node**
|
||||
(`image-recipe/_archived/build-auto-installer-iso.sh:717-726`, extracted at
|
||||
`:2303`), and it bakes SSH host keys (via the `openssh-server` install at `:345`)
|
||||
and a TLS keypair (`:463-469`). `archipelago-first-boot-secrets.service` correctly
|
||||
regenerates both per device — but both branches are **fail-open** (`:1647`,
|
||||
`:1659`) and `touch "$MARKER"` at `:1663` runs **unconditionally**, so a single
|
||||
transient failure permanently leaves that node on the image-wide shared SSH host
|
||||
key and TLS private key, with the failure visible only in a log file. Fix:
|
||||
(a) set the marker only when both regenerations succeeded, so it retries next
|
||||
boot; (b) surface the failure in the UI/doctor, not just the log; (c) strip the
|
||||
baked keys from the rootfs tar so a failure degrades to "no key" rather than
|
||||
"shared key". Needs an ISO rebuild and two fresh flashes to verify.
|
||||
|
||||
- [ ] **Reconcile `Argon2::default()` with ADR-005** (entropy audit R-06, finding F-05).
|
||||
ADR-005 states 64 MB / 3 iterations
|
||||
(`docs/adr/005-chacha20-backup-encryption.md:31`); `Argon2::default()` in
|
||||
argon2 0.5.3 is Argon2id at **19 MiB / t=2 / p=1**. Used at
|
||||
`core/archipelago/src/seed.rs:249` and `:285`, `backup/identity.rs:38`/`:93`,
|
||||
`backup/full.rs:618`/`:650`. Either raise the parameters behind a versioned
|
||||
envelope **with a migration** (an existing `master_seed.enc` was encrypted under
|
||||
the old parameters and will not decrypt under new ones) or amend the ADR to state
|
||||
the real numbers. Do not change them silently.
|
||||
|
||||
- [ ] **Run the on-node entropy verification checklist** (entropy audit R-15, §6 of
|
||||
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`). Everything in that section is
|
||||
explicitly **UNVERIFIED** — it needs real hardware this environment cannot reach.
|
||||
Highest value first: **C-3** (are SSH host-key and TLS fingerprints actually
|
||||
different across two nodes flashed from the same ISO?) and **C-5** (the cross-node
|
||||
same-ISO seed collision test — the empirical check that would have caught the
|
||||
Coldcard defect). Also C-1 (`crng init done` vs seed-generation timestamp), C-2
|
||||
(`machine-id` uniqueness), C-4 (what the rootfs tar actually contains, run on the
|
||||
build host), C-6 (is `/rpc/v1` reachable unauthenticated from the LAN). Use a
|
||||
disposable node — C-5 overwrites node identity.
|
||||
|
||||
- [x] ~~immich → Quadlet migration~~ — investigated 2026-07-01, turned out already done:
|
||||
immich uses the same `install_stack_via_orchestrator` primitive as netbird/btcpay
|
||||
(`immich_stack_app_ids()` in `stacks.rs:690`), and is confirmed running as real
|
||||
@@ -181,63 +86,6 @@ those are marked ✅ below with the commit that did it, so we stop re-litigating
|
||||
|
||||
## Tier 2 — High effort, mostly unblocked (the actual next exit criteria)
|
||||
|
||||
- [ ] **🔴 Gate the unauthenticated seed RPCs** (entropy audit R-01, finding **F-01,
|
||||
Critical** — `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`). `seed.generate`,
|
||||
`seed.verify`, `seed.restore` and `seed.save-encrypted` are in
|
||||
`UNAUTHENTICATED_METHODS` (`core/archipelago/src/api/rpc/middleware.rs:24-28`),
|
||||
which skips session, RBAC **and** CSRF (`api/rpc/mod.rs:263`, `:295`, `:326`).
|
||||
Neither handler checks whether onboarding is already complete
|
||||
(`api/rpc/seed_rpc.rs:93-159`, `:226-305`), and `NodeIdentity::from_seed`
|
||||
overwrites `node_key`, `nostr_secret` and the FIPS mesh key **unconditionally**
|
||||
(`identity.rs:79-114`). There is no rate limit (`rate_limit.rs:60-97` has no
|
||||
`seed.*` entry). 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 (`server.rs:2080` asserts `/rpc/v1` passes the peer path filter).
|
||||
Net: **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`).
|
||||
Fix: bail when a node key exists and no onboarding mnemonic is pending; prefer
|
||||
also gating on `auth_manager.is_onboarding_complete()`; add rate limits at
|
||||
`auth.changePassword` strictness; narrow the peer path filter. Changes an
|
||||
authentication boundary on a live fleet — **needs its own `/gsd-plan-phase` with a
|
||||
federation re-verify**, not an opportunistic patch.
|
||||
|
||||
- [ ] **PSBT-first signing: Phase 1 — move the Bitcoin private key out of Core**
|
||||
(entropy audit R-04, finding **F-13** + `docs/security/PSBT-SIGNING-ARCHITECTURE.md`
|
||||
§8 Phase 1). `handle_bitcoin_init_wallet_from_seed` passes
|
||||
`disable_private_keys = false` (`core/archipelago/src/api/rpc/bitcoin.rs:203`) and
|
||||
imports `wpkh(xprv/0/*)` / `wpkh(xprv/1/*)` (`:229-231`), so the BIP-84 account
|
||||
**private** key is persisted in Bitcoin Core's `wallet.dat` — with an empty wallet
|
||||
passphrase (`:205`) — in addition to the daemon's Argon2 envelope. The descriptors
|
||||
also carry **no `[fingerprint/derivation]` key origin**, so no hardware signer
|
||||
could ever locate its key even if the private key were removed. Fix: watch-only
|
||||
(`disable_private_keys=true`) + xpub-with-origin descriptors + a migration that
|
||||
verifies balance and UTXO parity **before** removing the old wallet. This is the
|
||||
single highest-value change in the PSBT spec and unblocks every later phase.
|
||||
Needs a node with real UTXO history to verify.
|
||||
|
||||
- [ ] **PSBT-first signing: Phases 2-7 rollout**
|
||||
(`docs/security/PSBT-SIGNING-ARCHITECTURE.md` §8) — the spec is written to be
|
||||
consumed directly by `/gsd-plan-phase`, with per-phase goals, dependencies,
|
||||
candidate requirements and hardware gating. Sequence: PSBT construct/export →
|
||||
external-signer import + finalize → air-gap transport (BC-UR v2 primary, BBQr for
|
||||
Coldcard, file fallback always) → `wsh(sortedmulti)` multisig on BIP-48 → LND
|
||||
remote signing → hot-wallet spend limits and cold/warm/hot tiering. Two hard rules
|
||||
the spec fixes in place: a channel-funding PSBT must **never** be self-broadcast
|
||||
(funds can be lost), and no UI copy may imply a routing node's Lightning channel
|
||||
keys are cold — they are necessarily hot. Phases 3-6 need real hardware.
|
||||
|
||||
- [ ] **Confine the seed-bearing RPCs to loopback/TLS** (entropy audit R-07, finding
|
||||
F-04 / [ARCHY-4]). The 24-word master mnemonic is returned to the browser over
|
||||
JSON-RPC (`core/archipelago/src/api/rpc/seed_rpc.rs:147`, `:156-158`), held in
|
||||
process memory under a 10-minute TTL (`:27`) and deliberately **not** cleared at
|
||||
verify time (`:205-211`, with a documented and defensible rationale about client
|
||||
retries) — over a transport that is plaintext HTTP on LAN by design
|
||||
(`api/rpc/mod.rs:227-241`). Anyone with LAN traffic visibility during onboarding
|
||||
reads the phrase that unlocks the wallet and the node identity. Fix: force TLS or
|
||||
loopback for seed methods, shrink the TTL, and clear on an acknowledged verify
|
||||
with a short grace window. Touches the onboarding transport — needs a phase.
|
||||
|
||||
- [~] **Multinode test pass** (`docs/multinode-testing-plan.md`) — worked the
|
||||
preconditions on .198 2026-07-01:
|
||||
- ✅ cleared 2 stale failed-unit records (`archy-mempool-db.service`,
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
# Companion QR decoder — the zxing-cpp option (deferred)
|
||||
|
||||
*2026-08-11. Status: **NOT actioned.** Held as the next lever if the tuned
|
||||
ZXing-Java pipeline proves insufficient in field testing. Companion-only —
|
||||
touches `Android/` and nothing else.*
|
||||
|
||||
Related: [`qr-scanner-snappiness-handover.md`](qr-scanner-snappiness-handover.md)
|
||||
(web + native survey, 2026-07-29), [`companion-pairing-qr.md`](companion-pairing-qr.md)
|
||||
(the payload being scanned).
|
||||
|
||||
## Where we actually landed first
|
||||
|
||||
Before reaching for a new decoder, the native scanner
|
||||
(`Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt`)
|
||||
was rebuilt around one rule:
|
||||
|
||||
> **Every frame costs the same, and every frame sees the whole scene.**
|
||||
|
||||
Per frame: centre ROI at full resolution (dense invoices keep their
|
||||
pixels-per-module) + the whole frame at half resolution (coverage) + one
|
||||
alternating `GlobalHistogramBinarizer` pass. Bounded extras only: an inverted
|
||||
ROI every 8th frame, one `TRY_HARDER` pass over the *half*-frame at most once
|
||||
a second.
|
||||
|
||||
Two bugs were fixed on the way, both worth remembering because they are easy
|
||||
to reintroduce:
|
||||
|
||||
1. **Escalation-on-failure is backwards.** An earlier version unlocked
|
||||
progressively more expensive searches on each frame that missed, ending in
|
||||
a `TRY_HARDER` pass over the full 2 MP frame (150–300 ms). The result was a
|
||||
scanner that locked on instantly when the code was already in view at open,
|
||||
and crawled when the user opened the camera and then moved to the code —
|
||||
because hunting collapsed the rate from ~30 attempts/sec to ~4, each on a
|
||||
motion-blurred frame. Failure means the user is still aiming, which is when
|
||||
the scanner must be *fastest*, not most thorough.
|
||||
2. **A one-shot `startFocusAndMetering` locks the lens.** It puts AF in AUTO
|
||||
until auto-cancel; the 5 s default spans exactly the window where the user
|
||||
is swinging the phone toward the code, and a locked lens cannot follow.
|
||||
Auto-cancel is now 1 s so `CONTROL_AF_MODE_CONTINUOUS_PICTURE` does the
|
||||
tracking.
|
||||
|
||||
Plus `CONTROL_AE_TARGET_FPS_RANGE` pinned to the highest floor the back camera
|
||||
offers at ≤30 fps, which caps exposure (~33 ms) and kills the motion blur that
|
||||
indoor auto-exposure otherwise bakes into every hand-held frame.
|
||||
|
||||
That combination tested better on device (2026-08-11). This document covers
|
||||
what to do **if it is still not good enough.**
|
||||
|
||||
## The remaining structural limit
|
||||
|
||||
The decoder engine itself. ZXing's Java implementation is both the slow part
|
||||
and the picky part — most relevantly, it rejects perspective-skewed codes
|
||||
outright, which is much of what the sensor sees while the user is moving. No
|
||||
amount of frame budgeting fixes a decoder that will not accept the frame.
|
||||
|
||||
## The candidate: zxing-cpp
|
||||
|
||||
`io.github.zxing-cpp:android` — the maintained C++ rewrite of ZXing with an
|
||||
official Android/Kotlin wrapper.
|
||||
|
||||
**Why it clears the project's dependency bar** (`~/.claude/CLAUDE.md`):
|
||||
Apache-2.0, established OSS, fully on-device, no telemetry, no Play Services,
|
||||
no account or network dependency. This is the distinguishing point against
|
||||
**ML Kit**, which is the other fast option and is disqualified: it is
|
||||
proprietary and Play-Services-backed.
|
||||
|
||||
**What it buys:**
|
||||
|
||||
- Roughly 5–10× faster than ZXing-Java on the same frames.
|
||||
- Materially better on the cases that actually fail here: perspective/rotation
|
||||
(`tryRotate`, and its detector handles warp rather than rejecting it),
|
||||
blur, low contrast, damaged codes.
|
||||
- Built-in inversion handling (`tryInvert`), removing our alternating
|
||||
inverted-ROI pass.
|
||||
- Accepts an `ImageProxy` directly, so the manual Y-plane crop/copy machinery
|
||||
in `QrCodeAnalyzer` can largely be deleted — including the reused
|
||||
`roiBuffer`/`halfBuffer` and the `pixelStride` handling.
|
||||
|
||||
**Costs / risks:**
|
||||
|
||||
- New native dependency. APK grows ~1–2 MB — limited because the app is
|
||||
already arm64-only (`abiFilters += "arm64-v8a"`), so only one ABI ships.
|
||||
- Adds a native attack/maintenance surface next to the existing Rust FIPS
|
||||
core. Pin the version exactly, per project rules.
|
||||
- The tuned camera work above (AE FPS floor, AF auto-cancel, flat per-frame
|
||||
budget) stays relevant regardless — a faster decoder does not fix a blurred
|
||||
or out-of-focus frame. Do **not** rip that out as part of this change.
|
||||
|
||||
## Integration sketch
|
||||
|
||||
> ⚠️ Coordinates and API surface below are from memory and were **not**
|
||||
> verified against Maven Central — the machine this was written on had no
|
||||
> network. Confirm the current artifact version and wrapper API on the first
|
||||
> online Gradle sync before trusting the snippet.
|
||||
|
||||
`Android/app/build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
// Replaces com.google.zxing:core for the live-camera path.
|
||||
implementation("io.github.zxing-cpp:android:<pin-exact-version>")
|
||||
```
|
||||
|
||||
`QrCodeAnalyzer` collapses to roughly:
|
||||
|
||||
```kotlin
|
||||
private val reader = BarcodeReader().apply {
|
||||
options = BarcodeReader.Options(
|
||||
formats = setOf(BarcodeFormat.QR_CODE),
|
||||
tryHarder = true,
|
||||
tryRotate = true,
|
||||
tryInvert = true,
|
||||
)
|
||||
}
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
try {
|
||||
reader.read(image).firstOrNull()?.text?.let(onDecoded)
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep `com.google.zxing:core` for now regardless: the still-image path
|
||||
(`decodeQrFromUri` in `WalletQrScannerModal.kt`, used by "Upload image") and
|
||||
`prewarmQrScanner` both use it, and neither is on the hot path.
|
||||
|
||||
## Decision trigger
|
||||
|
||||
Action this only if field testing shows the current pipeline still failing the
|
||||
**move-to-the-code** case — open the scanner pointing at nothing, then bring it
|
||||
to a QR at a normal hand-held distance. If that reads within about a second in
|
||||
ordinary room light, the Java decoder is doing its job and this stays on the
|
||||
shelf.
|
||||
@@ -1,987 +0,0 @@
|
||||
# Entropy & Seed-Generation Security Audit — 2026-07-31
|
||||
|
||||
**Trigger:** the Coinkite COLDCARD entropy incident, disclosed 2026-07-30 (see
|
||||
`.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md`,
|
||||
"T1"). That defect silently rebound seed generation to a non-cryptographic PRNG through a
|
||||
build-time macro guard, reducing effective seed entropy to ≤2^32 and enabling a ~1,082 BTC
|
||||
sweep. This audit asks the same question of Archipelago.
|
||||
|
||||
**Why the stakes here are higher than a hardware wallet's.** Archipelago derives its *entire*
|
||||
key hierarchy from one 24-word BIP-39 mnemonic (`core/archipelago/src/seed.rs:1-18`): the node
|
||||
Ed25519 `did:key`, the node Nostr key, the FIPS mesh transport key, per-identity keys, the
|
||||
BIP-84 Bitcoin wallet, the LND aezeed entropy — **and the fleet release-root signing key**
|
||||
(`core/archipelago/src/seed.rs:143-146`). A Coldcard-class entropy defect here would not merely
|
||||
drain wallets; it would let an attacker forge signed release manifests and catalogs for every
|
||||
node in the fleet.
|
||||
|
||||
**Headline verdict:** **no Coldcard-class entropy defect exists in this codebase.** Every
|
||||
first-party key-generation call site draws from a genuine CSPRNG, and the code does several
|
||||
things better than most implementations. The findings below are (a) one structural pattern
|
||||
that is the *exact shape* of T1 and should be closed cheaply, (b) one **Critical**
|
||||
access-control defect found while tracing the secret classes — unrelated to entropy but far
|
||||
more immediately exploitable than anything entropy-related — and (c) a set of Medium/Low
|
||||
hygiene items.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope and method
|
||||
|
||||
### Directories covered
|
||||
|
||||
| Path | Coverage |
|
||||
|---|---|
|
||||
| `core/*/src/**/*.rs` | full RNG-API grep sweep + call-graph trace of every secret class |
|
||||
| `neode-ui/src/**/*.{ts,vue}` | full browser-RNG grep sweep |
|
||||
| `scripts/**/*.{sh,py}` | RNG / secret-material grep sweep |
|
||||
| `image-recipe/**` | entropy, seed-file, machine-id, host-key and first-boot ordering evidence |
|
||||
| `~/.cargo/registry/src/*/bip39-2.1.0/`, `argon2-0.5.3/` | vendored-dependency default-RNG / default-parameter reads |
|
||||
| `docs/adr/005-chacha20-backup-encryption.md` | Argon2 parameter cross-check |
|
||||
|
||||
### Explicitly excluded, and why
|
||||
|
||||
- **`core/target/`** — build output, not source. Excluded from every grep (the pipeline used
|
||||
`core/*/src`, which cannot reach it).
|
||||
- **`image-recipe/_archived/` — NOT excluded, contrary to the original scoping assumption.**
|
||||
This is a correction the next auditor should not have to re-derive:
|
||||
`image-recipe/build-debian-iso.sh:19-40` is a thin wrapper that copies
|
||||
`image-recipe/_archived/build-auto-installer-iso.sh` to a temp path, rewrites its relative
|
||||
paths, and `exec`s it (`image-recipe/build-debian-iso.sh:40`). **The "archived" auto-installer
|
||||
IS the live ISO build path.** Treating `_archived/` as dead code would have made [ARCHY-3]
|
||||
unanswerable. It is therefore in scope and is the primary [ARCHY-3] evidence surface.
|
||||
- `image-recipe/_archived/build/auto-installer/installer-iso/...` — a stale *build output* tree
|
||||
under `_archived/`, superseded by the generator above. Its `/dev/urandom` hits
|
||||
(`image-recipe/_archived/build/auto-installer/installer-iso/archipelago/scripts/first-boot-containers.sh:182`)
|
||||
are duplicates of the live `scripts/first-boot-containers.sh` and are not separately assessed.
|
||||
|
||||
### Greps run
|
||||
|
||||
```
|
||||
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
|
||||
cargo tree -i rand@0.8.5 -p archipelago ; cargo tree -i rand@0.9.2 -p archipelago
|
||||
```
|
||||
|
||||
Results of the two negative greps, stated so they count as findings rather than silence:
|
||||
|
||||
- `find image-recipe -name 'random-seed' -o -name '*.seed'` returned **nothing**. No seed file
|
||||
is checked into the image recipe.
|
||||
- `grep -cE 'haveged|jitterentropy|rng-tools|rngd' image-recipe/_archived/build-auto-installer-iso.sh`
|
||||
returned **0**. No userspace entropy daemon is installed by the image.
|
||||
- `grep -rnE 'SmallRng|seed_from_u64|rand::rngs::mock|StdRng' core/*/src` returned **no RNG
|
||||
hits at all** — the four matches are `NodeIdentity::from_seed(...)` calls
|
||||
(`core/archipelago/src/api/rpc/seed_rpc.rs:122`, `:255`;
|
||||
`core/archipelago/src/identity.rs:608`, `:634`), which is Archipelago's own
|
||||
seed-to-identity function, not `rand`'s `from_seed`. **No non-cryptographic PRNG and no
|
||||
deterministic seeding exists anywhere in the Rust workspace.**
|
||||
|
||||
### Not performed
|
||||
|
||||
- `cargo audit` — **`cargo-audit` is not installed on this host** (`command -v cargo-audit`
|
||||
fails). No RustSec snapshot was taken. This is recorded as gap **F-07**; the research's
|
||||
recommendation stands that `cargo audit`/`cargo deny` belongs in CI rather than in a
|
||||
point-in-time audit.
|
||||
- Anything requiring real hardware — see §6, the UNVERIFIED on-node checklist.
|
||||
|
||||
### Concurrent-work caveat
|
||||
|
||||
`core/archipelago/src/container/secrets.rs` and `neode-ui/src/views/OnboardingSeedGenerate.vue`
|
||||
had **uncommitted third-party changes** on disk at audit time (another agent working in the
|
||||
same tree). They were read as-is and not modified. Line numbers cited for those two files are
|
||||
against the working-tree state of 2026-07-31, not against `HEAD`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Executive summary
|
||||
|
||||
Archipelago's entropy path is structurally sound. Every first-party call site that produces key
|
||||
material draws from `rand::rngs::OsRng` (a direct `getrandom(2)` wrapper) or from
|
||||
`rand::random`/`rand::thread_rng` on `rand 0.8.5`, which is `ReseedingRng<ChaCha12Core, OsRng>`
|
||||
— a real CSPRNG that still carries fork protection in the 0.8 series. 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 master-seed function is preceded by a genuinely good, non-blocking
|
||||
CSPRNG-readiness probe (`core/archipelago/src/seed.rs:52-91`) that most implementations lack,
|
||||
and the derivation is domain-separated, zeroized, and pinned by known-answer tests.
|
||||
|
||||
Three things nonetheless warrant action, in this order:
|
||||
|
||||
1. **The most urgent finding is not about entropy at all.** While tracing secret classes (3)
|
||||
and (4), the audit found that `seed.generate` and `seed.restore` are in the
|
||||
**unauthenticated** RPC allowlist (`core/archipelago/src/api/rpc/middleware.rs:25-27`), carry
|
||||
**no onboarding-complete gate and no rate limit**, and unconditionally overwrite a live
|
||||
node's Ed25519 identity, Nostr key and FIPS mesh key
|
||||
(`core/archipelago/src/identity.rs:79-114`). The endpoint is proxied to the LAN over
|
||||
plaintext HTTP (`image-recipe/configs/nginx-archipelago.conf:11`, `:165`, `:192`) and is
|
||||
also reachable by mesh peers (`core/archipelago/src/server.rs:2080`). A guard function for
|
||||
exactly this already exists and is simply never called
|
||||
(`core/archipelago/src/identity.rs:117`). **Critical — F-01.**
|
||||
2. **The T1-shaped structural risk is real but currently benign.**
|
||||
`bip39::Mnemonic::generate(24)` at `core/archipelago/src/seed.rs:92` delegates its entropy
|
||||
source to a transitive dependency default. Not a vulnerability today; exactly the pattern
|
||||
that produced T1. **Medium — F-02**, and the one code change this audit applies.
|
||||
3. **The one-ISO-many-nodes story is better than feared but has a fail-open hole.** No
|
||||
`random-seed` file is baked, and per-device TLS/SSH regeneration exists — but the rootfs is
|
||||
a cached container export shared by every node, the regeneration is fail-open, and its
|
||||
completion marker is set even when regeneration failed, so a single failure leaves fleet-wide
|
||||
shared SSH host keys and TLS private key permanently. **High — F-03.**
|
||||
|
||||
Nothing in this audit suggests any existing Archipelago node has a weak master seed. No user
|
||||
action of the "your seed may be predictable, migrate now" kind is warranted — a point §7 of
|
||||
`docs/security/PSBT-SIGNING-ARCHITECTURE.md` depends on and must not overstate.
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings
|
||||
|
||||
| ID | Severity | Title | Primary evidence |
|
||||
|---|---|---|---|
|
||||
| F-01 | **Critical** | Unauthenticated, unrated `seed.generate`/`seed.restore` overwrite a live node's identity keys | `core/archipelago/src/api/rpc/middleware.rs:25`, `core/archipelago/src/identity.rs:79` |
|
||||
| F-02 | **Medium** | Master mnemonic's entropy source is a transitive-dependency default, not a call-site argument (T1 shape) | `core/archipelago/src/seed.rs:92` |
|
||||
| F-03 | **High** | First-boot per-device secret regeneration is fail-open and never retried, over a fleet-shared cached rootfs | `image-recipe/_archived/build-auto-installer-iso.sh:1647`, `:1659`, `:1663` |
|
||||
| F-04 | **Medium** | Master mnemonic crosses the RPC boundary and is held in memory for 10 min, deliberately un-cleared, over plaintext-capable HTTP | `core/archipelago/src/api/rpc/seed_rpc.rs:147`, `:205-211` |
|
||||
| F-05 | **Medium** | `Argon2::default()` is 19 MiB / t=2, not ADR-005's stated 64 MB / 3 iterations | `core/archipelago/src/seed.rs:249`, `docs/adr/005-chacha20-backup-encryption.md:31` |
|
||||
| F-06 | **Medium** | Release master mnemonic is passed via env var / stdout in the signing ceremony | `core/archipelago/src/ceremony.rs:71-77`, `:149-160` |
|
||||
| F-07 | **Medium** | No `cargo audit`/`cargo deny` in CI; two `rand` majors coexist in the graph | `core/archipelago/Cargo.toml:68` |
|
||||
| F-08 | **Low** | 24-word master mnemonic persisted in browser `sessionStorage` during onboarding | `neode-ui/src/views/OnboardingSeedGenerate.vue:330` |
|
||||
| F-09 | **Low** | Modulo bias in TOTP backup-code generation | `core/archipelago/src/totp.rs:305` |
|
||||
| F-10 | **Low** → **see F-10a** | Container `generated_secrets` use `thread_rng()` rather than an explicit `OsRng` (same T1 shape as F-02, smaller blast radius) | `core/archipelago/src/container/secrets.rs:92`, `:101` |
|
||||
| F-10a | **Medium** | **Scope correction to F-10 (2026-08-02):** the defaulted-RNG surface is crate-wide — **41 call sites across 15 files**, not 2 — and includes ecash and X3DH key material | `core/archipelago/src/session.rs` (16), `wallet/bdhke.rs` (4), `mesh/x3dh.rs` (2), `storage_crypto.rs`, +11 more — full table in §F-10a |
|
||||
| F-11 | **Informational** | `Math.random()` inside a seed-handling view (benign — UX challenge selection only) | `neode-ui/src/views/OnboardingSeedVerify.vue:159` |
|
||||
| F-12 | **Informational** | Identical default OS credentials on every flashed node | `image-recipe/archipelago-scripts/install-to-disk.sh:205` |
|
||||
| F-13 | **High** | BIP-84 account **private** key is imported into Bitcoin Core's wallet, duplicating the spending key outside the encrypted envelope | `core/archipelago/src/api/rpc/bitcoin.rs:203`, `:229-231` |
|
||||
|
||||
---
|
||||
|
||||
### F-01 — Unauthenticated `seed.generate` / `seed.restore` overwrite a live node's identity keys — **Critical**
|
||||
|
||||
**Evidence.**
|
||||
- `core/archipelago/src/api/rpc/middleware.rs:24-28` places `seed.generate`, `seed.verify`,
|
||||
`seed.restore` and `seed.save-encrypted` in `UNAUTHENTICATED_METHODS`, under the comment
|
||||
"Onboarding flow (before user has a session)".
|
||||
- `core/archipelago/src/api/rpc/mod.rs:263-265` — membership in that list skips the entire
|
||||
session check; `:295` skips RBAC; `:326` skips CSRF.
|
||||
- `core/archipelago/src/api/rpc/seed_rpc.rs:93-159` (`handle_seed_generate`) and `:226-305`
|
||||
(`handle_seed_restore`) contain **no** check that onboarding is already complete or that a
|
||||
node key already exists.
|
||||
- `core/archipelago/src/identity.rs:79-114` (`NodeIdentity::from_seed`) writes `node_key`,
|
||||
`node_key.pub` and, via `write_fips_key_from_seed` (`:108`), the FIPS mesh key —
|
||||
**unconditionally, with no existence check.** `seed_rpc.rs:130-131` and `:261-266` likewise
|
||||
overwrite `nostr_secret` / `nostr_pubkey`.
|
||||
- The guard already exists and is never called on this path:
|
||||
`core/archipelago/src/identity.rs:117-119` (`NodeIdentity::key_exists`). Its only callers are
|
||||
`core/archipelago/src/server.rs:63` and `core/archipelago/src/api/rpc/seed_rpc.rs:343`
|
||||
(read-only status).
|
||||
- No rate limit: `core/archipelago/src/rate_limit.rs:60-97` enumerates per-method limits and
|
||||
contains **no `seed.*` entry**, while explicitly acknowledging at `:96` that
|
||||
"Inter-node federation RPCs (unauthenticated, need stricter limits)".
|
||||
- Reachability: `image-recipe/configs/nginx-archipelago.conf:11` and `:15` bind port 80 as
|
||||
`default_server` (plaintext, LAN); `:165-175` proxies `/rpc/v1` and `:192-195` proxies
|
||||
`/rpc/` to `127.0.0.1:5678`. The FIPS mesh peer listener applies a path filter
|
||||
(`core/archipelago/src/server.rs:1375`, `:1270`) but that filter **allows** `/rpc/v1` —
|
||||
asserted at `core/archipelago/src/server.rs:2080`.
|
||||
|
||||
**Exploitability.** No credentials, no session, no CSRF token, no rate limit. A single
|
||||
unauthenticated JSON-RPC POST from anywhere on the LAN — or from any peer that can reach the
|
||||
mesh listener — is sufficient. `seed.restore` is the worse of the two because the attacker
|
||||
supplies the mnemonic: they then hold the node's Ed25519 signing key, its Nostr node key and
|
||||
its FIPS transport key. `seed.generate` is a pure destructive primitive: it mints a mnemonic
|
||||
nobody ever sees and overwrites the node's identity with it.
|
||||
|
||||
**Blast radius.** Node identity takeover or permanent identity destruction. Downstream: the
|
||||
node's `did:key` changes, so every federation trust relationship keyed on that DID breaks; the
|
||||
FIPS mesh key changes, so mesh peering breaks; the Nostr node key changes, so discovery
|
||||
announcements are signed by a key the fleet does not recognise. This does **not** by itself
|
||||
expose the user's Bitcoin funds (the on-disk `master_seed.enc` envelope is not overwritten by
|
||||
these handlers) — but do not read that as reassurance: an attacker who controls the node's
|
||||
identity keys controls how that node presents itself to the federation.
|
||||
|
||||
**This is not an entropy defect.** It surfaced because Step B of this audit required tracing
|
||||
secret classes (3) and (4) end-to-end rather than only checking where their bits come from.
|
||||
It is reported here because it is the most serious thing found and suppressing it until a
|
||||
"more appropriate" document would be indefensible.
|
||||
|
||||
**Remediation (concrete).** In `handle_seed_generate` and `handle_seed_restore`, bail early
|
||||
when `NodeIdentity::key_exists(&identity_dir)` is true *and* the in-memory onboarding mnemonic
|
||||
is absent — i.e. this is a booted, already-provisioned node rather than an onboarding retry.
|
||||
Prefer additionally gating on `auth_manager.is_onboarding_complete()`
|
||||
(`core/archipelago/src/auth.rs:182`). Add `seed.generate` / `seed.restore` to
|
||||
`rate_limit.rs`'s table at the strictness of `auth.changePassword` (3 per 300s). Consider
|
||||
removing `/rpc/v1` from `is_peer_allowed_path` for seed methods specifically, or filtering by
|
||||
method rather than path. Needs its own plan — see Backlog R-01.
|
||||
|
||||
---
|
||||
|
||||
### F-02 — Mnemonic entropy source is a transitive-dependency default — **Medium** — [ARCHY-1], **FIXED IN THIS AUDIT**
|
||||
|
||||
**Evidence.** `core/archipelago/src/seed.rs:92`:
|
||||
|
||||
```rust
|
||||
let mnemonic = bip39::Mnemonic::generate(24)
|
||||
```
|
||||
|
||||
Resolved against the vendored crate:
|
||||
`~/.cargo/registry/src/index.crates.io-.../bip39-2.1.0/src/lib.rs:311-313` →
|
||||
`generate_in` at `:296-298`, whose body is
|
||||
`Mnemonic::generate_in_with(&mut rand::thread_rng(), language, word_count)` →
|
||||
`generate_in_with` at `:267-283`, which is generic over `R: RngCore + CryptoRng` and fills the
|
||||
entropy buffer at `:281`.
|
||||
|
||||
So the entropy backend for Archipelago's whole key hierarchy — including the release-root
|
||||
signing key — was selected by `bip39`'s default, not stated at Archipelago's call site.
|
||||
|
||||
**Exploitability.** **None today.** `rand::thread_rng()` on `rand 0.8.5`
|
||||
(`core/archipelago/Cargo.toml:68`) is `ReseedingRng<ChaCha12Core, OsRng>`: seeded from
|
||||
`getrandom(2)`, reseeded every 64 KiB, `CryptoRng`, and still fork-protected in the 0.8 series.
|
||||
The mnemonic is genuinely 256-bit. This finding is about *future* exploitability, not present.
|
||||
|
||||
**Blast radius (if it ever rebinds).** Total. Every key in `seed.rs:1-18`, including the fleet
|
||||
release-root signing key at `:143-146`. That is strictly larger than a hardware wallet's,
|
||||
because it includes the ability to forge signed release manifests.
|
||||
|
||||
**Why it is worth fixing anyway.** This is the precise structural shape of T1: a call whose
|
||||
entropy backend is fixed by dependency/build configuration rather than by the calling code,
|
||||
with no compile error if it changes. `bip39` is pinned `=2.1.0`
|
||||
(`core/archipelago/Cargo.toml:74`) which contains the exposure today, and a future `rand` bump
|
||||
to 0.9+ removes fork protection (upstream changelog, 2025-01-27) without touching a line of
|
||||
Archipelago source.
|
||||
|
||||
**Remediation — applied.** `seed.rs` now routes generation through an internal helper that
|
||||
takes `&mut (impl CryptoRng + RngCore)` and calls `bip39::Mnemonic::generate_in_with`
|
||||
explicitly, with the production caller passing `OsRng`, plus a known-answer test that drives
|
||||
generation from a deterministic RNG and asserts the resulting words. That test is impossible
|
||||
to write against the pre-change code, because there was no seam to inject through. See §7.
|
||||
|
||||
---
|
||||
|
||||
### F-03 — Fail-open, never-retried first-boot secret regeneration over a fleet-shared rootfs — **High** — part of [ARCHY-3]
|
||||
|
||||
**Evidence.**
|
||||
- The installed root filesystem is a **container image exported to a tar**
|
||||
(`image-recipe/_archived/build-auto-installer-iso.sh:717-726`), cached across builds
|
||||
(`:267`), shipped on the ISO (`:1094`) and extracted verbatim onto every target disk
|
||||
(`:2303`, `tar -xf "$ROOTFS_TAR" -C /mnt/target`). Every node flashed from one ISO therefore
|
||||
starts from a byte-identical filesystem.
|
||||
- That rootfs installs `openssh-server` (`:345`). Debian's `openssh-server` postinst generates
|
||||
host keys at install time — i.e. **inside the container build** — so SSH host keys are baked
|
||||
into the shared tar.
|
||||
- It also bakes a self-signed RSA-2048 TLS keypair at `:463-469`
|
||||
(`openssl req -x509 -nodes -days 3650 -newkey rsa:2048 ... /etc/archipelago/ssl/archipelago.key`).
|
||||
- The mitigation exists and is correct in intent: `archipelago-first-boot-secrets.service`
|
||||
(`:1599-1614`) runs `first-boot-secrets.sh` (`:1616-1665`), which regenerates the TLS keypair
|
||||
(`:1635-1648`) and the full SSH host-key set via `ssh-keygen -A` into a staging dir and swaps
|
||||
on success (`:1651-1662`). It is installed at `:2587-2593` and enabled at `:3336`.
|
||||
- **The hole:** both branches are fail-open — `:1647` "WARNING: TLS regeneration failed,
|
||||
keeping baked key" and `:1659` "WARNING: ssh-keygen -A failed, keeping baked host keys" — and
|
||||
`touch "$MARKER"` at `:1663` runs **unconditionally, outside both `if` blocks**. The unit's
|
||||
`ConditionPathExists=!/var/lib/archipelago/.secrets-regenerated` (`:1605`) and the script's
|
||||
own `[ -f "$MARKER" ] && exit 0` (`:1625`) then guarantee it **never runs again**.
|
||||
- Timing: the unit declares `DefaultDependencies=no` and only `After=local-fs.target`
|
||||
(`:1603-1604`), so it runs very early — precisely when a freshly-flashed headless machine has
|
||||
the least accumulated entropy, and it is the first consumer of the pool.
|
||||
|
||||
**Exploitability.** One transient failure at first boot (a full disk, a slow-to-seed pool
|
||||
causing a timeout, an `openssl`/`ssh-keygen` hiccup) permanently leaves that node running the
|
||||
**image-wide shared** SSH host key and TLS private key. An attacker who obtains one copy of the
|
||||
ISO — which is a published artifact — holds the SSH host key and TLS private key of every node
|
||||
that hit that failure path, enabling transparent MITM of the web UI and undetectable SSH host
|
||||
impersonation. The failure is logged only to `/var/log/archipelago-first-boot-secrets.log` and
|
||||
surfaces nowhere in the UI.
|
||||
|
||||
**Blast radius.** Per-node, but silently and permanently, and correlated fleet-wide by ISO
|
||||
build.
|
||||
|
||||
**Remediation.** Move `touch "$MARKER"` inside a success branch that requires *both*
|
||||
regenerations to have succeeded; on failure, leave the marker absent so the oneshot retries on
|
||||
the next boot, and surface the condition (a `system.stats`/doctor field, not just a log file).
|
||||
Additionally add `After=systemd-random-seed.service` — harmless today (no seed file is baked,
|
||||
see [ARCHY-3]) and correct if one is ever introduced. Independently, strip the baked SSH host
|
||||
keys and TLS key from the rootfs tar at build time so a regeneration failure degrades to "no
|
||||
key / service refuses to start" rather than "shared key, silently".
|
||||
|
||||
---
|
||||
|
||||
### F-04 — Master mnemonic crosses the RPC boundary and lingers in memory — **Medium** — [ARCHY-4]
|
||||
|
||||
**Evidence.**
|
||||
- `core/archipelago/src/api/rpc/seed_rpc.rs:147` builds `words: Vec<String>` from the mnemonic
|
||||
and `:156-158` returns it as the JSON-RPC result.
|
||||
- Held server-side in a process-global `LazyLock<Arc<Mutex<Option<OnboardingMnemonicState>>>>`
|
||||
(`:13-19`) under a 10-minute TTL (`:27`).
|
||||
- **Deliberately not cleared at verify time** — `:205-211` documents the reasoning (the web
|
||||
client aborts at 15s and retries; clearing would make a retried verify fail). The rationale is
|
||||
sound; the residual risk is real and should be named rather than assumed away.
|
||||
- `save_pending_seed_encrypted` (`:42-57`) deliberately ignores the TTL, documented at `:35-39`.
|
||||
- Plaintext HTTP is a supported deployment: `core/archipelago/src/api/rpc/mod.rs:227-241`
|
||||
sets the session cookie's `Secure` flag **only** when `X-Forwarded-Proto: https` is present,
|
||||
with the comment "On LAN HTTP, Secure flag prevents browsers from sending cookies back" —
|
||||
i.e. plaintext LAN is an expected mode, corroborated by
|
||||
`image-recipe/configs/nginx-archipelago.conf:11` binding `:80` as `default_server`.
|
||||
|
||||
**Exploitability.** Passive: anyone with LAN traffic visibility during the ~1-2 minutes of
|
||||
onboarding reads the 24 words in cleartext. This unlocks the Bitcoin wallet, the node identity,
|
||||
and — if the same mnemonic is ever used as a release master seed — the fleet signing key.
|
||||
Requires being on-path during onboarding, which bounds it.
|
||||
|
||||
**Blast radius.** Total for that node's key hierarchy.
|
||||
|
||||
**Mitigating factors (real, and worth stating).** `OnboardingMnemonicState` implements `Drop`
|
||||
with `zeroize` (`:21-25`); the words are never logged; and `seed.reveal` — the *post*-onboarding
|
||||
path — is properly gated (see §5). The exposure is confined to the onboarding window.
|
||||
|
||||
**Remediation.** Confine seed-bearing methods to loopback or require TLS for them specifically;
|
||||
shrink `MNEMONIC_TTL`; clear on a *successful, acknowledged* verify with a short grace window
|
||||
rather than never. Deferred to a plan — Backlog R-04.
|
||||
|
||||
---
|
||||
|
||||
### F-05 — `Argon2::default()` does not match ADR-005 — **Medium**
|
||||
|
||||
**Evidence.** `docs/adr/005-chacha20-backup-encryption.md:31` specifies "Argon2id with high
|
||||
memory cost (64MB) and iterations (3)". The code uses `Argon2::default()` at
|
||||
`core/archipelago/src/seed.rs:249` and `:285` (the master-seed and aezeed envelope),
|
||||
`core/archipelago/src/backup/identity.rs:38` and `:93`, and
|
||||
`core/archipelago/src/backup/full.rs:618` and `:650`.
|
||||
|
||||
From the vendored crate `argon2-0.5.3`: `impl Default for Argon2` (`src/lib.rs:176-180`) uses
|
||||
`Params::default()`, whose constants are `DEFAULT_M_COST = 19 * 1024` KiB = **19 MiB**
|
||||
(`src/params.rs:42`), `DEFAULT_T_COST = 2` (`:52`), `DEFAULT_P_COST = 1` (`:61`).
|
||||
|
||||
**Actual: Argon2id, v0x13, m=19456 KiB, t=2, p=1. ADR-005 states: 64 MB, 3 iterations.** The
|
||||
algorithm choice (Argon2id) is correct; the cost parameters are roughly 3.4× weaker in memory
|
||||
and 1.5× weaker in time than the ADR claims. The defaults are the current OWASP minimum, so
|
||||
this is a documentation-vs-code divergence and a modest hardening gap, not a break.
|
||||
|
||||
**Exploitability.** Offline brute force of `master_seed.enc` / backup blobs by an attacker who
|
||||
already has file read access, at a lower cost than the ADR promises.
|
||||
|
||||
**Remediation.** Either construct `Argon2::new(Algorithm::Argon2id, Version::V0x13,
|
||||
Params::new(65536, 3, 1, None)?)` in one shared helper and use it everywhere, **or** amend
|
||||
ADR-005 to state the real parameters. Do **not** silently change the parameters on the
|
||||
master-seed envelope without a migration path: an existing `master_seed.enc` was encrypted
|
||||
under the old parameters and would fail to decrypt. That constraint is what makes this a
|
||||
backlog item rather than a quick fix.
|
||||
|
||||
---
|
||||
|
||||
### F-06 — Release master mnemonic passed by env var / printed to stdout — **Medium**
|
||||
|
||||
**Evidence.** `core/archipelago/src/ceremony.rs:70-78` (`cmd_gen`) prints
|
||||
`RELEASE_MASTER_MNEMONIC="<24 words>"` to **stdout** via `println!`. `:149-153`
|
||||
(`load_release_root_key`) reads the phrase via `read_mnemonic()`, which at `:157-160` prefers
|
||||
the `RELEASE_MASTER_MNEMONIC` environment variable and falls back to stdin.
|
||||
|
||||
**Exploitability.** An environment variable is readable from `/proc/<pid>/environ` by the same
|
||||
user and lands in shell history if set inline; stdout lands in terminal scrollback, tmux
|
||||
buffers, CI logs and `script`/asciinema captures. This is the seed that derives the **fleet
|
||||
release-root signing key** (`core/archipelago/src/seed.rs:143-146`) — compromise means forging
|
||||
signed manifests for every node.
|
||||
|
||||
**Mitigating factors.** The ceremony is a deliberate, human-operated, offline procedure, the
|
||||
tool prints a prominent warning at `ceremony.rs:73-75`, and the stdin path exists and is the
|
||||
documented practice (project memory: "sign via user TTY"). The env-var path is a convenience
|
||||
affordance, not the intended default.
|
||||
|
||||
**Remediation.** Make stdin/TTY the only supported input for `sign`/`pubkey` and remove or
|
||||
feature-gate the env-var branch; for `gen`, write the mnemonic to a `0600` file on explicitly
|
||||
named removable media rather than stdout, or require an interactive confirmation. Low effort,
|
||||
but it touches the signing ceremony — schedule it deliberately, not opportunistically.
|
||||
|
||||
---
|
||||
|
||||
### F-07 — No dependency-advisory gate in CI; two `rand` majors in the graph — **Medium**
|
||||
|
||||
**Evidence.** `cargo-audit` is not installed on this host, so no RustSec check was run.
|
||||
`cargo tree -i rand@0.8.5 -p archipelago` and `-i rand@0.9.2 -p archipelago` show **both**
|
||||
majors resolved into the same binary:
|
||||
|
||||
- `rand 0.8.5` — direct (`core/archipelago/Cargo.toml:68`), plus `archipelago-security`,
|
||||
`bip39 2.1.0`, `mainline 2.0.1`, `secp256k1 0.29.1`, `tungstenite 0.20.1`.
|
||||
- `rand 0.9.2` — transitively via `totp-rs 5.7.0` and `tungstenite 0.26.2` (through
|
||||
`tokio-tungstenite` → `async-wsocket` → `nostr-relay-pool` → `nostr-sdk 0.44.1`).
|
||||
|
||||
**No Archipelago-authored key-generation call site uses `rand 0.9.x`** — the direct dependency
|
||||
is pinned to `0.8.5` and every first-party `OsRng`/`thread_rng`/`rand::random` call resolves
|
||||
against it. But `rand 0.9.0` removed fork protection from `ThreadRng`, and the orchestrator
|
||||
forks and spawns constantly, so the day a `rand` bump lands the T1 shape in F-02 and F-10
|
||||
becomes materially worse. `getrandom` is likewise split across `0.2.17` and `0.3.4`.
|
||||
|
||||
**Remediation.** Add `cargo audit` (or `cargo deny check advisories bans`) to CI, with a `bans`
|
||||
rule that fails on duplicate `rand` majors so the split is visible rather than silent. Before
|
||||
any `rand` 0.9+ bump, convert every key-generation site to explicit `OsRng` (F-02, F-10) — after
|
||||
which the fork-protection removal is irrelevant to Archipelago.
|
||||
|
||||
---
|
||||
|
||||
### F-08 — 24-word master mnemonic persisted in browser `sessionStorage` — **Low**
|
||||
|
||||
**Evidence.** `neode-ui/src/views/OnboardingSeedGenerate.vue:330` writes the full word list:
|
||||
`sessionStorage.setItem('_seed_words', JSON.stringify(words.value))`; it is re-read at `:297`
|
||||
and at `neode-ui/src/views/OnboardingSeedVerify.vue:165`. The mnemonic itself arrives from
|
||||
`seed.generate` at `OnboardingSeedGenerate.vue:256-258`.
|
||||
|
||||
**Mitigating factors.** It **is** removed on successful verify
|
||||
(`neode-ui/src/views/OnboardingSeedVerify.vue:251`), and its exclusion from the logout
|
||||
cache-purge is a deliberate, test-pinned decision
|
||||
(`neode-ui/src/stores/__tests__/resourcesClear.test.ts:213`, `:231`) — onboarding must survive a
|
||||
reload. So this is a considered trade-off, not an oversight.
|
||||
|
||||
**Residual risk.** A user who abandons onboarding mid-flow leaves the master mnemonic in
|
||||
plaintext `sessionStorage` for the lifetime of the tab. On the node's own kiosk browser, that
|
||||
tab may stay open indefinitely. Any XSS in the UI during that window reads it directly.
|
||||
|
||||
**Remediation.** Clear `_seed_words` on route-leave from the onboarding flow as well as on
|
||||
verify, and add a wall-clock expiry to the stored blob mirroring the server's `MNEMONIC_TTL`.
|
||||
|
||||
---
|
||||
|
||||
### F-09 — Modulo bias in TOTP backup-code generation — **Low** — [ARCHY-5]
|
||||
|
||||
**Evidence.** `core/archipelago/src/totp.rs:305`:
|
||||
|
||||
```rust
|
||||
let idx = (rand::random::<u8>() as usize) % charset.len();
|
||||
```
|
||||
|
||||
with `charset` = 32 characters (`:298`). **32 divides 256 exactly**, so in the *current* code
|
||||
the bias is **zero** — the research's [ARCHY-5] framing of "classic modulo bias" is correct as a
|
||||
pattern but the concrete instance is presently unbiased. The defect is latent: any future edit
|
||||
to the charset (adding a symbol, removing an ambiguous letter) silently introduces bias with no
|
||||
test to catch it. Reported as Low on that basis, not on present harm.
|
||||
|
||||
**Remediation.** Replace with `rand::seq::SliceRandom::choose(&mut OsRng)`, which is
|
||||
unbiased for any charset length, and add an assertion or test that pins the property. Left to
|
||||
the backlog rather than applied here: the entropy source is already correct and the present
|
||||
bias is nil, so it does not meet this plan's bar for a code change.
|
||||
|
||||
---
|
||||
|
||||
### F-10 — Container `generated_secrets` use `thread_rng()` — **Low**
|
||||
|
||||
**Evidence.** `core/archipelago/src/container/secrets.rs:90-93` (`random_hex`) and `:98-102`
|
||||
(`random_base64`) both use `rand::thread_rng().fill_bytes(&mut buf)`. These materialise
|
||||
manifest-declared `generated_secrets` for every app (Bitcoin RPC password, DB passwords,
|
||||
netbird store encryption key, the Fedimint gateway credential at `:135-...`).
|
||||
|
||||
**Assessment.** Cryptographically fine on `rand 0.8.5` for the same reason as F-02, and the same
|
||||
T1-shaped structural objection applies with a smaller blast radius (per-app credentials rather
|
||||
than the master key hierarchy). File permissions were verified rather than assumed:
|
||||
`core/archipelago/src/container/secrets.rs:207` sets `.mode(0o600)` on creation, and `:269` and
|
||||
`:307` are tests asserting `mode == 0o600` for the written files. **CLAUDE.md's "0600/rootless"
|
||||
invariant holds and is test-enforced.**
|
||||
|
||||
*(This file carried uncommitted third-party changes at audit time — line numbers are against the
|
||||
2026-07-31 working tree.)*
|
||||
|
||||
**Remediation.** Swap both helpers to `rand::rngs::OsRng` when F-02's pattern is generalised.
|
||||
One-line change each; batched into the same backlog item.
|
||||
|
||||
---
|
||||
|
||||
### F-10a — Scope correction: the defaulted-RNG surface is crate-wide — **Medium**
|
||||
|
||||
> **Added 2026-08-02, after the original audit.** F-10 above reported this defect as two call
|
||||
> sites in one file. That was **understated**. This section records the true scope with evidence.
|
||||
> F-10's own text and remediation are left unedited above so the correction is auditable rather
|
||||
> than retroactive.
|
||||
|
||||
**Evidence.** `grep -rn "rand::random\|thread_rng()" core/archipelago/src --include=*.rs` returns
|
||||
**43 matches across 16 files**. Two of those (`seed.rs:87`, `:671`) are comments in the
|
||||
already-remediated F-02 file, leaving **41 matches across 15 files**:
|
||||
|
||||
| File | Matches | Generates |
|
||||
|---|---|---|
|
||||
| `core/archipelago/src/session.rs` | 16 | session tokens |
|
||||
| `core/archipelago/src/api/rpc/package/pine_ha.rs` | 6 | app credentials |
|
||||
| `core/archipelago/src/wallet/bdhke.rs` | 4 | **Cashu blinded-key-exchange values — key material** |
|
||||
| `core/archipelago/src/mesh/x3dh.rs` | 2 | **X3DH key agreement — key material** |
|
||||
| `core/archipelago/src/container/secrets.rs` | 2 | `generated_secrets` (the original F-10) |
|
||||
| `core/archipelago/src/api/rpc/package/install.rs` | 2 | install-time secrets |
|
||||
| `core/archipelago/src/storage_crypto.rs` | 1 | **ChaCha20-Poly1305 nonce — reuse breaks the AEAD** |
|
||||
| `core/archipelago/src/credentials/store.rs` | 1 | credential store material |
|
||||
| `core/archipelago/src/device_tokens.rs` | 1 | device tokens |
|
||||
| `core/archipelago/src/federation/invites.rs` | 1 | federation invites |
|
||||
| `core/archipelago/src/bitcoin_rpc.rs` | 1 | Bitcoin RPC password |
|
||||
| `core/archipelago/src/totp.rs` | 1 | TOTP backup codes (also F-09) |
|
||||
| `core/archipelago/src/transport/chunking.rs` | 1 | chunk identifiers |
|
||||
| `core/archipelago/src/fips/dial.rs` | 1 | dial jitter/identifiers |
|
||||
| `core/archipelago/src/api/rpc/auth.rs` | 1 | auth-path material |
|
||||
|
||||
**Per-site production-vs-test classification is deliberately NOT asserted here** — it is the
|
||||
first task of the remediation, not an assumption of this correction. The counts above are raw
|
||||
matches.
|
||||
|
||||
**Assessment.** Unchanged from F-10 in kind: `rand::random()` and `thread_rng()` are backed by
|
||||
ChaCha12 seeded from `getrandom(2)` on `rand 0.8.5`, so **nothing in this table is broken
|
||||
today**. What changes is the *blast radius* of the T1 structural objection. F-10 rated this Low
|
||||
on the basis of "per-app credentials rather than the master key hierarchy". That justification
|
||||
does not survive the true scope: `wallet/bdhke.rs` and `mesh/x3dh.rs` generate key material, and
|
||||
`storage_crypto.rs:39` draws an AEAD nonce, where a silent rebinding to a non-cryptographic PRNG
|
||||
would be catastrophic rather than merely undesirable. Re-rated **Medium**.
|
||||
|
||||
**Why the original audit missed it.** F-10 was reached by tracing the *manifest secrets* path
|
||||
(secret class 4). No step enumerated defaulted-RNG use across the whole crate independently of
|
||||
the traced paths — so files outside those traces were never in scope to be looked at. Recorded
|
||||
here because the same blind spot would recur in the next audit run under the same method.
|
||||
|
||||
**Remediation → tracked as KEY-05 in Phase 10** (`.planning/ROADMAP.md`), which supersedes R-13:
|
||||
a sealed allowlist trait so only approved RNGs can be passed at key-generation seams; a
|
||||
`clippy.toml` `disallowed-methods` ban on `rand::thread_rng` / `rand::random` crate-wide so the
|
||||
default cannot be inherited by *new* code either; `cargo-deny` failing on duplicate `rand`
|
||||
majors (R-05, the mechanism by which a bump could silently rebind); a degenerate-entropy runtime
|
||||
check before key generation; and persisting the CSPRNG-readiness verdict (R-09) that
|
||||
`seed.rs:59` already computes but discards.
|
||||
|
||||
---
|
||||
|
||||
### F-11 — `Math.random()` inside a seed-handling view — **Informational (benign)**
|
||||
|
||||
**Evidence.** `neode-ui/src/views/OnboardingSeedVerify.vue:157-163`, `pickRandomIndices` uses
|
||||
`Math.floor(Math.random() * max)` to choose which of the 24 words the user is quizzed on.
|
||||
|
||||
**Assessment: benign, and annotated here so the next auditor does not re-derive it.** The
|
||||
indices select a UX challenge only. They are not key material, not a nonce, not a salt, and not
|
||||
a secret: an attacker who predicts perfectly which words will be quizzed learns nothing — the
|
||||
words themselves are what they would need, and those are already on the user's screen. The
|
||||
verification is a *user*-facing "did you write it down" check, not an authentication boundary
|
||||
(the server compares against its own held copy at
|
||||
`core/archipelago/src/api/rpc/seed_rpc.rs:190-194`).
|
||||
|
||||
Other `Math.random()` sites, all confirmed non-security:
|
||||
`neode-ui/src/api/rpc-client.ts:183`, `:206`, `:215` (retry jitter);
|
||||
`neode-ui/src/views/Login.vue:317` (progress bar);
|
||||
`neode-ui/src/components/BootScreen.vue:112`, `:123` (starfield animation).
|
||||
|
||||
**No remediation required.** Optionally add a one-line comment at the call site so this stays
|
||||
annotated in the code rather than only in this document.
|
||||
|
||||
---
|
||||
|
||||
### F-12 — Identical default OS credentials on every flashed node — **Informational**
|
||||
|
||||
**Evidence.** `image-recipe/archipelago-scripts/install-to-disk.sh:205` sets
|
||||
`archipelago:archipelago` via `chpasswd`, and `:367-371` prints the credentials with a
|
||||
"Please change the password after first login!" warning.
|
||||
|
||||
**Assessment.** Not an entropy defect and a known, documented alpha-stage default. Recorded here
|
||||
only because it belongs to the same one-image-many-nodes correlation theme as [ARCHY-3]: it is
|
||||
the one identity artefact that is *deliberately* identical across the fleet, and unlike the SSH
|
||||
host key and TLS key (F-03) there is no first-boot regeneration for it. Out of scope to fix;
|
||||
in scope to name.
|
||||
|
||||
---
|
||||
|
||||
### F-13 — BIP-84 account **private** key is imported into Bitcoin Core — **High**
|
||||
|
||||
**Evidence.** `core/archipelago/src/api/rpc/bitcoin.rs:161-294`
|
||||
(`handle_bitcoin_init_wallet_from_seed`):
|
||||
|
||||
- `:203` passes `disable_private_keys = false` to `createwallet`.
|
||||
- `:188-189` derives the BIP-84 account **xprv** (`crate::seed::derive_bitcoin_xprv`,
|
||||
`core/archipelago/src/seed.rs:207-224`) and stringifies it.
|
||||
- `:229-231` builds `wpkh({xprv}/0/*)` and `wpkh({xprv}/1/*)`.
|
||||
- `:278-281` imports those descriptors via `importdescriptors`.
|
||||
|
||||
**Assessment.** The node's Bitcoin spending key is therefore persisted **twice**: once in the
|
||||
daemon's Argon2 + ChaCha20-Poly1305 envelope (`core/archipelago/src/seed.rs:238-269`, `0600` via
|
||||
`:318-324`), and once in Bitcoin Core's `wallet.dat`, which has neither the Argon2 passphrase
|
||||
protection nor the same ownership story — it lives in the Bitcoin Core container's data volume.
|
||||
The wallet is created with an **empty** encryption passphrase (`bitcoin.rs:205`), so Core's own
|
||||
wallet encryption is not engaged either.
|
||||
|
||||
**Not an entropy defect**, and reported here because Step B required tracing secret class (1),
|
||||
the user Bitcoin/LND wallet seed, from generation to consumer — and this is where that trace
|
||||
ends up.
|
||||
|
||||
**Credit where due:** the in-memory handling of the xprv string is careful — it is zeroized on
|
||||
both the error path (`bitcoin.rs:222`) and the success path (`:284`) — and the wallet is a
|
||||
*descriptor* wallet (`:207`), which is the correct foundation. The defect is which key goes
|
||||
into it.
|
||||
|
||||
**Secondary defect, same lines.** The descriptors at `:230-231` carry **no key-origin
|
||||
annotation** (`[fingerprint/derivation]`). Without it, no hardware signer can locate its key in
|
||||
a PSBT — so the current wallet could not be converted to an external-signer setup even if the
|
||||
private key were removed.
|
||||
|
||||
**Exploitability.** Requires read access to the Bitcoin Core data volume. That is a lower bar
|
||||
than the encrypted envelope: a container escape, a backup of the Bitcoin volume, or a
|
||||
misconfigured bind mount exposes it, whereas `master_seed.enc` additionally requires the user's
|
||||
password.
|
||||
|
||||
**Blast radius.** The node's entire on-chain Bitcoin balance at `m/84'/0'/0'`. It does **not**
|
||||
extend to the other key classes — the release-root key, node identity and FIPS keys are HKDF
|
||||
siblings, not children of the BIP-84 branch, so an attacker with the account xprv cannot climb
|
||||
back to the master seed.
|
||||
|
||||
**Remediation.** Pass `disable_private_keys = true`; import the account **xpub** with a
|
||||
key-origin annotation instead of the xprv; sign via the daemon (or an external signer) rather
|
||||
than via Core. This is Phase 1 of `docs/security/PSBT-SIGNING-ARCHITECTURE.md` §8, including
|
||||
the migration that verifies balance/UTXO parity before removing the private-key-bearing wallet.
|
||||
|
||||
---
|
||||
|
||||
## 4. [ARCHY-1] … [ARCHY-4] adjudication
|
||||
|
||||
### [ARCHY-1] — **CONFIRMED**
|
||||
|
||||
The research's claim that `bip39::Mnemonic::generate(24)` at `core/archipelago/src/seed.rs:92`
|
||||
resolves its entropy source through a transitive default is **exactly right**, and the citation
|
||||
is accurate: `bip39-2.1.0/src/lib.rs:296-298` is `generate_in`, whose body is
|
||||
`Mnemonic::generate_in_with(&mut rand::thread_rng(), language, word_count)`. The full chain is
|
||||
`generate` (`:311-313`) → `generate_in` (`:296-298`) → `generate_in_with` (`:267-283`).
|
||||
|
||||
**The entropy source is chosen by the dependency, not at the call site.** The injectable seam
|
||||
exists and is public — `generate_in_with<R: RngCore + CryptoRng>` — so closing this costs
|
||||
almost nothing. It is **not** a vulnerability today (`rand 0.8.5`'s `thread_rng` is a
|
||||
fork-protected ChaCha12 CSPRNG seeded from `getrandom(2)`), but it is the structural shape of
|
||||
T1. **Fixed in this audit — see §7 and F-02.**
|
||||
|
||||
### [ARCHY-2] — **CONFIRMED (as a positive finding)**
|
||||
|
||||
`kernel_csprng_ready()` at `core/archipelago/src/seed.rs:58-75` calls
|
||||
`libc::getrandom(..., libc::GRND_NONBLOCK)` (`:62-67`), maps a 1-byte success to `Some(true)`
|
||||
(`:68-69`), `EAGAIN` to `Some(false)` (`:70-71`), and anything else to `None` (`:73`). The
|
||||
single byte it draws is **discarded** — `byte` is never read again. It is used only by
|
||||
`MasterSeed::generate` at `:85-91` to emit `info!` or `warn!`.
|
||||
|
||||
**No key material is drawn from the non-blocking path.** The actual mnemonic entropy comes from
|
||||
`bip39::Mnemonic::generate(24)` at `:92`, i.e. `getrandom(2)` **without** `GRND_NONBLOCK`, which
|
||||
blocks until the pool is initialised. The doc comment at `:52-57` states this reasoning
|
||||
correctly. The research's assessment — "exactly right and better than most implementations" —
|
||||
holds. The two hardening notes it raised also hold and are carried to the backlog: the
|
||||
invariant depends on the `getrandom` crate using the blocking syscall (worth a test, not just a
|
||||
comment), and the `warn!` should be persisted as a structured, durable event so a node can
|
||||
answer post-hoc "was the pool ready when this seed was born?" — the question Coldcard owners
|
||||
cannot answer today.
|
||||
|
||||
### [ARCHY-3] — **PARTIALLY CONFIRMED; the tree answers three of four sub-questions, the fourth is UNVERIFIED**
|
||||
|
||||
First, a scoping correction the research could not have known: `image-recipe/_archived/` is
|
||||
**not** dead. `image-recipe/build-debian-iso.sh:19-40` execs
|
||||
`image-recipe/_archived/build-auto-installer-iso.sh`. That file is the ISO builder.
|
||||
|
||||
| Sub-question | Verdict | Evidence |
|
||||
|---|---|---|
|
||||
| Does the build bake a populated seed file into the image? | **NO** | `find image-recipe -name 'random-seed' -o -name '*.seed'` → empty. The rootfs is a container export (`build-auto-installer-iso.sh:717-726`); `systemd-random-seed.service` never runs inside a container build, so `/var/lib/systemd/random-seed` is never created. The installer extracts that tar (`:2303`) and adds no seed file. |
|
||||
| Is there a first-boot regeneration unit? | **YES, for TLS + SSH host keys — but it is fail-open and never retried** | `archipelago-first-boot-secrets.service` at `:1599-1614`, script at `:1616-1665`, installed `:2587-2593`, enabled `:3336`. Hole documented as **F-03** (`:1647`, `:1659`, `:1663`). It does **not** touch `/etc/machine-id` or any random-seed file. |
|
||||
| Does the image install `jitterentropy-rngd` / `haveged` / `rng-tools`? | **NO** | `grep -cE 'haveged\|jitterentropy\|rng-tools\|rngd' image-recipe/_archived/build-auto-installer-iso.sh` → `0`. The rootfs package list at `:330-352` and following contains no entropy daemon. Kernel ≥5.6's in-kernel jitter source is therefore the only supplemental source on headless hardware. |
|
||||
| Can onboarding key generation run before the kernel CSPRNG is initialised? | **NO — it can be *delayed* by it, but never weakened** | `bip39` fills entropy via `rand`'s `OsRng`/`ThreadRng` seeding, i.e. blocking `getrandom(2)`. `core/archipelago/src/seed.rs:52-57` documents exactly this and the probe at `:85-91` makes the ordering visible in the logs. The failure mode is a hang, not a weak key — the correct trade. |
|
||||
|
||||
**What remains genuinely UNVERIFIED.** Whether `/etc/machine-id` is empty (regenerated per node)
|
||||
or populated (shared) in the exported rootfs tar; whether SSH host keys are in fact present in
|
||||
that tar as the `openssh-server` install at `:345` implies; the real `crng init done` timestamp
|
||||
relative to seed generation on freshly-flashed hardware; and whether N nodes flashed from one
|
||||
ISO actually produce N distinct seeds. **None of these is answerable from this environment.**
|
||||
They are the on-node checklist in §6 and must not be reported as verified.
|
||||
|
||||
**Net assessment.** The most-feared version of [ARCHY-3] — a baked, credited `random-seed`
|
||||
giving every node a correlated pool — **does not exist**. The real exposure is narrower and
|
||||
different from what the research predicted: fleet-shared SSH host keys and a fleet-shared TLS
|
||||
private key in the cached rootfs, protected by a regeneration step that fails open and never
|
||||
retries (F-03).
|
||||
|
||||
### [ARCHY-4] — **CONFIRMED, and worse than described**
|
||||
|
||||
Every specific claim checks out:
|
||||
|
||||
- The mnemonic is returned to the web client as `words: Vec<String>` —
|
||||
`core/archipelago/src/api/rpc/seed_rpc.rs:147`, returned at `:156-158`. (The research cited
|
||||
"~line 147"; exact.)
|
||||
- 10-minute in-memory TTL — `MNEMONIC_TTL` at `:27`, state struct at `:16-19`.
|
||||
- Deliberately **not** cleared at verify time, with a documented rationale — `:205-211`.
|
||||
(Research cited `:205-209`; the comment block runs `:205-211`.)
|
||||
- Plaintext HTTP is a live mode — `core/archipelago/src/api/rpc/mod.rs:227-241` conditions the
|
||||
cookie `Secure` flag on `X-Forwarded-Proto: https` and comments explicitly on "LAN HTTP";
|
||||
`image-recipe/configs/nginx-archipelago.conf:11`, `:15` bind `:80` as `default_server` and
|
||||
`:165-195` proxy `/rpc/v1` and `/rpc/` to the daemon.
|
||||
|
||||
**Worse than described:** the research treated this as a confidentiality exposure. It is also an
|
||||
**integrity and availability** exposure, because the same four seed methods are in
|
||||
`UNAUTHENTICATED_METHODS` (`core/archipelago/src/api/rpc/middleware.rs:24-28`) with no
|
||||
onboarding gate and no rate limit, and the handlers overwrite live identity keys
|
||||
unconditionally. That is **F-01**, severity Critical.
|
||||
|
||||
### [ARCHY-5] — **CONFIRMED as a pattern, REFUTED as a present defect**
|
||||
|
||||
The line is exactly as cited (`core/archipelago/src/totp.rs:305`) but the charset at `:298` is
|
||||
32 characters, and 32 divides 256 exactly, so the current distribution is **uniform — there is
|
||||
no bias today**. The research's characterisation ("classic modulo bias whenever
|
||||
`charset.len()` does not divide 256") is technically precise; its implied conclusion that this
|
||||
instance is biased is not. Recorded as **F-09**, Low, on latent-defect grounds only. Stated
|
||||
plainly rather than quietly dropped, per this audit's honesty rule.
|
||||
|
||||
### Open question 9 (Argon2 parameters) — **DIVERGENCE CONFIRMED**
|
||||
|
||||
`Argon2::default()` = Argon2id, v0x13, **m=19456 KiB (19 MiB), t=2, p=1**
|
||||
(`argon2-0.5.3/src/params.rs:42`, `:52`, `:61`; `src/lib.rs:176-180`).
|
||||
`docs/adr/005-chacha20-backup-encryption.md:31` states **64 MB and 3 iterations**. The code does
|
||||
not match the ADR. Full detail and the migration constraint are in **F-05**.
|
||||
|
||||
### Also noted from the research, confirmed benign
|
||||
|
||||
`core/archipelago/src/storage_crypto.rs:39` and `core/archipelago/src/credentials/store.rs:69`
|
||||
draw 96-bit ChaCha20-Poly1305 nonces via `rand::random()`. CSPRNG-backed; fine. The
|
||||
random-nonce birthday bound (~2^32 messages per key) is not approached by either use. Same for
|
||||
`core/archipelago/src/mesh/crypto.rs:70` (explicit `OsRng`, with a correct explanatory comment
|
||||
at `:64`), `core/archipelago/src/fips/dial.rs:75` (a 16-bit dial ID, not a secret), and
|
||||
`core/archipelago/src/wallet/bdhke.rs:133`, `:139`.
|
||||
|
||||
---
|
||||
|
||||
## 5. What we do right
|
||||
|
||||
Credit where the code is correct — each with evidence, so a future refactor that removes any of
|
||||
these is visibly a regression.
|
||||
|
||||
1. **The CSPRNG-readiness probe.** `core/archipelago/src/seed.rs:52-91`. Uses `GRND_NONBLOCK`
|
||||
*as a probe only*, discards the byte, and logs the pool state immediately before generating
|
||||
the master seed. The doc comment reasons correctly about why blocking `getrandom(2)` makes a
|
||||
weak seed impossible. This is better than most wallet implementations and is precisely the
|
||||
audit trail Coldcard owners now wish they had.
|
||||
2. **Zeroization is real, not decorative.** `MasterSeed` is `#[derive(Zeroize, ZeroizeOnDrop)]`
|
||||
(`core/archipelago/src/seed.rs:47-50`); the Argon2-derived key is explicitly zeroized on both
|
||||
the encrypt and decrypt paths (`:262`, `:292`); the aezeed plaintext join is zeroized after
|
||||
use (`:384`, `:401`); the in-memory onboarding mnemonic zeroizes on `Drop`
|
||||
(`core/archipelago/src/api/rpc/seed_rpc.rs:21-25`); the reveal path zeroizes the password on
|
||||
every exit (`:396`, `:430`, `:441`, `:465`).
|
||||
3. **No `#[derive(Debug)]` on any secret-bearing type.**
|
||||
`grep -rn 'derive(Debug' core/archipelago/src/seed.rs core/archipelago/src/identity.rs
|
||||
core/archipelago/src/credentials/store.rs` returns **nothing** — the classic accidental-log
|
||||
escape is closed by construction.
|
||||
4. **No secret is logged.** The secret-logging grep across `core/*/src` returned only
|
||||
non-secret status lines. The most sensitive one,
|
||||
`core/archipelago/src/seed.rs:86` ("kernel CSPRNG initialized; generating master seed"),
|
||||
contains no material. `core/archipelago/src/identity.rs:103-106` logs only the first 16 hex
|
||||
chars of a **public** key. The file-level invariant at `core/archipelago/src/seed.rs:18`
|
||||
("Never log mnemonic or seed material at any level") is actually honoured.
|
||||
5. **Encrypted-at-rest envelope with per-blob salt and nonce from `OsRng`.**
|
||||
`core/archipelago/src/seed.rs:243-246`, AEAD at `:253-260`, and every identity blob written
|
||||
`0600` via a single shared helper (`:318-324`). One implementation, not five.
|
||||
6. **24-word enforcement on restore.** `core/archipelago/src/seed.rs:111-114` rejects any word
|
||||
count other than 24, so a 12-word (128-bit) mnemonic cannot be smuggled into a hierarchy that
|
||||
assumes 256 bits.
|
||||
7. **Domain-separated derivation, pinned by known-answer tests.** Distinct HKDF info strings
|
||||
per key class (`core/archipelago/src/seed.rs:37-41`), with KATs that pin the exact bytes:
|
||||
`:764-779` (node key, cross-checked against `scripts/verify-seed-derivation.py`) and
|
||||
`:800-816` (release-root private *and* public key). A derivation change cannot land silently.
|
||||
8. **An existing non-determinism regression guard.** `core/archipelago/src/seed.rs:597-622`
|
||||
generates 64 mnemonics and asserts both uniqueness and word-distribution spread, with a
|
||||
comment naming exactly the failure it guards against. This is a genuinely good instinct that
|
||||
predates the Coldcard incident — it would have caught a Yasmarang-class collapse.
|
||||
9. **`seed.reveal` is properly gated.** `core/archipelago/src/api/rpc/seed_rpc.rs:360-369`:
|
||||
authenticated session required (it is deliberately *not* in the unauthenticated allowlist),
|
||||
password re-verification, replay-protected TOTP when 2FA is on, and separate backup-passphrase
|
||||
decryption. The contrast with F-01's ungated `seed.generate`/`seed.restore` is what makes
|
||||
F-01 look like an oversight rather than a design position.
|
||||
10. **Correct browser RNG at the call sites that matter.**
|
||||
`neode-ui/src/views/OnboardingVerify.vue:105-109` uses `crypto.getRandomValues` for the
|
||||
32-byte signing challenge; `neode-ui/src/views/web5/Web5.vue:183-185` does the same, and
|
||||
guards on `crypto.subtle` being absent — which is exactly right, because `subtle` is
|
||||
undefined in an insecure context while `getRandomValues` keeps working over plain HTTP.
|
||||
11. **Container secret file modes are test-enforced, not assumed.**
|
||||
`core/archipelago/src/container/secrets.rs:207` sets `0o600`; `:269` and `:307` are tests
|
||||
asserting it. CLAUDE.md's invariant is mechanically defended.
|
||||
12. **The release-root key is derived, not stored, and nodes hold only the public half.**
|
||||
`core/archipelago/src/seed.rs:133-146` documents the publisher-only derivation;
|
||||
`core/archipelago/src/trust/anchor.rs:34` pins the public key. Fleet nodes never hold the
|
||||
signing key.
|
||||
13. **The FIPS mesh peer listener is path-filtered.** `core/archipelago/src/server.rs:1375`,
|
||||
`:1270`. The mechanism is right even though its current allowlist is too permissive for
|
||||
seed methods (F-01).
|
||||
|
||||
---
|
||||
|
||||
## 6. On-node verification checklist — **UNVERIFIED**
|
||||
|
||||
**Every item below is UNVERIFIED.** None was executed. Real hardware — a freshly-flashed node,
|
||||
`.228`, or the dev-box — is not reachable from the environment this audit ran in. Do not treat
|
||||
any of these as checked until an operator has run them and recorded the output.
|
||||
|
||||
**Run on a *freshly flashed* node, before completing onboarding, unless noted.**
|
||||
|
||||
### C-1 — Was the kernel CSPRNG ready when keys were generated? ([ARCHY-3])
|
||||
|
||||
```bash
|
||||
journalctl -b | grep -iE 'crng init|random: '
|
||||
journalctl -b -u archipelago | grep -i 'kernel CSPRNG'
|
||||
cat /proc/sys/kernel/random/entropy_avail
|
||||
systemd-analyze blame | grep -iE 'random|archipelago-first-boot-secrets'
|
||||
```
|
||||
**Pass:** `crng init done` timestamp strictly precedes the
|
||||
`kernel CSPRNG initialized; generating master seed` line from
|
||||
`core/archipelago/src/seed.rs:86`. A `not yet initialized` warn line from `:87-89` is the
|
||||
signal to escalate.
|
||||
|
||||
### C-2 — Is a seed file present, and is `machine-id` unique? ([ARCHY-3])
|
||||
|
||||
```bash
|
||||
ls -l /var/lib/systemd/random-seed /var/lib/urandom/random-seed 2>&1
|
||||
cat /etc/machine-id
|
||||
```
|
||||
**Pass:** either no seed file at first boot, or one created *after* first boot with a
|
||||
current mtime. `machine-id` must differ between two nodes flashed from the same ISO — run on
|
||||
both and compare.
|
||||
|
||||
### C-3 — Are SSH host keys and the TLS key per-node? (**F-03**, the highest-value check here)
|
||||
|
||||
On two nodes flashed from the same ISO:
|
||||
```bash
|
||||
for f in /etc/ssh/ssh_host_*_key.pub; do echo "$f: $(ssh-keygen -lf "$f")"; done
|
||||
openssl x509 -in /etc/archipelago/ssl/archipelago.crt -noout -fingerprint -sha256
|
||||
cat /var/lib/archipelago/.secrets-regenerated 2>&1; ls -l /var/lib/archipelago/.secrets-regenerated
|
||||
grep -i warning /var/log/archipelago-first-boot-secrets.log
|
||||
```
|
||||
**Fail:** any fingerprint matching between the two nodes, or any `WARNING:` line in the log
|
||||
alongside an existing `.secrets-regenerated` marker (that combination is exactly the fail-open
|
||||
path at `image-recipe/_archived/build-auto-installer-iso.sh:1647`/`:1659`/`:1663`).
|
||||
|
||||
### C-4 — Does the shipped rootfs tar contain identity artefacts? (**F-03**, run on the *build host*)
|
||||
|
||||
```bash
|
||||
tar -tvf <build-dir>/archipelago-rootfs.tar | grep -E 'etc/ssh/ssh_host|etc/machine-id|var/lib/systemd/random-seed|archipelago/ssl/archipelago.key'
|
||||
```
|
||||
**Expected:** SSH host keys and the TLS key **present** (they are baked — see
|
||||
`build-auto-installer-iso.sh:345`, `:463-469`), `random-seed` **absent**, `machine-id` absent
|
||||
or zero-length. Anything else changes F-03's severity.
|
||||
|
||||
### C-5 — Cross-node same-ISO seed collision test (the empirical proof that would have caught T1)
|
||||
|
||||
Flash N ≥ 3 nodes from one ISO. On each, without user interaction:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:5678/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"seed.generate","params":null}' \
|
||||
| sha256sum
|
||||
```
|
||||
**Pass:** N distinct digests. **Handle the output as key material** — these are real mnemonics;
|
||||
compare digests only, never the words, and re-provision every node used for this test.
|
||||
Do **not** run this against a node in real use — per F-01 it overwrites the node's identity.
|
||||
|
||||
### C-6 — Is the RPC endpoint reachable unauthenticated from the LAN? (**F-01**)
|
||||
|
||||
From a *different* machine on the same LAN, against a **disposable** node:
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://<node-ip>/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"seed.status","params":null}'
|
||||
```
|
||||
**Fail:** `200`. Use `seed.status` (read-only), **never** `seed.generate`/`seed.restore`,
|
||||
to probe this. Repeat over the Tor onion address and over the FIPS mesh ULA to establish the
|
||||
full exposure surface.
|
||||
|
||||
### C-7 — Is the daemon's memory swappable?
|
||||
|
||||
```bash
|
||||
systemctl cat archipelago.service | grep -E 'MemoryDenyWriteExecute|LimitMEMLOCK'
|
||||
swapon --show
|
||||
```
|
||||
Informational: the onboarding mnemonic lives in process memory for up to 10 minutes (F-04) and
|
||||
`image-recipe/archipelago-scripts/install-to-disk.sh:226-236` creates a 2-8 GB swapfile on
|
||||
every install.
|
||||
|
||||
---
|
||||
|
||||
## 7. `ARCHY-1` remediation status — **APPLIED**
|
||||
|
||||
The injectable-RNG-seam refactor described in F-02 was applied to
|
||||
`core/archipelago/src/seed.rs`. Scope, precisely:
|
||||
|
||||
- A new private helper `generate_mnemonic_with<R: rand::CryptoRng + rand::RngCore>(rng: &mut R)`
|
||||
calls `bip39::Mnemonic::generate_in_with(rng, Language::English, 24)` — the **injectable**
|
||||
bip39 entry point — instead of the defaulting `Mnemonic::generate(24)`.
|
||||
- `MasterSeed::generate()` passes `&mut rand::rngs::OsRng` explicitly.
|
||||
- A doc comment at the helper pins the rationale to this audit and to T1, so a future
|
||||
`rand`/`bip39` bump cannot rebind the entropy source without someone reading why it matters.
|
||||
- Two tests added to the existing module.
|
||||
|
||||
**Nothing else changed.** The derivation paths, the 24-word count, the empty-BIP-39-passphrase
|
||||
decision, the at-rest encryption envelope, and every existing test are untouched.
|
||||
|
||||
**Tests.** `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago seed::` →
|
||||
**25 passed, 0 failed.**
|
||||
|
||||
- `mnemonic_generation_uses_injected_rng` — drives generation from a deterministic
|
||||
`CryptoRng + RngCore` test RNG and asserts (a) the result equals
|
||||
`bip39::Mnemonic::from_entropy(<the exact bytes that RNG emitted>)`, which is the direct proof
|
||||
that the **injected** RNG — not bip39's transitive `rand::thread_rng()` default — is the one
|
||||
actually consumed; (b) a known-answer word list; (c) determinism across two identical RNG
|
||||
states. **This test is impossible to write against the pre-change code**, because
|
||||
`Mnemonic::generate(24)` exposes no seam through which the RNG can be observed or substituted.
|
||||
- `mnemonic_generation_is_256_bit` — the production `OsRng` path yields 24 words and two
|
||||
successive productions differ.
|
||||
|
||||
**The residual risk this does not close.** Making the source explicit does not make the *fix*
|
||||
retroactive: mnemonics generated before this change came from `rand::thread_rng()`. That was
|
||||
and remains a genuine CSPRNG (F-02, "Exploitability: none today"), so no existing seed is
|
||||
weakened — but the guarantee for those seeds rests on `rand 0.8.5`'s behaviour, not on this
|
||||
call site. Reviewers should read this as *removing a future failure mode*, not as repairing a
|
||||
past one.
|
||||
|
||||
Everything else in this document is queued in §8, not implemented.
|
||||
|
||||
---
|
||||
|
||||
## 8. Remediation Backlog
|
||||
|
||||
Prioritised by severity × effort, most valuable per unit of work first. **Only R-00 was
|
||||
implemented by this audit.** Everything else is queued here and mirrored into
|
||||
`docs/UNIFIED-TASK-TRACKER.md` so it is not stranded in a document nobody re-reads.
|
||||
|
||||
**Effort scale:** S = under an hour; M = half a day; L = a day or more; **PHASE** = needs its
|
||||
own `/gsd-plan-phase`, not an opportunistic edit.
|
||||
|
||||
| # | Closes | Change | Files | Effort | Hardware? |
|
||||
|---|---|---|---|---|---|
|
||||
| **R-00** | F-02 / [ARCHY-1] | **DONE in this audit.** Route mnemonic generation through an injectable-RNG helper; production passes `OsRng`; known-answer test proves the injected RNG is consumed | `core/archipelago/src/seed.rs` | S | no |
|
||||
| **R-01** | **F-01 (Critical)** | Gate `seed.generate` / `seed.restore` on onboarding being incomplete; add rate limits; narrow the mesh peer path filter | `core/archipelago/src/api/rpc/seed_rpc.rs`, `.../middleware.rs`, `.../rate_limit.rs`, `server.rs` | M | yes (re-onboard + federation re-verify) |
|
||||
| **R-02** | F-03 (High) | Move `touch "$MARKER"` inside a both-succeeded branch so a failed regeneration retries next boot; surface the failure beyond a log file | `image-recipe/_archived/build-auto-installer-iso.sh` | S | **yes** (ISO rebuild + fresh flash) |
|
||||
| **R-03** | F-03 (High) | Strip baked SSH host keys and the TLS keypair from the rootfs tar at build time, so a regeneration failure degrades to "no key" not "shared key" | `image-recipe/_archived/build-auto-installer-iso.sh` | M | **yes** |
|
||||
| **R-04** | **F-13 (High)** | `disable_private_keys=true`; import the **xpub** with a `[fingerprint/derivation]` key origin; migrate with balance/UTXO parity verification | `core/archipelago/src/api/rpc/bitcoin.rs` | **PHASE** | **yes** (node with real UTXO history) |
|
||||
| **R-05** | F-07 (Medium) | Add `cargo audit` / `cargo deny check advisories bans` to CI, with a `bans` rule failing on duplicate `rand` majors | CI config | S | no |
|
||||
| **R-06** | F-05 (Medium) | Reconcile `Argon2::default()` (19 MiB / t=2) with ADR-005 (64 MB / 3) — either raise the params behind a versioned envelope with a migration, or amend the ADR | `core/archipelago/src/seed.rs`, `backup/full.rs`, `backup/identity.rs`, `docs/adr/005-...` | M | no |
|
||||
| **R-07** | F-04 (Medium) | Confine seed-bearing RPCs to loopback/TLS; shrink `MNEMONIC_TTL`; clear on acknowledged verify with a short grace window | `core/archipelago/src/api/rpc/seed_rpc.rs`, `.../mod.rs`, nginx config | **PHASE** | yes |
|
||||
| **R-08** | F-06 (Medium) | Make stdin/TTY the only mnemonic input for `ceremony sign`/`pubkey`; stop printing the mnemonic to stdout in `ceremony gen` | `core/archipelago/src/ceremony.rs` | S | no (but schedule deliberately — it is the signing ceremony) |
|
||||
| **R-09** | [ARCHY-2] hardening | Persist the CSPRNG-readiness verdict as a durable structured event, so any node can answer post-hoc "was the pool ready when this seed was born?" | `core/archipelago/src/seed.rs` | S | no |
|
||||
| **R-10** | [ARCHY-2] hardening | Add a test asserting the `getrandom` crate uses the **blocking** syscall, so the invariant is mechanical rather than a comment | `core/archipelago/src/seed.rs` | S | no |
|
||||
| **R-11** | F-08 (Low) | Clear `_seed_words` on route-leave from onboarding, not only on successful verify; add a wall-clock expiry mirroring `MNEMONIC_TTL` | `neode-ui/src/views/OnboardingSeedGenerate.vue`, `OnboardingSeedVerify.vue` | S | no |
|
||||
| **R-12** | F-09 (Low) | Replace `% charset.len()` with `SliceRandom::choose(&mut OsRng)` and pin the uniformity property with a test | `core/archipelago/src/totp.rs` | S | no |
|
||||
| **R-13** | F-10 (Low) | ~~Swap `random_hex` / `random_base64` from `thread_rng()` to explicit `OsRng`~~ — **SUPERSEDED 2026-08-02 by R-16**; this file is 2 of 41 sites | `core/archipelago/src/container/secrets.rs` | S | no |
|
||||
| **R-16** | **F-10a (Medium)** | Crate-wide enforcement so a defaulted RNG cannot be inherited anywhere: sealed allowlist trait at key-generation seams; `clippy.toml` `disallowed-methods` ban on `rand::thread_rng`/`rand::random` (compile-time, CI-enforced); `cargo-deny` on duplicate `rand` majors; degenerate-entropy runtime check; persist the CSPRNG-readiness verdict (absorbs R-05, R-09, R-13) | 15 files — see §F-10a | **PHASE** — tracked as **KEY-05**, Phase 10 | no |
|
||||
| **R-14** | F-11 (Informational) | One-line comment at `pickRandomIndices` recording that the `Math.random()` is a UX challenge selector, not key material | `neode-ui/src/views/OnboardingSeedVerify.vue` | S | no |
|
||||
| **R-15** | §6 checklist | Run the on-node verification checklist — especially C-3 (per-node SSH/TLS keys) and C-5 (cross-node collision test) | — | M | **yes** (2+ nodes from one ISO) |
|
||||
|
||||
### Explicitly NOT implemented in this task, and why
|
||||
|
||||
- **R-01, R-04, R-07** need their own phase. R-01 changes an authentication boundary on a live
|
||||
fleet; R-04 moves the spending key out of a wallet holding real funds; R-07 changes the
|
||||
onboarding transport. Each needs a migration story and real-node verification that an
|
||||
audit-and-spec task cannot provide.
|
||||
- **R-02, R-03** require rebuilding the ISO and flashing at least two machines to verify. Not
|
||||
reachable from this environment.
|
||||
- **R-13** is blocked purely by tree hygiene: `core/archipelago/src/container/secrets.rs` had
|
||||
another agent's uncommitted changes at audit time and this plan's invariant is that no commit
|
||||
it authors touches their files. Trivial once that work lands.
|
||||
- **The whole of `docs/security/PSBT-SIGNING-ARCHITECTURE.md`** is a rollout, not a fix. It is
|
||||
queued as a spec for `/gsd-plan-phase`, not implemented anywhere.
|
||||
|
||||
---
|
||||
|
||||
## 9. Related documents
|
||||
|
||||
- `docs/security/PSBT-SIGNING-ARCHITECTURE.md` — the signing architecture this audit's
|
||||
conclusions feed into (watch-only descriptors, PSBT, multisig, honest LND limits).
|
||||
- `docs/hardware-signer-design.md` — exploratory TROPIC01 air-gapped signer.
|
||||
- `docs/adr/005-chacha20-backup-encryption.md` — the ADR that F-05 diverges from.
|
||||
- `.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md`
|
||||
— the incident analysis, the T1-T7 catalogue, and the audit checklist this document executed.
|
||||
@@ -1,592 +0,0 @@
|
||||
# PSBT-First Signing Architecture
|
||||
|
||||
> **Status: specification.** No implementation. This document defines a target architecture and
|
||||
> a phased rollout that a future `/gsd-plan-phase` can consume directly. It deliberately
|
||||
> contains no code, adds no dependencies, and changes no wallet or signing behaviour.
|
||||
>
|
||||
> **Companion document:** `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — the entropy and
|
||||
> seed-generation audit that motivated this spec. **Cross-linked design:**
|
||||
> `docs/hardware-signer-design.md` — the exploratory TROPIC01 air-gapped signer, which this
|
||||
> architecture treats as the future *first-party* signer, not as a competing design.
|
||||
|
||||
**Provenance rules used throughout.** Every architectural claim is grounded in either (a) a
|
||||
`file:line` from this tree, or (b) RESEARCH.md Part C
|
||||
(`.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md`,
|
||||
which cites Bitcoin Core `doc/psbt.md`, `doc/descriptors.md`, `doc/multisig-tutorial.md`, the
|
||||
Core 30.0 release notes, LND `docs/remote-signing.md` and `docs/psbt.md`). Anything from
|
||||
neither is marked `[UNVERIFIED]`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Why this document exists
|
||||
|
||||
The 2026-07-30 Coinkite COLDCARD entropy incident swept ~1,082 BTC from ~1,195 addresses. The
|
||||
Archipelago-specific reading is in the audit; the design-relevant lesson is narrower and is the
|
||||
organising principle of this spec:
|
||||
|
||||
> **T1's survivors were the users who took the *optional* extra step.** Users who rolled dice
|
||||
> contributed ≥128 bits independently of the broken RNG and were not at risk. The safe path
|
||||
> existed the whole time; it just was not the default.
|
||||
|
||||
Everything below follows from that. The safe path (watch-only + external signer + PSBT) must be
|
||||
the **default** and must feel like the normal way to use Archipelago, not an expert mode buried
|
||||
behind a warning. The hot wallet is retained, deliberately, as an explicitly-secondary tier —
|
||||
because a safe path users route around is not a safe path.
|
||||
|
||||
**Where the tree stands today (important, and not what the target says).**
|
||||
`core/archipelago/src/api/rpc/bitcoin.rs:161-294` already creates a **descriptor** wallet
|
||||
(`createwallet ... descriptors=true`, `:207`) — which is the right foundation — but it passes
|
||||
`disable_private_keys = false` (`:203`) and imports `wpkh(xprv/0/*)` and `wpkh(xprv/1/*)`
|
||||
(`:229-231`), i.e. **the BIP-84 account extended *private* key is imported into Bitcoin Core's
|
||||
`wallet.dat`.** The node's spending key therefore lives in two places: the daemon's Argon2 +
|
||||
ChaCha20-Poly1305 envelope (`core/archipelago/src/seed.rs:238-269`) *and* Core's wallet
|
||||
database. The code is careful with the string in memory (`bitcoin.rs:189`, zeroized at `:222`
|
||||
and `:284`), but the key itself is persisted by Core. Closing that gap is Phase 1 of the
|
||||
rollout in §8, and it is the single highest-value change in this document.
|
||||
|
||||
---
|
||||
|
||||
## 1. Target architecture
|
||||
|
||||
### 1.1 Watch-only descriptor wallet on the node
|
||||
|
||||
The node runs a Bitcoin Core wallet that is **structurally incapable of signing**:
|
||||
|
||||
- Created with `createwallet` passing **`disable_private_keys = true`** and
|
||||
`descriptors = true`. Note the ordering already used at
|
||||
`core/archipelago/src/api/rpc/bitcoin.rs:200-208` — the second positional argument is
|
||||
`disable_private_keys`, currently `false`.
|
||||
- Populated with `importdescriptors`, using **public** descriptors only
|
||||
(`wpkh([fingerprint/84h/0h/0h]xpub.../0/*)` and `.../1/*`).
|
||||
|
||||
Unsignability comes from the *absence of private key material*, not from a flag that could be
|
||||
flipped. That is the correct construction and is why "watch-only" here means "descriptor wallet
|
||||
with no private keys", not "a wallet we promise not to sign with".
|
||||
|
||||
**Descriptor-only from day one.** Bitcoin Core 30.0 removed the ability to create *or load* BDB
|
||||
legacy wallets (RESEARCH §C.1). Nothing in this design may depend on a legacy wallet, on
|
||||
`importmulti`, or on any of the 11 removed legacy RPCs. Archipelago is already descriptor-based
|
||||
(`bitcoin.rs:207`), so this costs nothing to preserve and would be expensive to lose.
|
||||
|
||||
### 1.2 The loop, with the actual RPCs
|
||||
|
||||
| Step | RPC | Scope | Notes |
|
||||
|---|---|---|---|
|
||||
| 1. Construct + fund | `walletcreatefundedpsbt` | **wallet** | Runs on the watch-only wallet. Selects inputs, adds change, attaches the metadata the signer needs. |
|
||||
| 2. Fill UTXO data (optional) | `utxoupdatepsbt` | node | Useful when the PSBT was built elsewhere or is missing witness UTXO data. |
|
||||
| 3. Inspect | `analyzepsbt` | node | **Drive all UI state from this** — see §1.3. |
|
||||
| 4. Export | — | — | Serialise to base64 / file / QR (§4). |
|
||||
| 5. Sign (offline) | external signer | — | Hardware device, or `descriptorprocesspsbt` on an offline machine holding the descriptors. |
|
||||
| 6. Import | — | — | Scan / upload the signed PSBT back. |
|
||||
| 7. Merge signatures | `combinepsbt` | node | Multisig only: merges signatures for the **same** transaction from multiple signers. |
|
||||
| 8. Merge transactions | `joinpsbts` | node | Different transactions into one. **Not** the multisig merge — a common and expensive confusion. |
|
||||
| 9. Finalize | `finalizepsbt` | node | Produces the network-serialized transaction. |
|
||||
| 10. Broadcast | `sendrawtransaction` | node | Except for LND channel funding — see §5. |
|
||||
|
||||
`walletprocesspsbt` (wallet-scoped) and `descriptorprocesspsbt` (node-scoped, takes a descriptor
|
||||
list, **needs no wallet**) are the two signing entry points. `descriptorprocesspsbt` is the
|
||||
right primitive for an offline signing machine that has descriptors but no wallet.
|
||||
|
||||
**Wallet-scoped vs node-scoped matters operationally**: wallet-scoped RPCs must be addressed to
|
||||
the specific wallet endpoint (`/wallet/<name>`), node-scoped ones must not. Archipelago's
|
||||
existing `bitcoin_rpc_call` helper (`core/archipelago/src/api/rpc/bitcoin.rs:191-210` usage)
|
||||
will need an explicit wallet-scoping parameter rather than one global endpoint.
|
||||
|
||||
### 1.3 `analyzepsbt` drives the UI — do not infer state
|
||||
|
||||
`analyzepsbt` reports, per input, what is still missing and **which role must act next**
|
||||
(updater / signer / finalizer). The UI must render from that, not from Archipelago's own guess
|
||||
about how many signatures a 2-of-3 needs. Rationale: role inference is where coordinators get
|
||||
multisig wrong, and the node already has an authoritative answer one RPC away. It also makes
|
||||
the "what do I do now" screen correct for free in partial-signature states.
|
||||
|
||||
### 1.4 Versions this runs against
|
||||
|
||||
From the manifests, so the spec is not written against an imaginary node:
|
||||
|
||||
| App | Manifest version | Image |
|
||||
|---|---|---|
|
||||
| Bitcoin Core | `28.4.0` (`apps/bitcoin-core/manifest.yml:4`) | `bitcoin:28.4` (`:10`) |
|
||||
| Bitcoin Knots | `28.1.0` (`apps/bitcoin-knots/manifest.yml:4`) | **`bitcoin-knots:latest`** (`:10`) |
|
||||
| LND | `0.18.4` (`apps/lnd/manifest.yml:4`) | `lnd:v0.18.4-beta` (`:8`), requires Bitcoin `>=26.0` (`:25`) |
|
||||
|
||||
**Flagged, in scope to name and out of scope to fix:** `bitcoin-knots:latest`
|
||||
(`apps/bitcoin-knots/manifest.yml:10`) is an **unpinned tag**, at odds with ADR-009's
|
||||
pinned-tag mandate and with every other image in these three manifests. For a wallet-bearing
|
||||
component, an unpinned tag means the descriptor/PSBT RPC surface underneath a user's funds can
|
||||
change on a `podman pull`. Fixing it belongs to whoever owns ADR-009 enforcement.
|
||||
|
||||
**PSBTv2 / BIP-370** is merged into Bitcoin Core (RESEARCH §C.1). **`[UNVERIFIED]`** — which
|
||||
released version first exposes it at the RPC surface, and how broadly hardware signers accept
|
||||
it, was not confirmed. **Build against PSBTv1 as the interop baseline**; treat v2 as
|
||||
opportunistic and never as a requirement for a user to spend their money.
|
||||
|
||||
---
|
||||
|
||||
## 2. Where each step lives
|
||||
|
||||
Three surfaces, one non-negotiable invariant.
|
||||
|
||||
### 2.1 The invariant
|
||||
|
||||
> **The BIP-84 private key stays in the daemon's encrypted store. Only the xpub goes into the
|
||||
> Core descriptor wallet. The private key is never imported into Core.**
|
||||
|
||||
Today this is violated (`core/archipelago/src/api/rpc/bitcoin.rs:229-231`, §0). The at-rest
|
||||
envelope that should hold it exclusively already exists and is sound: Argon2 + ChaCha20-Poly1305
|
||||
with per-blob salt and nonce from `OsRng`, written `0600`
|
||||
(`core/archipelago/src/seed.rs:238-269`, `:243-246`, `:318-324`).
|
||||
|
||||
### 2.2 Rust orchestrator — `core/archipelago`
|
||||
|
||||
Owns everything that touches keys or Core:
|
||||
|
||||
- Derives the BIP-84 account key (`core/archipelago/src/seed.rs:207-224`, path `m/84'/0'/0'`)
|
||||
and exports **only** the account-level xpub plus its key-origin fingerprint into descriptors.
|
||||
- Creates and maintains the watch-only wallet (rewrite of
|
||||
`handle_bitcoin_init_wallet_from_seed`, `core/archipelago/src/api/rpc/bitcoin.rs:161-294`).
|
||||
- Owns the PSBT lifecycle RPCs: construct, analyze, combine, finalize, broadcast.
|
||||
- Owns the *internal* software-signer path used by the hot tier (§6), which decrypts the seed
|
||||
under the user's password exactly as `bitcoin.rs:182-185` does today, signs, and zeroizes.
|
||||
- Enforces spend limits server-side (§6). **Limits enforced in the UI are not limits.**
|
||||
|
||||
### 2.3 `neode-ui`
|
||||
|
||||
Owns presentation and transport only. It must never see a private key, an xprv, or a mnemonic
|
||||
outside the onboarding flow the audit already scopes (F-04, F-08).
|
||||
|
||||
- Renders the PSBT review screen: inputs, outputs, fee, change, and the `analyzepsbt` "next
|
||||
role" state.
|
||||
- Renders the export payload as animated QR (§4) and offers file download.
|
||||
- Accepts the signed PSBT by camera scan or file upload.
|
||||
- Renders the cold / warm / hot tier badges (§6) and the honest Lightning copy (§5.4).
|
||||
|
||||
### 2.4 Companion app
|
||||
|
||||
Owns the air-gap camera path. It already has the two pieces this needs:
|
||||
|
||||
- A working QR scanner (project memory: native scan shipped in companion 0.5.22; dense-QR fix
|
||||
`07772b56`).
|
||||
- SeedQR encode/decode (`neode-ui/src/utils/seedqr.ts:11`), with a correct, honest note at
|
||||
`:9` that the LND aezeed is **not** BIP-39 and must never be SeedQR-encoded.
|
||||
|
||||
The companion is the natural home for scan-heavy multi-frame PSBT transport, because the node's
|
||||
own browser may be a TV kiosk with no camera.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tiers
|
||||
|
||||
### 3.1 Tier 1 — single-sig with an external hardware signer
|
||||
|
||||
- Descriptor: `wpkh([<fingerprint>/84h/0h/0h]xpub.../0/*)` and `.../1/*`.
|
||||
- **Key-origin annotation `[fingerprint/derivation]` is mandatory, not cosmetic.** Without it a
|
||||
hardware signer cannot locate its own key in the PSBT and will refuse to sign (RESEARCH §C.2).
|
||||
Every descriptor Archipelago emits must carry it. The current code emits descriptors with **no
|
||||
key-origin prefix** (`core/archipelago/src/api/rpc/bitcoin.rs:230-231`) — a second concrete
|
||||
reason Phase 1 must rewrite that function.
|
||||
- Descriptor checksums: obtain via `getdescriptorinfo` before `importdescriptors`, as the
|
||||
existing code correctly already does (`bitcoin.rs:234-259`). Core rejects a wrong checksum.
|
||||
|
||||
### 3.2 Tier 2 — `wsh(sortedmulti(k, ...))` multisig
|
||||
|
||||
- Script: `wsh(sortedmulti(k, xpub1/…, xpub2/…, xpub3/…))`.
|
||||
- **Why `sortedmulti` over ordered `multi`:** `sortedmulti` (BIP-67) lexicographically sorts the
|
||||
keys in the resulting script, so the wallet can be **recreated without preserving xpub order**.
|
||||
With ordered `multi`, losing the order loses the wallet even though every key survives — a
|
||||
recovery failure mode that is entirely avoidable. Use `sortedmulti` unless a specific
|
||||
cosigner demands ordered `multi`.
|
||||
- **BIP-48 derivation** for multisig accounts: `m/48'/<coin>'/<account>'/<script_type>'`, with
|
||||
`2'` = P2WSH. Every coordinator (Sparrow, Nunchuk, Caravan, Specter) expects this path; using
|
||||
anything else means users cannot import their Archipelago multisig anywhere else.
|
||||
- Descriptor exchange: each cosigner contributes an xpub **with key origin**; the coordinator
|
||||
assembles the descriptor and every participant imports the identical descriptor string. All
|
||||
participants must be able to export the descriptor for backup — a multisig backup is the
|
||||
descriptor plus each seed, and users who back up only seeds lose funds.
|
||||
- Reference to copy rather than re-derive: Bitcoin Core's `doc/multisig-tutorial.md` and the
|
||||
functional test `test/functional/wallet_multisig_descriptor_psbt.py`, which is the exact RPC
|
||||
sequence in executable form (RESEARCH §C.3).
|
||||
|
||||
### 3.3 Taproot / MuSig2 multisig — future work, deliberately
|
||||
|
||||
`tr(...)` descriptors exist, but **`[UNVERIFIED]`** — the 2026 state of MuSig2 key-aggregation
|
||||
support in Core's descriptor wallets and across hardware signers was not confirmed (RESEARCH
|
||||
§C.3, Open Question 4). Shipping a multisig scheme whose recovery depends on unconfirmed
|
||||
signer support is how users lose money years later. **Ship `wsh(sortedmulti(...))`.** Revisit
|
||||
taproot multisig when Core's support and at least two independent hardware signers can be
|
||||
verified against a real device.
|
||||
|
||||
---
|
||||
|
||||
## 4. Air-gapped transport
|
||||
|
||||
### 4.1 The format decision
|
||||
|
||||
| Format | Mechanism | Verdict |
|
||||
|---|---|---|
|
||||
| **BC-UR v2** (Blockchain Commons) | **Fountain-coded** (rateless erasure). Any sufficient subset of frames reconstructs the payload; order-independent. | **Recommended primary.** |
|
||||
| **BBQr** (Coinkite) | Payload split across sequential frames; receiver accumulates and must obtain each missing frame. | Support for Coldcard interop; not the primary. |
|
||||
| microSD / file (`.psbt`) | Plain file exchange. | **Mandatory fallback, always offered.** |
|
||||
| SeedQR | Static QR of mnemonic word indices. | **Seed transport only, not PSBT.** Already shipped (`neode-ui/src/utils/seedqr.ts:11`). |
|
||||
|
||||
**Recommendation: BC-UR v2 as primary, BBQr for Coldcard interop, file always available.**
|
||||
|
||||
The justification is specific to Archipelago's hardware reality rather than generic. The
|
||||
companion app scans QR from a phone camera, frequently at a TV or in a rack cupboard, in poor
|
||||
light. BBQr's sequential model means a single missed frame stalls the user until that exact
|
||||
frame comes round again — the failure mode is "keep pointing the camera and hope". BC-UR's
|
||||
fountain coding means *any* sufficient number of frames reconstructs the payload, so a bad
|
||||
scanning environment degrades into "takes longer" instead of "gets stuck". That difference is
|
||||
what makes an air-gap workflow tolerable enough that users keep using it — which, per §0, is
|
||||
the whole point.
|
||||
|
||||
**`[UNVERIFIED]`** — device support matrix. Confirmed from RESEARCH §C.4: Coldcard → BBQr
|
||||
(native) + microSD + NFC; Foundation Passport and Keystone → UR; SeedSigner → BC-UR v2. Jade,
|
||||
Krux, BitBox, Ledger and Trezor support was **not** confirmed and must be verified against real
|
||||
hardware before any of them is listed as supported in the UI.
|
||||
|
||||
### 4.2 QR density — animated is mandatory, not a nice-to-have
|
||||
|
||||
A QR code maxes out around ~2,953 bytes at the largest version with the lowest error correction,
|
||||
and far less at densities a phone camera can actually read across a room. **A real multi-input
|
||||
multisig PSBT routinely exceeds that.** Therefore:
|
||||
|
||||
- **Multi-frame animated QR is mandatory.** Single-QR PSBT export must not be the only path.
|
||||
- **A file fallback must always be offered**, on every export screen, with equal visual weight.
|
||||
microSD/file has no density limit and is the most reliable route for large PSBTs.
|
||||
- The UI must show frame progress (e.g. "142 of 210 frames received") so a stalled scan is
|
||||
visibly stalled rather than mysteriously slow.
|
||||
|
||||
### 4.3 Consistency with the first-party signer
|
||||
|
||||
`docs/hardware-signer-design.md` specifies a QR-only, camera-in/screen-out air-gapped signer
|
||||
(TROPIC01 + ESP32-S3), and lists "Animated/multi-part QR strategy for large PSBTs" as an open
|
||||
item (`docs/hardware-signer-design.md:167`) and "Define QR payload formats for both roles" at
|
||||
`:165`. **This document answers both for Bitcoin: BC-UR v2 primary, BBQr for Coldcard interop.**
|
||||
That signer, when built, should implement the same format so the same node-side transport code
|
||||
serves third-party signers and the first-party device identically. Its dual Nostr-signing role
|
||||
(`docs/hardware-signer-design.md:110-148`) is out of scope here but shares the transport layer,
|
||||
which is an argument for implementing transport as a payload-agnostic module.
|
||||
|
||||
---
|
||||
|
||||
## 5. LND — what is and is not achievable
|
||||
|
||||
### 5.1 Decision table
|
||||
|
||||
| Capability | Achievable? | Detail |
|
||||
|---|---|---|
|
||||
| Watch-only `lnd` + separate signer instance | **Yes** | `remotesigner.*` on the watch-only node; the signer needs no chain backend (`bitcoin.node=nochainbackend`). |
|
||||
| Signer fully offline | **No** | The signer must accept a **live inbound gRPC connection**. "Offline except for one connection" is not an air-gap. |
|
||||
| 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 of the entire design.** |
|
||||
| PSBT funding of channels | **Yes** | `lncli openchannel --psbt`; `PsbtShim` via `FundingStateStep`; batch by passing the returned PSBT as `base_psbt`. |
|
||||
| Open a channel with zero LND wallet balance | **Yes** | The `--psbt` flow explicitly supports funding from an external wallet. |
|
||||
| **Self-broadcast the funding transaction** | **NEVER** | LND must publish it "in the proper funding flow order **or the funds can be lost**". Encode as a hard UI rule — see §5.3. |
|
||||
| Sign arbitrary messages / on-chain txs externally | **Yes** | `signrpc` / `walletrpc` (`signer:generate`, `onchain:write`). |
|
||||
| Move private keys between instances after init | **No** | Not supported. |
|
||||
| Add accounts dynamically without wallet reconstruction | **No** | Not supported. |
|
||||
|
||||
Source: RESEARCH §C.5, from LND `docs/remote-signing.md` and `docs/psbt.md`.
|
||||
|
||||
### 5.2 Required accounts and the taproot gotcha
|
||||
|
||||
Remote signing requires xpubs for level-3 derivation accounts: purpose **49** (NP2WKH), **84**
|
||||
(P2WKH), **86** (P2TR), and **1017** accounts 0-255 (node identity, channels, watchtower,
|
||||
HTLCs). Setup is `lncli wallet accounts list > accounts-signer.json` on the signer, then
|
||||
`lncli createwatchonly accounts-signer.json` on the watch-only node. A minimal signer macaroon
|
||||
is `lncli bakemacaroon --save_to signer.custom.macaroon message:write signer:generate
|
||||
address:read onchain:write`.
|
||||
|
||||
**Taproot gotcha:** requires LND v0.15.3-beta+ and a manual
|
||||
`lncli wallet accounts import --address_type p2tr <xpub> default` on upgrade, or the node fails
|
||||
with `"account 0 not found"`. Archipelago pins LND `0.18.4` (`apps/lnd/manifest.yml:4`), so the
|
||||
version floor is satisfied; the manual import step is not automatic and must be part of any
|
||||
migration runbook.
|
||||
|
||||
Migrating an existing node is `remotesigner.migrate-wallet-to-watch-only=true`, which **purges
|
||||
private key material in place** — one-way, and therefore gated behind a verified backup.
|
||||
|
||||
### 5.3 The self-broadcast rule is a hard UI constraint
|
||||
|
||||
Archipelago already exposes `lnd.create-psbt` and `lnd.finalize-psbt`
|
||||
(`core/archipelago/src/api/rpc/dispatcher.rs:136-137`,
|
||||
implemented in `core/archipelago/src/api/rpc/lnd/wallet.rs:605` and `:711`), and the finalize
|
||||
handler already broadcasts (`core/archipelago/src/api/rpc/lnd/wallet.rs:757`). That is correct
|
||||
for an **on-chain** send and **catastrophic** for a channel-funding PSBT.
|
||||
|
||||
**Rule:** any PSBT produced by the channel-funding flow must be tagged as such end-to-end, and
|
||||
every broadcast path must refuse to broadcast a channel-funding PSBT. The refusal belongs in the
|
||||
Rust orchestrator, not in the UI, and it should be a type-level distinction (a distinct
|
||||
`ChannelFundingPsbt` wrapper) rather than a boolean anyone can forget to check. This is the one
|
||||
place in this document where a mistake destroys funds rather than exposing them.
|
||||
|
||||
### 5.4 On-chain vs Lightning — two genuinely different tiers
|
||||
|
||||
The design splits cleanly, and the split must be visible to users:
|
||||
|
||||
| | **On-chain balance** | **Lightning balance** |
|
||||
|---|---|---|
|
||||
| Key exposure | Can be fully cold — key never on the node | **Necessarily hot** — channel/revocation/HTLC keys must sign at protocol speed |
|
||||
| Protection mechanism | Watch-only descriptors + PSBT + external signer | Remote signing *relocates* keys to a hardened host; it does not remove hot exposure |
|
||||
| Honest claim | "Cold storage" is accurate | "Cold storage" is **false** |
|
||||
|
||||
**The exact sentence the UI should use:**
|
||||
|
||||
> *A Lightning routing node's channel keys are necessarily hot. Remote signing moves them to a
|
||||
> hardened machine; it does not make them cold. Only your on-chain balance can be genuinely
|
||||
> protected by an offline signer.*
|
||||
|
||||
**Any copy implying a routing node's channel keys are cold is misleading and must not ship.**
|
||||
This is not pedantry: a user who believes their Lightning balance is cold will keep more in it
|
||||
than they would otherwise, which is precisely the miscalibration that turns an incident into a
|
||||
loss. The Coldcard incident is a good reason to be conservative in this copy rather than
|
||||
optimistic.
|
||||
|
||||
---
|
||||
|
||||
## 6. The hot wallet as the explicitly-secondary option
|
||||
|
||||
The hot wallet stays. Removing it would push users to worse tools. It is framed, limited, and
|
||||
labelled as secondary.
|
||||
|
||||
1. **Hard separation of on-chain and Lightning balances** in the data model and in the UI.
|
||||
**Never one blended number.** They have different key exposure (§5.4), different recovery
|
||||
stories, and different risk. A single "balance" figure silently averages a cold number with a
|
||||
hot one, which is a lie of composition.
|
||||
2. **Server-enforced spend limits.** Per-transaction and rolling-daily, enforced in the Rust
|
||||
orchestrator. Anything above the limit is **forced onto the PSBT path** — not blocked, not
|
||||
warned-and-allowed: routed. Archipelago already rate-limits financial RPCs
|
||||
(`core/archipelago/src/rate_limit.rs:62-69`: `wallet.send` 5/300s, `lnd.sendcoins` 5/300s,
|
||||
`lnd.openchannel` 3/300s), so the enforcement point exists; value limits are the addition.
|
||||
3. **Reuse the existing at-rest envelope.** Argon2 + ChaCha20-Poly1305, per-blob salt and nonce
|
||||
from `OsRng`, `0600` (`core/archipelago/src/seed.rs:238-269`, `:318-324`). Do not invent a
|
||||
second envelope. See audit finding **F-05** on aligning the Argon2 parameters with ADR-005
|
||||
before this tier carries meaningful value.
|
||||
4. **Zeroization on every path.** The existing code is the standard to match:
|
||||
`core/archipelago/src/seed.rs:262`, `:292`, `:384`, `:401`;
|
||||
`core/archipelago/src/api/rpc/bitcoin.rs:222`, `:284`.
|
||||
5. **Explicit tiering in the UI**, named rather than hidden:
|
||||
- **Cold** — watch-only + external signer. On-chain only. The default for new wallets.
|
||||
- **Warm** — hot on-chain key in the daemon's envelope, under spend limits.
|
||||
- **Hot** — Lightning. Unavoidably hot; labelled as such.
|
||||
|
||||
### 6.1 Nudging toward PSBT without punishing the hot path
|
||||
|
||||
The failure mode to avoid is a safe path so tedious that users disable it, and a hot path so
|
||||
nagged-at that users stop reading warnings. Concretely:
|
||||
|
||||
- **Default new wallets to cold.** Do not make the user opt in to safety. This is the direct
|
||||
lesson of §0.
|
||||
- **One-time framing, not per-transaction nagging.** Explain the tiers once, at setup, and then
|
||||
show a small persistent tier badge. Repeated modal warnings train users to dismiss modals.
|
||||
- **Make the limit the teacher.** When a spend exceeds the warm limit, route it to the PSBT
|
||||
flow with a neutral explanation ("this amount uses your signing device") rather than an error.
|
||||
The user learns the tier boundary by using it.
|
||||
- **Never make the hot path feel broken.** A small Lightning payment should be one tap. If
|
||||
everyday use is painful, users move their funds to software that does not have any of this.
|
||||
- **Let the user raise limits, deliberately.** A limit the user cannot adjust gets worked around
|
||||
entirely; a limit they must consciously raise is a decision they remember making.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration for existing users
|
||||
|
||||
### 7.1 What the incident does and does not imply here
|
||||
|
||||
**Be precise, because both errors are costly.**
|
||||
|
||||
- **A software fix does not repair an already-generated seed.** If a seed was produced by a
|
||||
defective RNG, updating the software leaves it exactly as guessable. This is why Coinkite told
|
||||
users to migrate rather than merely update.
|
||||
- **The audit found no such defect in Archipelago.** `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`
|
||||
§2 and §4 record that every first-party key-generation call site draws from a genuine CSPRNG,
|
||||
that the mnemonic is a real 256-bit value, and that `[ARCHY-1]` is a *structural* risk with no
|
||||
present exploitability.
|
||||
|
||||
**Therefore: no Archipelago user needs to rotate their seed because of the COLDCARD incident.**
|
||||
Do not ship a banner implying otherwise. Over-alarming has a real cost — it triggers unnecessary
|
||||
fund movements, which have their own fee, privacy, and fat-finger risks, and it burns the
|
||||
credibility needed for a real advisory later.
|
||||
|
||||
**Who this section *does* apply to:**
|
||||
|
||||
1. **Users whose seed was generated on a Coldcard and imported into Archipelago**, on affected
|
||||
firmware. Their seed is at risk from T1, independent of Archipelago's own code quality. They
|
||||
should follow Coinkite's guidance and the sequence in §7.2.
|
||||
2. **Every user, at the point Phase 1 lands** — because the account xprv is currently imported
|
||||
into Bitcoin Core (`core/archipelago/src/api/rpc/bitcoin.rs:229-231`, §0). Moving to
|
||||
watch-only does not require a new seed; it requires re-creating the Core wallet without
|
||||
private keys. That is a *wallet* migration, not a *key* migration, and it must be presented
|
||||
as such — see §7.3.
|
||||
|
||||
### 7.2 Seed-rotation sequence (only when a seed is actually suspect)
|
||||
|
||||
Order matters; each step de-risks the next.
|
||||
|
||||
1. **Generate a new key** on trusted, fixed hardware or software.
|
||||
2. **Verify the backup** — restore it into a second wallet and confirm it reproduces the same
|
||||
first receive address before sending anything.
|
||||
3. **Verify a receive address** on the signing device's own screen, not only on the host.
|
||||
4. **Send a small test transaction** to the new wallet and confirm it arrives and is spendable.
|
||||
5. **Migrate the funds** from the old wallet to the new one.
|
||||
6. **Retain the old backup** until every output is confirmed spent and the new wallet's balance
|
||||
is verified. Destroying the old backup early is the most common way this sequence loses money.
|
||||
|
||||
If Lightning is in use, closing channels is part of step 5 and is slow (force-closes carry
|
||||
timelocks). Budget for it; do not present channel migration as instantaneous.
|
||||
|
||||
### 7.3 Wallet migration to watch-only (Phase 1) — *not* a seed rotation
|
||||
|
||||
For every existing user, when Phase 1 lands:
|
||||
|
||||
1. Confirm the encrypted seed backup exists and is decryptable
|
||||
(`core/archipelago/src/seed.rs:341-357`, `seed_exists` at `:360-362`).
|
||||
2. Derive the account xpub and build the key-origin-annotated descriptors.
|
||||
3. Create a **new** wallet with `disable_private_keys = true` and import the public descriptors.
|
||||
4. Rescan, and confirm the new watch-only wallet reports the **same balance and the same UTXO
|
||||
set** as the old one. Do not proceed on any mismatch.
|
||||
5. Only then unload and remove the private-key-bearing wallet from Core.
|
||||
|
||||
**The user's seed does not change and their funds do not move.** Say that plainly in the UI —
|
||||
the natural user fear on seeing any wallet-migration prompt is that their money is being touched.
|
||||
|
||||
---
|
||||
|
||||
## 8. Phased rollout
|
||||
|
||||
Each phase names a goal, its dependencies, candidate requirements, and whether it needs real
|
||||
hardware. This section is the input a future `/gsd-plan-phase` consumes.
|
||||
|
||||
### Phase 1 — Descriptor watch-only read path
|
||||
|
||||
**Goal:** the node's Bitcoin Core wallet holds no private keys; the daemon's encrypted store is
|
||||
the only place the BIP-84 key exists.
|
||||
|
||||
**Dependencies:** none. **This is the highest-value change in the document and it unblocks
|
||||
everything else** — no external-signer flow is meaningful while Core holds the xprv.
|
||||
|
||||
**Candidate requirements:**
|
||||
- `createwallet` is called with `disable_private_keys = true` (currently `false`,
|
||||
`core/archipelago/src/api/rpc/bitcoin.rs:203`).
|
||||
- Imported descriptors carry the **xpub** and a key-origin annotation
|
||||
`[fingerprint/84h/0h/0h]` (currently a bare xprv with no origin, `bitcoin.rs:229-231`).
|
||||
- A migration path re-creates the wallet watch-only and verifies balance/UTXO parity before
|
||||
removing the old wallet (§7.3).
|
||||
- The account xprv is never written to Core and never leaves the Argon2 envelope except in
|
||||
memory, zeroized.
|
||||
- Regression test: the wallet cannot sign — a signing attempt against it fails structurally.
|
||||
|
||||
**Real hardware:** yes, for the migration — verify on a node with real UTXO history (`.228`).
|
||||
|
||||
### Phase 2 — PSBT construct and export
|
||||
|
||||
**Goal:** the node can build a funded PSBT from the watch-only wallet and hand it out.
|
||||
|
||||
**Dependencies:** Phase 1.
|
||||
|
||||
**Candidate requirements:**
|
||||
- `walletcreatefundedpsbt` wired with explicit fee control, reusing the existing fee-preset UI.
|
||||
- `analyzepsbt` exposed and used as the single source of UI state (§1.3).
|
||||
- Export as base64 and as a `.psbt` file download.
|
||||
- A PSBT review screen showing inputs, outputs, fee, change, and destination — the human check
|
||||
the whole air-gap model depends on.
|
||||
|
||||
**Real hardware:** no (regtest/testnet sufficient).
|
||||
|
||||
### Phase 3 — External-signer import and finalize
|
||||
|
||||
**Goal:** a signed PSBT from a third-party signer completes the loop and broadcasts.
|
||||
|
||||
**Dependencies:** Phase 2.
|
||||
|
||||
**Candidate requirements:**
|
||||
- Import a signed PSBT by file upload; `combinepsbt` where multiple parts arrive.
|
||||
- `finalizepsbt` + `sendrawtransaction`, with the channel-funding refusal of §5.3 in place from
|
||||
day one — not retrofitted.
|
||||
- Clear error surfacing when `analyzepsbt` says signatures are still missing.
|
||||
|
||||
**Real hardware:** **yes** — must be verified end-to-end against at least one real signer
|
||||
(Coldcard or Passport) before it is offered to users.
|
||||
|
||||
### Phase 4 — Air-gap transport (BC-UR v2 + BBQr)
|
||||
|
||||
**Goal:** the loop closes over QR, with a file fallback, in the companion app.
|
||||
|
||||
**Dependencies:** Phase 3.
|
||||
|
||||
**Candidate requirements:**
|
||||
- BC-UR v2 encode (node) and decode (companion), fountain-coded, with visible frame progress.
|
||||
- BBQr decode for Coldcard interop.
|
||||
- File fallback offered with equal weight on every export and import screen (§4.2).
|
||||
- Payload-agnostic transport module, so `docs/hardware-signer-design.md`'s Nostr role can reuse
|
||||
it later without a rewrite.
|
||||
|
||||
**Real hardware:** **yes** — QR density and scan reliability cannot be evaluated in an emulator.
|
||||
Verify at realistic distance and lighting, including the TV-kiosk case.
|
||||
|
||||
### Phase 5 — Multisig
|
||||
|
||||
**Goal:** `wsh(sortedmulti(k, ...))` wallets with BIP-48 paths and descriptor exchange.
|
||||
|
||||
**Dependencies:** Phase 4 (large multisig PSBTs are exactly the case that needs robust transport).
|
||||
|
||||
**Candidate requirements:**
|
||||
- Create/import a `wsh(sortedmulti(...))` descriptor with per-key origin annotations.
|
||||
- BIP-48 `m/48'/0'/<account>'/2'` derivation for Archipelago's own key.
|
||||
- Descriptor export/backup UX that states plainly that the descriptor is part of the backup.
|
||||
- `combinepsbt` across N signers with `analyzepsbt`-driven progress.
|
||||
- Interop test against at least one external coordinator (Sparrow or Nunchuk).
|
||||
|
||||
**Real hardware:** **yes** — two independent signers minimum.
|
||||
|
||||
### Phase 6 — LND remote signing
|
||||
|
||||
**Goal:** LND runs watch-only with a separate signer instance, with honest UI copy.
|
||||
|
||||
**Dependencies:** Phase 1 (the on-chain story must be settled first; doing Lightning first would
|
||||
teach users the wrong mental model).
|
||||
|
||||
**Candidate requirements:**
|
||||
- Signer instance provisioning (`bitcoin.node=nochainbackend`, minimal macaroon) and watch-only
|
||||
setup via `createwatchonly`.
|
||||
- Explicit p2tr account import step (§5.2), or a documented failure with a fix-it action.
|
||||
- `remotesigner.migrate-wallet-to-watch-only=true` migration, gated behind a verified backup —
|
||||
it purges key material in place and is one-way.
|
||||
- UI copy carrying the §5.4 sentence verbatim, and no copy anywhere claiming Lightning funds are
|
||||
cold.
|
||||
|
||||
**Real hardware:** **yes** — two hosts, and a real channel.
|
||||
|
||||
### Phase 7 — Hot-wallet limits and tiering
|
||||
|
||||
**Goal:** the hot path is bounded, labelled, and routes large spends to PSBT.
|
||||
|
||||
**Dependencies:** Phase 3 (there must be a PSBT path to route *to*).
|
||||
|
||||
**Candidate requirements:**
|
||||
- Server-enforced per-transaction and rolling-daily limits, with over-limit spends routed to the
|
||||
PSBT flow rather than rejected (§6.1).
|
||||
- On-chain and Lightning balances separated in the data model and never summed in the UI.
|
||||
- Cold / warm / hot tier badges.
|
||||
- New wallets default to cold.
|
||||
|
||||
**Real hardware:** no, beyond normal on-node verification.
|
||||
|
||||
### Sequencing note
|
||||
|
||||
Phases 1-4 are the spine and should run in order. Phase 6 (LND) and Phase 7 (limits) can run in
|
||||
parallel with Phase 5 (multisig) once Phase 3 lands. Phase 1 alone materially improves the
|
||||
current security posture and should not wait for the rest.
|
||||
|
||||
---
|
||||
|
||||
## 9. Related documents
|
||||
|
||||
- `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — the audit motivating this spec; see F-05
|
||||
(Argon2 parameters) and the F-13 addendum on the xprv-in-Core issue.
|
||||
- `docs/hardware-signer-design.md` — the first-party TROPIC01 air-gapped signer; §4.3 above
|
||||
answers two of its open items.
|
||||
- `docs/adr/005-chacha20-backup-encryption.md` — the at-rest envelope §6 reuses.
|
||||
- `.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md`
|
||||
— Part C is the source for the Core RPC table, the LND capability matrix, and the air-gap
|
||||
format comparison.
|
||||
+58
-460
@@ -81,118 +81,11 @@ if (DEMO) {
|
||||
}
|
||||
|
||||
const app = express()
|
||||
// MOCK_BACKEND_PORT lets a harness (scripts/mock-rpc-parity.mjs) bind an
|
||||
// ephemeral port instead of colliding with a running dev preview on 5959.
|
||||
const PORT = Number(process.env.MOCK_BACKEND_PORT) || 5959
|
||||
const PORT = 5959
|
||||
|
||||
// Dev mode from environment (setup, onboarding, existing, boot, or default)
|
||||
const DEV_MODE = process.env.VITE_DEV_MODE || 'default'
|
||||
|
||||
// The demo's mesh peer list, shared by mesh.peers and mesh.contacts-list so
|
||||
// the two can never disagree about who is on the mesh (the daemon merges
|
||||
// contacts over the same peer map for exactly this reason).
|
||||
const DEMO_MESH_PEERS = [
|
||||
{
|
||||
contact_id: 1,
|
||||
advert_name: 'archy-198',
|
||||
did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9',
|
||||
pubkey_hex: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
|
||||
rssi: -67,
|
||||
snr: 9.5,
|
||||
hops: 0,
|
||||
},
|
||||
{
|
||||
contact_id: 2,
|
||||
advert_name: 'satoshi-relay',
|
||||
did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH',
|
||||
pubkey_hex: 'f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5',
|
||||
rssi: -82,
|
||||
snr: 4.2,
|
||||
hops: 1,
|
||||
},
|
||||
// No pubkey_hex — mesh.contacts-list omits this peer, matching the daemon's
|
||||
// `if let Some(pk) = peer.pubkey_hex` guard.
|
||||
{
|
||||
contact_id: 3,
|
||||
advert_name: 'mountain-node',
|
||||
did: null,
|
||||
pubkey_hex: null,
|
||||
rssi: -95,
|
||||
snr: 1.8,
|
||||
hops: 2,
|
||||
},
|
||||
{
|
||||
contact_id: 4,
|
||||
advert_name: 'bunker-alpha',
|
||||
did: 'did:key:z6MkrHKPxJP6tvCvXMaJKZd3rRA2Y44tyftVhR8FDCMKGFjb',
|
||||
pubkey_hex: 'c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
|
||||
rssi: -74,
|
||||
snr: 7.1,
|
||||
hops: 0,
|
||||
},
|
||||
]
|
||||
|
||||
// Ages the peer list's last_heard relative to now, as mesh.peers has always
|
||||
// reported it (the seed values are offsets in ms).
|
||||
const DEMO_PEER_LAST_HEARD_MS = { 1: 30000, 2: 120000, 3: 600000, 4: 45000 }
|
||||
|
||||
const DEMO_PEER_NAMES = Object.fromEntries(
|
||||
DEMO_MESH_PEERS.map((p) => [p.contact_id, p.advert_name]),
|
||||
)
|
||||
// This node's own key on the demo. Reactions/replies the visitor sends are
|
||||
// direction:'sent', which is what the UI keys '__self__' off — but the field
|
||||
// still has to be present and stable for the message key to work.
|
||||
const DEMO_SELF_PUBKEY = 'demo02abababababababababababababababababababababababababababab'
|
||||
|
||||
// Append a message to this session's mesh store, matching the field set of
|
||||
// mesh/types.rs MeshMessage (id, direction, peer_contact_id, peer_name,
|
||||
// plaintext, timestamp, delivered, encrypted, transport, message_type,
|
||||
// typed_payload, sender_pubkey, sender_seq).
|
||||
function pushMeshMessage({
|
||||
contact_id = null,
|
||||
plaintext = '',
|
||||
message_type = 'text',
|
||||
typed_payload = null,
|
||||
transport = 'meshcore',
|
||||
channel = null,
|
||||
}) {
|
||||
const meshStore = currentStore().mesh
|
||||
const id = 100 + meshStore.dynamic.length
|
||||
const msg = {
|
||||
id,
|
||||
direction: 'sent',
|
||||
peer_contact_id: contact_id,
|
||||
peer_name: contact_id === null ? (channel === null ? null : `channel-${channel}`) : DEMO_PEER_NAMES[contact_id] || `peer-${contact_id}`,
|
||||
plaintext,
|
||||
timestamp: new Date().toISOString(),
|
||||
delivered: true,
|
||||
// mountain-node (3) has no pubkey, so traffic to it is unencrypted — the
|
||||
// same asymmetry the rest of the demo already models.
|
||||
encrypted: contact_id !== 3,
|
||||
transport,
|
||||
sender_pubkey: DEMO_SELF_PUBKEY,
|
||||
sender_seq: id,
|
||||
message_type,
|
||||
typed_payload,
|
||||
}
|
||||
if (channel !== null) msg.channel = channel
|
||||
meshStore.dynamic.push(msg)
|
||||
return msg
|
||||
}
|
||||
|
||||
// Apply this session's edit/delete overrides over a message list. Keyed by
|
||||
// sender_seq, mirroring mesh/mod.rs apply_local_edit / apply_local_delete,
|
||||
// which match an own-Sent message by sender_seq. Kept as an overlay rather
|
||||
// than mutating the seed array, because the seed is rebuilt on every read.
|
||||
function applyMeshOverrides(messages) {
|
||||
const overrides = currentStore().mesh.overrides
|
||||
if (!overrides || Object.keys(overrides).length === 0) return messages
|
||||
return messages.map((m) => {
|
||||
const o = m.direction === 'sent' && m.sender_seq != null ? overrides[m.sender_seq] : null
|
||||
return o ? { ...m, ...o } : m
|
||||
})
|
||||
}
|
||||
|
||||
// Boot mode: simulate server startup delay
|
||||
let BOOT_START_TIME = Date.now()
|
||||
const BOOT_DELAY_MS = 25000 // 25 seconds of simulated startup (slower for analysis)
|
||||
@@ -335,15 +228,6 @@ function seedMockState() {
|
||||
return {
|
||||
analyticsEnabled: false,
|
||||
nodeVisibility: 'discoverable',
|
||||
// Mesh assistant config — mirrors the daemon's AssistantConfig defaults
|
||||
// (assistant.rs). Disabled by default, trusted-only, no model override.
|
||||
assistantConfig: {
|
||||
enabled: false,
|
||||
model: null,
|
||||
trusted_only: true,
|
||||
backend: 'ollama',
|
||||
allowed_contacts: [],
|
||||
},
|
||||
// Discovery starts OFF, matching the production default — enabling it in
|
||||
// the UI walks through the presence-signing overlay.
|
||||
nostrDiscovery: false,
|
||||
@@ -359,21 +243,6 @@ function seedMockState() {
|
||||
state: 'pending',
|
||||
outbound: false,
|
||||
},
|
||||
// An outbound request still awaiting an answer, so the demo can exercise
|
||||
// federation.cancel-request — the daemon only permits cancelling an
|
||||
// outbound request in 'sent' state, so without one the demo's cancel
|
||||
// button could only ever produce an error.
|
||||
{
|
||||
id: 'preq-demo-out-1',
|
||||
from_nostr_pubkey: '3c8f1a5d9e2b7c4a6f0d3b8e1a5c9f2d7b4e6a0c3f8d1b5e9a2c7f4d0b6e3a8c',
|
||||
from_nostr_npub: 'npub1demo0outbound0request0abcd',
|
||||
from_did: 'did:key:z6MkqR3nT8yVb2xLc7fPu4dWe6gJa9mHzN1oKiX5vYtA3sqp',
|
||||
from_name: 'harbor-node',
|
||||
message: 'Requested peering — waiting on their approval.',
|
||||
received_at: new Date(Date.now() - 20 * 60000).toISOString(),
|
||||
state: 'sent',
|
||||
outbound: true,
|
||||
},
|
||||
],
|
||||
tollgateEnabled: true,
|
||||
tollgatePrice: 21,
|
||||
@@ -3294,13 +3163,53 @@ app.post('/rpc/v1', (req, res) => {
|
||||
}
|
||||
|
||||
case 'mesh.peers': {
|
||||
// Built from the shared DEMO_MESH_PEERS list so mesh.contacts-list
|
||||
// cannot drift from it; last_heard is aged relative to now.
|
||||
const peers = DEMO_MESH_PEERS.map((p) => ({
|
||||
...p,
|
||||
last_heard: new Date(Date.now() - (DEMO_PEER_LAST_HEARD_MS[p.contact_id] || 0)).toISOString(),
|
||||
}))
|
||||
return res.json({ result: { peers, count: peers.length } })
|
||||
return res.json({
|
||||
result: {
|
||||
peers: [
|
||||
{
|
||||
contact_id: 1,
|
||||
advert_name: 'archy-198',
|
||||
did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9',
|
||||
pubkey_hex: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
|
||||
rssi: -67,
|
||||
snr: 9.5,
|
||||
last_heard: new Date(Date.now() - 30000).toISOString(),
|
||||
hops: 0,
|
||||
},
|
||||
{
|
||||
contact_id: 2,
|
||||
advert_name: 'satoshi-relay',
|
||||
did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH',
|
||||
pubkey_hex: 'f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5',
|
||||
rssi: -82,
|
||||
snr: 4.2,
|
||||
last_heard: new Date(Date.now() - 120000).toISOString(),
|
||||
hops: 1,
|
||||
},
|
||||
{
|
||||
contact_id: 3,
|
||||
advert_name: 'mountain-node',
|
||||
did: null,
|
||||
pubkey_hex: null,
|
||||
rssi: -95,
|
||||
snr: 1.8,
|
||||
last_heard: new Date(Date.now() - 600000).toISOString(),
|
||||
hops: 2,
|
||||
},
|
||||
{
|
||||
contact_id: 4,
|
||||
advert_name: 'bunker-alpha',
|
||||
did: 'did:key:z6MkrHKPxJP6tvCvXMaJKZd3rRA2Y44tyftVhR8FDCMKGFjb',
|
||||
pubkey_hex: 'c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
|
||||
rssi: -74,
|
||||
snr: 7.1,
|
||||
last_heard: new Date(Date.now() - 45000).toISOString(),
|
||||
hops: 0,
|
||||
},
|
||||
],
|
||||
count: 4,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
case 'mesh.messages': {
|
||||
@@ -3322,7 +3231,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
]
|
||||
// Messages sent this session (texts + attachments) ride after the
|
||||
// static seed so refresh-after-send shows them, same as a real node.
|
||||
const withDynamic = applyMeshOverrides([...allMessages, ...currentStore().mesh.dynamic])
|
||||
const withDynamic = [...allMessages, ...currentStore().mesh.dynamic]
|
||||
return res.json({
|
||||
result: {
|
||||
messages: withDynamic.slice(0, limit),
|
||||
@@ -4531,53 +4440,6 @@ app.post('/rpc/v1', (req, res) => {
|
||||
if (r) r.state = 'rejected'
|
||||
return res.json({ result: { rejected: true, id: params?.id || '' } })
|
||||
}
|
||||
// Mirrors federation/handlers.rs handle_federation_cancel_request: only
|
||||
// an OUTBOUND request still in 'sent' state may be cancelled, the row is
|
||||
// then dropped locally, and `notify` defaults to TRUE (the daemon calls
|
||||
// cancelling without notifying a footgun — the peer would keep showing
|
||||
// an unanswerable request).
|
||||
case 'federation.cancel-request': {
|
||||
const id = params?.id
|
||||
if (!id) {
|
||||
return res.json({ error: { code: -32602, message: 'Missing id' } })
|
||||
}
|
||||
const notify = params?.notify === undefined ? true : !!params.notify
|
||||
const req = mockState.pendingPeerRequests.find((x) => x.id === id)
|
||||
if (!req) {
|
||||
return res.json({
|
||||
error: { code: -32603, message: `Pending request not found: ${id}` },
|
||||
})
|
||||
}
|
||||
if (!req.outbound || req.state !== 'sent') {
|
||||
return res.json({
|
||||
error: {
|
||||
code: -32603,
|
||||
message: `Can only cancel outbound requests in Sent state (outbound=${!!req.outbound}, state=${req.state})`,
|
||||
},
|
||||
})
|
||||
}
|
||||
mockState.pendingPeerRequests = mockState.pendingPeerRequests.filter((x) => x.id !== id)
|
||||
return res.json({ result: { cancelled: true, id, notified: notify } })
|
||||
}
|
||||
// Mirrors handle_federation_notify_did_change: fans the rotation proof
|
||||
// out to every known node and reports per-peer results. The demo has no
|
||||
// real peers to reach, so every known node reports ok over its recorded
|
||||
// transport — the shape the UI renders, not a fabricated failure.
|
||||
case 'federation.notify-did-change': {
|
||||
for (const key of ['old_did', 'new_did', 'proof_signature', 'proof_message']) {
|
||||
if (!params?.[key]) {
|
||||
return res.json({ error: { code: -32602, message: `Missing '${key}'` } })
|
||||
}
|
||||
}
|
||||
const results = sessionFederationNodes().map((n) => ({
|
||||
did: n.did,
|
||||
status: 'ok',
|
||||
transport: n.transport || 'tor',
|
||||
}))
|
||||
return res.json({
|
||||
result: { notified: results.length, failed: 0, results },
|
||||
})
|
||||
}
|
||||
|
||||
// ── Mesh chat polish + flash flow (v1.7.117/118 demo coverage) ──────
|
||||
|
||||
@@ -4747,275 +4609,14 @@ app.post('/rpc/v1', (req, res) => {
|
||||
return res.json({ result: { cancelled: true } })
|
||||
}
|
||||
|
||||
// ── Contacts (peer aliasing) ────────────────────────────────────────
|
||||
// Mirrors typed_messages.rs handle_mesh_contacts_list /
|
||||
// handle_mesh_contacts_save: contact entries are keyed by pubkey_hex and
|
||||
// merged OVER the peer list, so a peer with no entry still appears with
|
||||
// null alias/notes. Peers without a pubkey_hex are omitted, exactly as
|
||||
// the daemon omits them (it only pushes when peer.pubkey_hex is Some).
|
||||
case 'mesh.contacts-list': {
|
||||
const contacts = currentStore().mesh.contacts
|
||||
const out = DEMO_MESH_PEERS.filter((p) => p.pubkey_hex).map((p) => {
|
||||
const entry = contacts[p.pubkey_hex] || {}
|
||||
return {
|
||||
pubkey: p.pubkey_hex,
|
||||
contact_id: p.contact_id,
|
||||
name: p.advert_name,
|
||||
alias: entry.alias ?? null,
|
||||
notes: entry.notes ?? null,
|
||||
pinned: entry.pinned ?? false,
|
||||
blocked: entry.blocked ?? false,
|
||||
}
|
||||
})
|
||||
return res.json({ result: { contacts: out } })
|
||||
}
|
||||
|
||||
// Upsert semantics match the daemon's: a field absent from params leaves
|
||||
// the stored value alone (it only assigns when the key is present), so
|
||||
// saving an alias never clears existing notes.
|
||||
case 'mesh.contacts-save': {
|
||||
const pubkey = params?.pubkey
|
||||
if (!pubkey) {
|
||||
return res.json({ error: { code: -32602, message: 'Missing pubkey' } })
|
||||
}
|
||||
const contacts = currentStore().mesh.contacts
|
||||
const entry = contacts[pubkey] || { alias: null, notes: null, pinned: false, blocked: false }
|
||||
if (params.alias !== undefined) entry.alias = params.alias
|
||||
if (params.notes !== undefined) entry.notes = params.notes
|
||||
if (params.pinned !== undefined) entry.pinned = !!params.pinned
|
||||
contacts[pubkey] = entry
|
||||
return res.json({
|
||||
result: {
|
||||
saved: true,
|
||||
pubkey,
|
||||
alias: entry.alias ?? null,
|
||||
notes: entry.notes ?? null,
|
||||
pinned: entry.pinned ?? false,
|
||||
blocked: entry.blocked ?? false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Mirrors status.rs handle_mesh_clear_all, which deletes the mesh state
|
||||
// files and clears in-memory state, returning { status: "cleared" }.
|
||||
// Here that means dropping this session's sent messages and blobs.
|
||||
case 'mesh.clear-all': {
|
||||
const meshStore = currentStore().mesh
|
||||
meshStore.dynamic.length = 0
|
||||
meshStore.blobs = {}
|
||||
return res.json({ result: { status: 'cleared' } })
|
||||
}
|
||||
|
||||
// ── Scheduled messages ──────────────────────────────────────────────
|
||||
// Mirrors assistant.rs handle_mesh_schedule_message / list / cancel and
|
||||
// scheduler.rs ScheduledMessage { id, contact_id, channel, body,
|
||||
// fire_at, attempts }. The daemon requires body + fire_at and one of
|
||||
// contact_id / channel, and errors otherwise — so does this.
|
||||
case 'mesh.schedule-message': {
|
||||
const body = params?.body
|
||||
const fireAt = params?.fire_at
|
||||
if (typeof body !== 'string') {
|
||||
return res.json({ error: { code: -32602, message: 'body is required' } })
|
||||
}
|
||||
if (typeof fireAt !== 'number') {
|
||||
return res.json({ error: { code: -32602, message: 'fire_at (unix seconds) is required' } })
|
||||
}
|
||||
const contactId = params?.contact_id
|
||||
const channel = params?.channel
|
||||
if (contactId === undefined && channel === undefined) {
|
||||
return res.json({
|
||||
error: { code: -32602, message: 'either contact_id or channel is required' },
|
||||
})
|
||||
}
|
||||
const meshStore = currentStore().mesh
|
||||
const entry = {
|
||||
id: meshStore.nextScheduledId++,
|
||||
contact_id: contactId ?? null,
|
||||
channel: channel ?? null,
|
||||
body,
|
||||
fire_at: fireAt,
|
||||
attempts: 0,
|
||||
}
|
||||
meshStore.scheduled.push(entry)
|
||||
return res.json({ result: entry })
|
||||
}
|
||||
|
||||
// Sorted by fire time, as the daemon's scheduler.list() returns them.
|
||||
case 'mesh.list-scheduled': {
|
||||
const messages = [...currentStore().mesh.scheduled].sort((a, b) => a.fire_at - b.fire_at)
|
||||
return res.json({ result: { messages } })
|
||||
}
|
||||
|
||||
case 'mesh.cancel-scheduled': {
|
||||
const id = params?.id
|
||||
if (typeof id !== 'number') {
|
||||
return res.json({ error: { code: -32602, message: 'id is required' } })
|
||||
}
|
||||
const meshStore = currentStore().mesh
|
||||
const before = meshStore.scheduled.length
|
||||
meshStore.scheduled = meshStore.scheduled.filter((m) => m.id !== id)
|
||||
return res.json({ result: { cancelled: meshStore.scheduled.length < before } })
|
||||
}
|
||||
|
||||
// ── Mesh assistant ──────────────────────────────────────────────────
|
||||
// Mirrors assistant.rs handle_mesh_assistant_status: the demo reports no
|
||||
// Ollama and no Claude key, which is the honest answer for a browser
|
||||
// demo with no local model — the UI's "not detected" path is what a
|
||||
// visitor should see, not a fabricated model list.
|
||||
case 'mesh.assistant-status': {
|
||||
const cfg = mockState.assistantConfig
|
||||
return res.json({
|
||||
result: {
|
||||
enabled: cfg.enabled,
|
||||
model: cfg.model,
|
||||
trusted_only: cfg.trusted_only,
|
||||
backend: cfg.backend,
|
||||
allowed_contacts: cfg.allowed_contacts,
|
||||
default_model: 'llama3.2:3b',
|
||||
ollama_detected: false,
|
||||
claude_available: false,
|
||||
models: [],
|
||||
denied_askers: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Mirrors handle_mesh_assistant_configure's key-presence semantics:
|
||||
// model present+string sets, present+null clears to default, absent
|
||||
// leaves; allowed_contacts present+array replaces the allowlist.
|
||||
case 'mesh.assistant-configure': {
|
||||
const cfg = mockState.assistantConfig
|
||||
if (params?.enabled !== undefined) cfg.enabled = !!params.enabled
|
||||
if (params?.trusted_only !== undefined) cfg.trusted_only = !!params.trusted_only
|
||||
if (params?.backend !== undefined) cfg.backend = params.backend
|
||||
if (params && 'model' in params) cfg.model = params.model
|
||||
if (Array.isArray(params?.allowed_contacts)) cfg.allowed_contacts = params.allowed_contacts
|
||||
return res.json({
|
||||
result: {
|
||||
enabled: cfg.enabled,
|
||||
model: cfg.model,
|
||||
trusted_only: cfg.trusted_only,
|
||||
backend: cfg.backend,
|
||||
allowed_contacts: cfg.allowed_contacts,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Chat mutations ──────────────────────────────────────────────────
|
||||
// Reactions, replies, receipts and forwards are new typed messages
|
||||
// carrying a target key (typed_messages.rs send_typed_wire); edits and
|
||||
// deletes mutate the target in place (mesh/mod.rs apply_local_edit /
|
||||
// apply_local_delete). The UI derives reaction chips and reply quotes
|
||||
// from exactly these shapes (Mesh.vue reactionIndex /
|
||||
// replyTargetPreview), so a bare ack renders as nothing at all.
|
||||
case 'mesh.send-reaction': {
|
||||
const emoji = params?.emoji ?? ''
|
||||
pushMeshMessage({
|
||||
contact_id: params?.contact_id ?? null,
|
||||
plaintext: emoji,
|
||||
message_type: 'reaction',
|
||||
typed_payload: {
|
||||
target: { sender_pubkey: params?.target_pubkey, sender_seq: params?.target_seq },
|
||||
// An empty emoji clears this reactor's reaction, per the UI's
|
||||
// reactionIndex (freshest per reactor wins; '' deletes).
|
||||
emoji,
|
||||
},
|
||||
})
|
||||
return res.json({ result: { ok: true, sent: true } })
|
||||
}
|
||||
|
||||
case 'mesh.send-reply': {
|
||||
const text = params?.text ?? ''
|
||||
pushMeshMessage({
|
||||
contact_id: params?.contact_id ?? null,
|
||||
plaintext: text,
|
||||
message_type: 'reply',
|
||||
typed_payload: {
|
||||
target: { sender_pubkey: params?.target_pubkey, sender_seq: params?.target_seq },
|
||||
text,
|
||||
},
|
||||
})
|
||||
return res.json({ result: { ok: true, sent: true } })
|
||||
}
|
||||
|
||||
case 'mesh.send-read-receipt': {
|
||||
pushMeshMessage({
|
||||
contact_id: params?.contact_id ?? null,
|
||||
plaintext: '',
|
||||
message_type: 'read_receipt',
|
||||
typed_payload: {
|
||||
target: { sender_pubkey: params?.target_pubkey, sender_seq: params?.target_seq },
|
||||
},
|
||||
})
|
||||
return res.json({ result: { ok: true, sent: true } })
|
||||
}
|
||||
|
||||
// apply_local_edit: replaces plaintext and merges { edited_at, text }
|
||||
// into the existing typed_payload of the own-Sent message with that seq.
|
||||
case 'mesh.edit-message': {
|
||||
const seq = params?.target_seq
|
||||
const newText = params?.new_text ?? ''
|
||||
if (seq == null) {
|
||||
return res.json({ error: { code: -32602, message: 'target_seq is required' } })
|
||||
}
|
||||
const overrides = currentStore().mesh.overrides
|
||||
const prior = overrides[seq] || {}
|
||||
overrides[seq] = {
|
||||
...prior,
|
||||
plaintext: newText,
|
||||
typed_payload: {
|
||||
...(prior.typed_payload || {}),
|
||||
edited_at: Math.floor(Date.now() / 1000),
|
||||
text: newText,
|
||||
},
|
||||
}
|
||||
return res.json({ result: { ok: true, sent: true } })
|
||||
}
|
||||
|
||||
// apply_local_delete: tombstone in place — the daemon does NOT remove
|
||||
// the row, it rewrites plaintext, sets typed_payload { deleted: true }
|
||||
// and message_type 'delete'. Mirrored exactly; do not "simplify" this
|
||||
// into a splice, the UI renders the tombstone bubble.
|
||||
case 'mesh.delete-message': {
|
||||
const seq = params?.target_seq
|
||||
if (seq == null) {
|
||||
return res.json({ error: { code: -32602, message: 'target_seq is required' } })
|
||||
}
|
||||
currentStore().mesh.overrides[seq] = {
|
||||
plaintext: '🗑 message deleted',
|
||||
typed_payload: { deleted: true },
|
||||
message_type: 'delete',
|
||||
}
|
||||
return res.json({ result: { ok: true, sent: true } })
|
||||
}
|
||||
|
||||
case 'mesh.forward-message': {
|
||||
const sourceId = params?.source_message_id
|
||||
const all = applyMeshOverrides(currentStore().mesh.dynamic)
|
||||
const source = all.find((m) => m.id === sourceId)
|
||||
pushMeshMessage({
|
||||
contact_id: params?.contact_id ?? null,
|
||||
plaintext: source ? source.plaintext : '(forwarded)',
|
||||
message_type: source?.message_type === 'content_ref' ? 'content_ref' : 'text',
|
||||
typed_payload: source?.typed_payload ?? null,
|
||||
})
|
||||
return res.json({ result: { ok: true, sent: true } })
|
||||
}
|
||||
|
||||
case 'mesh.send-channel': {
|
||||
pushMeshMessage({
|
||||
contact_id: null,
|
||||
channel: params?.channel ?? 0,
|
||||
plaintext: params?.message ?? '',
|
||||
message_type: 'text',
|
||||
})
|
||||
return res.json({ result: { ok: true, sent: true } })
|
||||
}
|
||||
|
||||
// mesh.refresh and mesh.reboot-radio stay bare acknowledgements ON
|
||||
// PURPOSE: the daemon's handlers have no message-store effect either
|
||||
// (refresh re-polls the radio, reboot-radio power-cycles it), so giving
|
||||
// them demo-side state would be divergence, not parity. Do not "fix".
|
||||
// Chat actions the demo only needs to acknowledge.
|
||||
case 'mesh.send-reaction':
|
||||
case 'mesh.send-reply':
|
||||
case 'mesh.send-read-receipt':
|
||||
case 'mesh.edit-message':
|
||||
case 'mesh.delete-message':
|
||||
case 'mesh.forward-message':
|
||||
case 'mesh.send-channel':
|
||||
case 'mesh.refresh':
|
||||
case 'mesh.reboot-radio': {
|
||||
return res.json({ result: { ok: true, sent: true } })
|
||||
@@ -6033,10 +5634,7 @@ function makeSessionStore() {
|
||||
// Mesh chat mutable state: messages sent this session + attachment bytes
|
||||
// (cid → {mime, filename, b64, thumb_b64}). Per-session so one demo
|
||||
// visitor's uploads are never visible to another.
|
||||
// contacts: pubkey_hex → { alias, notes, pinned, blocked }, mirroring the
|
||||
// daemon's state.contacts map. scheduled: queued messages awaiting their
|
||||
// fire_at, mirroring svc.scheduler's list.
|
||||
mesh: { dynamic: [], blobs: {}, contacts: {}, scheduled: [], nextScheduledId: 1, overrides: {} },
|
||||
mesh: { dynamic: [], blobs: {} },
|
||||
sockets: new Set(),
|
||||
lastSeen: Date.now(),
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"stop": "./stop-dev.sh",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:mock-parity": "node scripts/mock-rpc-parity.mjs",
|
||||
"dev": "vite",
|
||||
"dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' -- pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI — chat will show placeholder'\"",
|
||||
"dev:boot": "VITE_DEV_MODE=boot concurrently --raw \"VITE_DEV_MODE=boot node mock-backend.js\" \"VITE_DEV_MODE=boot vite\"",
|
||||
|
||||
Binary file not shown.
@@ -1,301 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Demo/real RPC parity harness (FED-04).
|
||||
//
|
||||
// STATIC stage: cross-references every mesh.*/federation.* RPC method the UI
|
||||
// actually calls against the cases mock-backend.js implements, and reports any
|
||||
// the demo would answer with "Method not found".
|
||||
//
|
||||
// LIVE stage: boots mock-backend.js on an ephemeral port and drives a scripted
|
||||
// JSON-RPC sequence, asserting the handlers mutate demo state rather than
|
||||
// returning bare acknowledgements.
|
||||
//
|
||||
// Exits non-zero on any failure. There are deliberately no `|| fallback`
|
||||
// escapes anywhere in here: a failed spawn, a failed fetch or a missing field
|
||||
// must fail the run, never produce a green run that measured nothing.
|
||||
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { join, dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'node:net'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const UI_ROOT = resolve(HERE, '..')
|
||||
const MOCK = join(UI_ROOT, 'mock-backend.js')
|
||||
const SRC = join(UI_ROOT, 'src')
|
||||
|
||||
// Methods the UI calls that the mock is knowingly allowed to be missing.
|
||||
// Task 2 emptied this: any gap now fails the run.
|
||||
const KNOWN_GAPS = []
|
||||
|
||||
let failures = 0
|
||||
const fail = (msg) => {
|
||||
console.error(` ✗ ${msg}`)
|
||||
failures++
|
||||
}
|
||||
const pass = (msg) => console.log(` ✓ ${msg}`)
|
||||
|
||||
// ── STATIC ──────────────────────────────────────────────────────────────────
|
||||
// Only `method: 'mesh.x'` / `method: "mesh.x"` counts as a call site. A bare
|
||||
// string literal is NOT enough: Mesh.vue and Federation.vue use the same
|
||||
// dotted names as resource-cache keys (`key: 'mesh.self-did'`,
|
||||
// `key: 'federation.nodes'`), which are not RPC methods at all. Matching those
|
||||
// would report permanent phantom gaps and make this harness unfailable.
|
||||
const CALL_RE = /method:\s*['"]((?:mesh|federation)\.[a-z0-9-]+)['"]/g
|
||||
const CASE_RE = /case\s*['"]((?:mesh|federation)\.[a-z0-9-]+)['"]/g
|
||||
|
||||
function walk(dir) {
|
||||
const out = []
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name)
|
||||
const st = statSync(p)
|
||||
if (st.isDirectory()) out.push(...walk(p))
|
||||
else if (/\.(ts|js|vue)$/.test(name)) out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function collect(re, text, into) {
|
||||
let m
|
||||
while ((m = re.exec(text)) !== null) into.add(m[1])
|
||||
}
|
||||
|
||||
console.log('STATIC: cross-referencing UI call sites against mock handlers')
|
||||
const called = new Set()
|
||||
for (const file of walk(SRC)) collect(new RegExp(CALL_RE), readFileSync(file, 'utf8'), called)
|
||||
|
||||
const handled = new Set()
|
||||
collect(new RegExp(CASE_RE), readFileSync(MOCK, 'utf8'), handled)
|
||||
|
||||
const missing = [...called].filter((m) => !handled.has(m)).sort()
|
||||
const unexpected = missing.filter((m) => !KNOWN_GAPS.includes(m))
|
||||
|
||||
console.log(` UI calls ${called.size} mesh/federation methods; mock implements ${handled.size}`)
|
||||
if (missing.length === 0) {
|
||||
pass('every UI-called mesh/federation method has a mock handler')
|
||||
} else {
|
||||
for (const m of unexpected) fail(`no mock handler for ${m}`)
|
||||
for (const m of missing.filter((x) => KNOWN_GAPS.includes(x))) {
|
||||
console.log(` … known gap (allowed): ${m}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── LIVE ────────────────────────────────────────────────────────────────────
|
||||
const freePort = () =>
|
||||
new Promise((res, rej) => {
|
||||
const srv = createServer()
|
||||
srv.on('error', rej)
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const { port } = srv.address()
|
||||
srv.close(() => res(port))
|
||||
})
|
||||
})
|
||||
|
||||
const port = await freePort()
|
||||
console.log(`LIVE: booting mock-backend.js on :${port}`)
|
||||
|
||||
const child = spawn(process.execPath, [MOCK], {
|
||||
cwd: UI_ROOT,
|
||||
env: { ...process.env, MOCK_BACKEND_PORT: String(port) },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
child.stdout.resume()
|
||||
child.stderr.resume()
|
||||
|
||||
let childExited = false
|
||||
child.on('exit', () => {
|
||||
childExited = true
|
||||
})
|
||||
|
||||
const base = `http://127.0.0.1:${port}`
|
||||
const rpc = async (method, params) => {
|
||||
const resp = await fetch(`${base}/rpc/v1`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`${method} → HTTP ${resp.status}`)
|
||||
const body = await resp.json()
|
||||
if (body.error) throw new Error(`${method} → RPC error ${body.error.code}: ${body.error.message}`)
|
||||
return body.result
|
||||
}
|
||||
|
||||
try {
|
||||
// Poll until the server answers (bounded).
|
||||
const deadline = Date.now() + 10000
|
||||
for (;;) {
|
||||
if (childExited) throw new Error('mock-backend exited before becoming ready')
|
||||
try {
|
||||
await rpc('server.echo', { message: 'parity-probe' })
|
||||
break
|
||||
} catch {
|
||||
if (Date.now() > deadline) throw new Error('mock-backend did not become ready within 10s')
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
}
|
||||
pass('mock-backend answered on the ephemeral port')
|
||||
|
||||
// 1. Peer aliasing round-trip (the FED-04 headline: renaming a peer sticks).
|
||||
const peers = await rpc('mesh.contacts-list')
|
||||
const target = peers.contacts.find((c) => c.pubkey)
|
||||
if (!target) throw new Error('mesh.contacts-list returned no contact with a pubkey')
|
||||
if (target.alias !== null) fail(`expected a fresh contact to have a null alias, got ${target.alias}`)
|
||||
|
||||
await rpc('mesh.contacts-save', { pubkey: target.pubkey, alias: 'Base Camp' })
|
||||
const after = await rpc('mesh.contacts-list')
|
||||
const renamed = after.contacts.find((c) => c.pubkey === target.pubkey)
|
||||
if (renamed?.alias === 'Base Camp') pass('mesh.contacts-save → contacts-list round-trips the alias')
|
||||
else fail(`alias did not persist: expected 'Base Camp', got ${JSON.stringify(renamed?.alias)}`)
|
||||
|
||||
// The daemon omits peers with no pubkey_hex; so must the demo.
|
||||
if (after.contacts.every((c) => c.pubkey)) pass('contacts-list omits peers without a pubkey, as the daemon does')
|
||||
else fail('contacts-list returned a contact with no pubkey')
|
||||
|
||||
// 2. Scheduling: schedule → list → cancel → list.
|
||||
const fireAt = Math.floor(Date.now() / 1000) + 3600
|
||||
const scheduled = await rpc('mesh.schedule-message', {
|
||||
body: 'mesh parity check',
|
||||
fire_at: fireAt,
|
||||
contact_id: 1,
|
||||
})
|
||||
if (typeof scheduled.id !== 'number') fail('mesh.schedule-message did not return a numeric id')
|
||||
|
||||
const listed = await rpc('mesh.list-scheduled')
|
||||
if (listed.messages.some((m) => m.id === scheduled.id)) pass('mesh.schedule-message → list-scheduled shows the entry')
|
||||
else fail('scheduled message missing from mesh.list-scheduled')
|
||||
|
||||
const cancelled = await rpc('mesh.cancel-scheduled', { id: scheduled.id })
|
||||
if (cancelled.cancelled !== true) fail('mesh.cancel-scheduled did not report a cancellation')
|
||||
const afterCancel = await rpc('mesh.list-scheduled')
|
||||
if (!afterCancel.messages.some((m) => m.id === scheduled.id)) pass('mesh.cancel-scheduled removed the entry')
|
||||
else fail('cancelled message still present in mesh.list-scheduled')
|
||||
|
||||
// Cancelling an unknown id must report false, not throw or lie.
|
||||
const ghost = await rpc('mesh.cancel-scheduled', { id: 999999 })
|
||||
if (ghost.cancelled === false) pass('cancel-scheduled reports false for an unknown id')
|
||||
else fail(`cancel-scheduled lied about an unknown id: ${JSON.stringify(ghost)}`)
|
||||
|
||||
// 3. clear-all empties this session's sent messages.
|
||||
await rpc('mesh.send-content-inline', {
|
||||
contact_id: 1,
|
||||
mime: 'text/plain',
|
||||
filename: 'parity.txt',
|
||||
bytes_b64: Buffer.from('parity').toString('base64'),
|
||||
})
|
||||
const withMsg = await rpc('mesh.messages', { limit: 500 })
|
||||
const cleared = await rpc('mesh.clear-all')
|
||||
if (cleared.status !== 'cleared') fail(`mesh.clear-all returned ${JSON.stringify(cleared)}`)
|
||||
const afterClear = await rpc('mesh.messages', { limit: 500 })
|
||||
if (afterClear.count < withMsg.count) pass('mesh.clear-all dropped this session\'s messages')
|
||||
else fail(`mesh.clear-all did not reduce the message count (${withMsg.count} → ${afterClear.count})`)
|
||||
|
||||
// 4. Assistant config round-trip.
|
||||
const status = await rpc('mesh.assistant-status')
|
||||
for (const k of ['enabled', 'model', 'trusted_only', 'backend', 'allowed_contacts', 'default_model', 'ollama_detected', 'claude_available', 'models', 'denied_askers']) {
|
||||
if (!(k in status)) fail(`mesh.assistant-status missing field '${k}'`)
|
||||
}
|
||||
const configured = await rpc('mesh.assistant-configure', { enabled: true, model: 'llama3.2:3b' })
|
||||
if (configured.enabled === true && configured.model === 'llama3.2:3b') pass('mesh.assistant-configure applies and echoes settings')
|
||||
else fail(`assistant-configure did not apply: ${JSON.stringify(configured)}`)
|
||||
const restatus = await rpc('mesh.assistant-status')
|
||||
if (restatus.enabled === true) pass('assistant config persists across calls')
|
||||
else fail('assistant config did not persist')
|
||||
|
||||
// 5. federation.cancel-request removes the outbound request.
|
||||
const pending = await rpc('federation.list-pending-requests')
|
||||
const outbound = pending.requests.find((r) => r.outbound && r.state === 'sent')
|
||||
if (!outbound) {
|
||||
fail('no outbound/sent request in the demo seed — cancel-request is unexercisable')
|
||||
} else {
|
||||
const res = await rpc('federation.cancel-request', { id: outbound.id })
|
||||
if (res.cancelled !== true || res.notified !== true) fail(`cancel-request returned ${JSON.stringify(res)}`)
|
||||
const post = await rpc('federation.list-pending-requests')
|
||||
if (!post.requests.some((r) => r.id === outbound.id)) pass('federation.cancel-request removed the request')
|
||||
else fail('cancelled request still listed as pending')
|
||||
}
|
||||
|
||||
// 6. Chat mutations are observable on the next mesh.messages read — the
|
||||
// whole point of FED-04's "not a bare ok acknowledgement" requirement.
|
||||
const sent = await rpc('mesh.send-content-inline', {
|
||||
contact_id: 1,
|
||||
mime: 'text/plain',
|
||||
filename: 'mutate.txt',
|
||||
bytes_b64: Buffer.from('mutate me').toString('base64'),
|
||||
})
|
||||
const targetSeq = sent.message_id
|
||||
|
||||
await rpc('mesh.send-reaction', {
|
||||
contact_id: 1,
|
||||
target_pubkey: 'demo02abababababababababababababababababababababababababababab',
|
||||
target_seq: targetSeq,
|
||||
emoji: '🔥',
|
||||
})
|
||||
let msgs = (await rpc('mesh.messages', { limit: 500 })).messages
|
||||
const reaction = msgs.find(
|
||||
(m) => m.message_type === 'reaction' && m.typed_payload?.target?.sender_seq === targetSeq,
|
||||
)
|
||||
if (reaction?.typed_payload?.emoji === '🔥') pass('mesh.send-reaction is visible as a reaction message with a target key')
|
||||
else fail('reaction did not appear in mesh.messages with the UI-expected shape')
|
||||
|
||||
await rpc('mesh.send-reply', {
|
||||
contact_id: 1,
|
||||
target_pubkey: 'demo02abababababababababababababababababababababababababababab',
|
||||
target_seq: targetSeq,
|
||||
text: 'replying to that',
|
||||
})
|
||||
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
|
||||
const reply = msgs.find((m) => m.message_type === 'reply' && m.typed_payload?.target?.sender_seq === targetSeq)
|
||||
if (reply?.plaintext === 'replying to that') pass('mesh.send-reply is visible as a reply carrying its target')
|
||||
else fail('reply did not appear with a target key')
|
||||
|
||||
await rpc('mesh.edit-message', { contact_id: 1, target_seq: targetSeq, new_text: 'edited text' })
|
||||
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
|
||||
const edited = msgs.find((m) => m.sender_seq === targetSeq && m.direction === 'sent')
|
||||
if (edited?.plaintext === 'edited text' && edited?.typed_payload?.edited_at)
|
||||
pass('mesh.edit-message rewrote the text and set the edited marker')
|
||||
else fail(`edit not applied: ${JSON.stringify(edited?.plaintext)}`)
|
||||
|
||||
await rpc('mesh.delete-message', { contact_id: 1, target_seq: targetSeq })
|
||||
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
|
||||
const deleted = msgs.find((m) => m.sender_seq === targetSeq && m.direction === 'sent')
|
||||
// The daemon tombstones in place rather than removing the row.
|
||||
if (deleted && deleted.message_type === 'delete' && deleted.typed_payload?.deleted === true)
|
||||
pass('mesh.delete-message tombstoned in place, as apply_local_delete does')
|
||||
else fail(`delete representation wrong: ${JSON.stringify(deleted)}`)
|
||||
|
||||
const beforeForward = (await rpc('mesh.messages', { limit: 500 })).count
|
||||
await rpc('mesh.forward-message', { contact_id: 2, source_message_id: targetSeq })
|
||||
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
|
||||
const forwarded = msgs.filter((m) => m.peer_contact_id === 2 && m.direction === 'sent')
|
||||
if (msgs.length > beforeForward && forwarded.length > 0) pass('mesh.forward-message pushed a copy for the destination peer')
|
||||
else fail('forward did not create a message for the destination peer')
|
||||
|
||||
await rpc('mesh.send-channel', { channel: 3, message: 'channel broadcast' })
|
||||
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
|
||||
if (msgs.some((m) => m.channel === 3 && m.plaintext === 'channel broadcast'))
|
||||
pass('mesh.send-channel pushed a channel-addressed message')
|
||||
else fail('channel message not visible in mesh.messages')
|
||||
|
||||
// 7. federation.notify-did-change reports per-peer results.
|
||||
const notified = await rpc('federation.notify-did-change', {
|
||||
old_did: 'did:key:zOld',
|
||||
new_did: 'did:key:zNew',
|
||||
proof_signature: 'demo-sig',
|
||||
proof_message: 'demo-msg',
|
||||
})
|
||||
if (Array.isArray(notified.results) && notified.notified === notified.results.length) {
|
||||
pass('federation.notify-did-change reports one result per node')
|
||||
} else {
|
||||
fail(`notify-did-change shape wrong: ${JSON.stringify(notified)}`)
|
||||
}
|
||||
} catch (err) {
|
||||
fail(err.message)
|
||||
} finally {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`\nFAILED: ${failures} parity problem(s)`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\nOK: demo/real mesh + federation RPC parity holds')
|
||||
@@ -44,11 +44,6 @@
|
||||
<!-- Nudge to back up the Lightning seed once a wallet exists (any page) -->
|
||||
<LndSeedBackupPrompt />
|
||||
|
||||
<!-- "You need a Lightning node" install prompt. Global because it is
|
||||
raised from inside other modals (wallet Receive, the Web5 sheet, the
|
||||
app launcher's paywall) — one instance, shared state. -->
|
||||
<LightningRequiredModal />
|
||||
|
||||
<!-- Global persistent audio player (bottom bar) -->
|
||||
<GlobalAudioPlayer />
|
||||
|
||||
@@ -103,7 +98,6 @@ import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue'
|
||||
import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue'
|
||||
import ExternalExplorerModal from './components/ExternalExplorerModal.vue'
|
||||
import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue'
|
||||
import LightningRequiredModal from './components/LightningRequiredModal.vue'
|
||||
import { useMeshStore } from './stores/mesh'
|
||||
|
||||
import { useControllerNav } from '@/composables/useControllerNav'
|
||||
|
||||
@@ -196,9 +196,6 @@ import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||||
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
|
||||
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
interface PaymentRequest {
|
||||
request_id: string
|
||||
@@ -353,10 +350,6 @@ function openInNewTab() {
|
||||
// (so the tap silently no-ops). The native bridge is reliable; fall back to
|
||||
// window.open in a plain mobile browser.
|
||||
const native = (window as any).ArchipelagoNative
|
||||
if (native && typeof native.openInAppEx === 'function' && store.title) {
|
||||
native.openInAppEx(store.url, '', store.title)
|
||||
return
|
||||
}
|
||||
if (native && typeof native.openInApp === 'function') {
|
||||
native.openInApp(store.url)
|
||||
return
|
||||
@@ -531,9 +524,6 @@ async function approvePayment() {
|
||||
})
|
||||
receipt = { method: 'ecash', token: res.token, amount_sats: res.amount_sats }
|
||||
} else if (method === 'lightning') {
|
||||
// Both arms below need a Lightning node — paying an invoice and minting
|
||||
// one. With none installed, raise the install modal instead of failing.
|
||||
if (!lightning.requireLightningNode()) return
|
||||
if (pay.invoice) {
|
||||
// Tracked to a real terminal state — slow routing is not a failure.
|
||||
const res = await rpcClient.payLightningInvoice({ payment_request: pay.invoice })
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
<template>
|
||||
<!-- z-3600: this is raised from INSIDE another modal (Receive, the Web5
|
||||
send/receive sheet, the app launcher's paywall), so it must sit above
|
||||
the standard modal layer (3000) but below the app overlay (4000) —
|
||||
same reasoning as ExternalExplorerModal. -->
|
||||
<BaseModal
|
||||
:show="lightning.show.value"
|
||||
title="Lightning node required"
|
||||
max-width="max-w-md"
|
||||
z-index="z-[3600]"
|
||||
@close="onClose"
|
||||
>
|
||||
<p class="text-sm text-white/70 leading-relaxed">
|
||||
Creating a Lightning invoice needs a Lightning node running on this
|
||||
Archipelago node. You don't have one installed yet — pick an
|
||||
implementation below and it'll be installed for you.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<div
|
||||
v-for="node in nodes"
|
||||
:key="node.id"
|
||||
class="rounded-xl border border-white/10 bg-white/[0.04] p-3"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-white">{{ node.name }}</span>
|
||||
<span
|
||||
v-if="!node.available"
|
||||
class="text-[10px] uppercase tracking-wide px-2 py-0.5 rounded-full bg-white/10 text-white/50"
|
||||
>Coming soon</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 mt-0.5 leading-relaxed">{{ node.blurb }}</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="node.available"
|
||||
:disabled="installing !== null"
|
||||
class="shrink-0 glass-button glass-button-warning px-3.5 py-1.5 rounded-lg text-xs font-medium disabled:opacity-50"
|
||||
@click="install(node.id)"
|
||||
>
|
||||
{{ installing === node.id ? 'Installing…' : 'Install' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
disabled
|
||||
class="shrink-0 glass-button px-3.5 py-1.5 rounded-lg text-xs opacity-40 cursor-not-allowed"
|
||||
>Install</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mt-3 alert-error text-sm">{{ error }}</p>
|
||||
|
||||
<p v-if="installing" class="mt-3 text-xs text-white/50 leading-relaxed">
|
||||
This takes a few minutes — the image has to be pulled and the node
|
||||
started. You can close this and carry on; the install keeps running.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2 mt-6">
|
||||
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="onClose">
|
||||
{{ installing ? 'Close' : 'Not now' }}
|
||||
</button>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||
|
||||
interface NodeChoice {
|
||||
id: string
|
||||
name: string
|
||||
blurb: string
|
||||
/** False renders the row as "Coming soon" with a dead Install button. */
|
||||
available: boolean
|
||||
}
|
||||
|
||||
// Core Lightning is listed deliberately while unavailable: the choice is the
|
||||
// point of this modal, and showing it greyed tells the user the platform is
|
||||
// not LND-only. When its app id lands in the catalog, flip `available` here
|
||||
// and add the id to LIGHTNING_NODE_APP_IDS — nothing else changes.
|
||||
const nodes: NodeChoice[] = [
|
||||
{
|
||||
id: 'lnd',
|
||||
name: 'LND',
|
||||
blurb: 'Lightning Network Daemon. The implementation Archipelago ships today — wallet, channels and payments are wired to it.',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 'core-lightning',
|
||||
name: 'Core Lightning',
|
||||
blurb: 'Blockstream\'s implementation. Not packaged yet — it will appear here as a choice once it ships.',
|
||||
available: false,
|
||||
},
|
||||
]
|
||||
|
||||
const appStore = useAppStore()
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
/** App id currently installing, or null. */
|
||||
const installing = ref<string | null>(null)
|
||||
const error = ref('')
|
||||
|
||||
async function install(id: string) {
|
||||
installing.value = id
|
||||
error.value = ''
|
||||
try {
|
||||
await appStore.installPackage(id, '', 'latest')
|
||||
// The gate reads install state from the package list, so once the install
|
||||
// lands the modal simply stops being raised. Close on success rather than
|
||||
// holding the user here watching a spinner.
|
||||
lightning.close()
|
||||
} catch (err) {
|
||||
error.value = `Install failed: ${err instanceof Error ? err.message : 'Unknown error'}`
|
||||
} finally {
|
||||
installing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
error.value = ''
|
||||
lightning.close()
|
||||
}
|
||||
</script>
|
||||
@@ -91,10 +91,8 @@ import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
||||
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||
|
||||
const { t } = useI18n()
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
@@ -156,10 +154,6 @@ async function receive() {
|
||||
error.value = ''
|
||||
try {
|
||||
if (receiveMethod.value === 'lightning') {
|
||||
// No Lightning implementation installed is not an error — it is a
|
||||
// missing prerequisite. Raise the install modal instead of letting
|
||||
// lnd.createinvoice fail with connection-refused (FED-08 follow-up).
|
||||
if (!lightning.requireLightningNode()) return
|
||||
if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return }
|
||||
const res = await rpcClient.call<{ payment_request: string }>({
|
||||
method: 'lnd.createinvoice',
|
||||
|
||||
@@ -14,18 +14,13 @@ import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Visual size: 'default' matches the screensaver; 'compact' drops the
|
||||
* min-width breakpoints (useful inside overlays on narrower canvases);
|
||||
* 'badge' is the paid-tick size that fits inside a modal card (FED-06). */
|
||||
size?: 'default' | 'compact' | 'badge'
|
||||
* min-width breakpoints (useful inside overlays on narrower canvases). */
|
||||
size?: 'default' | 'compact'
|
||||
/** Override segment count. Defaults to 48 (screensaver standard). */
|
||||
segmentCount?: number
|
||||
}>(), { size: 'default', segmentCount: 48 })
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
if (props.size === 'compact') return 'viz-ring-compact'
|
||||
if (props.size === 'badge') return 'viz-ring-badge'
|
||||
return 'viz-ring-default'
|
||||
})
|
||||
const sizeClass = computed(() => props.size === 'compact' ? 'viz-ring-compact' : 'viz-ring-default')
|
||||
|
||||
function getSegmentStyle(i: number) {
|
||||
const deg = (i / props.segmentCount) * 360
|
||||
@@ -75,21 +70,6 @@ function getSegmentStyle(i: number) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Paid-tick badge (FED-06) — sized to sit inside a modal card without
|
||||
clipping, unlike the compact variant (240-320px against a ~112px core). */
|
||||
.viz-ring-badge {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
--viz-radius: 80px;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.viz-ring-badge {
|
||||
width: 192px;
|
||||
height: 192px;
|
||||
--viz-radius: 96px;
|
||||
}
|
||||
}
|
||||
|
||||
.viz-segment {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
@@ -131,13 +111,4 @@ function getSegmentStyle(i: number) {
|
||||
92.9%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
100% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); }
|
||||
}
|
||||
|
||||
/* Site-wide reduced-motion convention — applies to every size variant, so
|
||||
the ring holds a static, legible pose instead of pulsing. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.viz-segment {
|
||||
animation: none;
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
<!-- ============ SUCCESS PANE — the payment's moment, not a footnote ============ -->
|
||||
<template v-if="successInfo">
|
||||
<div class="text-center py-4">
|
||||
<div class="send-success-badge mx-auto mb-6">
|
||||
<ScreensaverRing size="badge" />
|
||||
<div class="send-success-burst">
|
||||
<div class="burst-core">
|
||||
<svg class="w-14 h-14 text-green-400 burst-check" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="send-success-burst mx-auto mb-6">
|
||||
<span class="burst-ring"></span>
|
||||
<span class="burst-ring burst-ring-2"></span>
|
||||
<span class="burst-ring burst-ring-3"></span>
|
||||
<div class="burst-core">
|
||||
<svg class="w-14 h-14 text-green-400 burst-check" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -259,7 +259,6 @@ import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import ScreensaverRing from '@/components/ScreensaverRing.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -682,27 +681,10 @@ async function send() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Success badge (FED-06) — the branded ScreensaverRing carries the motion,
|
||||
with the emerald pop-in check centred over it. */
|
||||
.send-success-badge {
|
||||
position: relative;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.send-success-badge {
|
||||
width: 192px;
|
||||
height: 192px;
|
||||
}
|
||||
}
|
||||
/* Success burst — pop-in check inside radiating rings, wallet palette
|
||||
(emerald for the settled payment, one Archipelago-orange ring). */
|
||||
.send-success-burst {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
position: relative;
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
}
|
||||
@@ -722,6 +704,20 @@ async function send() {
|
||||
stroke-dashoffset: 32;
|
||||
animation: burst-draw 0.45s ease-out 0.25s forwards;
|
||||
}
|
||||
.burst-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid rgba(16, 185, 129, 0.45);
|
||||
animation: burst-ripple 1.8s ease-out infinite;
|
||||
}
|
||||
.burst-ring-2 {
|
||||
animation-delay: 0.45s;
|
||||
}
|
||||
.burst-ring-3 {
|
||||
animation-delay: 0.9s;
|
||||
border-color: rgba(249, 115, 22, 0.35);
|
||||
}
|
||||
@keyframes burst-pop {
|
||||
from { transform: scale(0.3); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
@@ -729,8 +725,13 @@ async function send() {
|
||||
@keyframes burst-draw {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
@keyframes burst-ripple {
|
||||
0% { transform: scale(0.7); opacity: 0.9; }
|
||||
100% { transform: scale(2); opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.burst-core, .burst-check { animation: none; }
|
||||
.burst-core, .burst-check, .burst-ring { animation: none; }
|
||||
.burst-check { stroke-dashoffset: 0; }
|
||||
.burst-ring { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="transactions.length === 0" class="flex items-center justify-center py-12">
|
||||
<div v-if="transactions.length === 0" class="flex-1 flex items-center justify-center py-12">
|
||||
<p class="text-white/40 text-sm">{{ t('transactions.noTransactionsYet') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredTransactions.length === 0" class="flex items-center justify-center py-12">
|
||||
<div v-else-if="filteredTransactions.length === 0" class="flex-1 flex items-center justify-center py-12">
|
||||
<p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
class="text-sm font-medium"
|
||||
:class="tx.direction === 'incoming' ? 'text-green-400' : 'text-red-400'"
|
||||
>
|
||||
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ displayAmount(tx).toLocaleString() }} sats
|
||||
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ Math.abs(tx.amount_sats).toLocaleString() }} sats
|
||||
</span>
|
||||
<span
|
||||
v-if="isOnchain(tx)"
|
||||
@@ -88,7 +88,6 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<p class="text-[11px] text-white/40 font-mono truncate">{{ tx.tx_hash }}</p>
|
||||
<span v-if="feeFor(tx)" class="text-[10px] text-white/35 shrink-0">fee {{ feeFor(tx).toLocaleString() }} sats</span>
|
||||
<span v-if="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,18 +166,6 @@ function isOnchain(tx: WalletTransaction): boolean {
|
||||
return !tx.kind || tx.kind === 'onchain'
|
||||
}
|
||||
|
||||
function feeFor(tx: WalletTransaction): number {
|
||||
return tx.direction === 'outgoing' ? (tx.total_fees || 0) : 0
|
||||
}
|
||||
|
||||
/** Outgoing rows show the amount the RECIPIENT got (gross minus fee); the fee
|
||||
* itself is broken out on its own tag. Incoming rows are untouched. */
|
||||
function displayAmount(tx: WalletTransaction): number {
|
||||
const gross = Math.abs(tx.amount_sats)
|
||||
const fee = feeFor(tx)
|
||||
return fee > 0 && gross > fee ? gross - fee : gross
|
||||
}
|
||||
|
||||
function kindLabel(tx: WalletTransaction): string {
|
||||
if (tx.kind === 'lightning') return '⚡ Lightning'
|
||||
if (tx.kind === 'cashu') return 'Cashu'
|
||||
|
||||
@@ -229,13 +229,10 @@
|
||||
|
||||
<!-- ============ SUCCESS PANE ============ -->
|
||||
<div v-else key="success" class="text-center py-2">
|
||||
<div class="scan-success-badge mx-auto mb-5">
|
||||
<ScreensaverRing size="badge" />
|
||||
<div class="scan-success-core">
|
||||
<svg class="w-14 h-14 text-green-400" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="inline-flex items-center justify-center w-24 h-24 rounded-full mx-auto mb-5 success-ring">
|
||||
<svg class="w-14 h-14 text-green-400" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div v-if="successAmount > 0" class="text-5xl font-black text-green-400 mb-1">
|
||||
@@ -271,7 +268,6 @@ import { rpcClient } from '@/api/rpc-client'
|
||||
import { useAnimatedQRDecoder } from '@/composables/useAnimatedQRDecoder'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
|
||||
import ScreensaverRing from '@/components/ScreensaverRing.vue'
|
||||
|
||||
type Rail = 'onchain' | 'lightning' | 'cashu' | 'fedimint'
|
||||
type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join'
|
||||
@@ -862,33 +858,7 @@ onUnmounted(stopScanning)
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* Paid tick (FED-06) — same composition as SendBitcoinModal: the branded
|
||||
ScreensaverRing badge with the emerald checkmark core centred over it. */
|
||||
.scan-success-badge {
|
||||
position: relative;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.scan-success-badge {
|
||||
width: 192px;
|
||||
height: 192px;
|
||||
}
|
||||
}
|
||||
.scan-success-core {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
.success-ring {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
box-shadow: 0 0 40px rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import MediaLightbox from '../cloud/MediaLightbox.vue'
|
||||
import { usePipSession } from '../../composables/usePipSession'
|
||||
import type { FileBrowserItem } from '../../api/filebrowser-client'
|
||||
|
||||
// jsdom has no picture-in-picture implementation — stub the pieces the
|
||||
// component and the session touch so `enterpictureinpicture` /
|
||||
// `leavepictureinpicture` can be dispatched like a real browser would.
|
||||
beforeEach(() => {
|
||||
// jsdom doesn't implement these either — MediaLightbox's onUnmounted
|
||||
// revokes every cached blob URL, which throws "not implemented" otherwise.
|
||||
if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:stub')
|
||||
if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn()
|
||||
|
||||
Object.defineProperty(document, 'pictureInPictureEnabled', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
})
|
||||
if (!('pictureInPictureElement' in document)) {
|
||||
Object.defineProperty(document, 'pictureInPictureElement', {
|
||||
value: null,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
if (!HTMLVideoElement.prototype.requestPictureInPicture) {
|
||||
HTMLVideoElement.prototype.requestPictureInPicture = async function () {
|
||||
return null as unknown as PictureInPictureWindow
|
||||
}
|
||||
}
|
||||
if (!document.exitPictureInPicture) {
|
||||
document.exitPictureInPicture = async () => {}
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
usePipSession().release()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
const videoItem: FileBrowserItem = {
|
||||
name: 'clip.mp4',
|
||||
path: '/clip.mp4',
|
||||
isDir: false,
|
||||
} as FileBrowserItem
|
||||
|
||||
// MediaLightbox teleports its content to <body>, so its markup lives
|
||||
// outside the mounted wrapper's own root element — query the document
|
||||
// directly rather than through `wrapper.find`.
|
||||
function findVideo(): HTMLVideoElement {
|
||||
const video = document.body.querySelector('video')
|
||||
if (!video) throw new Error('video not rendered')
|
||||
return video as HTMLVideoElement
|
||||
}
|
||||
|
||||
function findBackdrop(): HTMLElement {
|
||||
const backdrop = document.body.querySelector('.lightbox-backdrop')
|
||||
if (!backdrop) throw new Error('backdrop not rendered')
|
||||
return backdrop as HTMLElement
|
||||
}
|
||||
|
||||
async function mountLightbox() {
|
||||
const wrapper = mount(MediaLightbox, {
|
||||
props: {
|
||||
items: [videoItem],
|
||||
startIndex: 0,
|
||||
show: true,
|
||||
fetchBlobUrl: vi.fn().mockResolvedValue('blob:fetch'),
|
||||
streamUrl: vi.fn().mockResolvedValue('blob:stream'),
|
||||
},
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('MediaLightbox picture-in-picture handoff', () => {
|
||||
it('entering PiP emits close exactly once and adopts the video before doing so', async () => {
|
||||
const wrapper = await mountLightbox()
|
||||
const video = findVideo()
|
||||
|
||||
video.dispatchEvent(new Event('enterpictureinpicture'))
|
||||
|
||||
// Adopted synchronously, before the animation/emit has finished — the
|
||||
// video is no longer a descendant of the lightbox's own subtree.
|
||||
expect(usePipSession().active.value).toBe(true)
|
||||
expect(usePipSession().element.value).toBe(video)
|
||||
expect(findBackdrop().contains(video)).toBe(false)
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
|
||||
// Bounded fallback fires the close even without a real transitionend
|
||||
// (jsdom does not run CSS transitions).
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
|
||||
wrapper.unmount()
|
||||
// Still connected to the document after the owner unmounts.
|
||||
expect(document.body.contains(video)).toBe(true)
|
||||
})
|
||||
|
||||
it('applies the handoff class on the PiP path', async () => {
|
||||
await mountLightbox()
|
||||
const video = findVideo()
|
||||
|
||||
video.dispatchEvent(new Event('enterpictureinpicture'))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(findBackdrop().classList.contains('lightbox-pip-handoff')).toBe(true)
|
||||
})
|
||||
|
||||
it('applies no handoff class on a button-driven close', async () => {
|
||||
const wrapper = await mountLightbox()
|
||||
const closeButton = document.body.querySelector(
|
||||
'.lightbox-topbar .lightbox-btn:last-child'
|
||||
) as HTMLButtonElement
|
||||
closeButton.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
expect(findBackdrop().classList.contains('lightbox-pip-handoff')).toBe(false)
|
||||
})
|
||||
|
||||
it('releases the session when picture-in-picture is left', async () => {
|
||||
await mountLightbox()
|
||||
const video = findVideo()
|
||||
|
||||
video.dispatchEvent(new Event('enterpictureinpicture'))
|
||||
expect(usePipSession().active.value).toBe(true)
|
||||
|
||||
video.dispatchEvent(new Event('leavepictureinpicture'))
|
||||
|
||||
expect(usePipSession().active.value).toBe(false)
|
||||
})
|
||||
|
||||
it('does not change props or emits declared by the component', async () => {
|
||||
// Contract guard mirrored from the diff-based acceptance criterion:
|
||||
// this component is used by a second, parallel instance (plan 01-14)
|
||||
// and must keep working with an unmodified prop/emit set.
|
||||
const wrapper = await mountLightbox()
|
||||
expect(wrapper.props('startIndex')).toBe(0)
|
||||
expect(wrapper.props('show')).toBe(true)
|
||||
expect(typeof wrapper.props('fetchBlobUrl')).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import i18n from '@/i18n'
|
||||
import ScreensaverRing from '../ScreensaverRing.vue'
|
||||
import SendBitcoinModal from '../SendBitcoinModal.vue'
|
||||
import WalletScanModal from '../WalletScanModal.vue'
|
||||
|
||||
// Both modals fetch balances/fees on open. None of that is what this suite is
|
||||
// about — stub the transport so mounting is deterministic and offline.
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}))
|
||||
|
||||
// WalletScanModal reaches for camera/QR APIs jsdom does not implement.
|
||||
beforeEach(() => {
|
||||
if (!navigator.mediaDevices) {
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
value: { getUserMedia: vi.fn().mockRejectedValue(new Error('no camera')), enumerateDevices: vi.fn().mockResolvedValue([]) },
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Teleported modal markup outlives the wrapper's root, so clear it between
|
||||
// cases — otherwise one modal's nodes answer the next one's queries.
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
const mountOpts = { props: { show: true }, global: { plugins: [i18n] } }
|
||||
|
||||
describe('paid tick renders the branded ring (FED-06)', () => {
|
||||
it('SendBitcoinModal: payment success shows exactly one badge ring, no ripple burst', async () => {
|
||||
const wrapper = mount(SendBitcoinModal, mountOpts)
|
||||
// Drive the component into its settled-payment state directly — this
|
||||
// suite is about what success *renders*, not how a payment settles.
|
||||
;(wrapper.vm as unknown as Record<string, unknown>).successInfo = {
|
||||
amount: 12345,
|
||||
methodLabel: 'Sent via Lightning',
|
||||
}
|
||||
await flushPromises()
|
||||
|
||||
const rings = wrapper.findAllComponents(ScreensaverRing)
|
||||
expect(rings).toHaveLength(1)
|
||||
expect(rings[0]?.props('size')).toBe('badge')
|
||||
|
||||
// BaseModal teleports its content to <body>, so the rendered markup lives
|
||||
// outside the wrapper's own root element — assert against the document.
|
||||
// The checkmark core survives the ring swap...
|
||||
expect(document.querySelector('.burst-core')).not.toBeNull()
|
||||
expect(document.querySelector('.burst-check')).not.toBeNull()
|
||||
// ...and the CSS ripple elements it replaced are gone entirely.
|
||||
expect(document.querySelectorAll('.burst-ring')).toHaveLength(0)
|
||||
|
||||
// Decoration only: the amount and SENT copy are untouched.
|
||||
expect(document.body.textContent).toContain('12,345')
|
||||
expect(document.body.textContent).toContain('SENT')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('WalletScanModal: success pane shows the same badge ring and keeps its checkmark', async () => {
|
||||
const wrapper = mount(WalletScanModal, mountOpts)
|
||||
;(wrapper.vm as unknown as Record<string, unknown>).pane = 'success'
|
||||
await flushPromises()
|
||||
|
||||
const rings = wrapper.findAllComponents(ScreensaverRing)
|
||||
expect(rings).toHaveLength(1)
|
||||
expect(rings[0]?.props('size')).toBe('badge')
|
||||
expect(document.querySelector('.scan-success-core')).not.toBeNull()
|
||||
expect(document.querySelector('svg path[d="M5 13l4 4L19 7"]')).not.toBeNull()
|
||||
// The plain fixed-size circle the ring replaced is gone.
|
||||
expect(document.querySelectorAll('.success-ring')).toHaveLength(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ScreensaverRing from '../ScreensaverRing.vue'
|
||||
|
||||
// The ring is shared by the screensaver (default), SystemDangerZone (compact)
|
||||
// and — as of FED-06 — the payment-success tick (badge). These assertions pin
|
||||
// the variant mapping so adding a size can never silently re-point an existing
|
||||
// call site at different geometry.
|
||||
describe('ScreensaverRing', () => {
|
||||
it('maps each size variant to its own ring class', () => {
|
||||
expect(mount(ScreensaverRing).classes()).toContain('viz-ring-default')
|
||||
expect(mount(ScreensaverRing, { props: { size: 'compact' } }).classes()).toContain(
|
||||
'viz-ring-compact',
|
||||
)
|
||||
expect(mount(ScreensaverRing, { props: { size: 'badge' } }).classes()).toContain(
|
||||
'viz-ring-badge',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the existing variants off the badge class', () => {
|
||||
expect(mount(ScreensaverRing).classes()).not.toContain('viz-ring-badge')
|
||||
expect(mount(ScreensaverRing, { props: { size: 'compact' } }).classes()).not.toContain(
|
||||
'viz-ring-badge',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders one segment per segmentCount, defaulting to 48', () => {
|
||||
expect(mount(ScreensaverRing).findAll('.viz-segment')).toHaveLength(48)
|
||||
expect(
|
||||
mount(ScreensaverRing, { props: { segmentCount: 12 } }).findAll('.viz-segment'),
|
||||
).toHaveLength(12)
|
||||
expect(
|
||||
mount(ScreensaverRing, { props: { size: 'badge', segmentCount: 24 } }).findAll('.viz-segment'),
|
||||
).toHaveLength(24)
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,6 @@
|
||||
<div
|
||||
v-if="show"
|
||||
class="lightbox-backdrop"
|
||||
:class="{ 'lightbox-pip-handoff': pipHandoff }"
|
||||
@click.self="close"
|
||||
@keydown="onKeydown"
|
||||
tabindex="0"
|
||||
@@ -20,7 +19,7 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="pipAvailable && currentItem && isVideoFile(currentItem)"
|
||||
v-if="pipSupported && currentItem && isVideoFile(currentItem)"
|
||||
class="lightbox-btn"
|
||||
title="Picture-in-picture"
|
||||
@click.stop="togglePip(videoEl)"
|
||||
@@ -85,8 +84,6 @@
|
||||
autoplay
|
||||
@dblclick="toggleFullscreen"
|
||||
@error="onMediaError"
|
||||
@enterpictureinpicture="onEnterPip"
|
||||
@leavepictureinpicture="onLeavePip"
|
||||
/>
|
||||
|
||||
<!-- Audio -->
|
||||
@@ -127,8 +124,7 @@
|
||||
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
||||
import { getFileCategory } from '@/composables/useFileType'
|
||||
import { isPipSupported, togglePip } from '@/utils/pip'
|
||||
import { usePipSession } from '@/composables/usePipSession'
|
||||
import { pipSupported, togglePip } from '@/utils/pip'
|
||||
|
||||
const props = defineProps<{
|
||||
items: FileBrowserItem[]
|
||||
@@ -149,16 +145,6 @@ const currentUrl = ref<string | null>(null)
|
||||
const backdropEl = ref<HTMLElement | null>(null)
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
const pipAvailable = isPipSupported()
|
||||
const pipSession = usePipSession()
|
||||
const pipHandoff = ref(false)
|
||||
// Bounded fallback for the transitionend-driven close below — covers a
|
||||
// browser that skips the transition entirely (including the reduced-motion
|
||||
// path, where the CSS transition duration is zero and transitionend never
|
||||
// fires).
|
||||
const PIP_HANDOFF_FALLBACK_MS = 350
|
||||
let handoffTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const urlCache = new Map<string, string>()
|
||||
|
||||
const mediaItems = computed(() =>
|
||||
@@ -248,38 +234,6 @@ function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function finishPipHandoff() {
|
||||
backdropEl.value?.removeEventListener('transitionend', finishPipHandoff)
|
||||
if (handoffTimer) {
|
||||
clearTimeout(handoffTimer)
|
||||
handoffTimer = null
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Listens for the enterpictureinpicture event itself, rather than inferring
|
||||
// from the button click, so PiP entered by any route (the browser's own
|
||||
// control, a keyboard shortcut) behaves the same. Order matters here and
|
||||
// must stay exactly this: adopt first, animate second, emit last — adopting
|
||||
// first is what makes the element survive the unmount the emit triggers.
|
||||
function onEnterPip() {
|
||||
const video = videoEl.value
|
||||
if (!video) return
|
||||
pipSession.adopt(video)
|
||||
pipHandoff.value = true
|
||||
backdropEl.value?.addEventListener('transitionend', finishPipHandoff, { once: true })
|
||||
handoffTimer = setTimeout(finishPipHandoff, PIP_HANDOFF_FALLBACK_MS)
|
||||
}
|
||||
|
||||
// The session's own leavepictureinpicture listener (attached in
|
||||
// usePipSession at adopt time) is the primary release path, since the
|
||||
// lightbox has normally already unmounted by the time PiP is exited. This
|
||||
// handler only covers the case where the lightbox is somehow still
|
||||
// mounted; release() is idempotent so calling it twice is harmless.
|
||||
function onLeavePip() {
|
||||
pipSession.release()
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
const el = videoEl.value
|
||||
if (!el) return
|
||||
@@ -310,7 +264,6 @@ watch(currentItem, (item) => {
|
||||
|
||||
watch(() => props.show, async (visible) => {
|
||||
if (visible) {
|
||||
pipHandoff.value = false
|
||||
currentIndex.value = props.startIndex
|
||||
const item = mediaItems.value[props.startIndex]
|
||||
if (item) {
|
||||
@@ -469,38 +422,6 @@ onUnmounted(() => {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Picture-in-picture handoff (UIFIX-05): reads as the video moving into the
|
||||
PiP window rather than a dismissal. The backdrop's blur/opacity fall away
|
||||
while the content scales down and drifts toward the bottom-right corner
|
||||
most browsers default the PiP window to — a best-effort convention, since
|
||||
the actual corner is browser- and user-controlled. Applied only on the
|
||||
PiP path; a normal close (button or Escape) never gets this class. */
|
||||
.lightbox-pip-handoff {
|
||||
transition: opacity 0.3s ease, backdrop-filter 0.3s ease;
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0px);
|
||||
-webkit-backdrop-filter: blur(0px);
|
||||
}
|
||||
.lightbox-pip-handoff .lightbox-topbar,
|
||||
.lightbox-pip-handoff .lightbox-nav {
|
||||
transition: opacity 0.3s ease;
|
||||
opacity: 0;
|
||||
}
|
||||
.lightbox-pip-handoff .lightbox-content {
|
||||
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease;
|
||||
transform: scale(0.4) translate(40vw, 40vh);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lightbox-pip-handoff,
|
||||
.lightbox-pip-handoff .lightbox-topbar,
|
||||
.lightbox-pip-handoff .lightbox-nav,
|
||||
.lightbox-pip-handoff .lightbox-content {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.lightbox-content { padding: 3rem 0; }
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useLightningRequired } from '../useLightningRequired'
|
||||
|
||||
// The gate reads install state off the app store's package list. Stub the
|
||||
// store rather than the RPC layer so the test pins the decision, not the
|
||||
// transport.
|
||||
const packages = vi.hoisted(() => ({ value: {} as Record<string, unknown> }))
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
get packages() {
|
||||
return packages.value
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useLightningRequired', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
packages.value = {}
|
||||
// Module-scope `show` is shared by design (one global modal), so reset it
|
||||
// between cases or the first opener leaks into the next test.
|
||||
useLightningRequired().close()
|
||||
})
|
||||
|
||||
it('lets the action through when a Lightning node is installed', () => {
|
||||
packages.value = { lnd: {}, 'bitcoin-knots': {} }
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.hasLightningNode()).toBe(true)
|
||||
expect(lightning.requireLightningNode()).toBe(true)
|
||||
expect(lightning.show.value).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks and raises the install modal when no Lightning node is installed', () => {
|
||||
packages.value = { 'bitcoin-knots': {}, immich: {} }
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.hasLightningNode()).toBe(false)
|
||||
// Returns false so the caller bails WITHOUT surfacing an error string —
|
||||
// that was the whole defect: a missing prerequisite rendered as a failure.
|
||||
expect(lightning.requireLightningNode()).toBe(false)
|
||||
expect(lightning.show.value).toBe(true)
|
||||
})
|
||||
|
||||
it('shares one modal state across call sites', () => {
|
||||
packages.value = {}
|
||||
const a = useLightningRequired()
|
||||
const b = useLightningRequired()
|
||||
|
||||
a.requireLightningNode()
|
||||
expect(b.show.value).toBe(true)
|
||||
b.close()
|
||||
expect(a.show.value).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an empty package list as no Lightning node', () => {
|
||||
packages.value = {}
|
||||
expect(useLightningRequired().hasLightningNode()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,189 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// ── Module boundary stubs (per plan: jsdom has no real blob decoding —
|
||||
// assert on what was requested and what was routed where, not byte content) ──
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const playMock = vi.fn()
|
||||
vi.mock('../useAudioPlayer', () => ({
|
||||
useAudioPlayer: () => ({ play: playMock }),
|
||||
}))
|
||||
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { usePaidItemViewer, paidItemKey, type OwnedItemLike } from '../usePaidItemViewer'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
const IMAGE_ITEM: OwnedItemLike = {
|
||||
onion: 'abc123.onion',
|
||||
content_id: 'content-1',
|
||||
filename: 'photos/sunset.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
size_bytes: 2048,
|
||||
}
|
||||
const VIDEO_ITEM: OwnedItemLike = {
|
||||
onion: 'abc123.onion',
|
||||
content_id: 'content-2',
|
||||
filename: 'clips/holiday.mp4',
|
||||
mime_type: 'video/mp4',
|
||||
size_bytes: 4096,
|
||||
}
|
||||
const AUDIO_ITEM: OwnedItemLike = {
|
||||
onion: 'abc123.onion',
|
||||
content_id: 'content-3',
|
||||
filename: 'music/track.mp3',
|
||||
mime_type: 'audio/mpeg',
|
||||
size_bytes: 1024,
|
||||
}
|
||||
const DOC_ITEM: OwnedItemLike = {
|
||||
onion: 'abc123.onion',
|
||||
content_id: 'content-4',
|
||||
filename: 'docs/invoice.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
size_bytes: 512,
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void
|
||||
let reject!: (e: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('usePaidItemViewer — UIFIX-04 (lightbox routing) + UIFIX-06 (loading/error)', () => {
|
||||
let createObjectURLSpy: ReturnType<typeof vi.fn>
|
||||
let revokeObjectURLSpy: ReturnType<typeof vi.fn>
|
||||
let windowOpenSpy: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
createObjectURLSpy = vi.fn(() => 'blob:mock-url')
|
||||
revokeObjectURLSpy = vi.fn()
|
||||
URL.createObjectURL = createObjectURLSpy as unknown as typeof URL.createObjectURL
|
||||
URL.revokeObjectURL = revokeObjectURLSpy as unknown as typeof URL.revokeObjectURL
|
||||
windowOpenSpy = vi.fn()
|
||||
window.open = windowOpenSpy as unknown as typeof window.open
|
||||
// atob is provided by jsdom; stub it to avoid depending on real base64 semantics.
|
||||
vi.stubGlobal('atob', vi.fn(() => 'binarydata'))
|
||||
})
|
||||
|
||||
it('routes an image mime to the lightbox, not window.open', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(IMAGE_ITEM)
|
||||
|
||||
expect(windowOpenSpy).not.toHaveBeenCalled()
|
||||
expect(viewer.lightboxIndex.value).toBe(0)
|
||||
expect(viewer.lightboxItems.value).toHaveLength(1)
|
||||
expect(viewer.error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('routes a video mime to the lightbox, not window.open', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'video/mp4' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(VIDEO_ITEM)
|
||||
|
||||
expect(windowOpenSpy).not.toHaveBeenCalled()
|
||||
expect(viewer.lightboxIndex.value).toBe(0)
|
||||
expect(viewer.lightboxItems.value[0]?.name).toBe('holiday.mp4')
|
||||
})
|
||||
|
||||
it('routes an audio mime to the audio player, never the lightbox', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'audio/mpeg' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(AUDIO_ITEM)
|
||||
|
||||
expect(playMock).toHaveBeenCalledWith('blob:mock-url', 'track.mp3')
|
||||
expect(viewer.lightboxIndex.value).toBeNull()
|
||||
expect(windowOpenSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the browser tab for a mime with no in-app viewer', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'application/pdf' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(DOC_ITEM)
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith('blob:mock-url', '_blank', 'noopener')
|
||||
expect(viewer.lightboxIndex.value).toBeNull()
|
||||
// Existing revoke timer for the browser-tab path is untouched.
|
||||
vi.advanceTimersByTime(60000)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
})
|
||||
|
||||
it('the synthetic lightbox item name carries the real extension', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(IMAGE_ITEM)
|
||||
|
||||
expect(viewer.lightboxItems.value[0]?.name.endsWith('.jpg')).toBe(true)
|
||||
})
|
||||
|
||||
it('sets opening for the whole duration of the fetch and clears it on success', async () => {
|
||||
const d = deferred<{ data_base64: string; mime_type: string }>()
|
||||
mockedRpc.call.mockReturnValue(d.promise as unknown as ReturnType<typeof rpcClient.call>)
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
const p = viewer.open(IMAGE_ITEM)
|
||||
expect(viewer.opening.value).toBe(paidItemKey(IMAGE_ITEM))
|
||||
|
||||
d.resolve({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
await p
|
||||
|
||||
expect(viewer.opening.value).toBeNull()
|
||||
})
|
||||
|
||||
it('clears opening and does not derive it from a background-refresh flag — it is driven only by the fetch in flight', async () => {
|
||||
// No cached-resource / refreshing concept is wired into this composable at
|
||||
// all: opening only ever reflects the current open() call's own RPC.
|
||||
const d = deferred<{ data_base64: string; mime_type: string }>()
|
||||
mockedRpc.call.mockReturnValue(d.promise as unknown as ReturnType<typeof rpcClient.call>)
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
expect(viewer.opening.value).toBeNull() // idle before any open()
|
||||
const p = viewer.open(IMAGE_ITEM)
|
||||
expect(viewer.opening.value).toBe(paidItemKey(IMAGE_ITEM))
|
||||
d.resolve({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
await p
|
||||
expect(viewer.opening.value).toBeNull() // back to idle the instant the fetch settles — no lingering "refreshing" state
|
||||
})
|
||||
|
||||
it('surfaces a rejected/timed-out fetch as an error, clears opening, and does not throw past the caller', async () => {
|
||||
mockedRpc.call.mockRejectedValue(new Error('Request timeout'))
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await expect(viewer.open(IMAGE_ITEM)).resolves.toBeUndefined()
|
||||
|
||||
expect(viewer.error.value).toBeTruthy()
|
||||
expect(viewer.opening.value).toBeNull()
|
||||
expect(viewer.lightboxIndex.value).toBeNull()
|
||||
})
|
||||
|
||||
it('issues exactly one RPC when open() is called twice in quick succession for the same item', async () => {
|
||||
const d = deferred<{ data_base64: string; mime_type: string }>()
|
||||
mockedRpc.call.mockReturnValue(d.promise as unknown as ReturnType<typeof rpcClient.call>)
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
const p1 = viewer.open(IMAGE_ITEM)
|
||||
const p2 = viewer.open(IMAGE_ITEM)
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledTimes(1)
|
||||
|
||||
d.resolve({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
await Promise.all([p1, p2])
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,123 +0,0 @@
|
||||
import { describe, it, expect, afterEach, beforeEach } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { usePipSession } from '../usePipSession'
|
||||
import { isPipSupported } from '../../utils/pip'
|
||||
|
||||
// jsdom has no picture-in-picture implementation at all, so these three
|
||||
// document/element members don't exist until stubbed — matching the plan's
|
||||
// note that `pipSupported` (module-level) can't be restubbed after import,
|
||||
// which is exactly why `isPipSupported()` exists as a call-time check.
|
||||
function definePipStub(enabled: boolean) {
|
||||
Object.defineProperty(document, 'pictureInPictureEnabled', {
|
||||
value: enabled,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
definePipStub(true)
|
||||
if (!('pictureInPictureElement' in document)) {
|
||||
Object.defineProperty(document, 'pictureInPictureElement', {
|
||||
value: null,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
if (!HTMLVideoElement.prototype.requestPictureInPicture) {
|
||||
HTMLVideoElement.prototype.requestPictureInPicture = async function () {
|
||||
return null as unknown as PictureInPictureWindow
|
||||
}
|
||||
}
|
||||
if (!document.exitPictureInPicture) {
|
||||
document.exitPictureInPicture = async () => {}
|
||||
}
|
||||
})
|
||||
|
||||
function hostEl(): HTMLElement | null {
|
||||
return document.querySelector('[data-pip-session-host]')
|
||||
}
|
||||
|
||||
describe('usePipSession', () => {
|
||||
afterEach(() => {
|
||||
usePipSession().release()
|
||||
})
|
||||
|
||||
it('adopts a video into a body-level host that survives the owner unmounting', () => {
|
||||
const Owner = defineComponent({
|
||||
setup() {
|
||||
return () => h('div', [h('video')])
|
||||
},
|
||||
})
|
||||
const wrapper = mount(Owner, { attachTo: document.body })
|
||||
const video = wrapper.find('video').element as HTMLVideoElement
|
||||
|
||||
const session = usePipSession()
|
||||
session.adopt(video)
|
||||
|
||||
expect(session.active.value).toBe(true)
|
||||
expect(session.element.value).toBe(video)
|
||||
expect(document.body.contains(video)).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
// The owning component is gone; the video must still be connected.
|
||||
expect(document.body.contains(video)).toBe(true)
|
||||
expect(hostEl()?.contains(video)).toBe(true)
|
||||
})
|
||||
|
||||
it('release() removes the adopted element and leaves the host empty', () => {
|
||||
const video = document.createElement('video')
|
||||
const session = usePipSession()
|
||||
session.adopt(video)
|
||||
expect(hostEl()?.contains(video)).toBe(true)
|
||||
|
||||
session.release()
|
||||
|
||||
expect(session.active.value).toBe(false)
|
||||
expect(session.element.value).toBeNull()
|
||||
expect(hostEl()?.contains(video)).toBe(false)
|
||||
expect(hostEl()?.childElementCount).toBe(0)
|
||||
})
|
||||
|
||||
it('creates exactly one host node no matter how many times the composable is called', () => {
|
||||
const video = document.createElement('video')
|
||||
usePipSession().adopt(video)
|
||||
usePipSession()
|
||||
usePipSession()
|
||||
expect(document.querySelectorAll('[data-pip-session-host]').length).toBe(1)
|
||||
})
|
||||
|
||||
it('a leavepictureinpicture event on the adopted element releases the session', () => {
|
||||
const video = document.createElement('video')
|
||||
const session = usePipSession()
|
||||
session.adopt(video)
|
||||
|
||||
video.dispatchEvent(new Event('leavepictureinpicture'))
|
||||
|
||||
expect(session.active.value).toBe(false)
|
||||
expect(session.element.value).toBeNull()
|
||||
})
|
||||
|
||||
it('adopting a second element while one is active releases the first rather than leaking it', () => {
|
||||
const first = document.createElement('video')
|
||||
const second = document.createElement('video')
|
||||
const session = usePipSession()
|
||||
|
||||
session.adopt(first)
|
||||
session.adopt(second)
|
||||
|
||||
expect(session.element.value).toBe(second)
|
||||
expect(hostEl()?.contains(first)).toBe(false)
|
||||
expect(hostEl()?.contains(second)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPipSupported', () => {
|
||||
it('reads document state at call time, not at import time', () => {
|
||||
definePipStub(false)
|
||||
expect(isPipSupported()).toBe(false)
|
||||
|
||||
definePipStub(true)
|
||||
expect(isPipSupported()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
// Shared "this action needs a Lightning node" gate (2026-08-02).
|
||||
//
|
||||
// Creating a Lightning invoice with no Lightning node installed used to fail
|
||||
// at the RPC layer — `lnd.createinvoice` returns a connection-refused error
|
||||
// and the Receive screen showed it as a red failure string. That reads as the
|
||||
// wallet being broken, when in fact the node simply has no Lightning
|
||||
// implementation installed yet.
|
||||
//
|
||||
// Callers ask `requireLightningNode()` BEFORE attempting the call. When no
|
||||
// node is installed it opens the global LightningRequiredModal (which offers
|
||||
// to install one) and returns false, so the caller bails without surfacing an
|
||||
// error at all.
|
||||
//
|
||||
// Detection is install-state, not reachability, on purpose: an installed node
|
||||
// that is merely stopped or still starting is a different situation (wait or
|
||||
// start it) and must NOT be answered with "install a Lightning node".
|
||||
import { ref } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
/** Package ids that provide a Lightning node.
|
||||
*
|
||||
* `lnd` ships today. Core Lightning is the next implementation the modal
|
||||
* offers — when its app id lands in the catalog, add it here and flip its
|
||||
* `available` flag in LightningRequiredModal so the same gate recognises it
|
||||
* with no other change. */
|
||||
export const LIGHTNING_NODE_APP_IDS = ['lnd'] as const
|
||||
|
||||
// Module-scope: one source of truth shared by every caller and the single
|
||||
// global modal mounted in App.vue.
|
||||
const show = ref(false)
|
||||
|
||||
export function useLightningRequired() {
|
||||
const appStore = useAppStore()
|
||||
|
||||
/** True when some Lightning implementation is installed on this node. */
|
||||
function hasLightningNode(): boolean {
|
||||
const installed = Object.keys(appStore.packages ?? {})
|
||||
return installed.some((pkgId) =>
|
||||
(LIGHTNING_NODE_APP_IDS as readonly string[]).includes(pkgId),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a Lightning-only action. Returns true to proceed; returns false and
|
||||
* opens the install modal when this node has no Lightning implementation.
|
||||
*/
|
||||
function requireLightningNode(): boolean {
|
||||
if (hasLightningNode()) return true
|
||||
show.value = true
|
||||
return false
|
||||
}
|
||||
|
||||
function close() {
|
||||
show.value = false
|
||||
}
|
||||
|
||||
return { show, hasLightningNode, requireLightningNode, close }
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
||||
import { useAudioPlayer } from './useAudioPlayer'
|
||||
|
||||
/**
|
||||
* Shape of a purchased item as listed by `content.owned-list` — the minimum
|
||||
* fields this composable needs to fetch and route it. Cloud.vue's `PaidItem`
|
||||
* interface satisfies this structurally.
|
||||
*/
|
||||
export interface OwnedItemLike {
|
||||
onion: string
|
||||
content_id: string
|
||||
filename: string
|
||||
mime_type: string
|
||||
size_bytes: number
|
||||
}
|
||||
|
||||
/** Same key formula the Paid Files row uses for `:key` — one key, one row. */
|
||||
export function paidItemKey(it: OwnedItemLike): string {
|
||||
return it.onion + it.content_id
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch, decode, route-to-viewer and loading/error state for a purchased
|
||||
* item (UIFIX-04 / UIFIX-06).
|
||||
*
|
||||
* - image/video → routed into the caller's MediaLightbox via `lightboxItems`
|
||||
* / `lightboxIndex`, fed a synthetic FileBrowserItem whose blob URL is
|
||||
* served back through `resolveBlobUrl`. The lightbox owns revoking that
|
||||
* URL on unmount — this composable must never schedule a competing revoke
|
||||
* for a URL it has handed to the lightbox.
|
||||
* - audio → today's global bottom-bar player, unchanged.
|
||||
* - anything else (no in-app viewer) → today's browser-tab fallback,
|
||||
* unchanged, including its own revoke timer.
|
||||
*/
|
||||
export function usePaidItemViewer() {
|
||||
const audioPlayer = useAudioPlayer()
|
||||
|
||||
const opening = ref<string | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
const lightboxItems = ref<FileBrowserItem[]>([])
|
||||
const lightboxIndex = ref<number | null>(null)
|
||||
|
||||
// Synthetic-path -> already-fetched blob URL. Populated only for items
|
||||
// routed to the lightbox; resolveBlobUrl reads from here and never fetches.
|
||||
const urlByPath = new Map<string, string>()
|
||||
// One in-flight fetch per item key — a second open() for the same key
|
||||
// while the first is pending returns the same promise instead of issuing
|
||||
// a second RPC (UIFIX-04 concurrency edge / T-01-65).
|
||||
const inFlight = new Map<string, Promise<void>>()
|
||||
|
||||
async function resolveBlobUrl(path: string): Promise<string> {
|
||||
const url = urlByPath.get(path)
|
||||
if (!url) throw new Error('Not resolved')
|
||||
return url
|
||||
}
|
||||
|
||||
function runOpen(it: OwnedItemLike, key: string): Promise<void> {
|
||||
return (async () => {
|
||||
opening.value = key
|
||||
error.value = null
|
||||
try {
|
||||
const res = await rpcClient.call<{ data_base64?: string; data?: string; mime_type?: string }>({
|
||||
method: 'content.owned-get',
|
||||
params: { onion: it.onion, content_id: it.content_id },
|
||||
timeout: 60000,
|
||||
})
|
||||
const b64 = res.data_base64 || res.data
|
||||
if (!b64) {
|
||||
error.value = "Couldn't open this file — the peer returned no data."
|
||||
return
|
||||
}
|
||||
const bin = atob(b64)
|
||||
const arr = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
|
||||
const mime = res.mime_type || it.mime_type
|
||||
const url = URL.createObjectURL(new Blob([arr], { type: mime }))
|
||||
|
||||
// Music ALWAYS plays in the global bottom-bar player — never a
|
||||
// lightbox (blob URL stays alive for the bar; it owns playback now).
|
||||
if (mime.startsWith('audio/')) {
|
||||
audioPlayer.play(url, it.filename.split('/').pop() || it.filename)
|
||||
return
|
||||
}
|
||||
|
||||
if (mime.startsWith('image/') || mime.startsWith('video/')) {
|
||||
const basename = it.filename.split('/').pop() || it.filename
|
||||
const path = `paid://${key}`
|
||||
urlByPath.set(path, url)
|
||||
const synthetic: FileBrowserItem = {
|
||||
name: basename,
|
||||
path,
|
||||
size: it.size_bytes,
|
||||
modified: '',
|
||||
isDir: false,
|
||||
type: mime,
|
||||
extension: basename.includes('.') ? basename.split('.').pop()!.toLowerCase() : '',
|
||||
}
|
||||
lightboxItems.value = [synthetic]
|
||||
lightboxIndex.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
// No in-app viewer (documents, etc.) — keep today's behaviour.
|
||||
window.open(url, '_blank', 'noopener')
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
||||
} catch {
|
||||
error.value = "Couldn't open this file — it may be unavailable right now."
|
||||
} finally {
|
||||
opening.value = null
|
||||
inFlight.delete(key)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
function open(it: OwnedItemLike): Promise<void> {
|
||||
const key = paidItemKey(it)
|
||||
const existing = inFlight.get(key)
|
||||
if (existing) return existing
|
||||
const task = runOpen(it, key)
|
||||
inFlight.set(key, task)
|
||||
return task
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
lightboxIndex.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
opening,
|
||||
error,
|
||||
lightboxItems,
|
||||
lightboxIndex,
|
||||
resolveBlobUrl,
|
||||
open,
|
||||
closeLightbox,
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
/** Singleton picture-in-picture session.
|
||||
*
|
||||
* A video handed to `adopt()` is moved into a body-level custodial host so
|
||||
* that it survives the unmount of whatever view rendered it — a Teleport
|
||||
* and a KeepAlive'd view both move their subtree on deactivation, and a
|
||||
* moved element is a removed element as far as the picture-in-picture spec
|
||||
* is concerned. Owning the element at the document level sidesteps that
|
||||
* entirely: nothing about where the video *used* to live can touch it once
|
||||
* it has been adopted.
|
||||
*
|
||||
* Module singleton, no Vue lifecycle hooks — a lifecycle hook in a bare
|
||||
* composable silently no-ops outside a component's setup(). */
|
||||
|
||||
const active = ref(false)
|
||||
const element = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
let host: HTMLDivElement | null = null
|
||||
let leaveHandler: (() => void) | null = null
|
||||
|
||||
function ensureHost(): HTMLDivElement {
|
||||
if (host) return host
|
||||
const el = document.createElement('div')
|
||||
el.setAttribute('data-pip-session-host', '')
|
||||
el.setAttribute('aria-hidden', 'true')
|
||||
el.style.position = 'fixed'
|
||||
el.style.top = '0'
|
||||
el.style.left = '0'
|
||||
el.style.width = '1px'
|
||||
el.style.height = '1px'
|
||||
el.style.overflow = 'hidden'
|
||||
el.style.opacity = '0'
|
||||
el.style.pointerEvents = 'none'
|
||||
el.style.zIndex = '-1'
|
||||
document.body.appendChild(el)
|
||||
host = el
|
||||
return el
|
||||
}
|
||||
|
||||
function release(): void {
|
||||
const video = element.value
|
||||
if (video) {
|
||||
if (leaveHandler) video.removeEventListener('leavepictureinpicture', leaveHandler)
|
||||
video.pause()
|
||||
video.removeAttribute('src')
|
||||
video.src = ''
|
||||
video.load()
|
||||
if (video.parentNode) video.parentNode.removeChild(video)
|
||||
}
|
||||
leaveHandler = null
|
||||
element.value = null
|
||||
active.value = false
|
||||
}
|
||||
|
||||
function adopt(video: HTMLVideoElement): void {
|
||||
if (active.value && element.value && element.value !== video) {
|
||||
release()
|
||||
}
|
||||
const hostEl = ensureHost()
|
||||
hostEl.appendChild(video)
|
||||
element.value = video
|
||||
active.value = true
|
||||
leaveHandler = () => release()
|
||||
video.addEventListener('leavepictureinpicture', leaveHandler)
|
||||
}
|
||||
|
||||
export function usePipSession() {
|
||||
return {
|
||||
active: readonly(active),
|
||||
element: readonly(element),
|
||||
adopt,
|
||||
release,
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,9 @@ import { ref, watch } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { recordAppLaunch } from '@/utils/appUsage'
|
||||
import { requestExternalOpen } from '@/api/remote-relay'
|
||||
import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils/openExternal'
|
||||
import { openInAppOrNewTab, isCompanionApp } from '@/utils/openExternal'
|
||||
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
||||
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
||||
|
||||
/**
|
||||
@@ -201,17 +200,6 @@ export interface NostrConsentRequest {
|
||||
reject: () => void
|
||||
}
|
||||
|
||||
/** App identity (catalog icon + display name) for the companion's native
|
||||
* branded loader. Undefined when the app isn't in package-data. */
|
||||
function launchMeta(appId: string): InAppLaunchMeta | undefined {
|
||||
const pkg = useAppStore().data?.['package-data']?.[appId]
|
||||
if (!pkg) return undefined
|
||||
return {
|
||||
iconUrl: resolveAppIcon(appId, pkg),
|
||||
name: pkg.manifest?.title || appId,
|
||||
}
|
||||
}
|
||||
|
||||
export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const isOpen = ref(false)
|
||||
const url = ref('')
|
||||
@@ -237,7 +225,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
|
||||
const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
|
||||
if (launchUrl) {
|
||||
openInAppOrNewTab(launchUrl, launchMeta(appId))
|
||||
openInAppOrNewTab(launchUrl)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -247,7 +235,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
if (IS_DEMO && isDemoExternal(appId)) {
|
||||
const ext = demoAppUrl(appId)
|
||||
if (ext) {
|
||||
if (mobile) openInAppOrNewTab(ext, launchMeta(appId))
|
||||
if (mobile) openInAppOrNewTab(ext)
|
||||
else openExternal(ext)
|
||||
return
|
||||
}
|
||||
@@ -260,7 +248,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
if (NEW_TAB_APP_IDS.has(appId) && !(IS_DEMO && isDemoApp(appId))) {
|
||||
const launchUrl = directAppUrl(appId)
|
||||
if (launchUrl) {
|
||||
if (mobile) openInAppOrNewTab(launchUrl, launchMeta(appId))
|
||||
if (mobile) openInAppOrNewTab(launchUrl)
|
||||
else openExternal(launchUrl)
|
||||
return
|
||||
}
|
||||
@@ -339,7 +327,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
// Companion app: never fall through to the iframe overlay — hand the URL
|
||||
// to the native in-app WebView instead (see openSession).
|
||||
if (!IS_DEMO && isCompanionApp()) {
|
||||
openInAppOrNewTab(launchUrl, resolvedId ? launchMeta(resolvedId) : undefined)
|
||||
openInAppOrNewTab(launchUrl)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1452,24 +1452,6 @@ html.kiosk-safe-area #app {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Horizontal filter-pill rail: single row, swipes sideways on narrow
|
||||
screens, no visible scrollbar — pills never wrap or squish. */
|
||||
.pill-rail {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.pill-rail::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.pill-rail > * {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for glass containers */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
|
||||
@@ -12,15 +12,6 @@
|
||||
interface ArchipelagoNativeBridge {
|
||||
openExternal?: (url: string) => void
|
||||
openInApp?: (url: string) => void
|
||||
/** Richer launch (companion ≥0.5.26): catalog icon + display name drive the
|
||||
* native branded loader instead of the site favicon. */
|
||||
openInAppEx?: (url: string, iconUrl: string, name: string) => void
|
||||
}
|
||||
|
||||
/** Optional app identity for the native loading screen. */
|
||||
export interface InAppLaunchMeta {
|
||||
iconUrl?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
function nativeBridge(): ArchipelagoNativeBridge | undefined {
|
||||
@@ -55,15 +46,9 @@ export function openExternalUrl(url: string): void {
|
||||
* inside Archipelago with the native back/forward/reload/close controls.
|
||||
* - Plain mobile browser (PWA): open directly in a new browser tab.
|
||||
*/
|
||||
export function openInAppOrNewTab(url: string, meta?: InAppLaunchMeta): void {
|
||||
export function openInAppOrNewTab(url: string): void {
|
||||
if (!url) return
|
||||
const native = nativeBridge()
|
||||
if (native && typeof native.openInAppEx === 'function' && (meta?.iconUrl || meta?.name)) {
|
||||
// Absolutize the icon path so the native shell can fetch it directly.
|
||||
const icon = meta.iconUrl ? new URL(meta.iconUrl, window.location.origin).href : ''
|
||||
native.openInAppEx(url, icon, meta.name ?? '')
|
||||
return
|
||||
}
|
||||
if (native && typeof native.openInApp === 'function') {
|
||||
native.openInApp(url)
|
||||
return
|
||||
|
||||
@@ -8,17 +8,6 @@ export const pipSupported =
|
||||
'pictureInPictureEnabled' in document &&
|
||||
document.pictureInPictureEnabled
|
||||
|
||||
/** Same three checks as `pipSupported`, but evaluated at call time rather
|
||||
* than import time — lets a test stub support before or after this module
|
||||
* is imported, and lets a component re-check support on demand. */
|
||||
export function isPipSupported(): boolean {
|
||||
return (
|
||||
typeof document !== 'undefined' &&
|
||||
'pictureInPictureEnabled' in document &&
|
||||
document.pictureInPictureEnabled
|
||||
)
|
||||
}
|
||||
|
||||
export async function togglePip(video: HTMLVideoElement | null | undefined): Promise<void> {
|
||||
if (!video || !pipSupported) return
|
||||
try {
|
||||
|
||||
+63
-28
@@ -262,36 +262,42 @@
|
||||
@confirm="onConfirmUninstall"
|
||||
/>
|
||||
|
||||
<!-- House modal (BaseModal glass-card), not a hand-rolled panel: the old
|
||||
one painted its own rgba(8,10,18,.98) navy card, which read as blue
|
||||
against every other modal in the app. BaseModal also brings the
|
||||
standard scroll contract, Esc/focus handling and body scroll lock. -->
|
||||
<BaseModal
|
||||
:show="credentialModal.show"
|
||||
:title="credentialModal.title"
|
||||
max-width="max-w-lg"
|
||||
z-index="z-[2700]"
|
||||
@close="closeCredentialModal"
|
||||
>
|
||||
<p v-if="credentialModal.description" class="text-sm text-white/55 -mt-1 mb-4">
|
||||
{{ credentialModal.description }}
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="credentialModal.show"
|
||||
class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/80 backdrop-blur-md p-4"
|
||||
@click.self="closeCredentialModal"
|
||||
>
|
||||
<div class="credential-modal-panel">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
|
||||
<p class="text-sm text-white/55 mt-1">{{ credentialModal.description }}</p>
|
||||
</div>
|
||||
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeCredentialModal">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="credential-modal-body space-y-3">
|
||||
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="credential-modal-actions mt-5 flex flex-col sm:flex-row gap-3">
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg" @click="closeCredentialModal">Cancel</button>
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg font-semibold" @click="continueCredentialLaunch">Continue to app</button>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg" @click="closeCredentialModal">Cancel</button>
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg font-semibold" @click="continueCredentialLaunch">Continue to app</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
@@ -382,7 +388,6 @@ import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { type AppCredential, type AppCredentialsResponse, type PackageDataEntry, type PackageState } from '@/types/api'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import AppCard from './apps/AppCard.vue'
|
||||
import AppIconGrid from './apps/AppIconGrid.vue'
|
||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||
@@ -905,4 +910,34 @@ async function submitSideload() {
|
||||
}
|
||||
.sideload-input::placeholder { color: rgba(255, 255, 255, 0.38); }
|
||||
.sideload-input:focus { border-color: rgba(255, 255, 255, 0.38); }
|
||||
.credential-modal-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
/* Centered card that never exceeds the visible viewport (minus safe areas),
|
||||
matching the wallet receive modal / AppIconGrid credential modal. The body
|
||||
scrolls if content overflows rather than the panel stretching edge-to-edge. */
|
||||
max-height: calc(
|
||||
100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) -
|
||||
var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem
|
||||
);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(8, 10, 18, 0.98);
|
||||
padding: 1.25rem;
|
||||
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.credential-modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.credential-modal-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -161,9 +161,8 @@
|
||||
<div
|
||||
v-for="it in paidItems"
|
||||
:key="it.onion + it.content_id"
|
||||
class="glass-card p-3 flex items-center gap-3 transition-colors"
|
||||
:class="paidItemViewer.opening.value === paidItemKey(it) ? 'cursor-default' : 'cursor-pointer hover:bg-white/5'"
|
||||
@click="paidItemViewer.opening.value === paidItemKey(it) ? null : viewPaidItem(it)"
|
||||
class="glass-card p-3 flex items-center gap-3 cursor-pointer hover:bg-white/5 transition-colors"
|
||||
@click="viewPaidItem(it)"
|
||||
>
|
||||
<span class="text-xl shrink-0">{{ it.mime_type.startsWith('image/') ? '🖼️' : it.mime_type.startsWith('video/') ? '🎬' : it.mime_type.startsWith('audio/') ? '🎵' : '📄' }}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
@@ -174,14 +173,7 @@
|
||||
<span v-if="it.purchased_at"> · {{ new Date(it.purchased_at).toLocaleDateString() }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
v-if="paidItemViewer.opening.value === paidItemKey(it)"
|
||||
class="text-[10px] px-2 py-0.5 rounded-full bg-white/10 text-white/60 shrink-0 flex items-center gap-1.5"
|
||||
>
|
||||
<span class="w-3 h-3 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></span>
|
||||
Opening…
|
||||
</span>
|
||||
<span v-else class="text-[10px] px-2 py-0.5 rounded-full bg-emerald-400/15 text-emerald-300 shrink-0">Paid</span>
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full bg-emerald-400/15 text-emerald-300 shrink-0">Paid</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -406,18 +398,6 @@
|
||||
:stream-url="cloudStore.streamUrl"
|
||||
@close="lightboxIndex = null"
|
||||
/>
|
||||
|
||||
<!-- Media viewer for purchased items (UIFIX-04) — synthetic items fed by
|
||||
usePaidItemViewer, resolved from the already-fetched blob URL. -->
|
||||
<MediaLightbox
|
||||
v-if="paidItemViewer.lightboxIndex.value !== null"
|
||||
:items="paidItemViewer.lightboxItems.value"
|
||||
:start-index="paidItemViewer.lightboxIndex.value"
|
||||
:show="paidItemViewer.lightboxIndex.value !== null"
|
||||
:fetch-blob-url="paidItemViewer.resolveBlobUrl"
|
||||
:stream-url="paidItemViewer.resolveBlobUrl"
|
||||
@close="paidItemViewer.closeLightbox()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -432,7 +412,6 @@ import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-clien
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { getFileCategory } from '../composables/useFileType'
|
||||
import { useAudioPlayer } from '../composables/useAudioPlayer'
|
||||
import { usePaidItemViewer, paidItemKey } from '../composables/usePaidItemViewer'
|
||||
import FileCard from '../components/cloud/FileCard.vue'
|
||||
import ShareModal from '../components/cloud/ShareModal.vue'
|
||||
import MediaLightbox from '../components/cloud/MediaLightbox.vue'
|
||||
@@ -490,13 +469,29 @@ const paidLoading = computed(() => paidResource.entry.loadState === 'loading')
|
||||
function loadPaidItems() {
|
||||
return paidResource.refresh()
|
||||
}
|
||||
// Fetch, decode, route-to-viewer state for a purchased item (UIFIX-04
|
||||
// lightbox routing + UIFIX-06 loading/error surfacing). See the composable
|
||||
// for the mime-routing rules and URL-ownership contract.
|
||||
const paidItemViewer = usePaidItemViewer()
|
||||
async function viewPaidItem(it: PaidItem) {
|
||||
await paidItemViewer.open(it)
|
||||
if (paidItemViewer.error.value) loadError.value = paidItemViewer.error.value
|
||||
try {
|
||||
const res = await rpcClient.call<{ data_base64?: string; data?: string; mime_type?: string }>({
|
||||
method: 'content.owned-get',
|
||||
params: { onion: it.onion, content_id: it.content_id },
|
||||
timeout: 60000,
|
||||
})
|
||||
const b64 = res.data_base64 || res.data
|
||||
if (!b64) return
|
||||
const bin = atob(b64)
|
||||
const arr = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
|
||||
const mime = res.mime_type || it.mime_type
|
||||
const url = URL.createObjectURL(new Blob([arr], { type: mime }))
|
||||
// Music ALWAYS plays in the global bottom-bar player — never a popup/
|
||||
// lightbox (blob URL stays alive for the bar; it owns playback now).
|
||||
if (mime.startsWith('audio/')) {
|
||||
audioPlayer.play(url, it.filename.split('/').pop() || it.filename)
|
||||
return
|
||||
}
|
||||
window.open(url, '_blank', 'noopener')
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
||||
} catch { /* viewer is best-effort; the file is also in the user's folders */ }
|
||||
}
|
||||
watch(activeTab, (t) => { if (t === 'paid') void loadPaidItems() })
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div ref="scrollContainer" class="flex-1 overflow-y-auto overflow-x-hidden px-6 sm:px-8 min-h-0">
|
||||
<div ref="contentWrapper" class="flex flex-col items-center gap-3 sm:gap-4 py-3">
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden px-6 sm:px-8 min-h-0">
|
||||
<div class="flex flex-col items-center gap-3 sm:gap-4 py-3">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div class="flex justify-center mb-4">
|
||||
@@ -95,7 +95,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Checkbox -->
|
||||
<label ref="confirmLabel" class="flex items-center justify-center gap-3 mt-3 cursor-pointer select-none">
|
||||
<label class="flex items-center justify-center gap-3 mt-3 cursor-pointer select-none">
|
||||
<input
|
||||
v-model="confirmed"
|
||||
type="checkbox"
|
||||
@@ -105,22 +105,6 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Transition name="onb-cue-fade">
|
||||
<div v-if="showScrollCue" class="sticky bottom-0 inset-x-0 h-16 flex items-end justify-center pointer-events-none">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/65 to-transparent"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="relative z-10 mb-2 pointer-events-auto inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-full bg-black/60 backdrop-blur-md border border-white/10 text-white/75 text-xs"
|
||||
aria-label="Scroll down to the confirmation checkbox"
|
||||
@click="revealConfirm"
|
||||
>
|
||||
<span>One more step below</span>
|
||||
<svg class="onb-cue-chevron w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer -->
|
||||
@@ -148,46 +132,6 @@ const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const words = ref<string[]>([])
|
||||
|
||||
// Bottom scroll cue (UIFIX-03) — on short viewports the confirmation
|
||||
// checkbox sits below the fold inside the scrolling region while the
|
||||
// Continue button stays pinned, disabled, in the fixed footer below. The
|
||||
// cue is a pure wayfinding affordance: it only appears when the scroll
|
||||
// region actually has more content below the fold than fits, and it never
|
||||
// touches `confirmed` or the Continue button itself.
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const contentWrapper = ref<HTMLElement | null>(null)
|
||||
const confirmLabel = ref<HTMLElement | null>(null)
|
||||
const showScrollCue = ref(false)
|
||||
let cueResizeObserver: ResizeObserver | null = null
|
||||
|
||||
function updateScrollCue() {
|
||||
const container = scrollContainer.value
|
||||
const label = confirmLabel.value
|
||||
if (!container || !label || loading.value || words.value.length === 0 || confirmed.value) {
|
||||
showScrollCue.value = false
|
||||
return
|
||||
}
|
||||
const hasOverflow = container.scrollHeight > container.clientHeight
|
||||
if (!hasOverflow) {
|
||||
showScrollCue.value = false
|
||||
return
|
||||
}
|
||||
// Viewport-relative rects rather than offsetTop/offsetHeight — offsetTop is
|
||||
// relative to the nearest *positioned* ancestor (here, the outer card,
|
||||
// which carries `relative` for its own z-index stacking), not necessarily
|
||||
// this scroll container, so it cannot be trusted to measure "below the
|
||||
// scroll region's visible bottom".
|
||||
const labelBottom = label.getBoundingClientRect().bottom
|
||||
const containerBottom = container.getBoundingClientRect().bottom
|
||||
showScrollCue.value = labelBottom > containerBottom
|
||||
}
|
||||
|
||||
// Wayfinding only — this must never set `confirmed`, never focus/enable the
|
||||
// Continue button, and never call proceed().
|
||||
function revealConfirm() {
|
||||
confirmLabel.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
// Words / QR code view of the seed — words are the default first view.
|
||||
// The QR tab defaults to SeedQR (BIP39 word-index digit stream), the format
|
||||
// hardware wallets like Passport Prime / SeedSigner actually import; plain
|
||||
@@ -282,48 +226,23 @@ watch(confirmed, (val) => {
|
||||
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)
|
||||
})
|
||||
}
|
||||
updateScrollCue()
|
||||
})
|
||||
|
||||
// Words arrive asynchronously (RPC or restored from sessionStorage) and the
|
||||
// word grid changes height when the words/QR tabs switch — both can flip the
|
||||
// scroll region from non-overflowing to overflowing, so re-measure whenever
|
||||
// either happens.
|
||||
watch(words, () => { nextTick(updateScrollCue) })
|
||||
watch(loading, () => { nextTick(updateScrollCue) })
|
||||
|
||||
onMounted(() => {
|
||||
// Restore previously generated seed if navigating back (don't regenerate)
|
||||
const saved = sessionStorage.getItem('_seed_words')
|
||||
let restored = false
|
||||
const setup = () => {
|
||||
scrollContainer.value?.addEventListener('scroll', updateScrollCue)
|
||||
window.addEventListener('resize', updateScrollCue)
|
||||
if (contentWrapper.value && typeof ResizeObserver !== 'undefined') {
|
||||
cueResizeObserver = new ResizeObserver(() => updateScrollCue())
|
||||
cueResizeObserver.observe(contentWrapper.value)
|
||||
}
|
||||
nextTick(updateScrollCue)
|
||||
}
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved)
|
||||
if (Array.isArray(parsed) && parsed.length === 24) {
|
||||
words.value = parsed
|
||||
restored = true
|
||||
return
|
||||
}
|
||||
} catch { /* regenerate */ }
|
||||
}
|
||||
setup()
|
||||
if (!restored) generateSeed()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
stopTimers()
|
||||
scrollContainer.value?.removeEventListener('scroll', updateScrollCue)
|
||||
window.removeEventListener('resize', updateScrollCue)
|
||||
cueResizeObserver?.disconnect()
|
||||
cueResizeObserver = null
|
||||
generateSeed()
|
||||
})
|
||||
onUnmounted(() => { stopTimers() })
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
@@ -340,24 +259,4 @@ function proceed() {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.08); opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Bottom scroll cue (UIFIX-03) — chevron bob + fade transition. */
|
||||
.onb-cue-chevron {
|
||||
animation: onb-cue-bob 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes onb-cue-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(3px); }
|
||||
}
|
||||
.onb-cue-fade-enter-active,
|
||||
.onb-cue-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.onb-cue-fade-enter-from,
|
||||
.onb-cue-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.onb-cue-chevron { animation: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import OnboardingSeedGenerate from '../OnboardingSeedGenerate.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const WORDS = Array.from({ length: 24 }, (_, i) => `word${i + 1}`)
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn(() => Promise.resolve()) }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useNavSounds', () => ({
|
||||
playNavSound: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Set the scroll region's and confirmation label's geometry directly — jsdom
|
||||
// has no layout engine, so scrollHeight/clientHeight/scrollTop are normally 0
|
||||
// and getBoundingClientRect() always returns an all-zero rect, so both must
|
||||
// be driven explicitly.
|
||||
function setGeometry(
|
||||
container: HTMLElement,
|
||||
label: HTMLElement,
|
||||
opts: { scrollHeight: number; clientHeight: number; scrollTop: number; containerBottom: number; labelBottom: number },
|
||||
) {
|
||||
Object.defineProperty(container, 'scrollHeight', { value: opts.scrollHeight, writable: true, configurable: true })
|
||||
Object.defineProperty(container, 'clientHeight', { value: opts.clientHeight, writable: true, configurable: true })
|
||||
Object.defineProperty(container, 'scrollTop', { value: opts.scrollTop, writable: true, configurable: true })
|
||||
container.getBoundingClientRect = () => ({ bottom: opts.containerBottom } as DOMRect)
|
||||
label.getBoundingClientRect = () => ({ bottom: opts.labelBottom } as DOMRect)
|
||||
}
|
||||
|
||||
describe('OnboardingSeedGenerate scroll cue (UIFIX-03)', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() })))
|
||||
vi.mocked(rpcClient.call).mockReset()
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
async function mountWithWords() {
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ words: WORDS })
|
||||
const wrapper = mount(OnboardingSeedGenerate)
|
||||
await flushPromises()
|
||||
await wrapper.vm.$nextTick()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
it('renders no cue when the scroll region reports no overflow', async () => {
|
||||
const wrapper = await mountWithWords()
|
||||
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
|
||||
const label = wrapper.get('label').element as HTMLElement
|
||||
|
||||
setGeometry(container, label, {
|
||||
scrollHeight: 400,
|
||||
clientHeight: 400,
|
||||
scrollTop: 0,
|
||||
containerBottom: 400,
|
||||
labelBottom: 350,
|
||||
})
|
||||
await container.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('One more step below')
|
||||
})
|
||||
|
||||
it('renders the cue when there is overflow and the tickbox is below the fold', async () => {
|
||||
const wrapper = await mountWithWords()
|
||||
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
|
||||
const label = wrapper.get('label').element as HTMLElement
|
||||
|
||||
setGeometry(container, label, {
|
||||
scrollHeight: 800,
|
||||
clientHeight: 400,
|
||||
scrollTop: 0,
|
||||
containerBottom: 400,
|
||||
labelBottom: 750,
|
||||
})
|
||||
await container.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('One more step below')
|
||||
})
|
||||
|
||||
it('removes the cue once scrolling brings the tickbox into view', async () => {
|
||||
const wrapper = await mountWithWords()
|
||||
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
|
||||
const label = wrapper.get('label').element as HTMLElement
|
||||
|
||||
setGeometry(container, label, {
|
||||
scrollHeight: 800,
|
||||
clientHeight: 400,
|
||||
scrollTop: 0,
|
||||
containerBottom: 400,
|
||||
labelBottom: 750,
|
||||
})
|
||||
await container.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).toContain('One more step below')
|
||||
|
||||
// Scroll down: the container's own viewport rect doesn't move, but its
|
||||
// scrolled content does — the label's viewport-relative bottom shifts up
|
||||
// by the scroll delta, bringing it inside the visible window.
|
||||
setGeometry(container, label, {
|
||||
scrollHeight: 800,
|
||||
clientHeight: 400,
|
||||
scrollTop: 400,
|
||||
containerBottom: 400,
|
||||
labelBottom: 350,
|
||||
})
|
||||
await container.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('One more step below')
|
||||
})
|
||||
|
||||
it('activating the cue scrolls the tickbox into view and never touches confirmed', async () => {
|
||||
const wrapper = await mountWithWords()
|
||||
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
|
||||
const label = wrapper.get('label').element as HTMLElement
|
||||
|
||||
setGeometry(container, label, {
|
||||
scrollHeight: 800,
|
||||
clientHeight: 400,
|
||||
scrollTop: 0,
|
||||
containerBottom: 400,
|
||||
labelBottom: 750,
|
||||
})
|
||||
await container.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const cueButton = wrapper.findAll('button').find((b) => b.text().includes('One more step below'))
|
||||
expect(cueButton).toBeDefined()
|
||||
|
||||
await cueButton!.trigger('click')
|
||||
|
||||
expect(label.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' })
|
||||
const checkbox = wrapper.get('input[type="checkbox"]').element as HTMLInputElement
|
||||
expect(checkbox.checked).toBe(false)
|
||||
})
|
||||
|
||||
it('never shows the cue while loading, regardless of overflow', async () => {
|
||||
let resolveCall: (v: { words: string[] }) => void = () => {}
|
||||
vi.mocked(rpcClient.call).mockReturnValue(new Promise((resolve) => { resolveCall = resolve }))
|
||||
const wrapper = mount(OnboardingSeedGenerate)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('One more step below')
|
||||
expect(wrapper.text()).toContain('Generating your seed phrase')
|
||||
|
||||
resolveCall({ words: WORDS })
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
it('removes the cue once the tickbox is ticked', async () => {
|
||||
const wrapper = await mountWithWords()
|
||||
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
|
||||
const label = wrapper.get('label').element as HTMLElement
|
||||
|
||||
setGeometry(container, label, {
|
||||
scrollHeight: 800,
|
||||
clientHeight: 400,
|
||||
scrollTop: 0,
|
||||
containerBottom: 400,
|
||||
labelBottom: 750,
|
||||
})
|
||||
await container.dispatchEvent(new Event('scroll'))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).toContain('One more step below')
|
||||
|
||||
await wrapper.get('input[type="checkbox"]').setValue(true)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('One more step below')
|
||||
})
|
||||
})
|
||||
@@ -91,41 +91,43 @@
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<!-- House modal (BaseModal glass-card), not a hand-rolled panel: the old
|
||||
one painted its own rgba(8,10,18,.98) navy card, which read as blue
|
||||
against every other modal in the app. -->
|
||||
<BaseModal
|
||||
:show="credentialModal.show"
|
||||
:title="credentialModal.title"
|
||||
max-width="max-w-lg"
|
||||
z-index="z-[2700]"
|
||||
@close="closeCredentialModal"
|
||||
>
|
||||
<p v-if="credentialModal.description" class="text-sm text-white/55 -mt-1 mb-4">
|
||||
{{ credentialModal.description }}
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/80 backdrop-blur-md p-4" @click.self="closeCredentialModal">
|
||||
<div class="credential-modal-panel">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
|
||||
<p class="text-sm text-white/55 mt-1">{{ credentialModal.description }}</p>
|
||||
</div>
|
||||
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeCredentialModal">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="credential-modal-body space-y-3">
|
||||
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="credential-modal-actions mt-5 flex flex-col sm:flex-row gap-3">
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg" @click="closeCredentialModal">Cancel</button>
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg font-semibold" @click="continueCredentialLaunch">Continue to app</button>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg" @click="closeCredentialModal">Cancel</button>
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg font-semibold" @click="continueCredentialLaunch">Continue to app</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/types/api'
|
||||
@@ -365,4 +367,33 @@ function scrollToPage(index: number) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
.credential-modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.credential-modal-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
/* Centered card that never exceeds the visible viewport (minus safe areas),
|
||||
matching the wallet receive modal. The body scrolls if content overflows
|
||||
rather than the panel stretching edge-to-edge. */
|
||||
max-height: calc(
|
||||
100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) -
|
||||
var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem
|
||||
);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(8, 10, 18, 0.98);
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.credential-modal-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user