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 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-12 23:50:56 +00:00
parent 398e94b5d3
commit 3383b43a75
6 changed files with 170 additions and 2 deletions

View File

@ -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"

View File

@ -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<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(&params).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(&params).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(&params).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(&params).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(

View File

@ -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,

View File

@ -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<nostr_sdk::Keys> {
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<String> {
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<String> {
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<String> {
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<String> {
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 ---
}

View File

@ -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)

View File

@ -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}`)
}