Files

197 lines
16 KiB
Markdown
Raw Permalink Normal View History

2026-08-12 10:55:49 +00:00
---
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>