feat: import matching business nsec into native identity storage

This commit is contained in:
yaya
2026-10-08 12:08:51 -04:00
committed by archipelago
parent 92d2855bff
commit fe398df8f6
4 changed files with 78 additions and 6 deletions
@@ -159,6 +159,8 @@ impl RpcHandler {
// Multi-identity management
"identity.list" => self.handle_identity_list(params).await,
"identity.capabilities" => Ok(serde_json::json!({"import_nostr":true})),
"identity.import-nostr" => self.handle_identity_import_nostr(params).await,
"identity.create" => self.handle_identity_create(params).await,
"identity.get" => self.handle_identity_get(params).await,
"identity.delete" => self.handle_identity_delete(params).await,
@@ -112,6 +112,25 @@ impl RpcHandler {
}))
}
/// Explicit owner-key import into a separate native business identity.
pub(in crate::api::rpc) async fn handle_identity_import_nostr(
&self, params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let password = params.get("password").and_then(|v| v.as_str()).unwrap_or("");
if !self.auth_manager.verify_password(password).await? {
anyhow::bail!("Invalid node password");
}
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("Just Works");
anyhow::ensure!(!name.trim().is_empty() && name.len() <= 100, "Invalid identity name");
let nsec = params.get("nsec").and_then(|v| v.as_str()).unwrap_or("").trim();
let npub = params.get("expected_npub").and_then(|v| v.as_str()).unwrap_or("");
let manager = IdentityManager::new(&self.config.data_dir).await?;
let record = manager.import_nostr(name.to_string(), nsec, npub).await?;
Ok(serde_json::json!({"id":record.id, "name":record.name,
"nostr_npub":record.nostr_npub, "nostr_pubkey":record.nostr_pubkey}))
}
/// Get a single identity by ID.
pub(in crate::api::rpc) async fn handle_identity_get(
&self,
+56 -6
View File
@@ -198,8 +198,25 @@ impl IdentityManager {
Ok(pubkeys.join(","))
}
/// Import a business key without replacing any existing identity or default.
pub async fn import_nostr(&self, name: String, nsec: &str, expected_npub: &str) -> Result<IdentityRecord> {
anyhow::ensure!(nsec.starts_with("nsec1") && nsec.len() == 63, "Enter a plain nsec owner key");
let secret = nostr_sdk::SecretKey::parse(nsec).map_err(|_| anyhow::anyhow!("Invalid owner key"))?;
let keys = nostr_sdk::Keys::new(secret);
anyhow::ensure!(keys.public_key().to_bech32()? == expected_npub, "Owner key does not match this website");
let (existing, _) = self.list().await?;
if let Some(record) = existing.into_iter().find(|r| r.purpose == IdentityPurpose::Business && r.nostr_pubkey.as_deref() == Some(keys.public_key().to_hex().as_str())) {
return Ok(record);
}
self.create_with_nostr(name, IdentityPurpose::Business, Some(keys)).await
}
/// Create a new identity.
pub async fn create(&self, name: String, purpose: IdentityPurpose) -> Result<IdentityRecord> {
self.create_with_nostr(name, purpose, None).await
}
async fn create_with_nostr(&self, name: String, purpose: IdentityPurpose, imported: Option<nostr_sdk::Keys>) -> Result<IdentityRecord> {
let signing_key = SigningKey::generate(&mut OsRng);
let pubkey_hex = hex::encode(signing_key.verifying_key().as_bytes());
let did = did_key_from_pubkey_hex(&pubkey_hex)?;
@@ -222,8 +239,8 @@ impl IdentityManager {
pubkey_hex: pubkey_hex.clone(),
did: did.clone(),
created_at: created_at.clone(),
nostr_secret_hex: None,
nostr_pubkey_hex: None,
nostr_secret_hex: imported.as_ref().map(|keys| keys.secret_key().display_secret().to_string()),
nostr_pubkey_hex: imported.as_ref().map(|keys| keys.public_key().to_hex()),
profile: Some(default_profile),
derivation_index: None,
};
@@ -231,9 +248,15 @@ impl IdentityManager {
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
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options.open(&file_path).await.context("Failed to create identity file")?;
tokio::io::AsyncWriteExt::write_all(&mut file, json.as_bytes()).await
.context("Failed to write identity file")?;
tokio::io::AsyncWriteExt::flush(&mut file).await
.context("Failed to flush identity file")?;
#[cfg(unix)]
{
@@ -245,12 +268,14 @@ impl IdentityManager {
// If this is the first identity, make it the default
let (existing, _) = self.list().await?;
if existing.len() <= 1 {
if existing.len() <= 1 && imported.is_none() {
self.set_default(&id).await?;
}
// Auto-generate Nostr keypair so every identity has both key types (legacy path)
let _ = self.create_nostr_key(&id).await;
if imported.is_none() {
let _ = self.create_nostr_key(&id).await;
}
// Re-read to pick up the Nostr keys
let record = self.get(&id).await?;
@@ -902,6 +927,31 @@ mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn import_nostr_preserves_identities_and_rejects_mismatches() {
let dir = tempdir().unwrap();
let manager = IdentityManager::new(dir.path()).await.unwrap();
let original = manager.create("Personal".into(), IdentityPurpose::Personal).await.unwrap();
let keys = nostr_sdk::Keys::generate();
let nsec = keys.secret_key().to_bech32().unwrap();
let npub = keys.public_key().to_bech32().unwrap();
assert!(manager.import_nostr("Wrong".into(), &nsec, "npub1wrong").await.is_err());
assert_eq!(manager.list().await.unwrap().0.len(), 1);
let imported = manager.import_nostr("Website".into(), &nsec, &npub).await.unwrap();
assert_eq!(imported.nostr_npub.as_deref(), Some(npub.as_str()));
assert_eq!(manager.import_nostr("Again".into(), &nsec, &npub).await.unwrap().id, imported.id);
let (records, default) = manager.list().await.unwrap();
assert_eq!(records.len(), 2);
assert_eq!(default.as_deref(), Some(original.id.as_str()));
assert_eq!(manager.get(&original.id).await.unwrap().nostr_pubkey, original.nostr_pubkey);
assert_eq!(manager.export_keys(&imported.id).await.unwrap()["nostr_nsec"], nsec);
#[cfg(unix)] {
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.path().join("identities").join(format!("{}.json", imported.id))).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
}
#[tokio::test]
async fn test_create_identity_did_key_format() {
let dir = tempdir().unwrap();
+1
View File
@@ -92,6 +92,7 @@ impl EndpointRateLimiter {
limits.insert("lnd.finalize-psbt".to_string(), (5, 300));
// Identity/credential operations
limits.insert("identity.create".to_string(), (10, 300));
limits.insert("identity.import-nostr".to_string(), (5, 300));
limits.insert("identity.issue-credential".to_string(), (20, 300));
// Backup operations (resource-intensive)
limits.insert("backup.create".to_string(), (10, 600));