Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit 25d938cd1a
2315 changed files with 510085 additions and 0 deletions
@@ -0,0 +1,345 @@
---
phase: 01-federation-mesh-hardening
plan: 16
subsystem: security
tags: [secrets, bcrypt, fedimint, migration, rotation, reconcile]
requires:
- phase: 01-federation-mesh-hardening
provides: "01-11's KNOWN_DEFAULT_GATEWAY_HASHES denylist, ensure_gateway_credential, gateway_bcrypt_hash and the atomic 0600 write_secret — rotation reuses all of it and adds no new generation or file-writing code"
provides:
- "rotate_compromised_gateway_credential(secrets_dir) -> Result<bool>: denylist-exact detection plus rotation of a shipped gateway credential"
- "Self-healing on the existing reconcile tick, so an affected node rotates without operator action and without a hand-rolled container teardown"
affects: [fedimint-gateway, container-secrets, reconcile]
tech-stack:
added: []
patterns:
- "Rotate by changing the secret, not by touching the container: writing the new credential changes the resolved secret env, which changes secret_env_hash, which the drift check reads as a container-label mismatch — so the platform's own recreate path rebuilds the container around unchanged data, ports, volumes and name."
- "Denylist-exact detection: rotate only on an exact match against known-compromised values, never on 'unrecognised'. An operator's deliberately-set credential is unrecognised too."
key-files:
created: []
modified:
- core/archipelago/src/container/secrets.rs
- core/archipelago/src/container/prod_orchestrator.rs
key-decisions:
- "Bcrypt generation was factored out of ensure_one's Bcrypt arm into write_bcrypt_pair(dir, name), which both ensure_one and rotation call. 01-11 had left that arm inline, and rotation cannot reuse ensure_gateway_credential directly because ensure_one's idempotent fast path returns early when the file is present and non-empty — which is exactly the case rotation must act on."
- "The rotation call is gated on `manifest.app.id == \"fedimint-gateway\"` rather than running for every app on every tick. It hangs off resolve_dynamic_env, immediately after ensure_generated_secrets, as the plan specified."
- "Errors propagate (`?`) rather than being logged-and-continued: write_secret's atomic temp-file-plus-rename leaves the previous credential intact on failure, so surfacing the error is strictly safer than proceeding with a half-rotated gateway."
- "No boot-specific wiring was added — see the boot-reconciler finding below."
requirements-completed: []
coverage:
- id: D1
description: "A node carrying the shipped default rotates itself onto a unique credential without operator action"
requirement: "FED-07"
verification:
- kind: unit
ref: "core/archipelago/src/container/secrets.rs#rotates_a_denylisted_gateway_credential"
status: pass
- kind: manual_procedural
ref: "Task 2 checkpoint, archi-dev-box 2026-08-01 — the credential FILE rotates correctly (~15s after restart, fresh unique value, 0600), but the RUNNING gateway keeps the pre-rotation credential: Quadlet rewrites the unit without restarting it, and the gateway is classified restart-sensitive so drift is detected and deliberately ignored on every tick"
status: fail
human_judgment: true
- id: D2
description: "A node already carrying a unique credential is left completely alone; detection never fires on merely-unrecognised values"
requirement: "FED-07"
verification:
- kind: unit
ref: "…#leaves_a_unique_gateway_credential_alone, …#leaves_an_unrecognised_credential_alone"
status: pass
human_judgment: false
- id: D3
description: "Rotation runs at most once per affected node; later ticks detect nothing and change nothing"
requirement: "FED-07"
verification:
- kind: unit
ref: "…#rotation_is_idempotent"
status: pass
human_judgment: false
- id: D4
description: "Rotation replaces one credential pair and nothing else — no other secret, and no app data, is touched"
requirement: "FED-07"
verification:
- kind: unit
ref: "…#rotation_touches_no_other_secret (four bystander secrets asserted byte-identical)"
status: pass
- kind: other
ref: "git diff of prod_orchestrator.rs contains zero added rm -f / remove_dir_all / podman rm / chown"
status: pass
human_judgment: false
- id: D5
description: "The rotation is announced in the node's logs without ever printing the credential"
requirement: "FED-07"
verification:
- kind: other
ref: "The info! line interpolates self.secrets_dir and the secret NAME only; no value is in scope at the call site (rotate returns bool, not the credential)"
status: pass
- kind: manual_procedural
ref: "Task 2 step 3, archi-dev-box 2026-08-01 — one info line fired, naming /var/lib/archipelago/secrets/fedimint-gateway-hash.pw; no credential value anywhere in the log"
status: pass
human_judgment: true
- id: D6
description: "Generation where no credential exists stays ensure_gateway_credential's job"
requirement: "FED-07"
verification:
- kind: unit
ref: "…#no_op_when_no_gateway_credential_exists"
status: pass
human_judgment: false
duration: 140min
completed: 2026-08-01
status: task-1-complete-checkpoint-FAILED-recreate-does-not-fire
---
# Phase 1 Plan 16: Rotate Existing Installs Off the Shipped Gateway Credential (FED-07) Summary
**Task 1 rotates the credential correctly and was proven to do so on a real node. But the checkpoint DISPROVED the assumption it rests on: the rotated credential never reaches the running container, because the Quadlet path does not restart units and the gateway is classified restart-sensitive. FED-07 remains open.**
## Status
**FED-07 is NOT closed, and this plan alone cannot close it.** The checkpoint ran on archi-dev-box on
2026-08-01 and found that rotation does not propagate to the running gateway. A follow-up plan is
required — see the checkpoint result below.
## Accomplishments
- `rotate_compromised_gateway_credential(secrets_dir) -> Result<bool>` in `container::secrets`:
reads the canonical hash file, returns `Ok(false)` for absent/unreadable/unique/unrecognised, and
only on an **exact** denylist match writes a fresh pair and returns `Ok(true)`.
- `write_bcrypt_pair(dir, name)` factored out of `ensure_one`'s `Bcrypt` arm so there is exactly one
bcrypt-generation implementation, called by both generation and rotation.
- Wired into `resolve_dynamic_env` beside `ensure_generated_secrets`, gated on the gateway's app id,
with an info-level announcement that names the *path* to the new plaintext and never the value.
- Six new tests covering rotate-on-denylisted (including 0600 modes and that the `.pw` sibling
verifies against the new hash), no-op-on-unique, no-op-on-unrecognised, no-op-on-absent,
idempotence, and four bystander secrets left byte-identical.
## Findings the plan asked for
### Boot reconciler needs no separate call
`boot_reconciler` calls `reconcile_all()``reconcile_all_with_mode()` → per-manifest
`ensure_running_with_mode()` (prod_orchestrator.rs:1714) → `resolve_dynamic_env()`
(prod_orchestrator.rs:1914) → the rotation call. `install_fresh` reaches it by the same route.
So boot and reconcile funnel through one chokepoint and **no boot-specific wiring was added**;
`boot_reconciler.rs` is not in `files_modified`.
### ✅ RESOLVED 2026-08-02: the carve-out closes it, proven on the same node
The failure documented below was fixed and re-verified on archi-dev-box.
**The fix.** Rotation now records the app id in a `credential_rotated` set on the orchestrator, and
the drift check consumes that flag to recreate the container **even when the app is
restart-sensitive**, with a `WARN` naming the reason. It deliberately mirrors the published-port
carve-out sitting a few lines above it in the same function, which already makes exactly this trade
for exactly this reason: a container that is already non-functional (there) or already compromised
(here) is not protected by leaving it running. No teardown was hand-rolled — the existing recreate
path does the work.
**The design call.** Restart-sensitivity protects *working* services. A gateway answering to a
credential published in this repository is not working, it is compromised, and an attacker with
gateway admin can drain Lightning liquidity. Indefinite exposure loses to a few seconds of restart.
The alternative — rotate but only raise an operator alert — was rejected because the monitoring
system fires alerts from metric thresholds only (`check_alerts(&MetricSnapshot)`), so it would have
needed new event-alert plumbing to deliver something strictly weaker than just fixing it.
**Before / after on the same box, same scenario:**
| | Before (15:51, first checkpoint) | After (06:39, re-run) |
|---|---|---|
| Credential file | rotated ✅ | rotated ✅ |
| Container recreated | ❌ never — same PID 25 min later | ✅ 4s after rotation, PID 3923125 → 148426 |
| Running credential | the compromised default | matches the file (`636d6031…`) |
| Log | "leaving running restart-sensitive app untouched" ×4 and counting | "recreating restart-sensitive app: its admin credential was rotated off a publicly known default…" |
Post-recreate state: container healthy, **same name**, **same ports** (8176/9737), `gatewayd.db`
intact at 18 files with `IDENTITY` present, credential `0600 archipelago:archipelago`, 32 containers
untouched, and **zero repeat rotations** on subsequent ticks (the rotated value is not denylisted, so
it is self-terminating — T-01-73 holds).
Covered by three new tests: `rotating_a_compromised_credential_flags_the_app_for_recreate`,
`a_unique_credential_does_not_flag_the_app`, `a_second_pass_does_not_re_flag_the_app`.
---
### ⛔ ORIGINAL CHECKPOINT RESULT (2026-08-01) — retained: this is what the fix had to defeat
**Run on archi-dev-box, 2026-08-01.** The code-reading conclusion below was **wrong in practice**,
which is exactly why the plan made this a blocking checkpoint. The rotation works; the propagation
to the running container does not.
Observed: rotation fired ~15s after restart, wrote a fresh unique credential, and logged correctly.
But **25 minutes later the running gatewayd process was still using the pre-rotation credential.**
`/proc/<pid>/environ` for PID 1 held the old value while the file and the podman secret held the new
one. The orchestrator says why, in its own log lines:
```
Quadlet unit drift-synced — file rewritten, .service NOT restarted
(operator restart picks up new config) app_id=fedimint-gateway
container drift detected during boot reconcile;
leaving running restart-sensitive app untouched app_id=fedimint-gateway
```
Two independent guards, both deliberate:
1. **The Quadlet path rewrites the `.container` unit but never restarts the `.service`.** The unit
file was rewritten at 15:51:36 (same second as the rotation) carrying the new
`secret-env-hash=de9870c642a515f7` label — so the definition updated correctly. Systemd does not
apply a changed unit to a running container without a restart.
2. **`fedimint-gateway` is classified restart-sensitive**, so the drift check *detects* the change on
every reconcile tick and then deliberately leaves the container alone. That line repeated at
15:51, 15:53, 15:54, 15:56 — it will repeat forever.
**Consequence: on a real affected node, rotation makes the credential file unique but the gateway
keeps answering to the compromised one indefinitely** — until an unrelated reboot or a manual
restart. Worse, the operator reading `fedimint-gateway-hash.pw` gets a password the running gateway
does not accept, which is the lockout risk T-01-77 inverted.
Confirmed the fix works when applied: `systemctl --user restart fedimint-gateway.service` produced a
new PID whose `FEDI_HASH` is the rotated value, container healthy, same name, same ports, marker file
and `gatewayd.db` intact.
**Deliberately NOT hand-rolled.** The plan's action says: "If that path does not fire for this app for
some reason you discover, do not hand-roll a remove-and-run; stop and record what you found." So this
is recorded, not patched. The fix belongs in a follow-up and has to answer a real design question:
a compromised credential is arguably the one case that should override restart-sensitivity — or,
failing that, the rotation must raise an operator-facing "restart required" alert rather than logging
into the void.
### What the checkpoint DID prove
| Step | Result |
|---|---|
| 1. State recorded | Node was CLEAN; affected state seeded deliberately (plan's Planner Assumption) |
| 2. Deploy | `install -m0755` to `/usr/local/bin/archipelago` + `systemctl restart archipelago`; rollback kept at `archipelago.bak-pre-fed07` |
| 3. Rotation announced | ✅ One info line, fired once, names the `.pw` path, **no credential value in the log** |
| 4. Credential unique | ✅ Third distinct value (not the default, not the pre-test original), `0600 archipelago:archipelago` |
| 5. Data preserved | ✅ Marker file and all 13 `gatewayd.db` files incl. `IDENTITY` intact; same container name; same ports 8176/9737 |
| 5. Container recreated | ❌ **FAILED — see above** |
| 6. Auth proof | ⚠️ Not obtainable: `gateway-cli` in this image returns the same "Invalid request" for a correct and an incorrect password, so it cannot distinguish them. Substituted PID-1 `environ` comparison, which is stronger evidence of *which* credential is in force. |
| 7. Fresh install differs | ⏸ Not run |
| 8. `run-gate.sh` | ⏸ Not run |
Also proved incidentally: **restarting `archipelago` does not kill containers on this box** — 29/29
and later 31/31 survived, and the orchestrator logged "Adopted 31 existing container(s)". The
CLAUDE.md "restart SIGKILLs containers" rule does not apply under `ARCHIPELAGO_USE_QUADLET_BACKENDS=true`
with podman in the user slice (the service is `system.slice`/`KillMode=control-group`; the containers
live in `user-1000.slice/…/libpod-*`, a different cgroup entirely).
### Original code-reading conclusion (retained — it is what the checkpoint disproved)
`resolve_dynamic_env` computes `secret_env_content_hash(&secret_bearing)` over the resolved
secret-bearing env and stores it as `manifest.app.container.secret_env_hash`
(prod_orchestrator.rs:3309). The drift check (prod_orchestrator.rs:3374) inspects the running
container's `SECRET_ENV_HASH_LABEL` and returns "drifted" when it differs from the expected hash,
which drives the existing recreate. The gateway's `FEDI_HASH` comes from the rotated file, so a
rotation necessarily changes that hash and therefore the label comparison.
**This is a code-reading conclusion. It has not been observed firing on a node** — that is Task 2
step 5, and it is the single most important thing the checkpoint proves.
### Operator recovery: the surface exists but does NOT cover this app — a real gap
- The UI path is live: `Apps.vue` calls `package.credentials` with an `app_id` before launching an
app and renders a credentials modal from the response.
- The backend, `handle_package_credentials` in
`core/archipelago/src/api/rpc/package/install.rs:2093`, is a hardcoded per-app if-chain covering
**only `filebrowser` and `photoprism`**. Every other app, including `fedimint-gateway`, falls
through to `Ok(json!({ "credentials": [] }))`.
- **Consequence:** after rotation the operator has no in-UI way to obtain the new gateway password.
The recovery path is the file the log line names: `/var/lib/archipelago/secrets/fedimint-gateway-hash.pw`
(0600, service user), readable over SSH.
- **Gap owner:** `handle_package_credentials` in `core/archipelago/src/api/rpc/package/install.rs`.
Adding a `fedimint-gateway` arm that reads the `.pw` sibling would close it; the UI needs no change.
Deliberately not done here — this plan's `files_modified` is scoped to two files, and that handler
belongs to the app-credentials surface, not to FED-07's rotation.
## Adjacent finding — NOT part of this plan, raised deliberately
`apps/photoprism/manifest.yml:35` sets `PHOTOPRISM_ADMIN_PASSWORD=archipelago`, and
`handle_package_credentials` hands that same literal back to the UI. That is a shipped default
credential in a manifest — the same class of defect as FED-07, on a different app. Every node running
PhotoPrism answers to `admin` / `archipelago`.
It is out of scope here (this plan is the gateway migration) and was not touched. It wants its own
requirement and plan, and probably the same treatment: a `generated_secrets` entry plus a denylist
entry for the shipped value.
## Deviations from Plan
### Bcrypt generation had to be factored out first
**Found during:** Task 1
**Issue:** The plan says rotation should "generate a replacement pair through the same helper
`ensure_gateway_credential` uses". 01-11 never actually created such a helper — it left the bcrypt
arm inline in `ensure_one` and had `ensure_gateway_credential` call `ensure_one`. Rotation cannot
call `ensure_gateway_credential`, because `ensure_one`'s idempotent fast path returns early when the
target files are present and non-empty, which is precisely the state rotation acts on.
**Resolution:** Extracted `write_bcrypt_pair(dir, name)` from the `Bcrypt` arm; `ensure_one` and
rotation both call it. Still exactly one generation implementation, which is what the instruction was
protecting.
**Files modified:** `core/archipelago/src/container/secrets.rs`
## Known Stubs
None.
## Threat Flags
- **T-01-72 (critical, EoP)** — mitigated in code, **not yet proven on a node**. Task 2 step 6 (old
credential rejected, new one accepted) is the proof and has not been run.
- **T-01-73 (DoS, rotation loop)** — mitigated and unit-tested: the rotated value is not on the
denylist, so the next tick is a no-op (`rotation_is_idempotent`).
- **T-01-74 (info disclosure)** — mitigated structurally: `rotate_compromised_gateway_credential`
returns `bool`, so the credential is not even in scope at the logging call site.
- **T-01-75 (tampering / data loss)** — mitigated: no teardown primitives added (grep-verified), the
recreate goes through `secret_env_hash`. On-node data-survival check is Task 2 step 5, not run.
- **T-01-76 (repudiation — signing off without exercising rotation)** — **live risk, unresolved.**
Whether archi-dev-box is affected or already clean is still unknown; the plan requires declaring
which case it is and deliberately seeding the old value if the node is clean.
- **T-01-77 (operator lockout)** — partially mitigated: the plaintext exists at a named 0600 path and
the log line points at it, but there is no UI retrieval path (see the gap above).
- **T-01-SC** — no crates added.
## Self-Check
- CONFIRMED: `cargo test -p archipelago secrets`**16 passed, 0 failed** (the `container::secrets`
module holds 14 `#[test]` fns, all six new rotation cases among them:
`rotates_a_denylisted_gateway_credential`, `leaves_a_unique_gateway_credential_alone`,
`leaves_an_unrecognised_credential_alone`, `no_op_when_no_gateway_credential_exists`,
`rotation_is_idempotent`, `rotation_touches_no_other_secret`)
- FOUND: `rotate_compromised_gateway_credential` in `secrets.rs` (definition + 5 test uses)
- FOUND: exactly 1 non-comment reference in `prod_orchestrator.rs`
- CONFIRMED: 0 added teardown primitives (`rm -f` / `remove_dir_all` / `podman rm` / `chown`) in the
`prod_orchestrator.rs` diff
- CONFIRMED: `cargo fmt --check -p archipelago` clean. It was **not** clean before this plan —
`install.rs` carried drift introduced by 01-11's commit (`42652547`), fixed here. That check has
blocked the release gate before (`37d293be`), so it is worth keeping green rather than discovering
at ship time.
- CONFIRMED: `cargo test -p archipelago` (after `cargo clean -p archipelago`) — **1008 passed, 1
failed**. The failure is `container::boot_reconciler::tests::second_pass_fires_after_interval`, the
same wall-clock-timed test (50ms tick) that was flaky during 01-11; re-run in isolation it is
**4 passed / 0 failed in 0.46s**. `boot_reconciler.rs` is untouched by this plan.
- **NOT RUN:** Task 2's eight-step on-node checkpoint, and `tests/lifecycle/run-gate.sh`
### A false alarm worth recording, because it cost an hour
An intermediate full-suite run reported `credentials::operations::tests::test_list_credentials_filter_by_did`
failing with "invalid utf-8 sequence of 1 bytes from index 2" — an identity-credentials test in a
module this plan does not touch, which had passed in the 01-11 run two hours earlier.
Cause: **corrupted build artifacts, not a regression.** Two duplicate `cargo test` runs had been
started against the same workspace lock and one was `SIGTERM`ed to free it. The next compile surfaced
`rust-lld: error: undefined hidden symbol` — precisely the incremental-cache corruption CLAUDE.md
documents. After `cargo clean -p archipelago` the credentials test passes and the only failure is the
known timing flake above.
Lesson for the next executor on this box: do not kill an in-flight `cargo` to free the build lock —
let it finish. A corrupted target dir produces failures in modules you never touched, which reads
exactly like a real regression and is not one.
</content>