fix(01-16): recreate the gateway when its credential was rotated (FED-07)

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) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 07:20:20 -04:00
co-authored by Claude Opus 5
parent 5cf44c9a58
commit 06e0e6954e
5 changed files with 424 additions and 8 deletions
+6 -6
View File
@@ -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 |
@@ -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
@@ -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.
</content>
@@ -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.
</content>
@@ -1254,6 +1254,13 @@ pub struct ProdContainerOrchestrator {
/// secret calls; a rotation (hash change) falls through and
/// re-registers.
env_secret_cache: Mutex<HashMap<String, String>>,
/// 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<HashSet<String>>,
#[cfg(test)]
test_disk_gb: Option<u64>,
#[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());