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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user