Backend: identity.nostr-encrypt-nip04, identity.nostr-decrypt-nip04, identity.nostr-encrypt-nip44, identity.nostr-decrypt-nip44 endpoints with auto-resolve to default identity. Frontend: appLauncher routes nip04.* and nip44.* postMessage calls to backend RPC. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
511 lines
19 KiB
Rust
511 lines
19 KiB
Rust
//! RPC handlers for multi-identity management.
|
|
|
|
use super::RpcHandler;
|
|
use crate::identity_manager::{IdentityManager, IdentityPurpose};
|
|
use anyhow::{Context, Result};
|
|
use nostr_sdk::ToBech32;
|
|
|
|
impl RpcHandler {
|
|
/// List all identities with their default status.
|
|
pub(super) async fn handle_identity_list(
|
|
&self,
|
|
_params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
|
let (identities, default_id) = manager.list().await?;
|
|
|
|
let items: Vec<serde_json::Value> = identities
|
|
.into_iter()
|
|
.map(|id| {
|
|
let is_default = default_id.as_deref() == Some(&id.id);
|
|
serde_json::json!({
|
|
"id": id.id,
|
|
"name": id.name,
|
|
"purpose": id.purpose,
|
|
"pubkey": id.pubkey_hex,
|
|
"did": id.did,
|
|
"created_at": id.created_at,
|
|
"is_default": is_default,
|
|
"nostr_pubkey": id.nostr_pubkey,
|
|
"nostr_npub": id.nostr_npub,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(serde_json::json!({ "identities": items }))
|
|
}
|
|
|
|
/// Create a new identity.
|
|
pub(super) async fn handle_identity_create(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.unwrap_or_default();
|
|
let name = params
|
|
.get("name")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("Personal")
|
|
.to_string();
|
|
|
|
let purpose_str = params
|
|
.get("purpose")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("personal");
|
|
|
|
let purpose = match purpose_str {
|
|
"business" => IdentityPurpose::Business,
|
|
"anonymous" => IdentityPurpose::Anonymous,
|
|
_ => IdentityPurpose::Personal,
|
|
};
|
|
|
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
|
let record = manager.create(name, purpose).await?;
|
|
|
|
Ok(serde_json::json!({
|
|
"id": record.id,
|
|
"name": record.name,
|
|
"purpose": record.purpose,
|
|
"pubkey": record.pubkey_hex,
|
|
"did": record.did,
|
|
"created_at": record.created_at,
|
|
"nostr_pubkey": record.nostr_pubkey,
|
|
"nostr_npub": record.nostr_npub,
|
|
}))
|
|
}
|
|
|
|
/// Get a single identity by ID.
|
|
pub(super) async fn handle_identity_get(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.unwrap_or_default();
|
|
let id = params
|
|
.get("id")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
|
|
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
|
let record = manager.get(id).await?;
|
|
let (_, default_id) = manager.list().await?;
|
|
let is_default = default_id.as_deref() == Some(&record.id);
|
|
|
|
Ok(serde_json::json!({
|
|
"id": record.id,
|
|
"name": record.name,
|
|
"purpose": record.purpose,
|
|
"pubkey": record.pubkey_hex,
|
|
"did": record.did,
|
|
"created_at": record.created_at,
|
|
"is_default": is_default,
|
|
"nostr_pubkey": record.nostr_pubkey,
|
|
"nostr_npub": record.nostr_npub,
|
|
}))
|
|
}
|
|
|
|
/// Delete an identity.
|
|
pub(super) async fn handle_identity_delete(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.unwrap_or_default();
|
|
let id = params
|
|
.get("id")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
|
|
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
|
manager.delete(id).await?;
|
|
|
|
Ok(serde_json::json!({ "ok": true }))
|
|
}
|
|
|
|
/// Set the default identity.
|
|
pub(super) async fn handle_identity_set_default(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.unwrap_or_default();
|
|
let id = params
|
|
.get("id")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
|
|
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
|
manager.set_default(id).await?;
|
|
|
|
Ok(serde_json::json!({ "ok": true }))
|
|
}
|
|
|
|
/// Sign a message with a specific identity.
|
|
pub(super) async fn handle_identity_sign(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.unwrap_or_default();
|
|
let id = params
|
|
.get("id")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
|
let message = params
|
|
.get("message")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: message"))?;
|
|
|
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
|
let signature = manager.sign(id, message.as_bytes()).await?;
|
|
let record = manager.get(id).await?;
|
|
|
|
Ok(serde_json::json!({
|
|
"did": record.did,
|
|
"message": message,
|
|
"signature": signature,
|
|
}))
|
|
}
|
|
|
|
/// Verify a signature against a DID.
|
|
pub(super) async fn handle_identity_verify(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.unwrap_or_default();
|
|
let did = params
|
|
.get("did")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: did"))?;
|
|
let message = params
|
|
.get("message")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: message"))?;
|
|
let signature = params
|
|
.get("signature")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: signature"))?;
|
|
|
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
|
let valid = manager.verify(did, message.as_bytes(), signature).await?;
|
|
|
|
Ok(serde_json::json!({ "valid": valid }))
|
|
}
|
|
|
|
/// Resolve a DID to its W3C DID Document.
|
|
/// If no DID is provided, returns the node's own DID Document.
|
|
pub(super) async fn handle_identity_resolve_did(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.unwrap_or_default();
|
|
|
|
// If a DID is provided, resolve it; otherwise use the node's DID
|
|
let is_local = params.get("did").and_then(|v| v.as_str()).is_none();
|
|
let pubkey_hex = if let Some(did) = params.get("did").and_then(|v| v.as_str()) {
|
|
let pubkey_bytes = crate::identity::pubkey_bytes_from_did_key(did)?;
|
|
hex::encode(pubkey_bytes)
|
|
} else {
|
|
let (data, _) = self.state_manager.get_snapshot().await;
|
|
data.server_info.pubkey.clone()
|
|
};
|
|
|
|
// For local node, include Nostr secp256k1 key in DID Document (paired identity)
|
|
let document = if is_local {
|
|
let identity_dir = self.config.data_dir.join("identity");
|
|
match crate::nostr_discovery::get_nostr_pubkey(&identity_dir).await {
|
|
Ok(nostr_pubkey) => {
|
|
crate::identity::did_document_with_nostr(&pubkey_hex, &nostr_pubkey)?
|
|
}
|
|
Err(_) => crate::identity::did_document_from_pubkey_hex(&pubkey_hex)?,
|
|
}
|
|
} else {
|
|
crate::identity::did_document_from_pubkey_hex(&pubkey_hex)?
|
|
};
|
|
Ok(document)
|
|
}
|
|
|
|
/// Verify a DID Document: validate structure, check key material matches DID.
|
|
pub(super) async fn handle_identity_verify_did_document(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
let document = params
|
|
.get("document")
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: document"))?;
|
|
|
|
// Validate required fields
|
|
let did = document["id"]
|
|
.as_str()
|
|
.ok_or_else(|| anyhow::anyhow!("DID Document missing 'id' field"))?;
|
|
|
|
let context = document["@context"]
|
|
.as_array()
|
|
.ok_or_else(|| anyhow::anyhow!("DID Document missing '@context' array"))?;
|
|
|
|
let has_did_context = context.iter().any(|c| c.as_str() == Some("https://www.w3.org/ns/did/v1"));
|
|
if !has_did_context {
|
|
return Ok(serde_json::json!({
|
|
"valid": false,
|
|
"errors": ["Missing required @context: https://www.w3.org/ns/did/v1"]
|
|
}));
|
|
}
|
|
|
|
let verification_methods = document["verificationMethod"]
|
|
.as_array()
|
|
.ok_or_else(|| anyhow::anyhow!("DID Document missing 'verificationMethod' array"))?;
|
|
|
|
if verification_methods.is_empty() {
|
|
return Ok(serde_json::json!({
|
|
"valid": false,
|
|
"errors": ["verificationMethod array is empty"]
|
|
}));
|
|
}
|
|
|
|
// Verify the DID matches the key material (for did:key method)
|
|
let mut errors: Vec<String> = Vec::new();
|
|
|
|
if did.starts_with("did:key:") {
|
|
match crate::identity::pubkey_bytes_from_did_key(did) {
|
|
Ok(pubkey_bytes) => {
|
|
// Check that at least one verification method has matching key
|
|
let pubkey_multibase = format!("z{}", bs58::encode(&pubkey_bytes).into_string());
|
|
let has_matching_key = verification_methods.iter().any(|vm| {
|
|
vm["publicKeyMultibase"].as_str() == Some(&pubkey_multibase)
|
|
});
|
|
if !has_matching_key {
|
|
errors.push("No verificationMethod matches the DID's public key".to_string());
|
|
}
|
|
}
|
|
Err(e) => {
|
|
errors.push(format!("Failed to extract pubkey from DID: {}", e));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check authentication is present
|
|
if document["authentication"].as_array().map_or(true, |a| a.is_empty()) {
|
|
errors.push("Missing or empty 'authentication' field".to_string());
|
|
}
|
|
|
|
Ok(serde_json::json!({
|
|
"valid": errors.is_empty(),
|
|
"did": did,
|
|
"errors": errors,
|
|
"verification_methods": verification_methods.len(),
|
|
}))
|
|
}
|
|
|
|
/// Create a Nostr keypair linked to an identity.
|
|
pub(super) async fn handle_identity_create_nostr_key(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.unwrap_or_default();
|
|
let id = params
|
|
.get("id")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
|
|
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
|
let pubkey_hex = manager.create_nostr_key(id).await?;
|
|
|
|
// Derive npub (bech32 NIP-19) from hex
|
|
let npub = nostr_sdk::PublicKey::from_hex(&pubkey_hex)
|
|
.ok()
|
|
.and_then(|pk| pk.to_bech32().ok());
|
|
|
|
Ok(serde_json::json!({
|
|
"nostr_pubkey": pubkey_hex,
|
|
"nostr_npub": npub,
|
|
}))
|
|
}
|
|
|
|
/// Sign a Nostr event hash with an identity's Nostr key.
|
|
pub(super) async fn handle_identity_nostr_sign(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.unwrap_or_default();
|
|
let id = params
|
|
.get("id")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
|
let event_hash = params
|
|
.get("event_hash")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: event_hash"))?;
|
|
|
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
|
let signature = manager.nostr_sign(id, event_hash).await?;
|
|
|
|
Ok(serde_json::json!({
|
|
"signature": signature,
|
|
}))
|
|
}
|
|
|
|
/// Resolve the identity ID from params, falling back to the default identity.
|
|
async fn resolve_identity_id(&self, params: &serde_json::Value) -> Result<String> {
|
|
if let Some(id) = params.get("id").and_then(|v| v.as_str()) {
|
|
return Ok(id.to_string());
|
|
}
|
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
|
let (records, default_id) = manager.list().await?;
|
|
// Prefer the default identity
|
|
if let Some(default_id) = default_id {
|
|
return Ok(default_id);
|
|
}
|
|
// Fall back to first identity with a Nostr key, or just the first identity
|
|
records.iter()
|
|
.find(|i| i.nostr_pubkey.is_some())
|
|
.or(records.first())
|
|
.map(|i| i.id.clone())
|
|
.ok_or_else(|| anyhow::anyhow!("No identity found"))
|
|
}
|
|
|
|
/// NIP-04 encrypt plaintext for a peer.
|
|
pub(super) async fn handle_identity_nostr_encrypt_nip04(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.unwrap_or_default();
|
|
let id = self.resolve_identity_id(¶ms).await?;
|
|
let pubkey = params.get("pubkey").and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
|
let plaintext = params.get("plaintext").and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: plaintext"))?;
|
|
|
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
|
let ciphertext = manager.nostr_encrypt_nip04(&id, pubkey, plaintext).await?;
|
|
|
|
Ok(serde_json::json!({ "ciphertext": ciphertext }))
|
|
}
|
|
|
|
/// NIP-04 decrypt ciphertext from a peer.
|
|
pub(super) async fn handle_identity_nostr_decrypt_nip04(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.unwrap_or_default();
|
|
let id = self.resolve_identity_id(¶ms).await?;
|
|
let pubkey = params.get("pubkey").and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
|
let ciphertext = params.get("ciphertext").and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: ciphertext"))?;
|
|
|
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
|
let plaintext = manager.nostr_decrypt_nip04(&id, pubkey, ciphertext).await?;
|
|
|
|
Ok(serde_json::json!({ "plaintext": plaintext }))
|
|
}
|
|
|
|
/// NIP-44 encrypt plaintext for a peer.
|
|
pub(super) async fn handle_identity_nostr_encrypt_nip44(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.unwrap_or_default();
|
|
let id = self.resolve_identity_id(¶ms).await?;
|
|
let pubkey = params.get("pubkey").and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
|
let plaintext = params.get("plaintext").and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: plaintext"))?;
|
|
|
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
|
let ciphertext = manager.nostr_encrypt_nip44(&id, pubkey, plaintext).await?;
|
|
|
|
Ok(serde_json::json!({ "ciphertext": ciphertext }))
|
|
}
|
|
|
|
/// NIP-44 decrypt ciphertext from a peer.
|
|
pub(super) async fn handle_identity_nostr_decrypt_nip44(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.unwrap_or_default();
|
|
let id = self.resolve_identity_id(¶ms).await?;
|
|
let pubkey = params.get("pubkey").and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
|
let ciphertext = params.get("ciphertext").and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: ciphertext"))?;
|
|
|
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
|
let plaintext = manager.nostr_decrypt_nip44(&id, pubkey, ciphertext).await?;
|
|
|
|
Ok(serde_json::json!({ "plaintext": plaintext }))
|
|
}
|
|
|
|
/// Resolve a remote peer's DID Document over Tor.
|
|
/// Queries the peer's /rpc/ endpoint for identity.resolve-did.
|
|
pub(super) async fn handle_identity_resolve_remote_did(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
|
let onion = params
|
|
.get("onion")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: onion"))?;
|
|
|
|
// Build URL for peer's RPC endpoint over Tor
|
|
let host = if onion.ends_with(".onion") {
|
|
onion.to_string()
|
|
} else {
|
|
format!("{}.onion", onion)
|
|
};
|
|
let url = format!("http://{}/rpc/", host);
|
|
|
|
// Use SOCKS5 proxy to reach .onion address
|
|
let proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050")
|
|
.context("Failed to create Tor proxy")?;
|
|
let client = reqwest::Client::builder()
|
|
.proxy(proxy)
|
|
.timeout(std::time::Duration::from_secs(30))
|
|
.build()
|
|
.context("Failed to build HTTP client")?;
|
|
|
|
let rpc_body = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "identity.resolve-did",
|
|
"params": {}
|
|
});
|
|
|
|
let resp = client
|
|
.post(&url)
|
|
.json(&rpc_body)
|
|
.send()
|
|
.await
|
|
.context("Failed to connect to peer over Tor")?;
|
|
|
|
let body: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.context("Failed to parse peer response")?;
|
|
|
|
// Extract the DID Document from the RPC response
|
|
let document = body
|
|
.get("result")
|
|
.ok_or_else(|| anyhow::anyhow!("Peer returned error or missing result"))?;
|
|
|
|
// Cache the resolved DID locally
|
|
let did = document["id"]
|
|
.as_str()
|
|
.unwrap_or("unknown");
|
|
let cache_dir = self.config.data_dir.join("did-cache");
|
|
tokio::fs::create_dir_all(&cache_dir).await.ok();
|
|
let cache_file = cache_dir.join(format!("{}.json", onion.replace('.', "_")));
|
|
let cache_entry = serde_json::json!({
|
|
"document": document,
|
|
"resolved_at": chrono::Utc::now().to_rfc3339(),
|
|
"onion": onion,
|
|
});
|
|
tokio::fs::write(&cache_file, serde_json::to_string_pretty(&cache_entry).unwrap_or_default())
|
|
.await
|
|
.ok();
|
|
|
|
Ok(serde_json::json!({
|
|
"document": document,
|
|
"did": did,
|
|
"resolved_from": onion,
|
|
"cached": true,
|
|
}))
|
|
}
|
|
}
|