--- 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` 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