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:
archipelago
2026-08-01 05:46:02 -04:00
co-authored by Claude Opus 5
parent 5faf1a3c5f
commit 4265254700
10 changed files with 558 additions and 139 deletions
+12 -9
View File
@@ -646,18 +646,18 @@ pub(super) async fn get_app_config(
allocator: &mut PortAllocator,
rpc_user: &str,
rpc_pass: &str,
) -> (
) -> Result<(
Vec<String>,
Vec<String>,
Vec<String>,
Option<String>,
Option<Vec<String>>,
) {
)> {
if let Some(config) = dynamic_app_config(app_id).await {
return config;
return Ok(config);
}
match app_id {
Ok(match app_id {
"homeassistant" | "home-assistant" => (
vec!["8123:8123".to_string()],
vec!["/var/lib/archipelago/home-assistant:/config".to_string()],
@@ -1049,10 +1049,13 @@ pub(super) async fn get_app_config(
]),
),
"fedimint-gateway" => {
let fedi_hash = read_secret(
"fedimint-gateway-hash",
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC",
);
// FED-07: no fallback literal. A fresh install self-provisions its
// own credential; a node that can't obtain one fails this install
// outright (propagated via `?`) rather than start an
// unauthenticated/default-credentialed gateway.
let gateway_secrets_dir = std::path::Path::new("/var/lib/archipelago/secrets");
crate::container::secrets::ensure_gateway_credential(gateway_secrets_dir)?;
let fedi_hash = crate::container::secrets::gateway_bcrypt_hash(gateway_secrets_dir)?;
(
vec!["8176:8176".to_string(), "9737:9737".to_string()],
vec!["/var/lib/archipelago/fedimint-gateway:/data".to_string()],
@@ -1196,5 +1199,5 @@ pub(super) async fn get_app_config(
tracing::warn!("No catalog runtime config found for app: {} — using minimal defaults", app_id);
(vec![], vec![], vec![], None, None)
}
}
})
}
@@ -717,6 +717,11 @@ fn order_present_containers(package_id: &str, containers: Vec<String>) -> Vec<St
/// Configure Fedimint Gateway to use LND instead of LDK.
/// Modifies ports, volumes, and command args in place when LND credentials exist.
///
/// `fedi_hash` is the already-resolved per-install gateway credential
/// (`container::secrets::gateway_bcrypt_hash`) — this function does not read
/// the secrets file itself, so there is exactly one read site and one
/// failure point for that credential (FED-07).
pub(super) fn configure_fedimint_lnd(
host_ip: &str,
ports: &mut Vec<String>,
@@ -724,20 +729,13 @@ pub(super) fn configure_fedimint_lnd(
custom_args: &mut Option<Vec<String>>,
rpc_user: &str,
rpc_pass: &str,
fedi_hash: &str,
) {
let lnd_cert = "/var/lib/archipelago/lnd/tls.cert";
let lnd_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
if std::path::Path::new(lnd_cert).exists() && std::path::Path::new(lnd_macaroon).exists() {
info!("LND detected with credentials — configuring gateway in lnd mode");
// Read bcrypt hash from secrets file, fall back to default
let fedi_hash =
std::fs::read_to_string("/var/lib/archipelago/secrets/fedimint-gateway-hash")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| {
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string()
});
ports.retain(|p| p != "9737:9737");
volumes.push(format!("{}:/lnd/tls.cert:ro", lnd_cert));
volumes.push(format!("{}:/lnd/admin.macaroon:ro", lnd_macaroon));
@@ -748,7 +746,7 @@ pub(super) fn configure_fedimint_lnd(
"--listen".to_string(),
"0.0.0.0:8176".to_string(),
"--bcrypt-password-hash".to_string(),
fedi_hash,
fedi_hash.to_string(),
"--network".to_string(),
"bitcoin".to_string(),
"--bitcoind-url".to_string(),
@@ -589,11 +589,18 @@ impl RpcHandler {
&rpc_user,
&rpc_pass,
)
.await
.await?
};
// Fedimint Gateway: auto-detect LND and switch to lnd mode
if package_id == "fedimint-gateway" && deps.has_lnd {
// get_app_config's "fedimint-gateway" arm already called
// 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"),
)?;
configure_fedimint_lnd(
&self.config.host_ip,
&mut ports,
@@ -601,6 +608,7 @@ impl RpcHandler {
&mut custom_args,
&rpc_user,
&rpc_pass,
&fedi_hash,
);
}
+148
View File
@@ -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