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
parent 5da9e217e6
commit 19dcfd4f31
50 changed files with 2200 additions and 258 deletions

View File

@@ -90,6 +90,9 @@ struct IdentityFile {
/// Nostr profile metadata
#[serde(default)]
profile: Option<IdentityProfile>,
/// BIP-39 seed derivation index (if created from seed).
#[serde(default, skip_serializing_if = "Option::is_none")]
derivation_index: Option<u32>,
}
pub struct IdentityManager {
@@ -150,6 +153,7 @@ impl IdentityManager {
nostr_secret_hex: None,
nostr_pubkey_hex: None,
profile: None,
derivation_index: None,
};
let file_path = self.identities_dir.join(format!("{}.json", id));
@@ -173,7 +177,7 @@ impl IdentityManager {
self.set_default(&id).await?;
}
// Auto-generate Nostr keypair so every identity has both key types
// Auto-generate Nostr keypair so every identity has both key types (legacy path)
let _ = self.create_nostr_key(&id).await;
// Re-read to pick up the Nostr keys
@@ -184,6 +188,72 @@ impl IdentityManager {
Ok(record)
}
/// Create a new identity with keys derived from a BIP-39 master seed.
/// The derivation index is auto-incremented and persisted.
pub async fn create_from_seed(
&self,
name: String,
purpose: IdentityPurpose,
seed: &crate::seed::MasterSeed,
data_dir: &std::path::Path,
) -> Result<IdentityRecord> {
let index = crate::seed::load_identity_index(data_dir).await?;
let signing_key = crate::seed::derive_identity_ed25519(seed, index)?;
let pubkey_hex = hex::encode(signing_key.verifying_key().as_bytes());
let did = did_key_from_pubkey_hex(&pubkey_hex)?;
let id = uuid::Uuid::new_v4().to_string();
let created_at = chrono::Utc::now().to_rfc3339();
// Derive Nostr key from the same seed via BIP-32.
let nostr_keys = crate::seed::derive_nostr_identity_key(seed, index)?;
let nostr_secret_hex = nostr_keys.secret_key().display_secret().to_string();
let nostr_pubkey_hex = nostr_keys.public_key().to_hex();
let identity_file = IdentityFile {
id: id.clone(),
name: name.clone(),
purpose: purpose.clone(),
secret_key: signing_key.to_bytes().to_vec(),
pubkey_hex: pubkey_hex.clone(),
did: did.clone(),
created_at: created_at.clone(),
nostr_secret_hex: Some(nostr_secret_hex),
nostr_pubkey_hex: Some(nostr_pubkey_hex),
profile: None,
derivation_index: Some(index),
};
let file_path = self.identities_dir.join(format!("{}.json", id));
let json = serde_json::to_string_pretty(&identity_file)
.context("Failed to serialize identity")?;
fs::write(&file_path, json.as_bytes())
.await
.context("Failed to write identity file")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o600))
.await
.context("Failed to set identity file permissions")?;
}
// Increment the derivation index for next identity.
crate::seed::save_identity_index(data_dir, index + 1).await?;
// If first identity, make it the default.
let (existing, _) = self.list().await?;
if existing.len() <= 1 {
self.set_default(&id).await?;
}
let record = self.get(&id).await?;
tracing::info!("Created seed-derived identity '{}' ({}) at index {}", name, purpose, index);
Ok(record)
}
/// Get a single identity by ID (without secret key).
pub async fn get(&self, id: &str) -> Result<IdentityRecord> {
let file_path = self.identities_dir.join(format!("{}.json", id));