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:
parent
59cc10f4aa
commit
b09142e0df
@ -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;
|
||||
|
||||
75
core/archipelago/src/api/rpc/lnd/seed_backup.rs
Normal file
75
core/archipelago/src/api/rpc/lnd/seed_backup.rs
Normal 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(¶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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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'
|
||||
|
||||
173
neode-ui/src/views/appDetails/LndSeedBackup.vue
Normal file
173
neode-ui/src/views/appDetails/LndSeedBackup.vue
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user