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
parent 59cc10f4aa
commit b09142e0df
10 changed files with 559 additions and 82 deletions

View File

@ -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,

View File

@ -1,6 +1,7 @@
mod channels;
mod info;
mod payments;
mod seed_backup;
mod wallet;
use crate::api::rpc::RpcHandler;

View File

@ -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 }))
}
}

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!({

View File

@ -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.",
] {

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)
}
}

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,

View File

@ -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<Vec<u8>> {
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<bip39::Mnemonic> {
/// Decrypt a salt||nonce||ciphertext blob produced by `encrypt_blob`.
fn decrypt_blob(blob: &[u8], passphrase: &str) -> Result<Vec<u8>> {
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<bip39::Mnemonic> {
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<Vec<String>> {
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<String> = 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<String> = (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();

View File

@ -19,6 +19,8 @@
@channels="router.push('/dashboard/apps/lnd/channels')"
/>
<LndSeedBackup v-if="packageKey === 'lnd' && pkg.installed" />
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<AppContentSection
:pkg="pkg"
@ -86,6 +88,7 @@ import BackButton from '@/components/BackButton.vue'
import AppHeroSection from './appDetails/AppHeroSection.vue'
import AppContentSection from './appDetails/AppContentSection.vue'
import AppSidebar from './appDetails/AppSidebar.vue'
import LndSeedBackup from './appDetails/LndSeedBackup.vue'
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
import { resolveAppUrl } from './appSession/appSessionConfig'
import { resolveAppCredentials } from './apps/appCredentials'

View File

@ -0,0 +1,173 @@
<script setup lang="ts">
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<string[]>([])
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<string, string> = { 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
}
}
</script>
<template>
<div v-if="statusLoaded && available" class="glass-card px-6 py-6 mb-6" :class="!acknowledged ? 'border border-orange-400/40' : ''">
<div v-if="!acknowledged" class="flex items-center gap-2 mb-3 text-orange-300 text-sm font-medium" role="alert">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86l-8.4 14.55A1.5 1.5 0 003.19 21h17.62a1.5 1.5 0 001.3-2.59l-8.4-14.55a1.5 1.5 0 00-2.62 0z" />
</svg>
Your Lightning seed hasn't been backed up yet
</div>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="text-xl font-semibold text-white/96 mb-1">Lightning wallet seed</h2>
<p class="text-sm text-white/60">
View the 24-word recovery seed for this node's Lightning wallet. You'll need to
confirm your password (and 2FA code, if enabled). Write it down and store it
offline anyone with these words controls your Lightning funds.
</p>
</div>
<button
type="button"
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
:class="!acknowledged ? 'bg-orange-500/20 border-orange-400/30' : ''"
@click="openReveal"
>{{ acknowledged ? 'Reveal' : 'Back up now' }}</button>
</div>
</div>
<Teleport to="body">
<div v-if="showRevealModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closeReveal">
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="reveal-lnd-seed-title">
<h3 id="reveal-lnd-seed-title" class="text-lg font-semibold text-white mb-1">Reveal Lightning seed</h3>
<template v-if="revealedWords.length === 0">
<p class="text-sm text-white/60 mb-4">Confirm your credentials to display the 24-word Lightning seed.</p>
<form @submit.prevent="submitReveal" class="space-y-3">
<div>
<label class="block text-xs text-white/60 mb-1">Password</label>
<input v-model="revealPassword" type="password" autocomplete="current-password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Your login password" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">2FA code <span class="text-white/30">(if enabled)</span></label>
<input v-model="revealCode" inputmode="numeric" autocomplete="one-time-code" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono tracking-widest focus:outline-none focus:border-white/30" placeholder="123456" />
</div>
<p v-if="revealError" class="text-xs text-red-300 bg-red-500/10 border border-red-400/20 rounded-lg px-3 py-2">{{ revealError }}</p>
<div class="flex gap-2 pt-1">
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">Cancel</button>
<button type="submit" :disabled="revealing || !revealPassword" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50">
{{ revealing ? 'Verifying…' : 'Reveal' }}
</button>
</div>
</form>
</template>
<template v-else>
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.</p>
<div class="relative">
<div
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
:class="wordsHidden ? 'blur-md' : ''"
@click="wordsHidden = !wordsHidden"
>
<div v-for="(w, i) in revealedWords" :key="i" class="flex items-center gap-1.5 text-sm">
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
<span class="text-white font-mono">{{ w }}</span>
</div>
</div>
<button v-if="wordsHidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="wordsHidden = false">Tap to reveal</button>
</div>
<div class="flex gap-2 pt-4">
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
<button type="button" :disabled="acking" @click="confirmBackedUp" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50">
{{ acking ? 'Saving…' : "I've backed it up" }}
</button>
</div>
</template>
</div>
</div>
</Teleport>
</template>