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:
@@ -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,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(¶ms, "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 }))
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
] {
|
||||
|
||||
@@ -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(¶ms, "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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user