From 0eff666a6cac06e2202a24013eccf45cfea9866f Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 31 Jul 2026 07:43:40 -0400 Subject: [PATCH] docs(01): gap plans 01-11..01-18 for FED-07 + UIFIX-01..06 Phase 1 success criteria 7-13 were added 2026-07-30, after the phase's original 10 plans were written. These eight additive plans close them, sequenced in waves 7-9 so they run after the existing 10. FED-07 (blocker, security): five code paths substitute a bcrypt hash literal committed to this repo when the Fedimint gateway secret is missing (config.rs, dependencies.rs, first-boot-containers.sh, deploy-to-target.sh, deploy-tailscale.sh), and one deploy path substitutes a plaintext password literal. 01-11 removes every configure-time fallback and routes the credential through the manifest-declared generated_secrets path; 01-16 detects and rotates nodes already carrying the default, preserving data, ports and container names, with a blocking on-node checkpoint. UIFIX-01..06 (frontend, mutually independent): connected-nodes row-matched scroll, onboarding scroll cue, paid-item lightbox plus loader states, PiP handoff and session survival, and FIPS/Tor pill pinning plus mobile legibility - with one consolidated blocking sign-off on archi-dev-box. ROADMAP: phase 1 plan count 10 -> 18, new plans appended with waves. Co-Authored-By: Claude Opus 5 (1M context) --- .planning/ROADMAP.md | 19 +- .../01-11-PLAN.md | 311 +++++++++++++++ .../01-12-PLAN.md | 235 +++++++++++ .../01-13-PLAN.md | 267 +++++++++++++ .../01-14-PLAN.md | 291 ++++++++++++++ .../01-15-PLAN.md | 365 ++++++++++++++++++ .../01-16-PLAN.md | 277 +++++++++++++ .../01-17-PLAN.md | 246 ++++++++++++ .../01-18-PLAN.md | 227 +++++++++++ 9 files changed, 2237 insertions(+), 1 deletion(-) create mode 100644 .planning/phases/01-federation-mesh-hardening/01-11-PLAN.md create mode 100644 .planning/phases/01-federation-mesh-hardening/01-12-PLAN.md create mode 100644 .planning/phases/01-federation-mesh-hardening/01-13-PLAN.md create mode 100644 .planning/phases/01-federation-mesh-hardening/01-14-PLAN.md create mode 100644 .planning/phases/01-federation-mesh-hardening/01-15-PLAN.md create mode 100644 .planning/phases/01-federation-mesh-hardening/01-16-PLAN.md create mode 100644 .planning/phases/01-federation-mesh-hardening/01-17-PLAN.md create mode 100644 .planning/phases/01-federation-mesh-hardening/01-18-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 4c68f092..514e5479 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -53,7 +53,7 @@ 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**: 10 plans +**Plans**: 18 plans Plans: @@ -68,6 +68,23 @@ Plans: - [ ] 01-09-PLAN.md — Structured federation/mesh review + dev-pair deploy (FED-03) - [ ] 01-10-PLAN.md — Consolidated phase verification on the dev pair (FED-01/02/05/06) +**Wave 7** *(gap closure — criteria 7–13, added 2026-07-30 after the original 10 plans were written)* + +- [ ] 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)* + +- [ ] 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)* + +- [ ] 01-18-PLAN.md — Six-fix sign-off on archi-dev-box (UIFIX-01/02/03/04/05/06) + **UI hint**: yes ### Phase 2: UI Performance diff --git a/.planning/phases/01-federation-mesh-hardening/01-11-PLAN.md b/.planning/phases/01-federation-mesh-hardening/01-11-PLAN.md new file mode 100644 index 00000000..2a7286a2 --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-11-PLAN.md @@ -0,0 +1,311 @@ +--- +phase: 01-federation-mesh-hardening +plan: 11 +type: execute +wave: 7 +depends_on: [] +files_modified: + - core/archipelago/src/container/secrets.rs + - core/archipelago/src/api/rpc/package/config.rs + - core/archipelago/src/api/rpc/package/dependencies.rs + - scripts/first-boot-containers.sh + - scripts/deploy-to-target.sh + - scripts/deploy-tailscale.sh + - scripts/reconcile-containers.sh + - scripts/container-specs.sh +autonomous: true +requirements: [FED-07] +gap_closure: true + +must_haves: + truths: + - "A fresh Fedimint gateway install derives its admin credential from the per-install secret the manifest declares, so two nodes installed from the same image never share a gateway password (FED-07)" + - "No code path configures a gateway container with a credential literal carried in this repository — a missing or unreadable gateway secret makes the install fail loudly instead of quietly starting with a shipped default (FED-07 failure-surfacing)" + - "The compromised default hash exists in exactly one place in the tree, as a detection denylist that is never used to configure a container" + - "The gateway credential lives under one canonical secret name across the Rust orchestrator, first-boot, reconcile, and both deploy scripts — a node can no longer end up with the daemon reading one file while the scripts wrote another" + - "A first boot on a host without htpasswd still produces a unique per-install credential rather than falling back to a shipped one (FED-07 empty edge — the ISO path)" + - "Generating the gateway credential twice on the same node is idempotent: the second call leaves the existing value untouched, so a reconcile pass never rotates a working gateway out from under itself (FED-07 adjacency edge)" + prohibitions: + - statement: "No credential value that grants access to a running service may be committed, printed to a log line, embedded in a container image, or written into an ISO/release artifact — the denylist entry retained for detection is a bcrypt hash of an already-public value and is never passed to a container" + category: safety + - statement: "Removing the default MUST NOT silently disable the gateway — an install that cannot obtain a per-install credential reports an error naming the missing secret; it never starts an unauthenticated or partially configured gateway instead" + category: transparency + artifacts: + - path: core/archipelago/src/container/secrets.rs + provides: "Canonical per-install gateway credential accessor plus the known-default denylist" + contains: "KNOWN_DEFAULT_GATEWAY_HASHES" + key_links: + - from: core/archipelago/src/api/rpc/package/config.rs + to: core/archipelago/src/container/secrets.rs + via: "the fedimint-gateway spec builder asks container::secrets for the per-install hash and propagates the error instead of substituting a literal" + pattern: "gateway_bcrypt_hash" + - from: scripts/container-specs.sh + to: core/archipelago/src/container/secrets.rs + via: "both read the same canonical secret filename, so the shell reconcile path and the daemon agree on one credential" + pattern: "fedimint-gateway-hash" +--- + + +Remove every shipped Fedimint gateway credential from the tree and make each install derive its own, +so two nodes flashed from the same ISO never answer to the same gateway password. + +Purpose: FED-07 is a BLOCKER. `apps/fedimint-gateway/manifest.yml` already declares the right thing +(`generated_secrets: fedimint-gateway-hash, kind: bcrypt`), and `container::secrets` already +materialises it per install at 0600 — but five code paths bypass that and substitute a hash literal +committed to this repository when the secret is missing, and one deploy path substitutes a plaintext +password literal. Anyone with a copy of this repo holds the admin credential for every gateway that +ever took one of those fallbacks. The repo's own standing invariant already forbids this: "Secrets are +manifest-declared (`generated_secrets`, materialised by `container::secrets`, 0600/rootless) — never +hardcoded, per-app, or logged." +Output: one canonical per-install accessor, five fallback sites removed, a detection-only denylist, +and tests that fail if a credential literal is ever reintroduced. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md +@apps/fedimint-gateway/manifest.yml +@core/archipelago/src/container/secrets.rs + + +## Artifacts this phase produces + +Created or changed by **this plan**: + +| Symbol | Kind | File | +|---|---|---| +| `KNOWN_DEFAULT_GATEWAY_HASHES` | detection-only denylist constant | `core/archipelago/src/container/secrets.rs` | +| `gateway_bcrypt_hash(secrets_dir) -> Result` | canonical per-install accessor | same | +| `ensure_gateway_credential(secrets_dir) -> Result<()>` | idempotent generator (bcrypt hash + `.pw` sibling) | same | +| fallback-free `fedimint-gateway` spec arm | changed match arm | `core/archipelago/src/api/rpc/package/config.rs` | +| fallback-free `configure_fedimint_lnd` | changed function | `core/archipelago/src/api/rpc/package/dependencies.rs` | +| credential generation without a shipped fallback | changed shell blocks | `scripts/first-boot-containers.sh`, `scripts/reconcile-containers.sh`, `scripts/deploy-to-target.sh`, `scripts/deploy-tailscale.sh` | +| canonical secret-name read with an empty guard | changed shell block | `scripts/container-specs.sh` | + + + + + Task 1: End-to-end — a gateway spec that cannot be built without a per-install credential + 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/container/secrets.rs` — the whole file (about 225 lines). Note + `ensure_one`'s `SecretGenKind::Bcrypt` arm: it already generates a 24-byte random hex password, + bcrypt-hashes it, writes the hash to `` and the plaintext to `.pw`, both 0600 via + the atomic `write_secret` helper. Note the idempotent fast path and the self-heal branch. This + is the behaviour the new accessor must reuse, not reimplement. + - `core/archipelago/src/api/rpc/package/config.rs` lines 596-620 (`read_secret`, which takes a + `default: &str` — the mechanism that makes a fallback literal possible) and lines 1051-1084 + (the `"fedimint-gateway"` match arm inside the app-config table, where the hash is read with a + literal default and then passed to `--bcrypt-password-hash`). + - `core/archipelago/src/api/rpc/package/dependencies.rs` lines 718-769 (`configure_fedimint_lnd`) + — the second site, reading the same secret path directly with `unwrap_or_else` onto the same + literal, then rebuilding the whole argv in LND mode. + - `core/archipelago/src/api/rpc/package/install.rs` lines 583-606 — how `get_app_config` and + `configure_fedimint_lnd` are called during install, so you can see what an error from either + has to propagate through. + - `apps/fedimint-gateway/manifest.yml` — the `generated_secrets` block already declaring + `fedimint-gateway-hash` with `kind: bcrypt`, and the `secret_env` mapping `FEDI_HASH` to it. + The manifest is already correct; this task makes the non-manifest paths agree with it. + + + - `ensure_gateway_credential` on an empty secrets dir writes both the hash file and its `.pw` + sibling, each 0600, and the plaintext verifies against the hash. + - Called a second time on the same dir it changes nothing — the hash read back is byte-identical. + - `gateway_bcrypt_hash` on a dir with no gateway secret returns `Err`, and the error message names + the missing secret file so an operator can act on it. + - `gateway_bcrypt_hash` on a dir whose stored hash is a known-default denylist entry returns `Err` + rather than handing the compromised value back to a caller. + - Two successive fresh generations in two different temp dirs produce two different hashes — the + value is per install, not per build. + + + Write the tests in `secrets.rs`'s existing `mod tests` first and confirm they fail. + + In `core/archipelago/src/container/secrets.rs` add three items. + + First, a private denylist constant `KNOWN_DEFAULT_GATEWAY_HASHES: &[&str]` holding the single + bcrypt hash currently used as a fallback at `config.rs:1054` (copy it from there verbatim). Give + it a doc comment saying it exists only so an install carrying it can be detected and rotated, that + it must never be handed to a container, and that plan 01-16 consumes it for the migration. This is + the one and only place that value may appear in the tree after this plan. + + Second, `pub fn ensure_gateway_credential(secrets_dir: &Path) -> Result<()>` — a thin wrapper that + reuses the existing bcrypt generation path for the `fedimint-gateway-hash` name rather than + duplicating it. Factor the `SecretGenKind::Bcrypt` arm of `ensure_one` into a small helper both + call so there is exactly one bcrypt-generation implementation; keep `ensure_one`'s existing + idempotent fast path and self-heal semantics intact so callers on a reconcile tick never rotate a + working credential. + + Third, `pub fn gateway_bcrypt_hash(secrets_dir: &Path) -> Result` — reads the canonical + hash file, trims it, and returns `Err` with a message naming the file path when it is missing, + empty, or unreadable. Before returning Ok, compare the trimmed value against the denylist and + return `Err` if it matches, with a message saying the install is carrying a publicly known default + and pointing at the rotation path. + + In `config.rs`: change the `"fedimint-gateway"` arm to obtain its hash from + `container::secrets::gateway_bcrypt_hash`, calling `ensure_gateway_credential` first so a fresh + node self-provisions. Because `get_app_config` returns a tuple rather than a `Result`, do not + silently swallow the error — surface it the way the surrounding code surfaces other hard install + failures (an `Err` return threaded to the caller if the signature already allows it, otherwise a + logged error plus an argv the install path rejects; whichever you choose, an install with no + credential must not reach `podman run`). Record the choice and its reason in the SUMMARY. Delete + the `default` parameter from `read_secret` if no other caller needs it; if other callers do, leave + the helper alone and simply stop routing the gateway through it. + + In `dependencies.rs`: `configure_fedimint_lnd` must take the already-resolved hash as a parameter + from its caller rather than re-reading the file with its own fallback, so there is one read site + and one failure point. Update the `install.rs` call accordingly. + + Do not change the gateway's ports, volumes, data directory, network, capabilities, health check, + or any non-credential argv element. This task changes where the credential comes from, nothing + else about how the gateway runs. + + + cd core && cargo test -p archipelago secrets 2>&1 | tail -20 + + + - `cd core && cargo test -p archipelago secrets` exits 0 and its output names at least five test cases covering: fresh generation, idempotence, missing-secret error, denylisted-value error, and two dirs producing two different values. + - `grep -rl 't9YjjxkiktrlYvjajB' --include='*.rs' core/ | wc -l` equals 1, and that one file is `core/archipelago/src/container/secrets.rs`. + - `grep -c 't9YjjxkiktrlYvjajB' core/archipelago/src/api/rpc/package/config.rs` equals 0. + - `grep -c 't9YjjxkiktrlYvjajB' core/archipelago/src/api/rpc/package/dependencies.rs` equals 0. + - `grep -v '^\s*//' core/archipelago/src/api/rpc/package/config.rs | grep -c 'gateway_bcrypt_hash'` is at least 1. + - `grep -v '^\s*//' core/archipelago/src/container/secrets.rs | grep -c 'KNOWN_DEFAULT_GATEWAY_HASHES'` is at least 2 (the definition and its use in the accessor). + - `cd core && cargo build -p archipelago` exits 0. + - `cd core && cargo test -p archipelago` exits 0 — no existing suite regressed. + - The SUMMARY records how a credential-less install is made to fail and why that mechanism was chosen. + + The Rust orchestrator can only configure a gateway with a per-install credential; the compromised literal survives in exactly one detection-only location. + + + + Task 2: The shell install paths generate their own credential instead of shipping one + scripts/first-boot-containers.sh, scripts/reconcile-containers.sh, scripts/deploy-to-target.sh, scripts/deploy-tailscale.sh, scripts/container-specs.sh + `openssl` is on PATH on this machine (the scripts already rely on it for the other per-install database passwords, so the replacement generator introduces no new host dependency) + + - `scripts/first-boot-containers.sh` lines 390-426 — the per-install password loop for + mempool/btcpay/mysql-root (the correct pattern: `openssl rand`, write, chmod 600), then the + gateway block immediately below it that writes `fedimint-gateway-password`, tries `htpasswd` for + the hash, and on a host without `htpasswd` logs a warning and assigns the shipped literal. This + is the ISO first-boot path, so this is the site that put the default on real nodes. + - `scripts/reconcile-containers.sh` lines 690-710 — the same generate-or-skip block, with the same + `htpasswd` dependency and the same two-file naming. + - `scripts/deploy-to-target.sh` lines 1224-1262 — the remote generation block, the + `FEDI_HASH=` export read back over SSH, and the literal fallback when the read comes back empty. + - `scripts/deploy-tailscale.sh` lines 494-513 (generation plus the same literal fallback) and lines + 770-793 (the container-creation block, where a plaintext password fallback is substituted when + the password file cannot be read, and where the argv uses a plaintext password flag rather than + the hash flag every other path uses). + - `scripts/container-specs.sh` lines 60-72 — the shared spec loader, which reads + `fedimint-gateway-hash` (correct name) and escapes `$` so the bcrypt hash survives the + `eval` in `reconcile-containers.sh`'s `build_run_cmd`. Preserve that escaping. + + + Replace the htpasswd-or-fallback pattern everywhere with generation that has no fallback. + + In `first-boot-containers.sh` and `reconcile-containers.sh`: keep generating the plaintext with + `openssl rand`, but when `htpasswd` is unavailable do NOT assign a shipped value. Either compute + the bcrypt hash without `htpasswd` (openssl's `passwd` applet does not emit bcrypt, so if you go + this route use a hasher the host actually has — verify what is present on a node before choosing) + or, if no local hasher exists, leave the hash file absent and let the daemon's + `ensure_gateway_credential` from Task 1 materialise it on the next reconcile tick. The second + option is preferred: it removes the host dependency entirely and puts generation on the one + canonical path. In that case the script must log that the gateway credential will be generated by + the daemon, and must not create a half-provisioned pair of files. + + Unify the naming. The manifest and the daemon use `fedimint-gateway-hash` for the hash and + `fedimint-gateway-hash.pw` for the plaintext; the scripts use `fedimint-gateway-password` for the + plaintext. Converge on the manifest's names. Where a script currently writes + `fedimint-gateway-password`, have it write the `.pw` sibling name instead, and — because + migrations never destroy data — if the legacy file exists and the new one does not, copy the value + across (preserving 0600) rather than regenerating, so a node that already has a working unique + credential keeps it. Never delete the legacy file in this plan; plan 01-16 owns retirement. + + In `deploy-to-target.sh` and `deploy-tailscale.sh`: when the hash read back from the target comes + back empty, abort that step with a clear message instead of substituting the literal. A deploy that + cannot read the target's credential must not create a gateway container. In + `deploy-tailscale.sh`'s container-creation block, remove the plaintext-password fallback on line + 777 entirely and switch that argv to the same hash flag every other path uses, sourced from the + same secret; if the hash is unavailable, skip creating the gateway container and print why. + + In `container-specs.sh`: leave the secret name as-is (it is already canonical) and leave the `$` + escaping intact; only add the empty-value guard so a missing hash produces a skipped spec with a + message rather than an empty hash argument. + + Every changed script must stay `sh`-compatible where it already is and must pass `bash -n`. + + + for f in scripts/first-boot-containers.sh scripts/reconcile-containers.sh scripts/deploy-to-target.sh scripts/deploy-tailscale.sh scripts/container-specs.sh; do bash -n "$f" || exit 1; done; test "$(grep -rl 't9YjjxkiktrlYvjajB' --include='*.sh' scripts/ | wc -l)" -eq 0 + + + - `bash -n` exits 0 for all five scripts. + - `grep -rl 't9YjjxkiktrlYvjajB' --include='*.sh' scripts/ | wc -l` equals 0. + - `grep -c "|| echo 'archipelago'" scripts/deploy-tailscale.sh` equals 0. + - `grep -c -- '--password ' scripts/deploy-tailscale.sh` equals 0 — the gateway argv uses the hash flag, like every other path. + - `grep -rl 't9YjjxkiktrlYvjajB' . --include='*.rs' --include='*.sh' --include='*.yml' --include='*.json' --include='*.md' | wc -l` equals 1 (only the Task 1 denylist). + - `grep -v '^\s*#' scripts/first-boot-containers.sh | grep -c 'htpasswd'` is 0, or the SUMMARY records which hasher replaced it and that it is present on a node. + - `cd core && cargo test -p archipelago` exits 0. + - The SUMMARY records, for each of the five scripts, what the no-credential path now does, and confirms the legacy plaintext filename is copied forward rather than regenerated when present. + + No script in the tree can configure a gateway with a credential that shipped with the repo; a node with no credential gets one generated for it or is told why the gateway was skipped. + + + + +## Planner Assumptions (flagged, unresolved) + +- **Whether the compromised hash's plaintext is publicly recoverable:** the planner did not run + `bcrypt::verify` against candidate plaintexts. The severity of FED-07 does not depend on it (a + shipped hash is a shipped credential regardless), but the migration in plan 01-16 phrases its + operator message differently if the plaintext is a guessable word. Task 1's tests are the natural + place to settle it; record the finding in the SUMMARY either way. +- **Whether `get_app_config`'s signature can return `Result` without a wide refactor:** the planner + read the call site but not every arm of the table. Task 1 explicitly allows either mechanism and + requires the choice to be recorded, so this is a bounded implementation decision, not a scope gap. + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| repository → running node | Anything committed here reaches every node and every reader of the mirror | +| gateway admin API (`0.0.0.0:8176`) → network | The credential this plan governs is the only thing gating Lightning gateway administration | +| deploy host → target node over SSH | Credentials are read back across this boundary by two deploy scripts | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-01-50 | Elevation of Privilege | shipped default credential granting gateway admin on any node that took a fallback | critical | mitigate | Both tasks delete every configure-time fallback; the repo-wide grep acceptance criterion fails the task if any credential literal survives outside the detection denylist | +| T-01-51 | Spoofing | an attacker authenticating to a node's gateway with the publicly known default | critical | mitigate | `gateway_bcrypt_hash` refuses to return a denylisted value, so a node carrying it cannot be reconfigured with it even by this codebase | +| T-01-52 | Information Disclosure | the generated plaintext leaking through a log line or a deploy transcript | high | mitigate | Generation reuses `write_secret` (0600, atomic, never logged); the scripts are changed to log only that generation happened, never the value; the acceptance criteria forbid printing it | +| T-01-53 | Denial of Service | removing the fallback bricking installs on hosts without a bcrypt hasher | medium | mitigate | Task 2's preferred branch removes the host-tool dependency entirely by deferring to the daemon's own generator, and requires the skip path to print a reason rather than fail silently | +| T-01-54 | Tampering | a half-written credential pair leaving a gateway configured against a hash whose plaintext nobody holds | medium | mitigate | Generation reuses the existing atomic temp-file-plus-rename `write_secret` and its self-heal branch; Task 2 forbids creating a half-provisioned pair | +| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs no packages — it edits existing Rust and shell only. If an implementation choice would add a crate, stop and raise it: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any `[ASSUMED]`/`[SUS]` entry | + + + +- `cd core && cargo test -p archipelago` — green. +- `cd core && cargo build -p archipelago` — green. +- `bash -n` clean on all five changed scripts. +- Repo-wide: exactly one occurrence of the compromised hash, in the detection denylist. + + + +- The gateway credential comes from `container::secrets` on every path — daemon, first boot, reconcile, and both deploys. +- No credential literal in the tree configures anything; the one retained copy exists solely to detect and reject. +- A node with no credential gets one generated, or is told clearly why the gateway was not created. +- One canonical secret filename, with the legacy plaintext value carried forward rather than regenerated. + + + +Create `.planning/phases/01-federation-mesh-hardening/01-11-SUMMARY.md` when done, recording the +credential-less failure mechanism chosen, the per-script no-credential behaviour, and whether the +compromised hash's plaintext turned out to be recoverable. +Stage by explicit path, commit, and `git push gitea-ai main`. + diff --git a/.planning/phases/01-federation-mesh-hardening/01-12-PLAN.md b/.planning/phases/01-federation-mesh-hardening/01-12-PLAN.md new file mode 100644 index 00000000..5bdb03c1 --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-12-PLAN.md @@ -0,0 +1,235 @@ +--- +phase: 01-federation-mesh-hardening +plan: 12 +type: execute +wave: 7 +depends_on: [] +files_modified: + - neode-ui/src/views/web5/Web5ConnectedNodes.vue + - neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts +autonomous: true +requirements: [UIFIX-02] +gap_closure: true + +must_haves: + truths: + - "On a wide viewport the connected-nodes card's height is set by its row sibling, not by how many nodes are in the list — adding nodes makes the inner list scroll instead of making the row taller (UIFIX-02)" + - "The inner list scrolls within the matched height: with more rows than fit, a scrollbar appears inside the card and the card stays put" + - "With a short sibling the card still has a usable list height rather than collapsing to its header and tabs (UIFIX-02 empty edge, sibling half)" + - "With zero connected nodes the card renders its existing empty/loading row and does not collapse (UIFIX-02 empty edge, list half)" + - "All three tabs — trusted, observers, requests — share the same scroll behaviour, so switching tabs never changes the card's height (UIFIX-02 adjacency edge)" + - "The stacked single-column layout below the row breakpoint is unchanged: the list keeps its existing capped height and its existing scroll" + prohibitions: + - statement: "Nothing outside the connected-nodes card's own height and overflow behaviour may change — the card's glass styling, padding, header, tab strip, row markup, counts, and every animation stay byte-identical, and no sibling card in any Web5 row is restyled to make the fix work" + category: safety + artifacts: + - path: neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts + provides: "Structural pin on the scroll contract for all three tab panes" + min_lines: 30 + key_links: + - from: neode-ui/src/views/web5/Web5ConnectedNodes.vue + to: neode-ui/src/views/web5/Web5.vue + via: "the card is a min-height-zero flex column whose scroll pane contributes no intrinsic height at the row breakpoint, so the grid row is sized by the sibling and stretch gives the card that height" + pattern: "overflow-y-auto" +--- + + +Make the connected-nodes list obey the row: its height tracks the taller sibling beside it and the +list scrolls inside that height, instead of growing until every node fits. + +Purpose: UIFIX-02 is a BLOCKER, and it is a regression of an earlier request ("was still meant to +scroll"). Quick task 260729-je5 made the list fill the card's height; what is missing is the other +half — the list must not *drive* the card's height. Today all three tab panes carry +`max-h-72 xl:max-h-none`, so at the `xl` breakpoint where the row becomes two columns the cap is +lifted and nothing bounds the list: it grows to fit every row, stretches the grid row, and the +scrollbar the user expects never appears. +Output: a bounded, sibling-matched card with an internal scroll at the row breakpoint, an unchanged +stacked layout below it, and a test that pins the contract so a future cleanup cannot undo it again. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md +@neode-ui/src/views/web5/Web5.vue + + +## Artifacts this phase produces + +Created or changed by **this plan**: + +| Symbol | Kind | File | +|---|---|---| +| scroll-contract classes on the three tab panes | changed template classes | `neode-ui/src/views/web5/Web5ConnectedNodes.vue` | +| row-breakpoint height floor on the card root | changed template classes | same | +| `neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts` | new vitest suite | new file | + + + + + Task 1: End-to-end — the trusted pane scrolls at a sibling-matched height + neode-ui/src/views/web5/Web5ConnectedNodes.vue, neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts + + - `neode-ui/src/views/web5/Web5ConnectedNodes.vue` lines 1-135: the card root + (`glass-card p-6 scroll-mt-24 flex flex-col`), the desktop and mobile header blocks, the four-tab + strip, and the three `v-show` tab panes at lines 57, 90 and 120 — all three currently carrying + `space-y-2 flex-auto min-h-0 overflow-y-auto max-h-72 xl:max-h-none`. Also read the loading and + empty rows inside the trusted pane so you know what renders when the list is empty. + - `neode-ui/src/views/web5/Web5.vue` lines 57-74: the three `grid grid-cols-1 xl:grid-cols-2 gap-6` + rows. The connected-nodes card is the left item of the first row and `Web5NodeVisibility` is its + right sibling. Confirm no `items-start`/`self-start` is applied anywhere on that row — grid's + default `align-items: stretch` is what makes the sibling-matched height work, and this plan must + not add or remove alignment utilities on the row. + - `neode-ui/src/views/web5/Web5NodeVisibility.vue` — read only far enough to see roughly how tall + it renders (it is the sibling whose height the card must adopt). Do not modify it. + - `neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts` — the house convention for a + structural DOM/class pin test in this repo (this is the file the standing rule names as + must-stay-green; read it for its mounting and assertion style, do not change it). + + + - Mounting the component and reading the trusted pane's class list: it has `overflow-y-auto`, has + `min-h-0`, and has no class that removes its height bound at the row breakpoint. + - The same three assertions hold for the observers pane and the requests pane. + - The pane keeps a capped height below the row breakpoint (the stacked layout is unchanged). + - The card root is a flex column with a height floor at the row breakpoint, so a short sibling + cannot collapse the list area. + - With an empty node list the pane still renders (the existing empty/loading row is present) and + the pane element is still in the tree. + + + Write the test file first and confirm it fails. + + In `Web5ConnectedNodes.vue`, change only the height/overflow contract: + + On each of the three tab panes, replace the current sizing classes so that below the row + breakpoint nothing changes (keep the existing capped height and `overflow-y-auto`, keep basis + `auto` so the auto-height stacked column still sizes to content), and at the row breakpoint the + pane becomes a zero-basis growing flex child with no height cap — `xl:flex-1 xl:basis-0 + xl:max-h-none` alongside the existing `min-h-0 overflow-y-auto`. Zero basis is the whole trick: + it makes the pane contribute nothing to the card's intrinsic height, so the grid row is sized by + the sibling alone, `align-items: stretch` gives the card that row height, and `flex-1` then hands + the leftover height to the pane, which scrolls inside it. + + On the card root, keep `glass-card p-6 scroll-mt-24 flex flex-col` exactly as it is and add + `min-h-0` plus a row-breakpoint height floor (`xl:min-h-[20rem]`) so a sibling shorter than the + header-plus-tabs block still leaves a usable, scrolling list area rather than a collapsed strip. + Choose the floor to sit close to today's stacked cap so the visual weight of the card is familiar. + + Change nothing else. Do not touch the header blocks, the tab strip, the per-row markup, the count + badges, the pulse dot on the requests tab, any `v-show`/`v-if` condition, any script logic, or any + class on `Web5.vue`'s grid rows. Do not add a scrollbar style — the list already scrolls with the + house default below the breakpoint and must look identical above it. + + + cd neode-ui && test -f src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts && npx vitest run src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts + + + - The test file exists and `cd neode-ui && npx vitest run src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts` exits 0 (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`, so a missing file would pass vacuously). + - `grep -c 'xl:max-h-none' neode-ui/src/views/web5/Web5ConnectedNodes.vue` equals 3 and each of those three lines also matches `xl:basis-0`. + - `grep -c 'flex-auto' neode-ui/src/views/web5/Web5ConnectedNodes.vue` equals 0. + - `grep -c 'max-h-72' neode-ui/src/views/web5/Web5ConnectedNodes.vue` equals 3 — the stacked cap is untouched. + - `grep -c 'xl:min-h-' neode-ui/src/views/web5/Web5ConnectedNodes.vue` equals 1. + - `git diff --stat -- neode-ui/src/views/web5/Web5.vue` reports no change. + - `git diff -- neode-ui/src/views/web5/Web5ConnectedNodes.vue | grep -c '^[-+].* + All three panes carry the bounded scroll contract, the stacked layout is untouched, and a test pins it. + + + + Task 2: Prove it against the real preview and settle the second "connected nodes" surface + neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts + The local dev preview can be started (`cd neode-ui && npm run dev:mock` serves the UI on :8100 against the mock backend) — jsdom cannot compute layout, so the height claim has to be observed in a real browser engine + + - `neode-ui/DEV-SCRIPTS.md` lines 1-40 — how the dev preview and mock backend are started and on + which ports, and how to stop them cleanly. + - `neode-ui/src/views/settings/AccountInfoSection.vue` (grep it for "connected" / "nodes" first) — + the todo flags a second "connected nodes" block living in settings. Determine whether it is the + same list in a different place or unrelated copy, and record the verdict. + + + Start the dev preview, open the Web5 tab at a wide viewport (at or above the row breakpoint), and + observe the first row directly. Confirm three things and record each in the SUMMARY with the + viewport width you used: + + 1. The connected-nodes card and its right-hand sibling are the same height. + 2. With more connected nodes than fit, the list scrolls inside the card and the card does not grow + — if the mock backend does not supply enough nodes to overflow, temporarily add rows in the + browser's element inspector to force the condition rather than editing the mock backend, and say + so in the SUMMARY. + 3. Narrowing below the row breakpoint restores exactly the previous stacked appearance. + + Then settle the second surface: grep the settings section named above for a connected-nodes list. + If it is a genuinely separate list with the same grow-to-fit behaviour, fix it the same way in this + plan and add its file to the plan's `files_modified` in the SUMMARY. If it is unrelated (for + example a count or a link rather than a scrolling list), record that finding and leave it alone. + Do not silently skip this step — the todo explicitly flagged the ambiguity. + + Extend the test file with a case for whichever surface the investigation confirmed, so the pin + covers what actually shipped. + + + cd neode-ui && npx vitest run src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts && npm run build + + + - `cd neode-ui && npx vitest run src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts` exits 0. + - `cd neode-ui && npm run build` exits 0 and `grep -rq 'xl:basis-0' ../web/dist/neode-ui/assets/` succeeds (per CLAUDE.md the frontend build can silently no-op, so grep the built bundle for a string this plan introduced). + - The SUMMARY records all three dev-preview observations with the viewport width used for each. + - The SUMMARY records an explicit verdict on the settings "connected nodes" block: same defect and fixed here, or unrelated and why. + - `cd neode-ui && npx vitest run` exits 0. + + The behaviour is confirmed in a real browser at both sides of the breakpoint, and the second candidate surface has a recorded verdict rather than an assumption. + + + + +## Planner Assumptions (flagged, unresolved) + +- **The 20rem floor is a judgement call, not a measured value.** The planner did not render + `Web5NodeVisibility.vue` to learn its height. If the sibling is reliably taller than the floor the + floor never binds and the exact value is invisible; if it is shorter, the floor is what the user + sees. Task 2's dev-preview observation is where that gets confirmed — if the floor looks wrong on + screen, adjust it there and record the final value. + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| federated peer data → rendered node row | The list renders peer-supplied names and identifiers | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-01-55 | Information Disclosure | a bounded, scrolling list hiding a connected node the operator needs to notice | medium | mitigate | The tab strip's existing count badges stay untouched and remain visible above the scroll area, so the total is always readable without scrolling; the acceptance criteria forbid changing them | +| T-01-56 | Spoofing | a long peer-supplied node name overflowing the newly bounded pane and overlapping adjacent chrome | low | accept | Row markup is unchanged by this plan; the panes already truncate as they do today, and this plan alters only the container's height and overflow | +| T-01-57 | Denial of Service | a very large peer list making the card expensive to render | low | accept | The list is already fully rendered today; bounding the container reduces painted area rather than increasing it, and virtualisation is out of scope for a layout fix | +| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs nothing — template class changes and one vitest file only. If an implementation choice would add a dependency, stop: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any `[ASSUMED]`/`[SUS]` entry | + + + +- `cd neode-ui && npx vitest run` — green, including `keepAliveTabs.test.ts`. +- `cd neode-ui && npm run build` — green, and the built bundle carries the new class. +- Dev-preview observation recorded at both sides of the row breakpoint. + + + + +- The card's height comes from its row sibling; the list scrolls inside it and never grows to fit. +- A short sibling still leaves a usable list height. +- The stacked layout below the breakpoint is byte-identical to before. +- The settings "connected nodes" block has a recorded verdict. +- A test pins the contract so the behaviour cannot silently regress a third time. + + + +Create `.planning/phases/01-federation-mesh-hardening/01-12-SUMMARY.md` when done, recording the +dev-preview observations, the final floor value, and the settings-surface verdict. +Stage by explicit path, commit, and `git push gitea-ai main`. + diff --git a/.planning/phases/01-federation-mesh-hardening/01-13-PLAN.md b/.planning/phases/01-federation-mesh-hardening/01-13-PLAN.md new file mode 100644 index 00000000..ad8cd8dd --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-13-PLAN.md @@ -0,0 +1,267 @@ +--- +phase: 01-federation-mesh-hardening +plan: 13 +type: execute +wave: 7 +depends_on: [] +files_modified: + - neode-ui/src/views/OnboardingSeedGenerate.vue + - neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts +autonomous: true +requirements: [UIFIX-03] +gap_closure: true + +must_haves: + truths: + - "On a viewport too short to show the whole seed step, an on-brand cue at the bottom of the scrolling area tells the user there is more below — the confirmation tickbox is no longer silently out of sight (UIFIX-03)" + - "Activating the cue brings the confirmation tickbox into view, so discovering it takes one action rather than a guess" + - "The cue disappears once the tickbox is visible, and never reappears while it stays visible (UIFIX-03 adjacency edge)" + - "On a viewport tall enough to show everything the cue never renders at all — no element, no reserved space, no layout shift, so tall screens look exactly as they did (UIFIX-03 empty edge)" + - "The cue is absent while the seed is still generating and while an error is showing, because there is no tickbox to point at yet" + - "The cue's motion is disabled under prefers-reduced-motion, matching the site-wide convention" + prohibitions: + - statement: "Nothing about the existing onboarding step may change other than the addition of this cue — the header, the seed word grid, the words/QR tabs, the warning box, the tickbox itself, the fixed footer and its Continue button, and every existing animation stay exactly as they are, and the shared onboarding container styles in style.css are not touched" + category: safety + - statement: "The cue MUST NOT let a user proceed without ticking the box — it is a wayfinding affordance only; it never sets the confirmation state, never enables the Continue button, and never auto-ticks on scroll" + category: safety + artifacts: + - path: neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts + provides: "Overflow-driven show/hide behaviour of the cue, including the no-overflow no-render case" + min_lines: 40 + key_links: + - from: neode-ui/src/views/OnboardingSeedGenerate.vue + to: neode-ui/src/views/OnboardingSeedGenerate.vue + via: "the cue's visibility is derived from the scroll container's own overflow measurement and the tickbox's position within it, so it is impossible for the cue to show when there is nothing below" + pattern: "scrollHeight" +--- + + +Make the seed-confirmation tickbox obviously findable on short screens, in a way that looks like it +was always part of the design. + +Purpose: UIFIX-03 is a BLOCKER — on a short viewport the tickbox sits below the fold inside the +step's scrolling area while the Continue button stays pinned and disabled in the fixed footer, so +onboarding reads as broken rather than incomplete. The user asked for this to be solved "in a +beautiful way": the fix has to feel intentional and native to the house glass/dark style, not a +bolted-on arrow, and it must be invisible on screens tall enough not to need it. +Output: a bottom scroll cue on the seed step that appears only when it is needed, scrolls the tickbox +into view when activated, and vanishes once the tickbox is on screen. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md + + +## Artifacts this phase produces + +Created or changed by **this plan**: + +| Symbol | Kind | File | +|---|---|---| +| bottom scroll-cue overlay | new template block (conditional) | `neode-ui/src/views/OnboardingSeedGenerate.vue` | +| `showScrollCue` + `updateScrollCue()` + `revealConfirm()` | new script state and handlers | same | +| `.onb-cue-*` scoped styles incl. reduced-motion guard | new scoped CSS | same | +| `neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts` | new vitest suite | new file | + + + + + Task 1: End-to-end — a short viewport shows a cue that reveals the tickbox + neode-ui/src/views/OnboardingSeedGenerate.vue, neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts + + - `neode-ui/src/views/OnboardingSeedGenerate.vue` — the whole file (262 lines). The structure that + matters: a `h-[100dvh]` outer centring wrapper; a `path-glass-container onb-scroll-container + flex flex-col` card; a `flex-shrink-0` header; the scrolling middle region + (`flex-1 overflow-y-auto overflow-x-hidden px-6 sm:px-8 min-h-0`) that contains the loading + state, the error state, the words/QR tabs, the word grid, the orange warning box and — last — + the confirmation ` + + - With the scroll region reporting more content than fits and the tickbox below the visible area, + the cue element is in the DOM. + - With the scroll region reporting no overflow, the cue element is absent from the DOM entirely — + not merely hidden, so it can occupy no space and cause no shift. + - Scrolling to the bottom (tickbox now inside the visible area) removes the cue. + - Activating the cue calls the scroll-into-view path for the tickbox and does not change + `confirmed`. + - While `loading` is true, or while `errorMessage` is set and no words have arrived, the cue is + absent regardless of overflow. + - Ticking the box removes the cue. + + + Write the test file first and confirm it fails. In jsdom there is no layout engine, so drive the + measurements by defining `scrollHeight`, `clientHeight` and `scrollTop` on the scroll element with + `Object.defineProperty` and dispatching a `scroll` event — assert on what the component renders in + response, not on computed geometry. + + In `OnboardingSeedGenerate.vue`: + + Add a template ref to the existing scrolling middle region and one to the confirmation label. Add + a `showScrollCue` ref and an `updateScrollCue()` function that sets it true only when all of these + hold: words are present, not loading, the scroll element reports more scrollable content below the + current position, the confirmation label's bottom lies below the scroll element's visible bottom, + and `confirmed` is still false. Call it from a `scroll` listener on the scroll element, from a + `resize` listener on the window, from a `ResizeObserver` on the inner content wrapper (the word + grid changes height when the user switches between the words and QR tabs), from a watcher on + `words`, and from a watcher on `confirmed`. Remove every listener and disconnect the observer in + `onUnmounted` alongside the existing `stopTimers()` call. + + Render the cue as a `v-if="showScrollCue"` overlay positioned against the scrolling region's + bottom edge, inside a `Transition` so it fades rather than pops. Compose it from two layers, both + `pointer-events-none` except the button itself: + a soft gradient fade from transparent to the card's own dark backdrop across roughly 64px, so the + content appears to slide under the edge rather than being cut off; and, centred on it, a small + glass pill — the house `bg-black/60` + `backdrop-blur` treatment, `rounded-full`, `text-white/75` + at `text-xs`, with the orange accent (`#fb923c` / `text-orange-400`) used only for a downward + chevron drawn as inline 24×24 `stroke-width="2"` SVG per the icon convention in + `01-UI-SPEC.md`. Copy for the pill: **"One more step below"**. Give the chevron a gentle 2s + ease-in-out vertical bob of no more than 3px, defined in the file's existing scoped style block + next to `onb-lock-spin`, and guard it with the `prefers-reduced-motion` media query copied from + `SendBitcoinModal.vue`. + + Make the pill a real `