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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+171
-45
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user