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:
+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