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.connect-info" => self.handle_lnd_connect_info().await,
|
||||||
"lnd.export-channel-backup" => self.handle_lnd_export_channel_backup().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.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
|
// Multi-identity management
|
||||||
"identity.list" => self.handle_identity_list(params).await,
|
"identity.list" => self.handle_identity_list(params).await,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
mod channels;
|
mod channels;
|
||||||
mod info;
|
mod info;
|
||||||
mod payments;
|
mod payments;
|
||||||
|
mod seed_backup;
|
||||||
mod wallet;
|
mod wallet;
|
||||||
|
|
||||||
use crate::api::rpc::RpcHandler;
|
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 =
|
let wallet_password_b64 =
|
||||||
base64::engine::general_purpose::STANDARD.encode(node_wallet_pw.as_bytes());
|
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).
|
// LND must be running but NOT yet initialized (no existing wallet).
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.no_proxy()
|
.no_proxy()
|
||||||
@ -825,9 +824,45 @@ impl RpcHandler {
|
|||||||
.build()
|
.build()
|
||||||
.context("Failed to create HTTP client")?;
|
.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!({
|
let init_body = serde_json::json!({
|
||||||
"wallet_password": wallet_password_b64,
|
"wallet_password": wallet_password_b64,
|
||||||
"seed_entropy": entropy_b64,
|
"cipher_seed_mnemonic": cipher_seed_mnemonic,
|
||||||
});
|
});
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
@ -851,6 +886,13 @@ impl RpcHandler {
|
|||||||
return Err(anyhow::anyhow!("LND wallet init failed: {}", msg));
|
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");
|
info!("LND wallet initialized from master seed entropy");
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
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).
|
// "Operation failed. Check server logs." (which isn't even a crash).
|
||||||
"Incorrect",
|
"Incorrect",
|
||||||
"This node has no encrypted seed",
|
"This node has no encrypted seed",
|
||||||
|
"No Lightning seed backup",
|
||||||
|
"Could not decrypt the saved Lightning seed",
|
||||||
"A 2FA code is required",
|
"A 2FA code is required",
|
||||||
"2FA is enabled but",
|
"2FA is enabled but",
|
||||||
"Could not decrypt the saved seed",
|
"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 decrypt the saved seed. If you set a separate backup passphrase during setup, enter that passphrase.",
|
||||||
"Could not unlock 2FA with this password",
|
"Could not unlock 2FA with this password",
|
||||||
"No mnemonic available. Generate or restore a seed first.",
|
"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",
|
"Submitted words do not match generated seed",
|
||||||
"Already set up. Use auth.changePassword to change.",
|
"Already set up. Use auth.changePassword to change.",
|
||||||
] {
|
] {
|
||||||
|
|||||||
@ -370,14 +370,6 @@ impl RpcHandler {
|
|||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.unwrap_or_default();
|
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.
|
// Nothing to reveal if this node never stored an encrypted seed.
|
||||||
if !crate::seed::seed_exists(&self.config.data_dir) {
|
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? {
|
if !self.auth_manager.verify_password(&password).await? {
|
||||||
password.zeroize();
|
password.zeroize();
|
||||||
anyhow::bail!("Incorrect password");
|
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) {
|
if self.auth_manager.is_totp_enabled().await.unwrap_or(false) {
|
||||||
let code = params
|
let code = params
|
||||||
.get("code")
|
.get("code")
|
||||||
@ -402,7 +438,7 @@ impl RpcHandler {
|
|||||||
.to_string();
|
.to_string();
|
||||||
if code.is_empty() {
|
if code.is_empty() {
|
||||||
password.zeroize();
|
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
|
let totp_data = self
|
||||||
.auth_manager
|
.auth_manager
|
||||||
@ -431,25 +467,6 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Decrypt the stored seed. The backup passphrase may differ from the
|
Ok(password)
|
||||||
// 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 }))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -505,11 +505,11 @@ async fn init_wallet_via_rest() -> Result<()> {
|
|||||||
anyhow::bail!("LND genseed returned no seed words");
|
anyhow::bail!("LND genseed returned no seed words");
|
||||||
}
|
}
|
||||||
|
|
||||||
let wallet_password =
|
let node_secret = ensure_wallet_password().await?;
|
||||||
base64::engine::general_purpose::STANDARD.encode(ensure_wallet_password().await?);
|
let wallet_password = base64::engine::general_purpose::STANDARD.encode(&node_secret);
|
||||||
let req = InitWalletRequest {
|
let req = InitWalletRequest {
|
||||||
wallet_password,
|
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>(
|
match post_lnd_unlocker_json::<serde_json::Value>(
|
||||||
&client,
|
&client,
|
||||||
@ -519,7 +519,14 @@ async fn init_wallet_via_rest() -> Result<()> {
|
|||||||
.await
|
.await
|
||||||
.context("initializing LND wallet")?
|
.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 => {
|
UnlockerResponse::WalletAlreadyExists => {
|
||||||
unlock_existing_wallet().await?;
|
unlock_existing_wallet().await?;
|
||||||
}
|
}
|
||||||
@ -528,6 +535,32 @@ async fn init_wallet_via_rest() -> Result<()> {
|
|||||||
Ok(())
|
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>>(
|
async fn get_lnd_unlocker_json<T: for<'de> Deserialize<'de>>(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
path: &str,
|
path: &str,
|
||||||
|
|||||||
@ -30,6 +30,8 @@ const NONCE_LEN: usize = 12;
|
|||||||
const SEED_LEN: usize = 64;
|
const SEED_LEN: usize = 64;
|
||||||
const IDENTITY_INDEX_FILE: &str = "identity_index";
|
const IDENTITY_INDEX_FILE: &str = "identity_index";
|
||||||
const ENCRYPTED_SEED_FILE: &str = "master_seed.enc";
|
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.
|
// HKDF info strings for domain-separated key derivation.
|
||||||
const NODE_ED25519_INFO: &[u8] = b"archipelago/node/ed25519/v1";
|
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 ─────────────────────────────────────────────
|
// ─── Encrypted Seed Storage ─────────────────────────────────────────────
|
||||||
|
|
||||||
/// Encrypt and save the mnemonic words to disk (convenience backup).
|
/// Encrypt `plaintext` with Argon2(passphrase) + ChaCha20-Poly1305.
|
||||||
/// Uses Argon2 key derivation + ChaCha20-Poly1305 AEAD.
|
/// Blob format: salt || nonce || ciphertext.
|
||||||
pub async fn save_seed_encrypted(
|
fn encrypt_blob(plaintext: &[u8], passphrase: &str) -> Result<Vec<u8>> {
|
||||||
data_dir: &std::path::Path,
|
|
||||||
mnemonic: &bip39::Mnemonic,
|
|
||||||
passphrase: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
use argon2::Argon2;
|
use argon2::Argon2;
|
||||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||||
use rand::RngCore;
|
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 salt = [0u8; SALT_LEN];
|
||||||
let mut nonce = [0u8; NONCE_LEN];
|
let mut nonce = [0u8; NONCE_LEN];
|
||||||
rand::rngs::OsRng.fill_bytes(&mut salt);
|
rand::rngs::OsRng.fill_bytes(&mut salt);
|
||||||
@ -227,50 +218,26 @@ pub async fn save_seed_encrypted(
|
|||||||
let ciphertext = cipher
|
let ciphertext = cipher
|
||||||
.encrypt(
|
.encrypt(
|
||||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce),
|
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce),
|
||||||
plaintext.as_bytes(),
|
plaintext,
|
||||||
)
|
)
|
||||||
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
||||||
|
|
||||||
// Zeroize the plaintext and key from memory.
|
|
||||||
key.zeroize();
|
key.zeroize();
|
||||||
|
|
||||||
// Format: salt || nonce || ciphertext
|
|
||||||
let mut blob = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len());
|
let mut blob = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len());
|
||||||
blob.extend_from_slice(&salt);
|
blob.extend_from_slice(&salt);
|
||||||
blob.extend_from_slice(&nonce);
|
blob.extend_from_slice(&nonce);
|
||||||
blob.extend_from_slice(&ciphertext);
|
blob.extend_from_slice(&ciphertext);
|
||||||
|
Ok(blob)
|
||||||
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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load and decrypt the mnemonic from disk.
|
/// Decrypt a salt||nonce||ciphertext blob produced by `encrypt_blob`.
|
||||||
pub async fn load_seed_encrypted(
|
fn decrypt_blob(blob: &[u8], passphrase: &str) -> Result<Vec<u8>> {
|
||||||
data_dir: &std::path::Path,
|
|
||||||
passphrase: &str,
|
|
||||||
) -> Result<bip39::Mnemonic> {
|
|
||||||
use argon2::Argon2;
|
use argon2::Argon2;
|
||||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
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 {
|
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];
|
let salt = &blob[..SALT_LEN];
|
||||||
@ -287,13 +254,63 @@ pub async fn load_seed_encrypted(
|
|||||||
|
|
||||||
key.zeroize();
|
key.zeroize();
|
||||||
|
|
||||||
let plaintext = cipher
|
cipher
|
||||||
.decrypt(
|
.decrypt(
|
||||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
|
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
|
||||||
ciphertext,
|
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 words = String::from_utf8(plaintext).context("Decrypted seed is not valid UTF-8")?;
|
||||||
let mnemonic: bip39::Mnemonic = words
|
let mnemonic: bip39::Mnemonic = words
|
||||||
.parse()
|
.parse()
|
||||||
@ -307,6 +324,79 @@ pub fn seed_exists(data_dir: &std::path::Path) -> bool {
|
|||||||
data_dir.join("identity").join(ENCRYPTED_SEED_FILE).exists()
|
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 ────────────────────────────────────────────
|
// ─── Identity Index Tracking ────────────────────────────────────────────
|
||||||
|
|
||||||
/// Save the next unused identity derivation index.
|
/// Save the next unused identity derivation index.
|
||||||
@ -499,6 +589,42 @@ mod tests {
|
|||||||
assert_eq!(restored.to_string(), words);
|
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]
|
#[tokio::test]
|
||||||
async fn test_encrypted_storage_wrong_passphrase() {
|
async fn test_encrypted_storage_wrong_passphrase() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@ -19,6 +19,8 @@
|
|||||||
@channels="router.push('/dashboard/apps/lnd/channels')"
|
@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">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<AppContentSection
|
<AppContentSection
|
||||||
:pkg="pkg"
|
:pkg="pkg"
|
||||||
@ -86,6 +88,7 @@ import BackButton from '@/components/BackButton.vue'
|
|||||||
import AppHeroSection from './appDetails/AppHeroSection.vue'
|
import AppHeroSection from './appDetails/AppHeroSection.vue'
|
||||||
import AppContentSection from './appDetails/AppContentSection.vue'
|
import AppContentSection from './appDetails/AppContentSection.vue'
|
||||||
import AppSidebar from './appDetails/AppSidebar.vue'
|
import AppSidebar from './appDetails/AppSidebar.vue'
|
||||||
|
import LndSeedBackup from './appDetails/LndSeedBackup.vue'
|
||||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||||
import { resolveAppUrl } from './appSession/appSessionConfig'
|
import { resolveAppUrl } from './appSession/appSessionConfig'
|
||||||
import { resolveAppCredentials } from './apps/appCredentials'
|
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