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
+37 -4
View File
@@ -505,11 +505,11 @@ async fn init_wallet_via_rest() -> Result<()> {
anyhow::bail!("LND genseed returned no seed words");
}
let wallet_password =
base64::engine::general_purpose::STANDARD.encode(ensure_wallet_password().await?);
let node_secret = ensure_wallet_password().await?;
let wallet_password = base64::engine::general_purpose::STANDARD.encode(&node_secret);
let req = InitWalletRequest {
wallet_password,
cipher_seed_mnemonic: seed.cipher_seed_mnemonic,
cipher_seed_mnemonic: seed.cipher_seed_mnemonic.clone(),
};
match post_lnd_unlocker_json::<serde_json::Value>(
&client,
@@ -519,7 +519,14 @@ async fn init_wallet_via_rest() -> Result<()> {
.await
.context("initializing LND wallet")?
{
UnlockerResponse::Value(_) => {}
UnlockerResponse::Value(_) => {
persist_aezeed_backup(
std::path::Path::new(ARCHY_DATA_DIR),
&seed.cipher_seed_mnemonic,
&node_secret,
)
.await;
}
UnlockerResponse::WalletAlreadyExists => {
unlock_existing_wallet().await?;
}
@@ -528,6 +535,32 @@ async fn init_wallet_via_rest() -> Result<()> {
Ok(())
}
/// Persist the just-created wallet's aezeed words encrypted with the per-node
/// secret so they can be revealed later (`lnd.seed-reveal`). aezeed cannot be
/// re-derived after init, so this is the only capture point on the unattended
/// boot path. Best-effort: a failed backup must not fail wallet creation.
pub(crate) async fn persist_aezeed_backup(
data_dir: &std::path::Path,
words: &[String],
node_secret: &str,
) {
match crate::seed::save_lnd_aezeed_encrypted(data_dir, words, node_secret).await {
Ok(()) => {
// A fresh wallet means a fresh seed — any previous "user backed it
// up" acknowledgment no longer applies.
crate::seed::clear_lnd_aezeed_acknowledged(data_dir).await;
tracing::info!("[lnd] aezeed backup saved (encrypted with per-node secret)");
}
Err(e) => tracing::warn!("[lnd] failed to save aezeed backup: {e:#}"),
}
}
/// The per-node wallet password if one has been persisted; never generates.
/// Used by the reveal RPC to decrypt the aezeed backup.
pub(crate) async fn wallet_password_if_exists() -> Option<String> {
read_wallet_password().await
}
async fn get_lnd_unlocker_json<T: for<'de> Deserialize<'de>>(
client: &reqwest::Client,
path: &str,