Files
archy/.planning/phases/01-federation-mesh-hardening/01-11-PLAN.md
T

23 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, gap_closure, must_haves
phase plan type wave depends_on files_modified autonomous requirements gap_closure must_haves
01-federation-mesh-hardening 11 execute 7
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
true
FED-07
true
truths prohibitions artifacts key_links
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)
statement category
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 safety
statement category
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 transparency
path provides contains
core/archipelago/src/container/secrets.rs Canonical per-install gateway credential accessor plus the known-default denylist KNOWN_DEFAULT_GATEWAY_HASHES
from to via pattern
core/archipelago/src/api/rpc/package/config.rs core/archipelago/src/container/secrets.rs the fedimint-gateway spec builder asks container::secrets for the per-install hash and propagates the error instead of substituting a literal gateway_bcrypt_hash
from to via pattern
scripts/container-specs.sh core/archipelago/src/container/secrets.rs both read the same canonical secret filename, so the shell reconcile path and the daemon agree on one credential 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.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.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<String> 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<String>` — 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.

<threat_model>

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
</threat_model>
- `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.

<success_criteria>

  • 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. </success_criteria>
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`.