From 83c0092f1b9ce22ffcee3c9318beac3c2e06c9d2 Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 12 Mar 2026 23:50:56 +0000 Subject: [PATCH] feat: add NIP-04 and NIP-44 encrypt/decrypt RPC endpoints for iframe apps 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 --- core/archipelago/Cargo.toml | 2 +- core/archipelago/src/api/rpc/identity.rs | 91 ++++++++++++++++++++++++ core/archipelago/src/api/rpc/mod.rs | 4 ++ core/archipelago/src/identity_manager.rs | 49 +++++++++++++ loop/plan.md | 2 +- neode-ui/src/stores/appLauncher.ts | 24 +++++++ 6 files changed, 170 insertions(+), 2 deletions(-) diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index d66764f8..2b406305 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -63,7 +63,7 @@ serde_yaml = "0.9" reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls"] } # Nostr (node discovery + NIP-44 encrypted peer handshake) -nostr-sdk = { version = "0.44", features = ["nip44"] } +nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] } # Backup encryption (DID identity export) + TOTP 2FA encryption argon2 = "0.5" diff --git a/core/archipelago/src/api/rpc/identity.rs b/core/archipelago/src/api/rpc/identity.rs index 7cb9642e..0c272162 100644 --- a/core/archipelago/src/api/rpc/identity.rs +++ b/core/archipelago/src/api/rpc/identity.rs @@ -340,6 +340,97 @@ impl RpcHandler { })) } + /// Resolve the identity ID from params, falling back to the default identity. + async fn resolve_identity_id(&self, params: &serde_json::Value) -> Result { + 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, + ) -> Result { + 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, + ) -> Result { + 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, + ) -> Result { + 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, + ) -> Result { + 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( diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 7a75ed6e..41122485 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -337,6 +337,10 @@ impl RpcHandler { "identity.verify-did-document" => self.handle_identity_verify_did_document(params).await, "identity.create-nostr-key" => self.handle_identity_create_nostr_key(params).await, "identity.nostr-sign" => self.handle_identity_nostr_sign(params).await, + "identity.nostr-encrypt-nip04" => self.handle_identity_nostr_encrypt_nip04(params).await, + "identity.nostr-decrypt-nip04" => self.handle_identity_nostr_decrypt_nip04(params).await, + "identity.nostr-encrypt-nip44" => self.handle_identity_nostr_encrypt_nip44(params).await, + "identity.nostr-decrypt-nip44" => self.handle_identity_nostr_decrypt_nip44(params).await, // Bitcoin domain names (NIP-05) "identity.register-name" => self.handle_identity_register_name(params).await, diff --git a/core/archipelago/src/identity_manager.rs b/core/archipelago/src/identity_manager.rs index 7e0cefe7..da7df6f6 100644 --- a/core/archipelago/src/identity_manager.rs +++ b/core/archipelago/src/identity_manager.rs @@ -288,6 +288,55 @@ impl IdentityManager { Ok(sig.to_string()) } + /// Load the Nostr secret key for an identity, returning the parsed Keys. + async fn load_nostr_keys(&self, id: &str) -> Result { + let file_path = self.identities_dir.join(format!("{}.json", id)); + if !file_path.exists() { + return Err(anyhow::anyhow!("Identity not found: {}", id)); + } + let data = fs::read(&file_path).await.context("Failed to read identity file")?; + let file: IdentityFile = serde_json::from_slice(&data).context("Failed to parse identity file")?; + let secret_hex = file.nostr_secret_hex + .ok_or_else(|| anyhow::anyhow!("No Nostr key for this identity"))?; + nostr_sdk::Keys::parse(&secret_hex).context("Invalid Nostr secret key") + } + + /// NIP-04 encrypt plaintext for a peer pubkey. + pub async fn nostr_encrypt_nip04(&self, id: &str, peer_pubkey_hex: &str, plaintext: &str) -> Result { + let keys = self.load_nostr_keys(id).await?; + let peer_pk = nostr_sdk::PublicKey::from_hex(peer_pubkey_hex) + .context("Invalid peer pubkey hex")?; + nostr_sdk::nips::nip04::encrypt(keys.secret_key(), &peer_pk, plaintext) + .context("NIP-04 encryption failed") + } + + /// NIP-04 decrypt ciphertext from a peer pubkey. + pub async fn nostr_decrypt_nip04(&self, id: &str, peer_pubkey_hex: &str, ciphertext: &str) -> Result { + let keys = self.load_nostr_keys(id).await?; + let peer_pk = nostr_sdk::PublicKey::from_hex(peer_pubkey_hex) + .context("Invalid peer pubkey hex")?; + nostr_sdk::nips::nip04::decrypt(keys.secret_key(), &peer_pk, ciphertext) + .context("NIP-04 decryption failed") + } + + /// NIP-44 encrypt plaintext for a peer pubkey. + pub async fn nostr_encrypt_nip44(&self, id: &str, peer_pubkey_hex: &str, plaintext: &str) -> Result { + let keys = self.load_nostr_keys(id).await?; + let peer_pk = nostr_sdk::PublicKey::from_hex(peer_pubkey_hex) + .context("Invalid peer pubkey hex")?; + nostr_sdk::nips::nip44::encrypt(keys.secret_key(), &peer_pk, plaintext, nostr_sdk::nips::nip44::Version::V2) + .context("NIP-44 encryption failed") + } + + /// NIP-44 decrypt ciphertext from a peer pubkey. + pub async fn nostr_decrypt_nip44(&self, id: &str, peer_pubkey_hex: &str, ciphertext: &str) -> Result { + let keys = self.load_nostr_keys(id).await?; + let peer_pk = nostr_sdk::PublicKey::from_hex(peer_pubkey_hex) + .context("Invalid peer pubkey hex")?; + nostr_sdk::nips::nip44::decrypt(keys.secret_key(), &peer_pk, ciphertext) + .context("NIP-44 decryption failed") + } + // --- internal helpers --- } diff --git a/loop/plan.md b/loop/plan.md index 98cb04f2..9fd04fa8 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -488,7 +488,7 @@ - [x] **NIP07-03** — Test NIP-07 with a real Nostr web app. Install `nostr-rs-relay` container if not already running (it's in the app catalog). Deploy a Nostr web client that supports NIP-07 — add Nostrudel (https://nostrudel.ninja) as a web-only app entry in `Marketplace.vue` `getCuratedAppList()` (category: "Social", opens in iframe). Open Nostrudel, verify it detects `window.nostr`, can fetch the pubkey, and can sign events (post a note). **Acceptance**: Can post a signed Nostr note from within the Archipelago iframe using the node's Nostr identity. Verify the note appears on a public Nostr client. -- [ ] **NIP07-04** — Support NIP-04 and NIP-44 encryption in iframe provider. The `nostr-provider.js` already has stubs for `nip04.encrypt`, `nip04.decrypt`, `nip44.encrypt`, `nip44.decrypt`. Add backend RPC endpoints: `identity.nostr-encrypt-nip04`, `identity.nostr-decrypt-nip04`, `identity.nostr-encrypt-nip44`, `identity.nostr-decrypt-nip44`. Each takes the identity ID, peer pubkey, and plaintext/ciphertext. Use `nostr_sdk` for the actual crypto. Register in RPC router. Wire the appLauncher `handleNostrRequest` to route `nip04.*` and `nip44.*` calls to these endpoints. **Acceptance**: From an iframe app, call `window.nostr.nip44.encrypt(peerPubkey, "hello")` — returns ciphertext. Call `nip44.decrypt` with same ciphertext — returns "hello". Deploy and verify. +- [x] **NIP07-04** — Support NIP-04 and NIP-44 encryption in iframe provider. The `nostr-provider.js` already has stubs for `nip04.encrypt`, `nip04.decrypt`, `nip44.encrypt`, `nip44.decrypt`. Add backend RPC endpoints: `identity.nostr-encrypt-nip04`, `identity.nostr-decrypt-nip04`, `identity.nostr-encrypt-nip44`, `identity.nostr-decrypt-nip44`. Each takes the identity ID, peer pubkey, and plaintext/ciphertext. Use `nostr_sdk` for the actual crypto. Register in RPC router. Wire the appLauncher `handleNostrRequest` to route `nip04.*` and `nip44.*` calls to these endpoints. **Acceptance**: From an iframe app, call `window.nostr.nip44.encrypt(peerPubkey, "hello")` — returns ciphertext. Call `nip44.decrypt` with same ciphertext — returns "hello". Deploy and verify. ### Sprint 42: Tor Address Rotation & Per-App Toggle (May 2026 Week 1-2) diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index cc800a44..26a14167 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -218,6 +218,30 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { result = res } else if (method === 'getRelays') { result = {} + } else if (method === 'nip04.encrypt') { + const res = await rpcClient.call<{ ciphertext: string }>({ + method: 'identity.nostr-encrypt-nip04', + params: { pubkey: params.pubkey, plaintext: params.plaintext } + }) + result = res.ciphertext + } else if (method === 'nip04.decrypt') { + const res = await rpcClient.call<{ plaintext: string }>({ + method: 'identity.nostr-decrypt-nip04', + params: { pubkey: params.pubkey, ciphertext: params.ciphertext } + }) + result = res.plaintext + } else if (method === 'nip44.encrypt') { + const res = await rpcClient.call<{ ciphertext: string }>({ + method: 'identity.nostr-encrypt-nip44', + params: { pubkey: params.pubkey, plaintext: params.plaintext } + }) + result = res.ciphertext + } else if (method === 'nip44.decrypt') { + const res = await rpcClient.call<{ plaintext: string }>({ + method: 'identity.nostr-decrypt-nip44', + params: { pubkey: params.pubkey, ciphertext: params.ciphertext } + }) + result = res.plaintext } else { throw new Error(`Unsupported NIP-07 method: ${method}`) }