feat: BIP-39 master seed for unified key derivation

Replace fragmented random key generation with a single 24-word BIP-39
mnemonic that deterministically derives all node keys: Ed25519 (DID),
secp256k1 (Nostr/Bitcoin), BIP-84 xprv (Bitcoin Core), and LND aezeed
entropy. New onboarding flow: seed generate → word verification → identity
naming. Restore path enabled via 24-word entry. Includes seed RPC handlers,
mock backend support, LND/Bitcoin Core wallet-from-seed integration, and
UI polish across settings and discover views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-31 01:41:24 +01:00
co-authored by Claude Opus 4.6
parent 5da9e217e6
commit 19dcfd4f31
50 changed files with 2200 additions and 258 deletions
@@ -2,6 +2,7 @@ use crate::api::rpc::RpcHandler;
use anyhow::{Context, Result};
use base64::Engine;
use tracing::info;
use zeroize::Zeroize;
impl RpcHandler {
/// Generate a new on-chain Bitcoin address.
@@ -381,4 +382,71 @@ impl RpcHandler {
"broadcast": false,
}))
}
/// Initialize LND wallet with entropy derived from the node's BIP-39 master seed.
/// The 16-byte entropy deterministically produces an aezeed mnemonic inside LND.
/// Requires: password re-verification via params.password, encrypted seed on disk.
pub(in crate::api::rpc) async fn handle_lnd_init_wallet_from_seed(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let password = params.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'password' for seed access"))?;
let wallet_password = params.get("wallet_password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'wallet_password' for LND"))?;
// Verify user password before granting seed access.
self.auth_manager.verify_password(password).await
.context("Password verification failed")?;
// Load encrypted seed from disk.
let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password).await
.context("Failed to load encrypted seed. Was a seed phrase saved during onboarding?")?;
let seed = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
// Derive 16 bytes of LND entropy.
let mut entropy = crate::seed::derive_lnd_entropy(&seed)?;
let entropy_b64 = base64::engine::general_purpose::STANDARD.encode(&entropy);
entropy.zeroize();
let wallet_password_b64 = base64::engine::general_purpose::STANDARD.encode(wallet_password.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()
.timeout(std::time::Duration::from_secs(30))
.danger_accept_invalid_certs(true)
.build()
.context("Failed to create HTTP client")?;
let init_body = serde_json::json!({
"wallet_password": wallet_password_b64,
"seed_entropy": entropy_b64,
});
let resp = client
.post("https://127.0.0.1:8080/v1/initwallet")
.json(&init_body)
.send()
.await
.context("LND initwallet request failed — is LND running and uninitialized?")?;
let status = resp.status();
let body: serde_json::Value = resp.json().await
.context("Failed to parse initwallet response")?;
if !status.is_success() {
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
return Err(anyhow::anyhow!("LND wallet init failed: {}", msg));
}
info!("LND wallet initialized from master seed entropy");
Ok(serde_json::json!({
"initialized": true,
}))
}
}