fix(01-11): remove every shipped Fedimint gateway credential (FED-07)
Six code paths configured the Lightning gateway with a bcrypt hash committed to this repository — and one deploy path with a plaintext password literal — whenever the per-install secret was missing. Anyone holding a copy of the repo held the admin credential for every gateway that ever took a fallback. container::secrets now owns the credential end to end: ensure_gateway_credential (idempotent, delegates to ensure_one's bcrypt arm) and gateway_bcrypt_hash, which returns Err when the secret is missing/empty and when the stored value is on the KNOWN_DEFAULT_GATEWAY_HASHES denylist — so this codebase cannot hand back the compromised value even to a node already carrying it. get_app_config was widened to Result so a credential-less install cannot reach podman run at all; configure_fedimint_lnd takes the resolved hash instead of re-reading with its own fallback. The four shell paths stop generating credentials entirely (dropping the htpasswd host dependency) and skip container creation with a printed reason rather than substituting anything. Naming converges on the manifest's fedimint-gateway-hash/.pw, with legacy fedimint-gateway-password values copied forward rather than regenerated so no node loses a working unique credential. Plan 01-16 owns rotation of installs already carrying the default. Verified: cargo build clean; cargo test -p archipelago 999 passed (2 boot_reconciler timing tests failed under concurrent load, green in isolation, untouched by this diff); bash -n clean on all five scripts; the compromised literal now appears exactly once in the tree, as the denylist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5faf1a3c5f
commit
4265254700
@@ -102,6 +102,81 @@ fn random_base64(bytes: usize) -> String {
|
||||
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())
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
@@ -209,6 +284,79 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_heals_unreadable_secret() {
|
||||
// Simulate the root-owned case: a present-but-unreadable file. We can't
|
||||
|
||||
Reference in New Issue
Block a user