Files
archy/core/archipelago/src/container/secrets.rs
T
archipelagoandClaude Opus 5 9e2d2ef236 feat(01-16): rotate existing installs off the shipped gateway credential (FED-07)
01-11 stopped new installs from ever taking a shipped credential, but did
nothing for the nodes that already did — those gateways still answer to a
password published in this repository.

rotate_compromised_gateway_credential() detects an EXACT match against the
denylist and replaces the pair; absent, unique, or merely unrecognised values
are left alone and return false. That distinction is the point: an operator
who deliberately set their own credential also has an "unrecognised" one, and
rotating it would be the same class of harm as leaving the default in place.

It hangs off resolve_dynamic_env beside ensure_generated_secrets, gated on the
gateway's app id, so an affected node heals on its next reconcile tick. There
is deliberately no teardown here: the new hash 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 gateway around its
unchanged data directory, ports, volumes and name.

Rotation is self-terminating (the value written is not on the denylist, so the
next tick is a no-op) and errors propagate rather than being swallowed, because
the atomic write leaves the previous credential intact on failure.

Bcrypt generation was factored out of ensure_one into write_bcrypt_pair, which
both generation and rotation call — 01-11's SUMMARY claimed such a helper
existed but the arm was still inline, and rotation cannot reuse
ensure_gateway_credential because its idempotent fast path returns early
exactly when the file is present, which is the case rotation acts on.

Also fixes cargo fmt drift left by 42652547 in install.rs.

Verified: 6 new tests, secrets suite 16/16; full suite 1008 passed with one
known wall-clock flake (green 4/4 in isolation). NOT verified on a node —
Task 2's blocking checkpoint has not been run, so FED-07 stays open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:17:56 -04:00

543 lines
22 KiB
Rust

//! Declarative, self-healing generation of app secrets.
//!
//! An app declares `generated_secrets` in its manifest; this module materialises
//! them just before `secret_env` is resolved. That keeps the migration's
//! data-driven bar: an app installs from its manifest alone — no host
//! provisioning and no per-app Rust — and every secret lands `0600`, owned by
//! the unprivileged (rootless) service user.
//!
//! Two properties make it safe to call on every install/reconcile tick:
//!
//! * **Idempotent** — a target file that already exists, is readable and
//! non-empty is left untouched, so values are stable across ticks.
//! * **Self-healing without privilege** — a target file that exists but is
//! *unreadable* (the classic `root:root`-owned secret left by some earlier
//! path) is unlinked and rewritten. Unlinking needs write on the
//! service-owned secrets dir, not on the file, so this recovers the broken
//! state with no `chown` and no root — exactly what a rootless node needs.
use anyhow::{Context, Result};
use archipelago_container::{AppManifest, GeneratedSecret, SecretGenKind};
use rand::RngCore;
use std::fs;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
/// Plaintext-password length (bytes of entropy) for [`SecretGenKind::Bcrypt`].
const BCRYPT_PASSWORD_BYTES: usize = 24;
/// Materialise every declared generated secret for `manifest` under
/// `secrets_dir`. No-op when the manifest declares none. Safe to call on every
/// reconcile/install tick (idempotent + self-healing).
pub fn ensure_generated_secrets(secrets_dir: &Path, manifest: &AppManifest) -> Result<()> {
let specs = &manifest.app.container.generated_secrets;
if specs.is_empty() {
return Ok(());
}
fs::create_dir_all(secrets_dir)
.with_context(|| format!("creating secrets dir {}", secrets_dir.display()))?;
for gs in specs {
ensure_one(secrets_dir, gs).with_context(|| format!("generating secret '{}'", gs.name))?;
}
Ok(())
}
fn ensure_one(dir: &Path, gs: &GeneratedSecret) -> Result<()> {
let files = gs.target_files();
// Idempotent fast path: every target file present, readable and non-empty.
if files.iter().all(|f| readable_nonempty(&dir.join(f))) {
return Ok(());
}
// Self-heal: drop any stale/unreadable target so the write below recreates
// it owned by us. Unlinking uses the (service-owned) dir's write bit, so a
// wrongly root-owned secret is recovered with no privilege escalation.
for f in &files {
let p = dir.join(f);
if p.exists() && !readable_nonempty(&p) {
tracing::warn!("regenerating unreadable/stale secret {}", p.display());
fs::remove_file(&p)
.with_context(|| format!("removing stale secret {}", p.display()))?;
}
}
match gs.kind {
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 => write_bcrypt_pair(dir, &gs.name)?,
}
Ok(())
}
/// Generate a fresh bcrypt credential pair for `name` under `dir`: the
/// server-facing hash at `<name>` and its plaintext sibling at `<name>.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 {
fs::read_to_string(path)
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
}
fn random_hex(bytes: usize) -> String {
let mut buf = vec![0u8; bytes];
rand::thread_rng().fill_bytes(&mut buf);
hex::encode(buf)
}
/// `bytes` of entropy, standard base64 (with padding). For keys that a service
/// base64-decodes to recover the raw bytes (e.g. netbird's store encryptionKey).
fn random_base64(bytes: usize) -> String {
use base64::Engine as _;
let mut buf = vec![0u8; bytes];
rand::thread_rng().fill_bytes(&mut buf);
base64::engine::general_purpose::STANDARD.encode(buf)
}
/// Canonical secret name for the Fedimint gateway's admin bcrypt hash — must
/// match `generated_secrets: fedimint-gateway-hash` in
/// `apps/fedimint-gateway/manifest.yml` so the Rust orchestrator, first-boot
/// script, reconcile script and both deploy scripts all agree on one file
/// (FED-07: before this, scripts wrote `fedimint-gateway-password` while the
/// daemon read `fedimint-gateway-hash`).
pub const GATEWAY_HASH_SECRET_NAME: &str = "fedimint-gateway-hash";
/// Detection-only denylist of bcrypt hashes that shipped as hardcoded
/// fallback credentials in this repository before FED-07. `t9YjjxkiktrlYvjajB
/// /zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC` was substituted for the Fedimint
/// gateway's admin password whenever the real per-install secret was
/// missing — in `config.rs`, `dependencies.rs`, and every shell install path
/// — meaning anyone holding a copy of this repo held the admin credential for
/// every gateway that ever took that fallback.
///
/// This value exists **only** so an install still carrying it can be
/// detected and rotated (plan 01-16 owns the migration). It must NEVER be
/// passed to a container, written to a fresh install, or handed back to a
/// caller by [`gateway_bcrypt_hash`] — that function returns `Err` instead.
/// This is the one and only place this value may appear in the tree.
const KNOWN_DEFAULT_GATEWAY_HASHES: &[&str] =
&["$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC"];
/// Idempotently ensure the Fedimint gateway's admin credential exists under
/// `secrets_dir`: a fresh per-install bcrypt hash plus its `.pw` plaintext
/// sibling, both 0600. Delegates to [`ensure_one`] for the actual bcrypt
/// generation so there is exactly one implementation of that logic — this
/// also means a second call is a no-op (idempotent fast path) and a
/// present-but-unreadable file self-heals, so a reconcile tick never rotates
/// a working gateway credential out from under it.
pub fn ensure_gateway_credential(secrets_dir: &Path) -> Result<()> {
fs::create_dir_all(secrets_dir)
.with_context(|| format!("creating secrets dir {}", secrets_dir.display()))?;
let gs = GeneratedSecret {
name: GATEWAY_HASH_SECRET_NAME.to_string(),
kind: SecretGenKind::Bcrypt,
};
ensure_one(secrets_dir, &gs)
}
/// Read the Fedimint gateway's canonical per-install bcrypt hash.
///
/// Returns `Err` naming the missing file when it is absent, empty, or
/// unreadable — callers must propagate that error rather than substitute a
/// literal, so an install with no credential fails loudly instead of quietly
/// starting an unauthenticated/default-credentialed gateway. Also returns
/// `Err` when the stored value matches [`KNOWN_DEFAULT_GATEWAY_HASHES`]: a
/// node carrying the shipped default must not be handed that value back by
/// this codebase, even to reconfigure itself with the same value it already
/// (insecurely) has.
pub fn gateway_bcrypt_hash(secrets_dir: &Path) -> Result<String> {
let path = secrets_dir.join(GATEWAY_HASH_SECRET_NAME);
let hash = fs::read_to_string(&path).with_context(|| {
format!(
"gateway credential missing at {} — call ensure_gateway_credential (or wait for the \
next reconcile tick) to generate a per-install credential before starting the gateway",
path.display()
)
})?;
let hash = hash.trim();
if hash.is_empty() {
anyhow::bail!("gateway credential {} is empty", path.display());
}
if KNOWN_DEFAULT_GATEWAY_HASHES.contains(&hash) {
anyhow::bail!(
"gateway credential {} is a publicly known default that shipped hardcoded in this \
repository before FED-07 — this install must rotate it (see plan 01-16) before the \
gateway can be (re)configured",
path.display()
);
}
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<bool> {
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(&current.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`.
pub(crate) fn write_secret_file(path: &Path, value: &str) -> Result<()> {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)
.with_context(|| format!("creating secrets dir {}", dir.display()))?;
}
write_secret(path, value)
}
/// Atomically write a `0600` secret: a temp file in the same dir (so the rename
/// is atomic), fsynced, then renamed over the target.
fn write_secret(path: &Path, value: &str) -> Result<()> {
let dir = path
.parent()
.context("secret path has no parent directory")?;
let name = path
.file_name()
.and_then(|n| n.to_str())
.context("secret path has no filename")?;
let tmp = dir.join(format!(".{name}.tmp"));
let mut f = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp)
.with_context(|| format!("creating temp secret {}", tmp.display()))?;
f.write_all(value.as_bytes())
.with_context(|| format!("writing temp secret {}", tmp.display()))?;
f.sync_all()
.with_context(|| format!("fsync temp secret {}", tmp.display()))?;
drop(f);
fs::rename(&tmp, path)
.with_context(|| format!("renaming {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use archipelago_container::SecretGenKind;
use std::os::unix::fs::PermissionsExt;
fn manifest_with(secrets: Vec<GeneratedSecret>) -> AppManifest {
let mut m: AppManifest = serde_yaml::from_str(
"app:\n id: t\n name: t\n version: 1.0.0\n container:\n image: x:y\n",
)
.unwrap();
m.app.container.generated_secrets = secrets;
m
}
fn gs(name: &str, kind: SecretGenKind) -> GeneratedSecret {
GeneratedSecret {
name: name.to_string(),
kind,
}
}
#[test]
fn generates_hex_and_bcrypt_with_0600() {
let dir = tempfile::tempdir().unwrap();
let m = manifest_with(vec![
gs("tok", SecretGenKind::Hex16),
gs("admin", SecretGenKind::Bcrypt),
]);
ensure_generated_secrets(dir.path(), &m).unwrap();
let tok = std::fs::read_to_string(dir.path().join("tok")).unwrap();
assert_eq!(tok.trim().len(), 32, "hex16 = 16 bytes = 32 hex chars");
let hash = std::fs::read_to_string(dir.path().join("admin")).unwrap();
let pw = std::fs::read_to_string(dir.path().join("admin.pw")).unwrap();
assert!(hash.starts_with("$2"), "bcrypt hash shape");
assert!(
bcrypt::verify(pw.trim(), hash.trim()).unwrap(),
"pw matches hash"
);
for f in ["tok", "admin", "admin.pw"] {
let mode = std::fs::metadata(dir.path().join(f))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "{f} must be 0600");
}
}
#[test]
fn idempotent_value_is_stable() {
let dir = tempfile::tempdir().unwrap();
let m = manifest_with(vec![gs("tok", SecretGenKind::Hex32)]);
ensure_generated_secrets(dir.path(), &m).unwrap();
let first = std::fs::read_to_string(dir.path().join("tok")).unwrap();
ensure_generated_secrets(dir.path(), &m).unwrap();
let second = std::fs::read_to_string(dir.path().join("tok")).unwrap();
assert_eq!(
first, second,
"a present readable secret is never rewritten"
);
}
#[test]
fn gateway_credential_fresh_generation_verifies_and_is_0600() {
let dir = tempfile::tempdir().unwrap();
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();
assert!(bcrypt::verify(pw.trim(), hash.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 be 0600");
}
let read_back = gateway_bcrypt_hash(dir.path()).unwrap();
assert_eq!(read_back, hash.trim());
}
#[test]
fn gateway_credential_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
ensure_gateway_credential(dir.path()).unwrap();
let first = gateway_bcrypt_hash(dir.path()).unwrap();
ensure_gateway_credential(dir.path()).unwrap();
let second = gateway_bcrypt_hash(dir.path()).unwrap();
assert_eq!(first, second, "second call must not rotate the credential");
}
#[test]
fn gateway_credential_missing_is_a_named_error() {
let dir = tempfile::tempdir().unwrap();
let err = gateway_bcrypt_hash(dir.path()).unwrap_err();
assert!(
err.to_string().contains(GATEWAY_HASH_SECRET_NAME),
"error must name the missing secret file: {err}"
);
}
#[test]
fn gateway_credential_rejects_known_default() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(GATEWAY_HASH_SECRET_NAME),
KNOWN_DEFAULT_GATEWAY_HASHES[0],
)
.unwrap();
let err = gateway_bcrypt_hash(dir.path()).unwrap_err();
assert!(
err.to_string().to_lowercase().contains("default"),
"error must explain the denylisted value: {err}"
);
}
#[test]
fn gateway_credential_is_per_install_not_per_build() {
let dir_a = tempfile::tempdir().unwrap();
let dir_b = tempfile::tempdir().unwrap();
ensure_gateway_credential(dir_a.path()).unwrap();
ensure_gateway_credential(dir_b.path()).unwrap();
let hash_a = gateway_bcrypt_hash(dir_a.path()).unwrap();
let hash_b = gateway_bcrypt_hash(dir_b.path()).unwrap();
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
// chmod-away read as the owner in a unit test, so emulate "unreadable"
// via the empty-file branch (readable_nonempty == false), which drives
// the same unlink+regenerate path.
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("tok"), "").unwrap();
let m = manifest_with(vec![gs("tok", SecretGenKind::Hex16)]);
ensure_generated_secrets(dir.path(), &m).unwrap();
let v = std::fs::read_to_string(dir.path().join("tok")).unwrap();
assert_eq!(v.trim().len(), 32, "stale/empty secret was regenerated");
}
}