diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index cb7258cc..cb95a64d 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -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, diff --git a/core/archipelago/src/api/rpc/lnd/mod.rs b/core/archipelago/src/api/rpc/lnd/mod.rs index 973e2d61..4f2e4205 100644 --- a/core/archipelago/src/api/rpc/lnd/mod.rs +++ b/core/archipelago/src/api/rpc/lnd/mod.rs @@ -1,6 +1,7 @@ mod channels; mod info; mod payments; +mod seed_backup; mod wallet; use crate::api::rpc::RpcHandler; diff --git a/core/archipelago/src/api/rpc/lnd/seed_backup.rs b/core/archipelago/src/api/rpc/lnd/seed_backup.rs new file mode 100644 index 00000000..b424ccf7 --- /dev/null +++ b/core/archipelago/src/api/rpc/lnd/seed_backup.rs @@ -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 { + 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, + ) -> Result { + 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 { + crate::seed::mark_lnd_aezeed_acknowledged(&self.config.data_dir).await?; + Ok(serde_json::json!({ "acknowledged": true })) + } +} diff --git a/core/archipelago/src/api/rpc/lnd/wallet.rs b/core/archipelago/src/api/rpc/lnd/wallet.rs index 7d1f706e..71650834 100644 --- a/core/archipelago/src/api/rpc/lnd/wallet.rs +++ b/core/archipelago/src/api/rpc/lnd/wallet.rs @@ -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 = 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!({ diff --git a/core/archipelago/src/api/rpc/middleware.rs b/core/archipelago/src/api/rpc/middleware.rs index 51e04557..11a4516f 100644 --- a/core/archipelago/src/api/rpc/middleware.rs +++ b/core/archipelago/src/api/rpc/middleware.rs @@ -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.", ] { diff --git a/core/archipelago/src/api/rpc/seed_rpc.rs b/core/archipelago/src/api/rpc/seed_rpc.rs index 1a5dc52e..82daa407 100644 --- a/core/archipelago/src/api/rpc/seed_rpc.rs +++ b/core/archipelago/src/api/rpc/seed_rpc.rs @@ -370,14 +370,6 @@ impl RpcHandler { params: Option, ) -> Result { 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 = 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 { + 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 = 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) } } diff --git a/core/archipelago/src/container/lnd.rs b/core/archipelago/src/container/lnd.rs index 8772cdfe..fb14a912 100644 --- a/core/archipelago/src/container/lnd.rs +++ b/core/archipelago/src/container/lnd.rs @@ -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::( &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 { + read_wallet_password().await +} + async fn get_lnd_unlocker_json Deserialize<'de>>( client: &reqwest::Client, path: &str, diff --git a/core/archipelago/src/seed.rs b/core/archipelago/src/seed.rs index 96cc6cbe..b7a8f301 100644 --- a/core/archipelago/src/seed.rs +++ b/core/archipelago/src/seed.rs @@ -30,6 +30,8 @@ const NONCE_LEN: usize = 12; const SEED_LEN: usize = 64; const IDENTITY_INDEX_FILE: &str = "identity_index"; const ENCRYPTED_SEED_FILE: &str = "master_seed.enc"; +const ENCRYPTED_LND_SEED_FILE: &str = "lnd_aezeed.enc"; +const LND_SEED_ACK_FILE: &str = "lnd_aezeed.ack"; // HKDF info strings for domain-separated key derivation. const NODE_ED25519_INFO: &[u8] = b"archipelago/node/ed25519/v1"; @@ -194,24 +196,13 @@ pub fn derive_lnd_entropy(seed: &MasterSeed) -> Result<[u8; 16]> { // ─── Encrypted Seed Storage ───────────────────────────────────────────── -/// Encrypt and save the mnemonic words to disk (convenience backup). -/// Uses Argon2 key derivation + ChaCha20-Poly1305 AEAD. -pub async fn save_seed_encrypted( - data_dir: &std::path::Path, - mnemonic: &bip39::Mnemonic, - passphrase: &str, -) -> Result<()> { +/// Encrypt `plaintext` with Argon2(passphrase) + ChaCha20-Poly1305. +/// Blob format: salt || nonce || ciphertext. +fn encrypt_blob(plaintext: &[u8], passphrase: &str) -> Result> { use argon2::Argon2; use chacha20poly1305::aead::{Aead, KeyInit}; use rand::RngCore; - let identity_dir = data_dir.join("identity"); - tokio::fs::create_dir_all(&identity_dir) - .await - .context("Failed to create identity directory")?; - - let plaintext = mnemonic.to_string(); - let mut salt = [0u8; SALT_LEN]; let mut nonce = [0u8; NONCE_LEN]; rand::rngs::OsRng.fill_bytes(&mut salt); @@ -227,50 +218,26 @@ pub async fn save_seed_encrypted( let ciphertext = cipher .encrypt( chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce), - plaintext.as_bytes(), + plaintext, ) .map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?; - // Zeroize the plaintext and key from memory. key.zeroize(); - // Format: salt || nonce || ciphertext let mut blob = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len()); blob.extend_from_slice(&salt); blob.extend_from_slice(&nonce); blob.extend_from_slice(&ciphertext); - - let path = identity_dir.join(ENCRYPTED_SEED_FILE); - tokio::fs::write(&path, &blob) - .await - .context("Failed to write encrypted seed")?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) - .await - .context("Failed to set seed file permissions")?; - } - - Ok(()) + Ok(blob) } -/// Load and decrypt the mnemonic from disk. -pub async fn load_seed_encrypted( - data_dir: &std::path::Path, - passphrase: &str, -) -> Result { +/// Decrypt a salt||nonce||ciphertext blob produced by `encrypt_blob`. +fn decrypt_blob(blob: &[u8], passphrase: &str) -> Result> { use argon2::Argon2; use chacha20poly1305::aead::{Aead, KeyInit}; - let path = data_dir.join("identity").join(ENCRYPTED_SEED_FILE); - let blob = tokio::fs::read(&path) - .await - .context("Failed to read encrypted seed file")?; - if blob.len() < SALT_LEN + NONCE_LEN { - anyhow::bail!("Encrypted seed file too short"); + anyhow::bail!("Encrypted blob too short"); } let salt = &blob[..SALT_LEN]; @@ -287,13 +254,63 @@ pub async fn load_seed_encrypted( key.zeroize(); - let plaintext = cipher + cipher .decrypt( chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce), ciphertext, ) - .map_err(|_| anyhow::anyhow!("Decryption failed — wrong passphrase"))?; + .map_err(|_| anyhow::anyhow!("Decryption failed — wrong passphrase")) +} +/// Write an encrypted blob under `identity/` with 0600 permissions. +async fn write_identity_blob( + data_dir: &std::path::Path, + file_name: &str, + blob: &[u8], +) -> Result<()> { + let identity_dir = data_dir.join("identity"); + tokio::fs::create_dir_all(&identity_dir) + .await + .context("Failed to create identity directory")?; + + let path = identity_dir.join(file_name); + tokio::fs::write(&path, blob) + .await + .with_context(|| format!("Failed to write {file_name}"))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .await + .with_context(|| format!("Failed to set {file_name} permissions"))?; + } + + Ok(()) +} + +/// Encrypt and save the mnemonic words to disk (convenience backup). +/// Uses Argon2 key derivation + ChaCha20-Poly1305 AEAD. +pub async fn save_seed_encrypted( + data_dir: &std::path::Path, + mnemonic: &bip39::Mnemonic, + passphrase: &str, +) -> Result<()> { + let blob = encrypt_blob(mnemonic.to_string().as_bytes(), passphrase)?; + write_identity_blob(data_dir, ENCRYPTED_SEED_FILE, &blob).await +} + +/// Load and decrypt the mnemonic from disk. +pub async fn load_seed_encrypted( + data_dir: &std::path::Path, + passphrase: &str, +) -> Result { + let path = data_dir.join("identity").join(ENCRYPTED_SEED_FILE); + let blob = tokio::fs::read(&path) + .await + .context("Failed to read encrypted seed file")?; + + let plaintext = decrypt_blob(&blob, passphrase)?; let words = String::from_utf8(plaintext).context("Decrypted seed is not valid UTF-8")?; let mnemonic: bip39::Mnemonic = words .parse() @@ -307,6 +324,79 @@ pub fn seed_exists(data_dir: &std::path::Path) -> bool { data_dir.join("identity").join(ENCRYPTED_SEED_FILE).exists() } +// ─── Encrypted LND aezeed Backup ──────────────────────────────────────── +// +// LND's wallet seed is a 24-word aezeed cipher-seed generated INSIDE LND — +// it is not the BIP-39 master mnemonic and cannot be re-derived from it +// after wallet creation, so it is captured once at wallet-init time and +// stored encrypted for later reveal. aezeed words share the BIP-39 English +// wordlist but use their own checksum, so they are stored as a plain +// space-separated string, not a `bip39::Mnemonic`. + +/// Encrypt and save the LND aezeed words (space-separated) to disk. +pub async fn save_lnd_aezeed_encrypted( + data_dir: &std::path::Path, + words: &[String], + passphrase: &str, +) -> Result<()> { + if words.is_empty() { + anyhow::bail!("Refusing to save an empty aezeed"); + } + let mut plaintext = words.join(" "); + let blob = encrypt_blob(plaintext.as_bytes(), passphrase)?; + plaintext.zeroize(); + write_identity_blob(data_dir, ENCRYPTED_LND_SEED_FILE, &blob).await +} + +/// Load and decrypt the LND aezeed words from disk. +pub async fn load_lnd_aezeed_encrypted( + data_dir: &std::path::Path, + passphrase: &str, +) -> Result> { + let path = data_dir.join("identity").join(ENCRYPTED_LND_SEED_FILE); + let blob = tokio::fs::read(&path) + .await + .context("Failed to read encrypted aezeed file")?; + + let plaintext = decrypt_blob(&blob, passphrase)?; + let mut text = String::from_utf8(plaintext).context("Decrypted aezeed is not valid UTF-8")?; + let words: Vec = text.split_whitespace().map(str::to_string).collect(); + text.zeroize(); + if words.is_empty() { + anyhow::bail!("Decrypted aezeed is empty"); + } + Ok(words) +} + +/// Check if an encrypted LND aezeed backup exists. +pub fn lnd_aezeed_exists(data_dir: &std::path::Path) -> bool { + data_dir + .join("identity") + .join(ENCRYPTED_LND_SEED_FILE) + .exists() +} + +/// Whether the user has confirmed writing down the LND aezeed. +pub fn lnd_aezeed_acknowledged(data_dir: &std::path::Path) -> bool { + data_dir.join("identity").join(LND_SEED_ACK_FILE).exists() +} + +/// Remove the acknowledgment marker (a new wallet means a new seed). +pub async fn clear_lnd_aezeed_acknowledged(data_dir: &std::path::Path) { + let _ = tokio::fs::remove_file(data_dir.join("identity").join(LND_SEED_ACK_FILE)).await; +} + +/// Record that the user confirmed backing up the LND aezeed. +pub async fn mark_lnd_aezeed_acknowledged(data_dir: &std::path::Path) -> Result<()> { + let identity_dir = data_dir.join("identity"); + tokio::fs::create_dir_all(&identity_dir) + .await + .context("Failed to create identity directory")?; + tokio::fs::write(identity_dir.join(LND_SEED_ACK_FILE), b"1") + .await + .context("Failed to write aezeed ack marker") +} + // ─── Identity Index Tracking ──────────────────────────────────────────── /// Save the next unused identity derivation index. @@ -499,6 +589,42 @@ mod tests { assert_eq!(restored.to_string(), words); } + #[tokio::test] + async fn test_lnd_aezeed_storage_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + // aezeed words aren't BIP-39-checksum-valid; any 24 words must survive. + let words: Vec = (0..24).map(|i| format!("word{i}")).collect(); + + assert!(!lnd_aezeed_exists(dir.path())); + save_lnd_aezeed_encrypted(dir.path(), &words, "node-secret") + .await + .unwrap(); + assert!(lnd_aezeed_exists(dir.path())); + + let restored = load_lnd_aezeed_encrypted(dir.path(), "node-secret") + .await + .unwrap(); + assert_eq!(restored, words); + + assert!(load_lnd_aezeed_encrypted(dir.path(), "wrong") + .await + .is_err()); + } + + #[tokio::test] + async fn test_lnd_aezeed_empty_rejected() { + let dir = tempfile::tempdir().unwrap(); + assert!(save_lnd_aezeed_encrypted(dir.path(), &[], "x").await.is_err()); + } + + #[tokio::test] + async fn test_lnd_aezeed_ack_marker() { + let dir = tempfile::tempdir().unwrap(); + assert!(!lnd_aezeed_acknowledged(dir.path())); + mark_lnd_aezeed_acknowledged(dir.path()).await.unwrap(); + assert!(lnd_aezeed_acknowledged(dir.path())); + } + #[tokio::test] async fn test_encrypted_storage_wrong_passphrase() { let dir = tempfile::tempdir().unwrap(); diff --git a/neode-ui/src/views/AppDetails.vue b/neode-ui/src/views/AppDetails.vue index e97fa6cf..23e89396 100644 --- a/neode-ui/src/views/AppDetails.vue +++ b/neode-ui/src/views/AppDetails.vue @@ -19,6 +19,8 @@ @channels="router.push('/dashboard/apps/lnd/channels')" /> + +
+import { ref, onMounted } from 'vue' +import { rpcClient } from '@/api/rpc-client' + +// Lightning seed (aezeed) backup card. Shown on the LND app detail page. +// The backend captures the aezeed at wallet-init time; until the user +// confirms writing it down (`acknowledged`), the card renders as a +// prominent first-launch prompt. + +const available = ref(false) +const acknowledged = ref(true) +const statusLoaded = ref(false) + +async function loadStatus() { + try { + const res = await rpcClient.call<{ available: boolean; acknowledged: boolean }>({ + method: 'lnd.seed-backup-status', + timeout: 5000, + }) + available.value = !!res.available + acknowledged.value = !!res.acknowledged + statusLoaded.value = true + } catch { + statusLoaded.value = false + } +} + +onMounted(loadStatus) + +// Reveal modal — re-auth gated (password + 2FA when enabled), same UX as +// the recovery-phrase reveal in Settings → Backup. +const showRevealModal = ref(false) +const revealPassword = ref('') +const revealCode = ref('') +const revealing = ref(false) +const revealError = ref('') +const revealedWords = ref([]) +const wordsHidden = ref(true) +const wordsCopied = ref(false) +const acking = ref(false) + +function openReveal() { + revealPassword.value = '' + revealCode.value = '' + revealError.value = '' + revealedWords.value = [] + wordsHidden.value = true + showRevealModal.value = true +} + +async function submitReveal() { + if (revealing.value || !revealPassword.value) return + revealing.value = true + revealError.value = '' + try { + const params: Record = { password: revealPassword.value } + if (revealCode.value) params.code = revealCode.value + const res = await rpcClient.call<{ words: string[] }>({ method: 'lnd.seed-reveal', params }) + revealedWords.value = res.words || [] + wordsHidden.value = true + } catch (e: unknown) { + revealError.value = e instanceof Error ? e.message : 'Failed to reveal the Lightning seed' + } finally { + revealing.value = false + } +} + +function closeReveal() { + showRevealModal.value = false + revealedWords.value = [] + revealPassword.value = '' + revealCode.value = '' +} + +async function copyRevealedWords() { + try { + await navigator.clipboard.writeText(revealedWords.value.join(' ')) + wordsCopied.value = true + setTimeout(() => { wordsCopied.value = false }, 2000) + } catch { /* clipboard unavailable */ } +} + +async function confirmBackedUp() { + if (acking.value) return + acking.value = true + try { + await rpcClient.call({ method: 'lnd.seed-backup-ack' }) + acknowledged.value = true + closeReveal() + } catch { /* keep the modal open; user can retry */ } finally { + acking.value = false + } +} + + +