diff --git a/core/archipelago/src/api/rpc/auth.rs b/core/archipelago/src/api/rpc/auth.rs index d15685f4..649a8d2a 100644 --- a/core/archipelago/src/api/rpc/auth.rs +++ b/core/archipelago/src/api/rpc/auth.rs @@ -33,6 +33,19 @@ impl RpcHandler { tracing::info!("[onboarding] login successful"); + // Best-effort: heal a LOCKED LND wallet created with an unknown/legacy + // password by rotating it onto the per-node secret, using the password + // the user just authenticated with as a candidate. Non-blocking so login + // is never slowed or broken when LND isn't installed / already unlocked. + let candidate = password.to_string(); + tokio::spawn(async move { + match crate::container::lnd::migrate_locked_wallet(&[candidate]).await { + Ok(true) => tracing::info!("[login] LND wallet healed / auto-unlocked"), + Ok(false) => {} // not locked, or seed-recovery required + Err(e) => tracing::debug!("[login] LND wallet migration skipped: {e}"), + } + }); + // Ensure NostrVPN config exists — covers the case where onboardingComplete // was never called (e.g., user took the "already set up" shortcut). let data_dir = self.config.data_dir.clone(); diff --git a/core/archipelago/src/api/rpc/lnd/wallet.rs b/core/archipelago/src/api/rpc/lnd/wallet.rs index 2501ddaa..ecd9f17b 100644 --- a/core/archipelago/src/api/rpc/lnd/wallet.rs +++ b/core/archipelago/src/api/rpc/lnd/wallet.rs @@ -552,8 +552,15 @@ impl RpcHandler { let entropy_b64 = base64::engine::general_purpose::STANDARD.encode(entropy); entropy.zeroize(); + // Use the per-node secret as the LND wallet password (NOT the + // caller-supplied one) so the unattended boot path can auto-unlock this + // wallet. The wallet stays recoverable from the Archipelago seed via the + // derived entropy above. This unifies both init paths on one password + // source — the divergence here is what left wallets locked fleet-wide. + let _ = wallet_password; // accepted for API compat; superseded by the per-node secret + let node_wallet_pw = crate::container::lnd::ensure_wallet_password().await?; let wallet_password_b64 = - base64::engine::general_purpose::STANDARD.encode(wallet_password.as_bytes()); + base64::engine::general_purpose::STANDARD.encode(node_wallet_pw.as_bytes()); // Call LND REST API to initialize wallet with derived entropy. // LND must be running but NOT yet initialized (no existing wallet). diff --git a/core/archipelago/src/container/lnd.rs b/core/archipelago/src/container/lnd.rs index 8ac77ad8..e29ee1c0 100644 --- a/core/archipelago/src/container/lnd.rs +++ b/core/archipelago/src/container/lnd.rs @@ -11,7 +11,16 @@ use crate::update::host_sudo; pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/lnd"; pub const DEFAULT_CONF_PATH: &str = "/var/lib/archipelago/lnd/lnd.conf"; const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080"; -pub const WALLET_PASSWORD: &str = "hellohello"; + +/// Per-node LND wallet password file (random, 0600). Replaces the old +/// fleet-wide hardcoded constant: each node's wallet password is now unique, +/// high-entropy, and recorded here so the unattended boot path can auto-unlock. +const WALLET_PASSWORD_SECRET: &str = "/var/lib/archipelago/secrets/lnd-wallet-password"; + +/// Legacy fleet-wide wallet password (builds that hardcoded it). Kept ONLY as an +/// unlock fallback so wallets created by those builds still open; new wallets +/// never use it, and the login-path migration rotates away from it. +const LEGACY_WALLET_PASSWORD: &str = "hellohello"; #[derive(Debug, Clone)] pub struct EnsurePaths { @@ -121,6 +130,88 @@ async fn read_file_as_root(path: &str) -> Result> { } } +/// Read the per-node wallet password from the secrets file, if present. +/// Never generates one — absence means "fall back to legacy / not set yet". +async fn read_wallet_password() -> Option { + let bytes = fs::read(WALLET_PASSWORD_SECRET).await.ok()?; + let pw = String::from_utf8_lossy(&bytes).trim().to_string(); + (!pw.is_empty()).then_some(pw) +} + +/// Return the per-node wallet password, generating and persisting a fresh +/// 256-bit one (base64, 0600) if none exists. Use ONLY when creating a NEW +/// wallet — calling it merely to unlock an existing wallet would record a +/// password that doesn't match it. +pub(crate) async fn ensure_wallet_password() -> Result { + if let Some(pw) = read_wallet_password().await { + return Ok(pw); + } + use rand::RngCore; + let mut raw = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut raw); + let pw = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw); + let path = std::path::Path::new(WALLET_PASSWORD_SECRET); + if let Some(dir) = path.parent() { + fs::create_dir_all(dir) + .await + .with_context(|| format!("creating {}", dir.display()))?; + } + fs::write(path, &pw) + .await + .with_context(|| format!("writing {WALLET_PASSWORD_SECRET}"))?; + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await; + Ok(pw) +} + +/// Candidate passwords to try when unlocking an EXISTING wallet, in order: the +/// per-node secret (current scheme) first, then the legacy constant so wallets +/// created by older builds still open. +async fn unlock_password_candidates() -> Vec { + let mut v = Vec::new(); + if let Some(pw) = read_wallet_password().await { + v.push(pw); + } + v.push(LEGACY_WALLET_PASSWORD.to_string()); + v +} + +/// Outcome of a single unlock attempt — lets the caller fail fast on a wrong +/// password (no point retrying) vs keep waiting for LND to come up. +enum UnlockAttempt { + Unlocked, + WrongPassword, + NotReady, +} + +/// One unlock POST, no internal retry. Distinguishes "invalid passphrase" +/// (WrongPassword — try the next candidate, don't retry) from transient +/// not-ready / connection errors (NotReady — worth retrying). +async fn try_unlock_once(client: &reqwest::Client, password: &str) -> UnlockAttempt { + let body = serde_json::json!({ + "wallet_password": base64::engine::general_purpose::STANDARD.encode(password) + }); + match client + .post(format!("{LND_REST_BASE_URL}/v1/unlockwallet")) + .json(&body) + .send() + .await + { + Ok(resp) => { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if status.is_success() || text.contains("already unlocked") { + UnlockAttempt::Unlocked + } else if text.contains("invalid passphrase") { + UnlockAttempt::WrongPassword + } else { + UnlockAttempt::NotReady + } + } + Err(_) => UnlockAttempt::NotReady, + } +} + async fn unlock_existing_wallet() -> Result<()> { unlock_existing_wallet_via_rest().await } @@ -133,57 +224,127 @@ async fn unlock_existing_wallet_via_rest() -> Result<()> { .build() .context("building LND REST client")?; - let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD); - match post_lnd_unlocker_json::( - &client, - "/v1/unlockwallet", - serde_json::json!({ "wallet_password": wallet_password }), - ) - .await - .context("unlocking existing LND wallet")? - { - UnlockerResponse::Value(_) | UnlockerResponse::WalletAlreadyExists => Ok(()), + let candidates = unlock_password_candidates().await; + // Retry only while LND's unlocker isn't ready yet. If every candidate is + // *actively rejected* (invalid passphrase), retrying can't help — fail fast + // with a clear message instead of hanging the boot path for 60s+ (the wallet + // was created with a password this node doesn't have → migration/recovery). + for _ in 0..60 { + let mut all_rejected = true; + for pw in &candidates { + match try_unlock_once(&client, pw).await { + UnlockAttempt::Unlocked => return Ok(()), + UnlockAttempt::WrongPassword => {} + UnlockAttempt::NotReady => all_rejected = false, + } + } + if all_rejected { + anyhow::bail!( + "LND wallet unlock failed: none of the {} candidate password(s) were accepted \ + — the wallet was created with a password this node does not have; \ + user-assisted migration or seed-recovery is required", + candidates.len() + ); + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + anyhow::bail!("LND wallet unlock timed out waiting for the unlocker to become ready") +} + +/// Current LND wallet state via the unauthenticated `/v1/state` endpoint +/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable. +async fn wallet_state(client: &reqwest::Client) -> Option { + let resp = client + .get(format!("{LND_REST_BASE_URL}/v1/state")) + .send() + .await + .ok()?; + let v: serde_json::Value = resp.json().await.ok()?; + v.get("state") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()) +} + +/// ChangePassword via WalletUnlocker (wallet must be LOCKED). Both passwords are +/// base64-encoded. Ok(true) = current accepted and rotated; Ok(false) = current +/// rejected (wrong password — try the next candidate); Err = transport/other. +async fn change_wallet_password( + client: &reqwest::Client, + current: &str, + new: &str, +) -> Result { + let body = serde_json::json!({ + "current_password": base64::engine::general_purpose::STANDARD.encode(current), + "new_password": base64::engine::general_purpose::STANDARD.encode(new), + }); + let resp = client + .post(format!("{LND_REST_BASE_URL}/v1/changepassword")) + .json(&body) + .send() + .await + .context("calling LND changepassword")?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if status.is_success() { + Ok(true) + } else if text.contains("invalid passphrase") { + Ok(false) + } else { + anyhow::bail!("LND changepassword returned {status}: {text}") } } -#[allow(dead_code)] -async fn unlock_existing_wallet_via_lncli() -> Result<()> { - let mut last_err = None; - for _ in 0..60 { - let mut cmd = tokio::process::Command::new("podman"); - cmd.args(["exec", "-i", "lnd", "lncli", "unlock", "--stdin"]); - cmd.stdin(std::process::Stdio::piped()); - cmd.stdout(std::process::Stdio::piped()); - cmd.stderr(std::process::Stdio::piped()); +/// Best-effort migration of a LOCKED wallet onto the per-node secret. Called at +/// login, when the onboarding password is available as a candidate. If the +/// per-node secret already opens the wallet, just unlock. Otherwise try each +/// candidate as the CURRENT password and ChangePassword it to a fresh per-node +/// secret so all future boots auto-unlock. Ok(true) = healed/unlocked; +/// Ok(false) = not locked, or no candidate worked (seed-recovery required). +pub(crate) async fn migrate_locked_wallet(candidates: &[String]) -> Result { + let client = reqwest::Client::builder() + .no_proxy() + .timeout(std::time::Duration::from_secs(20)) + .danger_accept_invalid_certs(true) + .build() + .context("building LND REST client")?; - let mut child = cmd.spawn().context("spawning lncli wallet unlock")?; - if let Some(mut stdin) = child.stdin.take() { - use tokio::io::AsyncWriteExt; - stdin - .write_all(format!("{}\n", WALLET_PASSWORD).as_bytes()) - .await - .context("writing lncli password")?; - } - let out = child - .wait_with_output() - .await - .context("waiting for lncli")?; - if out.status.success() { - return Ok(()); - } - let stderr = String::from_utf8_lossy(&out.stderr); - let stdout = String::from_utf8_lossy(&out.stdout); - let msg = format!("{stderr}{stdout}"); - if msg.contains("wallet already unlocked") || msg.contains("already unlocked") { - return Ok(()); - } - last_err = Some(msg); - tokio::time::sleep(std::time::Duration::from_secs(1)).await; + // Only act on a wallet that is actually LOCKED. + if wallet_state(&client).await.as_deref() != Some("LOCKED") { + return Ok(false); } - anyhow::bail!( - "lncli wallet unlock failed: {}", - last_err.unwrap_or_else(|| "unknown error".to_string()) - ) + + // If the per-node secret already opens it, nothing to rotate — just unlock. + if let Some(secret) = read_wallet_password().await { + if matches!(try_unlock_once(&client, &secret).await, UnlockAttempt::Unlocked) { + return Ok(true); + } + } + + // The wallet's new password becomes the per-node secret (generate if absent). + let new_secret = ensure_wallet_password().await?; + + // ChangePassword requires LOCKED; bail out if a prior step already unlocked. + if wallet_state(&client).await.as_deref() != Some("LOCKED") { + return Ok(true); + } + + for cand in candidates { + if cand.is_empty() || *cand == new_secret { + continue; + } + match change_wallet_password(&client, cand, &new_secret).await { + Ok(true) => { + tracing::info!("[lnd-migrate] rotated locked wallet onto the per-node secret"); + return Ok(true); + } + Ok(false) => continue, // wrong current password — try next candidate + Err(e) => tracing::debug!("[lnd-migrate] changepassword error: {e}"), + } + } + tracing::warn!( + "[lnd-migrate] no candidate password opened the wallet — seed-recovery required" + ); + Ok(false) } #[derive(Debug, Deserialize)] @@ -225,7 +386,8 @@ async fn init_wallet_via_rest() -> Result<()> { anyhow::bail!("LND genseed returned no seed words"); } - let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD); + let wallet_password = + base64::engine::general_purpose::STANDARD.encode(ensure_wallet_password().await?); let req = InitWalletRequest { wallet_password, cipher_seed_mnemonic: seed.cipher_seed_mnemonic, @@ -450,7 +612,16 @@ mod tests { } #[test] - fn wallet_password_is_valid_for_lncli() { - assert!(WALLET_PASSWORD.len() > 8); + fn legacy_wallet_password_is_valid_for_lncli() { + // Legacy fallback must still be a valid lncli passphrase (>8 chars). + assert!(LEGACY_WALLET_PASSWORD.len() > 8); + } + + #[tokio::test] + async fn unlock_candidates_always_include_legacy_fallback() { + // With no per-node secret on disk in the test env, candidates fall back + // to the legacy constant so old wallets still open. + let cands = unlock_password_candidates().await; + assert!(cands.iter().any(|p| p == LEGACY_WALLET_PASSWORD)); } }