Archipelago — open-source initial import
This commit is contained in:
@@ -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 && cargo test -p archipelago secrets 2>&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>
|
||||
Reference in New Issue
Block a user