diff --git a/.planning/phases/01-federation-mesh-hardening/01-16-SUMMARY.md b/.planning/phases/01-federation-mesh-hardening/01-16-SUMMARY.md new file mode 100644 index 00000000..304f39d8 --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-16-SUMMARY.md @@ -0,0 +1,242 @@ +--- +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: 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 blocking checkpoint on archi-dev-box — NOT RUN" + status: deferred + 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 — confirming no value appears in a real node's log — NOT RUN" + status: deferred + 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: 45min +completed: 2026-08-01 +status: task-1-complete-checkpoint-pending +--- + +# Phase 1 Plan 16: Rotate Existing Installs Off the Shipped Gateway Credential (FED-07) Summary + +**Task 1 is complete: an affected node now detects the published default on its next reconcile tick and rotates itself onto a unique credential, with the container rebuilt through the platform's own drift-recreate path rather than any hand-rolled teardown. Task 2 — the blocking on-node checkpoint — has NOT been run.** + +## Status + +**FED-07 is not yet closed.** This plan's requirement stays open until the Task 2 checkpoint runs on a +real node. The code half is done and verified by unit tests; the on-node half is untouched. + +## Accomplishments + +- `rotate_compromised_gateway_credential(secrets_dir) -> Result` 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`. + +### The recreate fires through `secret_env_hash` — mechanism confirmed by reading, not yet observed running + +`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. + diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index 4f653199..a28030c7 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -598,9 +598,9 @@ impl RpcHandler { // ensure_gateway_credential above, so the secret is guaranteed to // exist here; re-reading it (rather than threading the value // through) keeps one canonical read site in container::secrets. - let fedi_hash = crate::container::secrets::gateway_bcrypt_hash( - std::path::Path::new("/var/lib/archipelago/secrets"), - )?; + let fedi_hash = crate::container::secrets::gateway_bcrypt_hash(std::path::Path::new( + "/var/lib/archipelago/secrets", + ))?; configure_fedimint_lnd( &self.config.host_ip, &mut ports, diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 37d899b1..547f5b8f 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -3244,6 +3244,37 @@ impl ProdContainerOrchestrator { // `secret_env` resolves — no per-app code, no host provisioning. crate::container::secrets::ensure_generated_secrets(&self.secrets_dir, manifest)?; + // FED-07 migration. A node installed before the shipped-default + // fallbacks were removed is still answering to a credential published + // in this repository. Detect that exact value and rotate it, once. + // + // The rotated hash changes the resolved secret env below, which changes + // `secret_env_hash`, which the drift check reads as a label mismatch on + // the running container — so the platform's own recreate path rebuilds + // it around an unchanged data directory, ports, volumes and container + // name. Deliberately no teardown here: a hand-rolled remove-and-run is + // the anti-pattern CLAUDE.md names, and it is what would lose the + // gateway's state. + // + // An error propagates rather than being swallowed: the atomic write + // leaves the previous credential in place, so surfacing the failure is + // strictly better than continuing with a half-rotated gateway. + if manifest.app.id == "fedimint-gateway" + && crate::container::secrets::rotate_compromised_gateway_credential(&self.secrets_dir)? + { + // 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!( + app = "fedimint-gateway", + "Rotated the Fedimint gateway admin credential: this node was carrying a publicly \ + known default that shipped in the repository (FED-07). The gateway will be \ + recreated around its existing data. The new password is readable by the service \ + user at {}/{}.pw", + self.secrets_dir.display(), + crate::container::secrets::GATEWAY_HASH_SECRET_NAME, + ); + } + let mut facts = self.detect_host_facts().await; // Only pay the podman cost to detect Knots-vs-Core when this manifest // actually templates the Bitcoin node into its env (mempool — B12). diff --git a/core/archipelago/src/container/secrets.rs b/core/archipelago/src/container/secrets.rs index 2102e6c7..d8b733d6 100644 --- a/core/archipelago/src/container/secrets.rs +++ b/core/archipelago/src/container/secrets.rs @@ -67,18 +67,29 @@ fn ensure_one(dir: &Path, gs: &GeneratedSecret) -> Result<()> { SecretGenKind::Hex16 => write_secret(&dir.join(&gs.name), &random_hex(16))?, SecretGenKind::Hex32 => write_secret(&dir.join(&gs.name), &random_hex(32))?, SecretGenKind::Base64 => write_secret(&dir.join(&gs.name), &random_base64(32))?, - SecretGenKind::Bcrypt => { - let password = random_hex(BCRYPT_PASSWORD_BYTES); - let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST) - .context("bcrypt-hashing generated password")?; - // Primary (server-facing hash) first, then the plaintext sibling. - write_secret(&dir.join(&gs.name), &hash)?; - write_secret(&dir.join(format!("{}.pw", gs.name)), &password)?; - } + SecretGenKind::Bcrypt => write_bcrypt_pair(dir, &gs.name)?, } Ok(()) } +/// Generate a fresh bcrypt credential pair for `name` under `dir`: the +/// server-facing hash at `` and its plaintext sibling at `.pw`, +/// both 0600 through the atomic [`write_secret`]. +/// +/// The single implementation of bcrypt generation on this platform — +/// [`ensure_one`]'s `Bcrypt` arm and +/// [`rotate_compromised_gateway_credential`] both go through here, so there is +/// one place where a credential comes into existence. +fn write_bcrypt_pair(dir: &Path, name: &str) -> Result<()> { + let password = random_hex(BCRYPT_PASSWORD_BYTES); + let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST) + .context("bcrypt-hashing generated password")?; + // Primary (server-facing hash) first, then the plaintext sibling. + write_secret(&dir.join(name), &hash)?; + write_secret(&dir.join(format!("{}.pw", name)), &password)?; + Ok(()) +} + /// True when `path` exists, is readable by this process, and is non-empty after /// trimming. Any error (missing, permission denied, empty) reads as false. fn readable_nonempty(path: &Path) -> bool { @@ -177,6 +188,48 @@ pub fn gateway_bcrypt_hash(secrets_dir: &Path) -> Result { Ok(hash.to_string()) } +/// Detect and rotate a Fedimint gateway credential that is a publicly known +/// shipped default (FED-07 migration). +/// +/// Returns `Ok(true)` only when the stored hash was an EXACT match for a +/// [`KNOWN_DEFAULT_GATEWAY_HASHES`] entry and has been replaced with a freshly +/// generated pair. An absent, unreadable, or simply unrecognised-but-unique +/// value returns `Ok(false)` and writes nothing: rotation must never fire on +/// "anything I did not generate this run", or an operator who deliberately set +/// their own credential would have it silently replaced. +/// +/// Generating a credential where none exists is +/// [`ensure_gateway_credential`]'s job, not this function's. +/// +/// **Rollback:** the replacement goes through [`write_secret`]'s atomic +/// temp-file-plus-rename, so a failure part-way through leaves the previous +/// credential file intact and the gateway keeps working with it. Do NOT +/// "improve" this into a truncate-in-place or a remove-then-write — that turns +/// a failed rotation into a gateway configured against a credential nobody +/// holds. +/// +/// **Self-terminating:** the value written is freshly generated and therefore +/// not on the denylist, so the next reconcile tick detects nothing and changes +/// nothing. Rotation happens at most once per affected node. +pub fn rotate_compromised_gateway_credential(secrets_dir: &Path) -> Result { + let path = secrets_dir.join(GATEWAY_HASH_SECRET_NAME); + let Ok(current) = fs::read_to_string(&path) else { + // Absent or unreadable: nothing to rotate. ensure_gateway_credential + // owns materialising it. + return Ok(false); + }; + if !KNOWN_DEFAULT_GATEWAY_HASHES.contains(¤t.trim()) { + return Ok(false); + } + write_bcrypt_pair(secrets_dir, GATEWAY_HASH_SECRET_NAME).with_context(|| { + format!( + "rotating compromised gateway credential at {}", + path.display() + ) + })?; + Ok(true) +} + /// Write an externally computed secret value (0600, atomic). For derived /// secrets that aren't random generators — e.g. the btcpay internal-LND /// connection string assembled in `container::lnd`. @@ -290,9 +343,8 @@ mod tests { ensure_gateway_credential(dir.path()).unwrap(); let hash = std::fs::read_to_string(dir.path().join(GATEWAY_HASH_SECRET_NAME)).unwrap(); - let pw = - std::fs::read_to_string(dir.path().join(format!("{GATEWAY_HASH_SECRET_NAME}.pw"))) - .unwrap(); + let pw = std::fs::read_to_string(dir.path().join(format!("{GATEWAY_HASH_SECRET_NAME}.pw"))) + .unwrap(); assert!(bcrypt::verify(pw.trim(), hash.trim()).unwrap()); for f in [ @@ -357,6 +409,123 @@ mod tests { assert_ne!(hash_a, hash_b, "two fresh installs must not share a hash"); } + // ── FED-07 migration: rotating a shipped default off an existing node ── + + #[test] + fn rotates_a_denylisted_gateway_credential() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(GATEWAY_HASH_SECRET_NAME), + KNOWN_DEFAULT_GATEWAY_HASHES[0], + ) + .unwrap(); + + assert!(rotate_compromised_gateway_credential(dir.path()).unwrap()); + + // The new value is readable through the normal accessor, which means + // it is neither empty nor still denylisted. + let rotated = gateway_bcrypt_hash(dir.path()).unwrap(); + assert!(!KNOWN_DEFAULT_GATEWAY_HASHES.contains(&rotated.as_str())); + + // The plaintext sibling was written too and verifies against the hash, + // so the operator can actually get back into the gateway. + let pw = std::fs::read_to_string(dir.path().join(format!("{GATEWAY_HASH_SECRET_NAME}.pw"))) + .unwrap(); + assert!(bcrypt::verify(pw.trim(), rotated.trim()).unwrap()); + + for f in [ + GATEWAY_HASH_SECRET_NAME.to_string(), + format!("{GATEWAY_HASH_SECRET_NAME}.pw"), + ] { + let mode = std::fs::metadata(dir.path().join(&f)) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600, "{f} must stay 0600 after rotation"); + } + } + + #[test] + fn leaves_a_unique_gateway_credential_alone() { + let dir = tempfile::tempdir().unwrap(); + ensure_gateway_credential(dir.path()).unwrap(); + let before = gateway_bcrypt_hash(dir.path()).unwrap(); + + assert!(!rotate_compromised_gateway_credential(dir.path()).unwrap()); + assert_eq!(before, gateway_bcrypt_hash(dir.path()).unwrap()); + } + + #[test] + fn leaves_an_unrecognised_credential_alone() { + // The adjacency edge that matters: an operator's own hand-set value is + // not on the denylist and must survive. Rotation is denylist-exact, + // never "anything I did not generate". + let dir = tempfile::tempdir().unwrap(); + let operator_set = "$2y$10$operatorChosenValueThatWeMustNeverTouchAAAAAAAAAAAAAAAAAAAAA"; + std::fs::write(dir.path().join(GATEWAY_HASH_SECRET_NAME), operator_set).unwrap(); + + assert!(!rotate_compromised_gateway_credential(dir.path()).unwrap()); + assert_eq!( + std::fs::read_to_string(dir.path().join(GATEWAY_HASH_SECRET_NAME)).unwrap(), + operator_set + ); + } + + #[test] + fn no_op_when_no_gateway_credential_exists() { + let dir = tempfile::tempdir().unwrap(); + assert!(!rotate_compromised_gateway_credential(dir.path()).unwrap()); + assert!(!dir.path().join(GATEWAY_HASH_SECRET_NAME).exists()); + } + + #[test] + fn rotation_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(GATEWAY_HASH_SECRET_NAME), + KNOWN_DEFAULT_GATEWAY_HASHES[0], + ) + .unwrap(); + + assert!(rotate_compromised_gateway_credential(dir.path()).unwrap()); + let after_first = gateway_bcrypt_hash(dir.path()).unwrap(); + + // Second tick: nothing detected, nothing changed. This is what stops a + // reconcile loop from recreating the gateway on every pass. + assert!(!rotate_compromised_gateway_credential(dir.path()).unwrap()); + assert_eq!(after_first, gateway_bcrypt_hash(dir.path()).unwrap()); + } + + #[test] + fn rotation_touches_no_other_secret() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(GATEWAY_HASH_SECRET_NAME), + KNOWN_DEFAULT_GATEWAY_HASHES[0], + ) + .unwrap(); + let bystanders = [ + ("mempool-db-password", "mempool-value"), + ("immich-db-password", "immich-value"), + ("fmcd-password", "fmcd-value"), + ("bitcoin-rpc-password", "bitcoin-value"), + ]; + for (name, value) in bystanders { + std::fs::write(dir.path().join(name), value).unwrap(); + } + + assert!(rotate_compromised_gateway_credential(dir.path()).unwrap()); + + for (name, value) in bystanders { + assert_eq!( + std::fs::read_to_string(dir.path().join(name)).unwrap(), + value, + "{name} must be byte-identical after a gateway rotation" + ); + } + } + #[test] fn self_heals_unreadable_secret() { // Simulate the root-owned case: a present-but-unreadable file. We can't