Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit adcd0f7ce8
1826 changed files with 403825 additions and 0 deletions
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md
@apps/fedimint-gateway/manifest.yml
@core/archipelago/src/container/secrets.rs
</context>
## 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` |
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: End-to-end — a gateway spec that cannot be built without a per-install credential</name>
<files>core/archipelago/src/container/secrets.rs, core/archipelago/src/api/rpc/package/config.rs, core/archipelago/src/api/rpc/package/dependencies.rs</files>
<read_first>
- `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 `<name>` and the plaintext to `<name>.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.
</read_first>
<behavior>
- `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.
</behavior>
<action>
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.
</action>
<verify>
<automated>cd core &amp;&amp; cargo test -p archipelago secrets 2>&amp;1 | tail -20</automated>
</verify>
<acceptance_criteria>
- `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.
</acceptance_criteria>
<done>The Rust orchestrator can only configure a gateway with a per-install credential; the compromised literal survives in exactly one detection-only location.</done>
</task>
<task type="auto">
<name>Task 2: The shell install paths generate their own credential instead of shipping one</name>
<files>scripts/first-boot-containers.sh, scripts/reconcile-containers.sh, scripts/deploy-to-target.sh, scripts/deploy-tailscale.sh, scripts/container-specs.sh</files>
<precondition>`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)</precondition>
<read_first>
- `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.
</read_first>
<action>
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`.
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- `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.
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
## 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>
<verification>
- `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.
</verification>
<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>
<output>
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`.
</output>