diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index b1dd36ad..65fafe59 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -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, diff --git a/core/archipelago/src/api/rpc/identity/handlers.rs b/core/archipelago/src/api/rpc/identity/handlers.rs index 1d289d56..eb53469e 100644 --- a/core/archipelago/src/api/rpc/identity/handlers.rs +++ b/core/archipelago/src/api/rpc/identity/handlers.rs @@ -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, + ) -> Result { + 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, diff --git a/core/archipelago/src/identity_manager.rs b/core/archipelago/src/identity_manager.rs index 26f81aa4..705553e3 100644 --- a/core/archipelago/src/identity_manager.rs +++ b/core/archipelago/src/identity_manager.rs @@ -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 { + 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 { + self.create_with_nostr(name, purpose, None).await + } + + async fn create_with_nostr(&self, name: String, purpose: IdentityPurpose, imported: Option) -> Result { 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(); diff --git a/core/archipelago/src/rate_limit.rs b/core/archipelago/src/rate_limit.rs index 74496abe..ebf29756 100644 --- a/core/archipelago/src/rate_limit.rs +++ b/core/archipelago/src/rate_limit.rs @@ -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));