From 8ae036cc23b973d5b5a395b650cbca831266e708 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 21 Oct 2025 21:15:27 -0400 Subject: [PATCH] Refactor Nip46 RPC cipher handling --- js/nostr.js | 63 +++++++++++++++++++++++++++++++------- tests/nostr.nip46.test.mjs | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 11 deletions(-) create mode 100644 tests/nostr.nip46.test.mjs diff --git a/js/nostr.js b/js/nostr.js index 554f1b61..e8b95c97 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -3555,15 +3555,56 @@ class Nip46RpcClient { let encrypt = null; let decrypt = null; - if (tools?.nip44?.v2?.encrypt && tools?.nip44?.v2?.decrypt) { - encrypt = (priv, pub, payload) => tools.nip44.v2.encrypt(priv, pub, payload); - decrypt = (priv, pub, payload) => tools.nip44.v2.decrypt(priv, pub, payload); - } else if (tools?.nip44?.encrypt && tools?.nip44?.decrypt) { - encrypt = (priv, pub, payload) => tools.nip44.encrypt(priv, pub, payload); - decrypt = (priv, pub, payload) => tools.nip44.decrypt(priv, pub, payload); + const nip44v2GetConversationKey = + typeof tools?.nip44?.v2?.getConversationKey === "function" + ? tools.nip44.v2.getConversationKey + : typeof tools?.nip44?.v2?.utils?.getConversationKey === "function" + ? tools.nip44.v2.utils.getConversationKey + : null; + + if ( + tools?.nip44?.v2?.encrypt && + tools?.nip44?.v2?.decrypt && + nip44v2GetConversationKey + ) { + const conversationKey = nip44v2GetConversationKey( + this.clientPrivateKey, + this.remotePubkey, + ); + + if (!conversationKey) { + throw new Error("Failed to derive a nip44 conversation key for remote signing."); + } + + encrypt = (plaintext, nonce) => + typeof nonce === "string" + ? tools.nip44.v2.encrypt(plaintext, conversationKey, nonce) + : tools.nip44.v2.encrypt(plaintext, conversationKey); + decrypt = (ciphertext) => tools.nip44.v2.decrypt(ciphertext, conversationKey); + } else if ( + tools?.nip44?.encrypt && + tools?.nip44?.decrypt && + typeof tools?.nip44?.getConversationKey === "function" + ) { + const conversationKey = tools.nip44.getConversationKey( + this.clientPrivateKey, + this.remotePubkey, + ); + + if (!conversationKey) { + throw new Error("Failed to derive a nip44 conversation key for remote signing."); + } + + encrypt = (plaintext, nonce) => + typeof nonce === "string" + ? tools.nip44.encrypt(plaintext, conversationKey, nonce) + : tools.nip44.encrypt(plaintext, conversationKey); + decrypt = (ciphertext) => tools.nip44.decrypt(ciphertext, conversationKey); } else if (tools?.nip04?.encrypt && tools?.nip04?.decrypt) { - encrypt = (priv, pub, payload) => tools.nip04.encrypt(priv, pub, payload); - decrypt = (priv, pub, payload) => tools.nip04.decrypt(priv, pub, payload); + const privateKey = this.clientPrivateKey; + const remotePubkey = this.remotePubkey; + encrypt = (plaintext) => tools.nip04.encrypt(privateKey, remotePubkey, plaintext); + decrypt = (ciphertext) => tools.nip04.decrypt(privateKey, remotePubkey, ciphertext); } if (!encrypt || !decrypt) { @@ -3577,12 +3618,12 @@ class Nip46RpcClient { async encryptPayload(payload) { const { encrypt } = await this.ensureCipher(); const serialized = typeof payload === "string" ? payload : JSON.stringify(payload); - return encrypt(this.clientPrivateKey, this.remotePubkey, serialized); + return encrypt(serialized); } async decryptPayload(ciphertext) { const { decrypt } = await this.ensureCipher(); - return decrypt(this.clientPrivateKey, this.remotePubkey, ciphertext); + return decrypt(ciphertext); } async ensureSubscription() { @@ -4433,7 +4474,7 @@ function getActiveKey(video) { return `LEGACY:${video.id}`; } -export { convertEventToVideo }; +export { convertEventToVideo, Nip46RpcClient }; export class NostrClient { constructor() { diff --git a/tests/nostr.nip46.test.mjs b/tests/nostr.nip46.test.mjs new file mode 100644 index 00000000..f22c9771 --- /dev/null +++ b/tests/nostr.nip46.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const PAYLOAD = { message: "hello", count: 42 }; + +const loadNostrTools = async () => { + const nostrTools = await import("nostr-tools"); + globalThis.nostrToolsReady = Promise.resolve(nostrTools); + return nostrTools; +}; + +test("Nip46RpcClient encrypts payloads with nip44 conversation keys", async () => { + const nostrTools = await loadNostrTools(); + const { generateSecretKey, getPublicKey, nip44, utils } = nostrTools; + + const clientSecret = utils.bytesToHex(generateSecretKey()); + const remoteSecret = utils.bytesToHex(generateSecretKey()); + const clientPublicKey = getPublicKey(clientSecret); + const remotePubkey = getPublicKey(remoteSecret); + + const { Nip46RpcClient } = await import("../js/nostr.js"); + + const rpcClient = new Nip46RpcClient({ + nostrClient: { relays: [] }, + clientPrivateKey: clientSecret, + clientPublicKey, + remotePubkey, + }); + + const serialized = JSON.stringify(PAYLOAD); + const ciphertext = await rpcClient.encryptPayload(PAYLOAD); + + assert.equal(typeof ciphertext, "string", "ciphertext should be encoded as a string"); + + const conversationKey = + typeof nip44.v2.getConversationKey === "function" + ? nip44.v2.getConversationKey(clientSecret, remotePubkey) + : nip44.v2.utils.getConversationKey(clientSecret, remotePubkey); + const decryptedWithTools = nip44.v2.decrypt(ciphertext, conversationKey); + assert.equal( + decryptedWithTools, + serialized, + "nip44 decrypt should recover the serialized payload", + ); + + const roundTrip = await rpcClient.decryptPayload(ciphertext); + assert.equal(roundTrip, serialized, "client should decrypt its own ciphertext"); + + const externalCiphertext = nip44.v2.encrypt(serialized, conversationKey); + const externalRoundTrip = await rpcClient.decryptPayload(externalCiphertext); + assert.equal( + externalRoundTrip, + serialized, + "client should decrypt ciphertext produced by nostr-tools", + ); +});