feat(lnd): capture aezeed at wallet init + encrypted backup + reveal UI

- aezeed words are now captured at BOTH wallet-init paths and stored
  encrypted (Argon2 + ChaCha20-Poly1305, per-node wallet secret) at
  identity/lnd_aezeed.enc; ack marker cleared when a wallet is recreated
- lnd.init-wallet-from-seed was posting seed_entropy to /v1/initwallet,
  which is a GenSeed field — the wallet was never actually derived from
  the master seed; now GenSeed(entropy) → InitWallet(words)
- new RPCs: lnd.seed-backup-status / lnd.seed-reveal (password + 2FA
  gated, same as seed.reveal via shared verify_reveal_auth) /
  lnd.seed-backup-ack
- LND app detail page: Lightning-seed card with first-launch backup
  prompt, tap-to-reveal modal, 'I've backed it up' acknowledgment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-07-13 20:51:02 +01:00
co-authored by Claude Fable 5
parent 59cc10f4aa
commit b09142e0df
10 changed files with 559 additions and 82 deletions
@@ -135,6 +135,9 @@ impl RpcHandler {
"lnd.connect-info" => self.handle_lnd_connect_info().await,
"lnd.export-channel-backup" => self.handle_lnd_export_channel_backup().await,
"lnd.init-wallet-from-seed" => self.handle_lnd_init_wallet_from_seed(params).await,
"lnd.seed-backup-status" => self.handle_lnd_seed_backup_status().await,
"lnd.seed-reveal" => self.handle_lnd_seed_reveal(params).await,
"lnd.seed-backup-ack" => self.handle_lnd_seed_backup_ack().await,
// Multi-identity management
"identity.list" => self.handle_identity_list(params).await,
+1
View File
@@ -1,6 +1,7 @@
mod channels;
mod info;
mod payments;
mod seed_backup;
mod wallet;
use crate::api::rpc::RpcHandler;
@@ -0,0 +1,75 @@
//! Encrypted LND aezeed backup: status, reveal, and acknowledgment.
//!
//! The aezeed is captured once at wallet-init time (see
//! `crate::container::lnd::persist_aezeed_backup`) and stored under
//! `identity/lnd_aezeed.enc`, encrypted with the per-node wallet secret.
//! Reveal is gated like `seed.reveal`: authenticated session + password
//! re-verification + TOTP when enabled.
use crate::api::rpc::RpcHandler;
use anyhow::Result;
use zeroize::Zeroize;
impl RpcHandler {
/// Whether an encrypted aezeed backup exists and whether the user has
/// confirmed writing it down. Drives the first-launch backup prompt.
pub(in crate::api::rpc) async fn handle_lnd_seed_backup_status(
&self,
) -> Result<serde_json::Value> {
let data_dir = &self.config.data_dir;
Ok(serde_json::json!({
"available": crate::seed::lnd_aezeed_exists(data_dir),
"acknowledged": crate::seed::lnd_aezeed_acknowledged(data_dir),
}))
}
/// Reveal the Lightning wallet's 24 aezeed words. Same gating as
/// `seed.reveal`; the words are returned to the caller only, never logged.
pub(in crate::api::rpc) async fn handle_lnd_seed_reveal(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
if !crate::seed::lnd_aezeed_exists(&self.config.data_dir) {
anyhow::bail!(
"No Lightning seed backup exists on this node. It is captured \
automatically when the Lightning wallet is first created."
);
}
let mut password = self
.verify_reveal_auth(&params, "the Lightning seed")
.await?;
password.zeroize();
// The backup is encrypted with the per-node wallet secret (the boot
// path has no user password), so re-auth above is the actual gate.
let mut node_secret = crate::container::lnd::wallet_password_if_exists()
.await
.ok_or_else(|| {
anyhow::anyhow!(
"Could not decrypt the saved Lightning seed — the per-node \
wallet secret is missing"
)
})?;
let words =
crate::seed::load_lnd_aezeed_encrypted(&self.config.data_dir, &node_secret).await;
node_secret.zeroize();
let words = words.map_err(|_| {
anyhow::anyhow!("Could not decrypt the saved Lightning seed backup")
})?;
let word_count = words.len();
Ok(serde_json::json!({ "words": words, "word_count": word_count }))
}
/// Record that the user confirmed backing up the Lightning seed, which
/// dismisses the first-launch prompt.
pub(in crate::api::rpc) async fn handle_lnd_seed_backup_ack(
&self,
) -> Result<serde_json::Value> {
crate::seed::mark_lnd_aezeed_acknowledged(&self.config.data_dir).await?;
Ok(serde_json::json!({ "acknowledged": true }))
}
}
+44 -2
View File
@@ -816,7 +816,6 @@ impl RpcHandler {
let wallet_password_b64 =
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).
let client = reqwest::Client::builder()
.no_proxy()
@@ -825,9 +824,45 @@ impl RpcHandler {
.build()
.context("Failed to create HTTP client")?;
// InitWallet does NOT accept raw entropy — `seed_entropy` is a GenSeed
// field. Posting it to /v1/initwallet meant the wallet was never
// actually derived from the Archipelago seed. GenSeed(entropy) returns
// the deterministic aezeed words, which InitWallet then consumes — and
// which we capture for the encrypted seed backup (lnd.seed-reveal).
let genseed_resp = client
.get(format!("{LND_REST_BASE_URL}/v1/genseed"))
.query(&[("seed_entropy", entropy_b64.as_str())])
.send()
.await
.context("LND genseed request failed — is LND running and uninitialized?")?;
let genseed_status = genseed_resp.status();
let genseed_body: serde_json::Value = genseed_resp
.json()
.await
.context("Failed to parse genseed response")?;
if !genseed_status.is_success() {
let msg = genseed_body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("LND seed generation failed: {}", msg));
}
let cipher_seed_mnemonic: Vec<String> = genseed_body
.get("cipher_seed_mnemonic")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|w| w.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
if cipher_seed_mnemonic.is_empty() {
anyhow::bail!("LND genseed returned no seed words");
}
let init_body = serde_json::json!({
"wallet_password": wallet_password_b64,
"seed_entropy": entropy_b64,
"cipher_seed_mnemonic": cipher_seed_mnemonic,
});
let resp = client
@@ -851,6 +886,13 @@ impl RpcHandler {
return Err(anyhow::anyhow!("LND wallet init failed: {}", msg));
}
crate::container::lnd::persist_aezeed_backup(
&self.config.data_dir,
&cipher_seed_mnemonic,
&node_wallet_pw,
)
.await;
info!("LND wallet initialized from master seed entropy");
Ok(serde_json::json!({
@@ -92,6 +92,8 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
// "Operation failed. Check server logs." (which isn't even a crash).
"Incorrect",
"This node has no encrypted seed",
"No Lightning seed backup",
"Could not decrypt the saved Lightning seed",
"A 2FA code is required",
"2FA is enabled but",
"Could not decrypt the saved seed",
@@ -136,6 +138,8 @@ mod sanitize_tests {
"Could not decrypt the saved seed. If you set a separate backup passphrase during setup, enter that passphrase.",
"Could not unlock 2FA with this password",
"No mnemonic available. Generate or restore a seed first.",
"No Lightning seed backup exists on this node. It is captured automatically when the Lightning wallet is first created.",
"Could not decrypt the saved Lightning seed backup",
"Submitted words do not match generated seed",
"Already set up. Use auth.changePassword to change.",
] {
+48 -31
View File
@@ -370,14 +370,6 @@ impl RpcHandler {
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let mut password = params
.get("password")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if password.is_empty() {
anyhow::bail!("Password is required to reveal the recovery phrase");
}
// Nothing to reveal if this node never stored an encrypted seed.
if !crate::seed::seed_exists(&self.config.data_dir) {
@@ -387,13 +379,57 @@ impl RpcHandler {
);
}
// 1) Re-authenticate with the login password.
let mut password = self
.verify_reveal_auth(&params, "the recovery phrase")
.await?;
// 3) Decrypt the stored seed. The backup passphrase may differ from the
// login password, so accept an explicit one and fall back to the
// password when the user used the same value for both.
let passphrase = params
.get("passphrase")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let secret_phrase = passphrase.unwrap_or_else(|| password.clone());
let reveal = crate::seed::load_seed_encrypted(&self.config.data_dir, &secret_phrase).await;
password.zeroize();
let mnemonic = reveal.map_err(|_| {
anyhow::anyhow!(
"Could not decrypt the saved seed. If you set a separate backup \
passphrase during setup, enter that passphrase."
)
})?;
let words: Vec<String> = mnemonic.words().map(|w| w.to_string()).collect();
let word_count = words.len();
Ok(serde_json::json!({ "words": words, "word_count": word_count }))
}
/// Re-authenticate a sensitive reveal: verify the login password from
/// `params.password` and, when 2FA is enabled, require a valid
/// replay-protected TOTP code from `params.code`. Returns the verified
/// password (some callers also use it as a decryption passphrase); the
/// caller must zeroize it. `what` names the secret in error messages,
/// e.g. "the recovery phrase".
pub(in crate::api::rpc) async fn verify_reveal_auth(
&self,
params: &serde_json::Value,
what: &str,
) -> Result<String> {
let mut password = params
.get("password")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if password.is_empty() {
anyhow::bail!("Password is required to reveal {what}");
}
if !self.auth_manager.verify_password(&password).await? {
password.zeroize();
anyhow::bail!("Incorrect password");
}
// 2) Require a valid 2FA code when TOTP is enabled (replay-protected).
if self.auth_manager.is_totp_enabled().await.unwrap_or(false) {
let code = params
.get("code")
@@ -402,7 +438,7 @@ impl RpcHandler {
.to_string();
if code.is_empty() {
password.zeroize();
anyhow::bail!("A 2FA code is required to reveal the recovery phrase");
anyhow::bail!("A 2FA code is required to reveal {what}");
}
let totp_data = self
.auth_manager
@@ -431,25 +467,6 @@ impl RpcHandler {
}
}
// 3) Decrypt the stored seed. The backup passphrase may differ from the
// login password, so accept an explicit one and fall back to the
// password when the user used the same value for both.
let passphrase = params
.get("passphrase")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let secret_phrase = passphrase.unwrap_or_else(|| password.clone());
let reveal = crate::seed::load_seed_encrypted(&self.config.data_dir, &secret_phrase).await;
password.zeroize();
let mnemonic = reveal.map_err(|_| {
anyhow::anyhow!(
"Could not decrypt the saved seed. If you set a separate backup \
passphrase during setup, enter that passphrase."
)
})?;
let words: Vec<String> = mnemonic.words().map(|w| w.to_string()).collect();
let word_count = words.len();
Ok(serde_json::json!({ "words": words, "word_count": word_count }))
Ok(password)
}
}