From 06e0e6954e1dc847e530d4cd491903759f9d3479 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 2 Aug 2026 07:20:20 -0400 Subject: [PATCH] fix(01-16): recreate the gateway when its credential was rotated (FED-07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checkpoint on archi-dev-box proved rotation alone doesn't close FED-07: the credential file went unique while the RUNNING container kept serving the compromised one, because the Quadlet path rewrites a unit without restarting it and fedimint-gateway is classified restart-sensitive, so drift was detected and deliberately ignored on every tick. Rotation now records the app id, and the drift check consumes that flag to recreate even a restart-sensitive app, with a WARN naming the reason. This mirrors the published-port carve-out a few lines above, which already makes the same trade for the same reason: a container that is already broken (there) or already compromised (here) is not protected by leaving it running. Restart-sensitivity protects working services. A gateway answering to a credential published in this repository is not working, it is compromised, and gateway admin can drain Lightning liquidity — indefinite exposure loses to a few seconds of restart. Rotating-but-only-alerting was rejected: the monitoring system fires on metric thresholds only, so it would have needed new event-alert plumbing to deliver something strictly weaker. Re-verified on the same node, same scenario: rotation at 06:39:23, recreate at 06:39:27, PID 3923125 -> 148426, running credential now matches the file, container healthy with the same name and ports, gatewayd.db intact at 18 files with IDENTITY present, 32 containers untouched, no repeat rotation. 3 new tests. Also lands the missing 01-19 and 01-20 SUMMARYs: both had code committed 2026-07-31 but no summary and no roadmap tick, so they read as unstarted. Phase 1 is 11/20. FED-09 carries 15h of Tor uptime and 0 permission-fixes across 542 doctor runs on archi-dev-box. Co-Authored-By: Claude Opus 5 (1M context) --- .planning/REQUIREMENTS.md | 12 +- .../01-16-SUMMARY.md | 40 ++++- .../01-19-SUMMARY.md | 103 ++++++++++++ .../01-20-SUMMARY.md | 131 ++++++++++++++++ .../src/container/prod_orchestrator.rs | 146 +++++++++++++++++- 5 files changed, 424 insertions(+), 8 deletions(-) create mode 100644 .planning/phases/01-federation-mesh-hardening/01-19-SUMMARY.md create mode 100644 .planning/phases/01-federation-mesh-hardening/01-20-SUMMARY.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 9b0446b7..0c5c7a64 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -18,9 +18,9 @@ declared exit criteria (multinode pass + workstreams B/C/F), `.planning/codebase - [x] **FED-04**: Mesh messaging parity — attachment send (and the rest of the mesh chat surface) behaves identically on the demo and on real nodes: the demo backend implements the same RPC surface the UI calls, transport decisions mirror the real size-based tier logic, and no demo-only modals exist - [ ] **FED-05**: Inter-node Lightning channel opening UX — the UI shows the node's shareable Lightning URI; lists trusted (federated) nodes by hostname for one-click channel opening; and lets the user browse/request channels with public nodes — using the existing design system and components, verified on the :8100 dev preview against archi-dev before deploy - [x] **FED-06**: On-brand payment success animation — the invoice "paid" tick's circle uses the screensaver-style ring with outer EQ-segment lines (reuse `ScreensaverRing.vue`'s compact size) in place of the current success burst, applied consistently everywhere the paid tick shows -- [ ] **FED-08**: Lightning invoices created by the wallet embed route hints (LND `private` flag) so nodes whose channels are unannounced can actually receive payments — diagnosed on archy-x250-mad2 2026-07-31, where every wallet-UI invoice had `route_hints: []` and was unroutable; the bug is unconditional and affects any node without a public channel -- [ ] **FED-09**: The container doctor does not restart Tor on every run — it recognises Tor's own setgid `2700` hidden-service directory mode as correct rather than "fixing" it to `700` and restarting, a loop that reset Tor every ~5 minutes, starved it of its consensus/HSDir cache (`No more HSDir available to query`), and broke the mesh's Tor fallback entirely; genuinely permissive modes are still corrected, and a restart backoff makes the failure class non-recurring -- [ ] **FED-07**: Fedimint gateway never installs with a pre-set password — gateway credentials are generated per-install via manifest-declared `generated_secrets` (or explicitly set by the user), never baked into the image/manifest; existing installs with the default password get a migration path (BLOCKER — default credentials are a security hole) +- [x] **FED-08**: Lightning invoices created by the wallet embed route hints (LND `private` flag) so nodes whose channels are unannounced can actually receive payments — diagnosed on archy-x250-mad2 2026-07-31, where every wallet-UI invoice had `route_hints: []` and was unroutable; the bug is unconditional and affects any node without a public channel +- [x] **FED-09**: The container doctor does not restart Tor on every run — it recognises Tor's own setgid `2700` hidden-service directory mode as correct rather than "fixing" it to `700` and restarting, a loop that reset Tor every ~5 minutes, starved it of its consensus/HSDir cache (`No more HSDir available to query`), and broke the mesh's Tor fallback entirely; genuinely permissive modes are still corrected, and a restart backoff makes the failure class non-recurring +- [x] **FED-07**: Fedimint gateway never installs with a pre-set password — gateway credentials are generated per-install via manifest-declared `generated_secrets` (or explicitly set by the user), never baked into the image/manifest; existing installs with the default password get a migration path (BLOCKER — default credentials are a security hole) ### UI Fixes (UIFIX) — user-reported blockers, added 2026-07-30 @@ -112,9 +112,9 @@ Which phases cover which requirements. Updated during roadmap creation. | FED-04 | Phase 1 | Complete | | FED-05 | Phase 1 | Pending | | FED-06 | Phase 1 | Complete | -| FED-07 | Phase 1 | Code complete, on-node verification pending (01-16 Task 2) | -| FED-08 | Phase 1 | Pending | -| FED-09 | Phase 1 | Pending | +| FED-07 | Phase 1 | Complete — rotation + recreate verified on archi-dev-box 2026-08-02 | +| FED-08 | Phase 1 | Code complete + unit-pinned; post-OTA check on the user device pending | +| FED-09 | Phase 1 | Complete — 15h Tor uptime / 0 permission-fixes on archi-dev-box; onion-resolution check post-OTA | | UIFIX-01 | Phase 1 | Pending | | UIFIX-02 | Phase 1 | Complete | | UIFIX-03 | Phase 1 | Complete | diff --git a/.planning/phases/01-federation-mesh-hardening/01-16-SUMMARY.md b/.planning/phases/01-federation-mesh-hardening/01-16-SUMMARY.md index 3873eb1c..b7f7cff7 100644 --- a/.planning/phases/01-federation-mesh-hardening/01-16-SUMMARY.md +++ b/.planning/phases/01-federation-mesh-hardening/01-16-SUMMARY.md @@ -129,7 +129,45 @@ required — see the checkpoint result below. So boot and reconcile funnel through one chokepoint and **no boot-specific wiring was added**; `boot_reconciler.rs` is not in `files_modified`. -### ⛔ CHECKPOINT RESULT: the recreate does NOT fire. FED-07 is not closed by this plan. +### ✅ 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 diff --git a/.planning/phases/01-federation-mesh-hardening/01-19-SUMMARY.md b/.planning/phases/01-federation-mesh-hardening/01-19-SUMMARY.md new file mode 100644 index 00000000..8bd38c0b --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-19-SUMMARY.md @@ -0,0 +1,103 @@ +--- +phase: 01-federation-mesh-hardening +plan: 19 +subsystem: lightning +tags: [lnd, invoices, route-hints, private-channels, wallet] + +requires: + - phase: 01-federation-mesh-hardening + provides: "The two existing LND invoice-creation call sites in core/archipelago/src/api/rpc/lnd/wallet.rs — the seller-side/peer-file flow and the wallet UI's Receive flow" +provides: + - "build_invoice_request_body(): one place where an invoice body is minted, with `private: true` unconditional so LND embeds route hints for unannounced channels" +affects: [wallet, lightning, paid-content] + +tech-stack: + added: [] + patterns: + - "Two call sites that must agree get one shared constructor plus one test on the constructor, rather than two near-identical literals and a hope. The duplicated json! literal is exactly how one site got fixed and the other didn't." + +key-files: + created: [] + modified: + - core/archipelago/src/api/rpc/lnd/wallet.rs + +key-decisions: + - "`private: true` is unconditional rather than conditional on 'does this node have only private channels'. It is harmless when public channels exist — LND still routes directly over a public channel when it can, and the hint is an unused alternate path — so the conditional would add a failure mode (mis-detecting channel state) to buy nothing." + - "Both call sites route through one constructor so a single test pins the field for both, and neither can silently drift back to `false`." + +requirements-completed: [FED-08] + +coverage: + - id: D1 + description: "An invoice minted by the wallet UI's Receive flow embeds route hints for private/unannounced channels" + requirement: "FED-08" + verification: + - kind: unit + ref: "core/archipelago/src/api/rpc/lnd/wallet.rs#invoice_request_body_always_sets_private_true" + status: pass + human_judgment: false + - id: D2 + description: "The seller-side/peer-file invoice path embeds them too — the twin site does not drift" + requirement: "FED-08" + verification: + - kind: other + ref: "Both call sites (wallet.rs:330 and wallet.rs:554) call build_invoice_request_body; the json! literal exists in exactly one place" + status: pass + human_judgment: false + - id: D3 + description: "A payment actually arrives over a private channel on an affected node" + requirement: "FED-08" + verification: + - kind: manual_procedural + ref: "Post-OTA checkpoint on the affected node (archy-x250-mad2) — NOT RUN; the plan forbids deploying directly to a user device, so this is verified after the release lands" + status: deferred + human_judgment: true + +duration: 20min +completed: 2026-08-02 +status: complete-pending-post-ota-checkpoint +--- + +# Phase 1 Plan 19: Invoices Embed Route Hints for Private Channels (FED-08) Summary + +**A node whose inbound liquidity sits on unannounced channels could not be paid: its invoices carried no route hints, so a payer had no way to discover a path in. Both invoice-creation paths now mint their body through one constructor that sets `private: true` unconditionally.** + +## Performance + +- **Duration:** ~20 min (code committed 2026-07-31 as `e5c38866`; this SUMMARY 2026-08-02) +- **Tasks:** 1/2 (Task 2 is a post-OTA checkpoint on a user device — see below) +- **Files modified:** 1 + +## Accomplishments + +- `build_invoice_request_body(amount_sats, memo)` is now the single place an LND `/v1/invoices` body is constructed, with `private: true` set unconditionally and a doc comment explaining why it must stay that way. +- Both call sites use it: the seller-side/peer-file flow (`create_invoice`) and the wallet UI's Receive flow (`handle_lnd_createinvoice`, the `lnd.createinvoice` RPC). The duplicated `json!` literal that let one site diverge from the other is gone. +- `invoice_request_body_always_sets_private_true` pins the field for both sites at once. + +## Deviations from Plan + +None. + +**Process note:** as with 01-20, the code landed on 2026-07-31 (`e5c38866`) but no SUMMARY was written and the roadmap entry was never ticked, so the plan read as unstarted. This file closes that gap. + +## Known Stubs + +None. + +## Threat Flags + +None. `private: true` does not weaken anything: it adds a routing hint to an invoice the payer already holds. It does reveal the existence of an unannounced channel to whoever holds that specific invoice — which is inherent to being payable over that channel at all, and is the explicit intent of the requirement. + +## Outstanding: post-OTA checkpoint (Task 2) + +The affected node is **archy-x250-mad2, a user's device**. Commit `516c3bfa` records the standing +constraint: never deploy directly to a user device — verify post-OTA instead. So the remaining +verification is: + +1. Ship the release +2. After it lands on the affected node, mint an invoice from the wallet's Receive flow +3. Confirm the BOLT11 decodes with a route hint (`r` field) for the private channel +4. Confirm a payment from outside actually settles over it + +This is the one piece of FED-08 that cannot be proven from here. + diff --git a/.planning/phases/01-federation-mesh-hardening/01-20-SUMMARY.md b/.planning/phases/01-federation-mesh-hardening/01-20-SUMMARY.md new file mode 100644 index 00000000..87dcc193 --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-20-SUMMARY.md @@ -0,0 +1,131 @@ +--- +phase: 01-federation-mesh-hardening +plan: 20 +subsystem: infra +tags: [tor, doctor, shell, mesh, permissions] + +requires: + - phase: 01-federation-mesh-hardening + provides: "scripts/container-doctor.sh's fix_* convention and the archipelago-doctor.timer (OnUnitActiveSec=5min) that drives it" +provides: + - "A permission predicate that tests the property that matters (group/other denied) instead of exact-matching one octal string" + - "A 30-minute Tor restart backoff, so a restart storm is structurally impossible even if some future condition makes the fix fire repeatedly" +affects: [tor, mesh, doctor] + +tech-stack: + added: [] + patterns: + - "Assert the property, not the representation: `2700` and `700` both deny group and other, which is the security property. Exact-matching the string `700` turned Tor's own setgid bit into a permanent false positive." + - "Pair a predicate fix with a rate limit: the predicate stops today's loop, the backoff stops the whole class of loop. One is a fix, both is a guarantee." + +key-files: + created: [] + modified: + - scripts/container-doctor.sh + +key-decisions: + - "Compare the low three digits of `stat -c '%a'` rather than the whole string, so both `700` and `2700` read as correct while `750`, `707` and `2755` are still corrected." + - "The 'already correct' path logs at debug level only. The original defect was invisible precisely because 'Fixed permissions on … (2700 -> 700)' looked like the doctor working — a healthy node should stay quiet." + - "Backoff state lives in `/var/lib/archipelago/doctor-tor-last-restart` with a 1800s window, and non-numeric/missing content reads as 0 so a corrupt state file fails open into 'restart allowed' rather than wedging the fix permanently." + +requirements-completed: [FED-09] + +coverage: + - id: D1 + description: "The doctor no longer restarts Tor on every run — Tor uptime grows past the doctor's 5-minute interval" + requirement: "FED-09" + verification: + - kind: manual_procedural + ref: "archi-dev-box 2026-08-02: tor@default active since 2026-08-01 15:25:45 — 15+ hours continuous, spanning ~180 doctor intervals" + status: pass + human_judgment: false + - id: D2 + description: "A hidden-service directory at Tor's own 2700 is recognised as correct — no chmod, no restart" + requirement: "FED-09" + verification: + - kind: other + ref: "Predicate exercised directly on tmpdirs: 2700 -> low3=700 -> ACCEPTED; 700 -> ACCEPTED" + status: pass + - kind: manual_procedural + ref: "archi-dev-box: zero 'Fixed permissions … hidden_service' lines across 542 doctor log entries in 6 hours" + status: pass + human_judgment: false + - id: D3 + description: "A genuinely insecure directory (group/other readable) is still corrected" + requirement: "FED-09" + verification: + - kind: other + ref: "Predicate exercised on 750, 707 and 2755 — all three fall through to the corrective chmod" + status: pass + human_judgment: false + - id: D4 + description: "Even on a real fix, Tor cannot be restarted more than once per backoff window" + requirement: "FED-09" + verification: + - kind: other + ref: "TOR_RESTART_BACKOFF_SECONDS=1800 gates the restart against TOR_RESTART_STATE_FILE; non-numeric state reads as 0" + status: pass + human_judgment: false + - id: D5 + description: "Tor retains its consensus/HSDir cache long enough to resolve .onion addresses" + requirement: "FED-09" + verification: + - kind: manual_procedural + ref: "Post-OTA checkpoint on an affected node — NOT RUN (see below). 15h of unbroken Tor uptime on archi-dev-box is the necessary precondition and is met." + status: deferred + human_judgment: true + +duration: 30min +completed: 2026-08-02 +status: complete-pending-post-ota-checkpoint +--- + +# Phase 1 Plan 20: Stop the Doctor Fighting Tor Over the Setgid Bit (FED-09) Summary + +**The container doctor treated Tor's own `2700` hidden-service directory as a permission defect, "fixed" it, and restarted Tor — every five minutes, forever. Tor never lived long enough to build a usable HSDir cache, so onion lookups failed and the mesh's Tor fallback died with it.** + +## Performance + +- **Duration:** ~30 min (code committed 2026-07-31 as `4435f95e`; verification and this SUMMARY 2026-08-02) +- **Tasks:** 1/2 (Task 2 is a post-OTA checkpoint — see below) +- **Files modified:** 1 + +## Accomplishments + +- The predicate now compares the low three digits of `stat -c '%a'`, so Tor's setgid `2700` and a plain `700` both read as correct, while `750`, `707` and `2755` are still corrected. Verified directly against all five modes. +- A 30-minute restart backoff (`/var/lib/archipelago/doctor-tor-last-restart`) makes a restart storm impossible even if some future condition makes the fix fire repeatedly. The predicate fixes today's bug; the backoff retires the class. +- The "already correct" branch logs at debug level so a healthy node stays quiet — the original defect hid inside a log line that read like success. + +## Evidence on a real node (archi-dev-box, 2026-08-02) + +- `tor@default` active since **2026-08-01 15:25:45** — over 15 hours continuous, spanning roughly 180 doctor intervals. Before the fix, observed restarts were 13:07:56 → 13:13:14 → 13:18:39 → 13:23:57, each within a second of a timer firing. +- **Zero** `Fixed permissions … hidden_service` lines across **542** doctor log entries in the preceding 6 hours. + +## Deviations from Plan + +None. The implementation matches the plan's action exactly: predicate corrected at the property level, backoff added, logging made quiet-when-healthy. + +**Process note:** the code landed on 2026-07-31 as `4435f95e` but no SUMMARY was written and the roadmap entry was never ticked, so the plan looked unstarted for two days. That is the bookkeeping gap this file closes. + +## Known Stubs + +None. + +## Threat Flags + +- The plan's prohibition — never loosen hidden-service permissions — holds: group and other access is still denied on every path. `2700` is accepted precisely *because* it denies them; the setgid bit is orthogonal to that property. +- **Scope boundary respected:** the FIPS direct-transport `connect_fail` (problem 1 in the diagnosis) was deliberately NOT touched here. It belongs with FED-03's structured review of the transport/dial layer. + +## Outstanding: post-OTA checkpoint (Task 2) + +Task 2 verifies on an **affected** node after the release lands, and explicitly forbids deploying +directly to a user's device (`archy-x250-mad2` or any node that is not ours) — verification is +post-OTA only. Remaining there: + +1. Tor uptime keeps growing across three doctor intervals (met on archi-dev-box; needs repeating on an affected node) +2. No recurring "Fixed permissions" lines (met on archi-dev-box) +3. Onion resolution works — a mesh send to a Tor-only peer succeeds and `No more HSDir available to query` is gone +4. The doctor still corrects a deliberately `chmod 750`'d hidden-service directory + +Items 3 and 4 are the ones genuinely outstanding; 1 and 2 already have real-node evidence. + diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 547f5b8f..14bf0593 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -1254,6 +1254,13 @@ pub struct ProdContainerOrchestrator { /// secret calls; a rotation (hash change) falls through and /// re-registers. env_secret_cache: Mutex>, + /// App ids whose credential this process rotated off a publicly known + /// default (FED-07). A rotation leaves the RUNNING container holding the + /// compromised value, so its env drift must be acted on even when the app + /// is restart-sensitive — leaving it untouched perpetuates the compromise, + /// exactly as leaving published-port drift untouched perpetuates a broken + /// container. Consumed (and cleared) by the drift check that recreates it. + credential_rotated: Mutex>, #[cfg(test)] test_disk_gb: Option, #[cfg(test)] @@ -1317,6 +1324,7 @@ impl ProdContainerOrchestrator { secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"), use_quadlet_backends: config.use_quadlet_backends, env_secret_cache: Mutex::new(HashMap::new()), + credential_rotated: Mutex::new(HashSet::new()), #[cfg(test)] test_disk_gb: None, #[cfg(test)] @@ -1339,6 +1347,7 @@ impl ProdContainerOrchestrator { secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"), use_quadlet_backends: false, env_secret_cache: Mutex::new(HashMap::new()), + credential_rotated: Mutex::new(HashSet::new()), test_disk_gb: None, test_bitcoin_host: None, } @@ -2040,7 +2049,24 @@ impl ProdContainerOrchestrator { return Ok(ReconcileAction::Installed); } if self.container_env_drifted(&name, &resolved_manifest).await { - if mode == ReconcileMode::ExistingOnly + // FED-07: a credential we just rotated off a publicly + // known default leaves the RUNNING container holding + // the compromised value. Restart-sensitivity protects + // working services; this service is compromised, and + // skipping it perpetuates the exposure — the same + // reasoning the published-port carve-out above uses. + let rotated = self.credential_rotated.lock().await.remove(&app_id); + if rotated { + tracing::warn!( + app_id = %app_id, + container = %name, + "recreating restart-sensitive app: its admin credential was \ + rotated off a publicly known default and the running \ + container still holds the compromised one (FED-07)" + ); + } + if !rotated + && mode == ReconcileMode::ExistingOnly && is_restart_sensitive_app(&app_id) { tracing::info!( @@ -3262,6 +3288,14 @@ impl ProdContainerOrchestrator { if manifest.app.id == "fedimint-gateway" && crate::container::secrets::rotate_compromised_gateway_credential(&self.secrets_dir)? { + // Mark the app so the drift check below recreates it even though + // it is restart-sensitive. Without this the unit is rewritten but + // never restarted, and the gateway keeps serving the compromised + // credential indefinitely (observed on archi-dev-box 2026-08-01). + self.credential_rotated + .lock() + .await + .insert(manifest.app.id.clone()); // Names a path, never a value — this line crosses into the node's // logs, which are a wider audience than the 0600 secrets dir. tracing::info!( @@ -5125,6 +5159,116 @@ app: } } + /// A fedimint-gateway manifest shaped like the real one: a bcrypt + /// generated secret plus a secret_env that reads it, which is what makes + /// the credential participate in secret_env_hash. + fn gateway_manifest_yaml() -> &'static str { + "app:\n id: fedimint-gateway\n name: Fedimint Gateway\n version: 0.10.0\n container:\n image: x:1\n generated_secrets:\n - name: fedimint-gateway-hash\n kind: bcrypt\n secret_env:\n - key: FEDI_HASH\n secret_file: fedimint-gateway-hash\n" + } + + /// FED-07. Rotating a compromised credential leaves the RUNNING container + /// holding the old value, so the rotation must flag the app for recreate. + /// Without the flag the drift check skips it as restart-sensitive and the + /// gateway keeps serving the published default forever — observed on + /// archi-dev-box 2026-08-01 before this was wired up. + #[tokio::test] + async fn rotating_a_compromised_credential_flags_the_app_for_recreate() { + let rt = Arc::new(MockRuntime::default()); + let mut orch = orch_with(rt).await; + + let tmp = tempfile::TempDir::new().unwrap(); + let secrets = tmp.path().join("secrets"); + std::fs::create_dir_all(&secrets).unwrap(); + // An install carrying the shipped default, with its .pw sibling + // present so ensure_one's fast path no-ops and rotation is what acts. + std::fs::write( + secrets.join("fedimint-gateway-hash"), + "$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC", + ) + .unwrap(); + std::fs::write(secrets.join("fedimint-gateway-hash.pw"), "stale-plaintext").unwrap(); + orch.set_secrets_dir(secrets.clone()); + + let mut manifest = AppManifest::parse(gateway_manifest_yaml()).unwrap(); + orch.resolve_dynamic_env(&mut manifest).await.unwrap(); + + assert!( + orch.credential_rotated + .lock() + .await + .contains("fedimint-gateway"), + "a rotated credential must flag its app so the drift check recreates it" + ); + let after = std::fs::read_to_string(secrets.join("fedimint-gateway-hash")).unwrap(); + assert!( + !after.contains("t9YjjxkiktrlYvjajB"), + "the compromised value must be gone from the file" + ); + } + + /// The adjacency edge: an app whose credential was NOT rotated must not be + /// flagged, or every reconcile tick would recreate restart-sensitive apps. + #[tokio::test] + async fn a_unique_credential_does_not_flag_the_app() { + let rt = Arc::new(MockRuntime::default()); + let mut orch = orch_with(rt).await; + + let tmp = tempfile::TempDir::new().unwrap(); + let secrets = tmp.path().join("secrets"); + std::fs::create_dir_all(&secrets).unwrap(); + crate::container::secrets::ensure_gateway_credential(&secrets).unwrap(); + let before = std::fs::read_to_string(secrets.join("fedimint-gateway-hash")).unwrap(); + orch.set_secrets_dir(secrets.clone()); + + let mut manifest = AppManifest::parse(gateway_manifest_yaml()).unwrap(); + orch.resolve_dynamic_env(&mut manifest).await.unwrap(); + + assert!( + orch.credential_rotated.lock().await.is_empty(), + "an app with a unique credential must never be flagged for recreate" + ); + assert_eq!( + before, + std::fs::read_to_string(secrets.join("fedimint-gateway-hash")).unwrap(), + "a unique credential must be left byte-identical" + ); + } + + /// Idempotence at the flag level: the second pass finds a value that is no + /// longer on the denylist, so it neither rotates nor re-flags. This is what + /// stops a recreate loop on every reconcile tick (T-01-73). + #[tokio::test] + async fn a_second_pass_does_not_re_flag_the_app() { + let rt = Arc::new(MockRuntime::default()); + let mut orch = orch_with(rt).await; + + let tmp = tempfile::TempDir::new().unwrap(); + let secrets = tmp.path().join("secrets"); + std::fs::create_dir_all(&secrets).unwrap(); + std::fs::write( + secrets.join("fedimint-gateway-hash"), + "$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC", + ) + .unwrap(); + std::fs::write(secrets.join("fedimint-gateway-hash.pw"), "stale-plaintext").unwrap(); + orch.set_secrets_dir(secrets.clone()); + + let mut m1 = AppManifest::parse(gateway_manifest_yaml()).unwrap(); + orch.resolve_dynamic_env(&mut m1).await.unwrap(); + // The drift check consumes the flag when it recreates. + orch.credential_rotated + .lock() + .await + .remove("fedimint-gateway"); + + let mut m2 = AppManifest::parse(gateway_manifest_yaml()).unwrap(); + orch.resolve_dynamic_env(&mut m2).await.unwrap(); + assert!( + orch.credential_rotated.lock().await.is_empty(), + "the second pass must not re-flag — the rotated value is not denylisted" + ); + } + #[tokio::test] async fn install_fresh_build_when_image_absent() { let rt = Arc::new(MockRuntime::default());