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
+114
View File
@@ -1,6 +1,7 @@
use super::RpcHandler;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use zeroize::Zeroize;
#[derive(Debug, Serialize)]
struct BitcoinInfo {
@@ -106,4 +107,117 @@ impl RpcHandler {
.result
.ok_or_else(|| anyhow::anyhow!("Bitcoin RPC returned null result"))
}
/// Initialize a Bitcoin Core descriptor wallet with keys derived from the master seed.
/// Creates a blank wallet and imports BIP-84 (native segwit) descriptors.
/// Requires: password re-verification, encrypted seed on disk.
pub(super) async fn handle_bitcoin_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_name = params.get("wallet_name")
.and_then(|v| v.as_str())
.unwrap_or("archipelago");
// Verify user password.
self.auth_manager.verify_password(password).await
.context("Password verification failed")?;
// Load encrypted seed.
let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password).await
.context("Failed to load encrypted seed")?;
let seed = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
// Derive BIP-84 account xprv.
let xprv = crate::seed::derive_bitcoin_xprv(&seed)?;
let mut xprv_str = xprv.to_string();
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Failed to create HTTP client")?;
// Step 1: Create a blank descriptor wallet.
let create_result = self.bitcoin_rpc_call::<serde_json::Value>(
&client,
"createwallet",
&[
serde_json::json!(wallet_name), // wallet_name
serde_json::json!(false), // disable_private_keys
serde_json::json!(true), // blank
serde_json::json!(""), // passphrase
serde_json::json!(false), // avoid_reuse
serde_json::json!(true), // descriptors
],
).await;
match create_result {
Ok(_) => tracing::info!("Created blank descriptor wallet '{}'", wallet_name),
Err(e) => {
let msg = e.to_string();
if msg.contains("already exists") {
tracing::info!("Wallet '{}' already exists, importing descriptors", wallet_name);
} else {
xprv_str.zeroize();
return Err(e.context("Failed to create wallet"));
}
}
}
// Step 2: Import BIP-84 descriptors (external + internal/change).
// Format: wpkh(xprv/0/*) for receive, wpkh(xprv/1/*) for change.
let external_desc = format!("wpkh({}/0/*)", xprv_str);
let internal_desc = format!("wpkh({}/1/*)", xprv_str);
// Get checksums from Bitcoin Core.
let ext_info: serde_json::Value = self.bitcoin_rpc_call(
&client, "getdescriptorinfo", &[serde_json::json!(external_desc)],
).await.context("getdescriptorinfo failed for external descriptor")?;
let int_info: serde_json::Value = self.bitcoin_rpc_call(
&client, "getdescriptorinfo", &[serde_json::json!(internal_desc)],
).await.context("getdescriptorinfo failed for internal descriptor")?;
let ext_desc_with_checksum = ext_info.get("descriptor")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?;
let int_desc_with_checksum = int_info.get("descriptor")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?;
let import_params = serde_json::json!([
{
"desc": ext_desc_with_checksum,
"timestamp": "now",
"active": true,
"internal": false,
"range": [0, 1000],
},
{
"desc": int_desc_with_checksum,
"timestamp": "now",
"active": true,
"internal": true,
"range": [0, 1000],
}
]);
let _import_result: serde_json::Value = self.bitcoin_rpc_call(
&client, "importdescriptors", &[import_params],
).await.context("importdescriptors failed")?;
// Zeroize the xprv string from memory.
xprv_str.zeroize();
tracing::info!("Bitcoin Core wallet '{}' initialized from master seed (BIP-84)", wallet_name);
Ok(serde_json::json!({
"initialized": true,
"wallet_name": wallet_name,
}))
}
}
@@ -22,6 +22,13 @@ impl RpcHandler {
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
"auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).await,
// Seed management (BIP-39 mnemonic)
"seed.generate" => self.handle_seed_generate().await,
"seed.verify" => self.handle_seed_verify(params).await,
"seed.restore" => self.handle_seed_restore(params).await,
"seed.save-encrypted" => self.handle_seed_save_encrypted(params).await,
"seed.status" => self.handle_seed_status().await,
// Container orchestration (for Archipelago-managed containers)
"container-install" => self.handle_container_install(params).await,
"container-start" => self.handle_container_start(params).await,
@@ -78,6 +85,7 @@ impl RpcHandler {
// Bitcoin & Lightning deep data
"bitcoin.getinfo" => self.handle_bitcoin_getinfo().await,
"bitcoin.init-wallet-from-seed" => self.handle_bitcoin_init_wallet_from_seed(params).await,
"lnd.getinfo" => self.handle_lnd_getinfo().await,
"lnd.listchannels" => self.handle_lnd_listchannels().await,
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
@@ -92,6 +100,7 @@ impl RpcHandler {
"lnd.gettransactions" => self.handle_lnd_gettransactions().await,
"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,
// Multi-identity management
"identity.list" => self.handle_identity_list(params).await,
@@ -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,
}))
}
}
@@ -21,6 +21,11 @@ pub(super) const UNAUTHENTICATED_METHODS: &[&str] = &[
"identity.create",
"identity.verify",
"identity.resolve-did",
// Seed management (onboarding — before user has a session)
"seed.generate",
"seed.verify",
"seed.restore",
"seed.save-encrypted",
// Onboarding restore (before user account exists)
"backup.restore-identity",
// Inter-node RPC: called by federated peers over Tor, no session cookies
+1
View File
@@ -24,6 +24,7 @@ mod package;
mod peers;
mod response;
mod router;
mod seed_rpc;
mod security;
mod tor;
mod transport;
+237
View File
@@ -0,0 +1,237 @@
//! RPC handlers for BIP-39 seed management.
//! Endpoints: seed.generate, seed.verify, seed.restore, seed.save-encrypted, seed.status
use super::RpcHandler;
use anyhow::{Context, Result};
use nostr_sdk::ToBech32;
use std::sync::Arc;
use tokio::sync::Mutex;
use zeroize::Zeroize;
/// In-memory storage for the mnemonic between generate and verify steps.
/// Auto-cleared after 10 minutes.
static ONBOARDING_MNEMONIC: std::sync::LazyLock<Arc<Mutex<Option<OnboardingMnemonicState>>>> =
std::sync::LazyLock::new(|| Arc::new(Mutex::new(None)));
struct OnboardingMnemonicState {
words: String,
created_at: std::time::Instant,
}
impl Drop for OnboardingMnemonicState {
fn drop(&mut self) {
self.words.zeroize();
}
}
const MNEMONIC_TTL: std::time::Duration = std::time::Duration::from_secs(600); // 10 minutes
impl RpcHandler {
/// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys.
/// Returns the words for the user to write down.
pub(in crate::api::rpc) async fn handle_seed_generate(
&self,
) -> Result<serde_json::Value> {
let (mnemonic, seed) = crate::seed::MasterSeed::generate()?;
// Derive and write node Ed25519 key.
let identity_dir = self.config.data_dir.join("identity");
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?;
// Derive and write node-level Nostr key.
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
let nostr_secret_path = identity_dir.join("nostr_secret");
let nostr_pub_path = identity_dir.join("nostr_pubkey");
let secret_hex = nostr_keys.secret_key().display_secret().to_string();
let pubkey_hex = nostr_keys.public_key().to_hex();
tokio::fs::write(&nostr_secret_path, secret_hex.as_bytes()).await?;
tokio::fs::write(&nostr_pub_path, pubkey_hex.as_bytes()).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(&nostr_secret_path, std::fs::Permissions::from_mode(0o600)).await?;
}
// Initialize identity index at 0.
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
let words: Vec<&str> = mnemonic.words().collect();
// Hold mnemonic in memory for the verify step.
{
let mut state = ONBOARDING_MNEMONIC.lock().await;
*state = Some(OnboardingMnemonicState {
words: mnemonic.to_string(),
created_at: std::time::Instant::now(),
});
}
Ok(serde_json::json!({
"words": words,
}))
}
/// Verify the user wrote down their seed correctly.
/// Also confirms the mnemonic by re-deriving and returning DID + npub.
pub(in crate::api::rpc) async fn handle_seed_verify(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let submitted_words: Vec<String> = serde_json::from_value(
params.get("words").cloned().ok_or_else(|| anyhow::anyhow!("Missing words"))?,
).context("Invalid words array")?;
// Validate against the held mnemonic.
let mnemonic_str = {
let mut state = ONBOARDING_MNEMONIC.lock().await;
match state.as_ref() {
Some(s) if s.created_at.elapsed() < MNEMONIC_TTL => s.words.clone(),
_ => {
*state = None;
anyhow::bail!("No pending seed generation or session expired. Please regenerate.");
}
}
};
let expected_words: Vec<&str> = mnemonic_str.split_whitespace().collect();
let submitted: Vec<&str> = submitted_words.iter().map(|s| s.as_str()).collect();
if expected_words != submitted {
anyhow::bail!("Submitted words do not match generated seed");
}
// Re-derive to get DID and npub.
let (mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&mnemonic_str)?;
let node_key = crate::seed::derive_node_ed25519(&seed)?;
let pubkey_hex = hex::encode(node_key.verifying_key().as_bytes());
let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?;
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
// Clear mnemonic from memory now that it's verified.
{
let mut state = ONBOARDING_MNEMONIC.lock().await;
*state = None;
}
// Save the encrypted seed for convenience backup.
// Use empty passphrase placeholder — the real encrypted save happens via seed.save-encrypted.
// For now we just confirm the mnemonic was valid.
drop(mnemonic);
Ok(serde_json::json!({
"verified": true,
"did": did,
"nostr_npub": nostr_npub,
}))
}
/// Restore node identity from a 24-word seed phrase.
pub(in crate::api::rpc) async fn handle_seed_restore(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let words: Vec<String> = serde_json::from_value(
params.get("words").cloned().ok_or_else(|| anyhow::anyhow!("Missing words"))?,
).context("Invalid words array")?;
let phrase = words.join(" ");
let (_mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&phrase)?;
// Derive and write node Ed25519 key.
let identity_dir = self.config.data_dir.join("identity");
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?;
// Derive and write node-level Nostr key.
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
let secret_hex = nostr_keys.secret_key().display_secret().to_string();
let pubkey_hex_nostr = nostr_keys.public_key().to_hex();
tokio::fs::write(identity_dir.join("nostr_secret"), secret_hex.as_bytes()).await?;
tokio::fs::write(identity_dir.join("nostr_pubkey"), pubkey_hex_nostr.as_bytes()).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(
identity_dir.join("nostr_secret"),
std::fs::Permissions::from_mode(0o600),
).await?;
}
// Initialize identity index.
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
// Create default identity from seed.
let manager = crate::identity_manager::IdentityManager::new(&self.config.data_dir).await?;
manager.create_from_seed(
"Personal".to_string(),
crate::identity_manager::IdentityPurpose::Personal,
&seed,
&self.config.data_dir,
).await?;
// Get DID and npub for the response.
let node_key = crate::seed::derive_node_ed25519(&seed)?;
let pubkey_hex = hex::encode(node_key.verifying_key().as_bytes());
let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?;
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
Ok(serde_json::json!({
"did": did,
"nostr_npub": nostr_npub,
"restored": true,
}))
}
/// Encrypt and save the mnemonic to disk for convenience backup.
pub(in crate::api::rpc) async fn handle_seed_save_encrypted(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let passphrase = params.get("passphrase")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing passphrase"))?;
// Try to get mnemonic from in-memory state first.
let mnemonic_str = {
let state = ONBOARDING_MNEMONIC.lock().await;
state.as_ref()
.filter(|s| s.created_at.elapsed() < MNEMONIC_TTL)
.map(|s| s.words.clone())
};
let mnemonic: bip39::Mnemonic = if let Some(words) = mnemonic_str {
words.parse().context("Invalid mnemonic in memory")?
} else {
anyhow::bail!("No mnemonic available. Generate or restore a seed first.");
};
crate::seed::save_seed_encrypted(&self.config.data_dir, &mnemonic, passphrase).await?;
Ok(serde_json::json!({ "saved": true }))
}
/// Return seed status information.
pub(in crate::api::rpc) async fn handle_seed_status(
&self,
) -> Result<serde_json::Value> {
let has_seed = crate::seed::seed_exists(&self.config.data_dir);
let has_node_key = crate::identity::NodeIdentity::key_exists(
&self.config.data_dir.join("identity"),
);
let is_legacy = has_node_key && !has_seed;
let next_index = crate::seed::load_identity_index(&self.config.data_dir).await.unwrap_or(0);
let manager = crate::identity_manager::IdentityManager::new(&self.config.data_dir).await?;
let (identities, _) = manager.list().await?;
Ok(serde_json::json!({
"has_seed": has_seed,
"is_legacy": is_legacy,
"identity_count": identities.len(),
"next_index": next_index,
}))
}
}
@@ -156,6 +156,7 @@ pub(super) fn parse_meminfo_kb(val: &str) -> Result<u64> {
/// Read disk usage via `df` for the root filesystem.
/// Returns (used_bytes, total_bytes).
#[allow(dead_code)]
pub(super) async fn read_disk_usage() -> Result<(u64, u64)> {
read_disk_usage_path("/").await
}