From 9d70a954f3f9867aaeaac1800cc93dffea77b7ae Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 22 Oct 2025 07:28:36 -0400 Subject: [PATCH] Handle structured NIP-46 handshake payloads --- js/nostr.js | 112 ++++++++++++++++++++++++++++++++++++- tests/nostr.nip46.test.mjs | 50 +++++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) diff --git a/js/nostr.js b/js/nostr.js index abfc241a..94909c57 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -3636,6 +3636,115 @@ function resolveLegacyNip44ConversationKeyGetter(tools) { return null; } +function normalizeNip46CiphertextPayload(payload) { + const visited = new Set(); + + const coerce = (value) => { + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + + const firstChar = trimmed.charAt(0); + const lastChar = trimmed.charAt(trimmed.length - 1); + if ( + (firstChar === "{" && lastChar === "}") || + (firstChar === "[" && lastChar === "]") + ) { + try { + const parsed = JSON.parse(trimmed); + const normalized = coerce(parsed); + if (normalized) { + return normalized; + } + } catch (error) { + // ignore JSON parse errors and fall through to returning the trimmed string + } + } + + return trimmed; + } + + if (Array.isArray(value)) { + const parts = []; + for (const entry of value) { + const normalized = coerce(entry); + if (normalized) { + parts.push(normalized); + } + } + if (!parts.length) { + return ""; + } + if (parts.length === 1) { + return parts[0]; + } + return parts.join("\n"); + } + + if (value && typeof value === "object") { + if (visited.has(value)) { + return ""; + } + visited.add(value); + + const ciphertextCandidates = [ + typeof value.ciphertext === "string" ? value.ciphertext.trim() : "", + typeof value.cipher_text === "string" ? value.cipher_text.trim() : "", + typeof value.content === "string" ? value.content.trim() : "", + typeof value.payload === "string" ? value.payload.trim() : "", + typeof value.result === "string" ? value.result.trim() : "", + typeof value.value === "string" ? value.value.trim() : "", + typeof value.data === "string" ? value.data.trim() : "", + ]; + + const ciphertext = ciphertextCandidates.find(Boolean) || ""; + const nonceCandidates = [ + typeof value.nonce === "string" ? value.nonce.trim() : "", + typeof value.iv === "string" ? value.iv.trim() : "", + ]; + const nonce = nonceCandidates.find(Boolean) || ""; + + if (ciphertext && nonce) { + return `${ciphertext}\n${nonce}`; + } + if (ciphertext) { + return ciphertext; + } + + const nestedKeys = [ + "ciphertext", + "cipher_text", + "payload", + "result", + "value", + "data", + "content", + ]; + + for (const key of nestedKeys) { + if (key in value) { + const normalized = coerce(value[key]); + if (normalized) { + return normalized; + } + } + } + + return ""; + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + return ""; + }; + + return coerce(payload); +} + export function createNip46Cipher(tools, privateKey, remotePubkey) { if (!tools) { throw new Error("NostrTools helpers are unavailable for remote signing."); @@ -3705,7 +3814,8 @@ async function decryptNip46PayloadWithKeys(privateKey, remotePubkey, ciphertext) } const { decrypt } = createNip46Cipher(tools, privateKey, remotePubkey); - return decrypt(ciphertext); + const normalizedCiphertext = normalizeNip46CiphertextPayload(ciphertext); + return decrypt(normalizedCiphertext); } class Nip46RpcClient { diff --git a/tests/nostr.nip46.test.mjs b/tests/nostr.nip46.test.mjs index ce912bea..dfcb55a1 100644 --- a/tests/nostr.nip46.test.mjs +++ b/tests/nostr.nip46.test.mjs @@ -81,3 +81,53 @@ test("decryptNip46PayloadWithKeys handles nip44.v2 ciphertext", async () => { assert.equal(roundTrip, serialized, "helper should decrypt nip44.v2 ciphertext"); }); + +test("decryptNip46PayloadWithKeys coerces structured handshake payloads", async () => { + const nostrTools = await loadNostrTools(); + const { generateSecretKey, getPublicKey, nip44, utils } = nostrTools; + + const clientSecret = utils.bytesToHex(generateSecretKey()); + const remoteSecret = utils.bytesToHex(generateSecretKey()); + const remotePubkey = getPublicKey(remoteSecret); + + const serialized = JSON.stringify(PAYLOAD); + const conversationKey = + typeof nip44.v2.getConversationKey === "function" + ? nip44.v2.getConversationKey(clientSecret, remotePubkey) + : nip44.v2.utils.getConversationKey(clientSecret, remotePubkey); + const ciphertext = nip44.v2.encrypt(serialized, conversationKey); + const [ciphertextPart, nonce] = ciphertext.split("\n"); + + const structuredPayload = { + ciphertext: ciphertextPart, + nonce, + }; + + const { __testExports } = await import("../js/nostr.js"); + const { decryptNip46PayloadWithKeys } = __testExports; + + const roundTrip = await decryptNip46PayloadWithKeys( + clientSecret, + remotePubkey, + structuredPayload, + ); + + assert.equal( + roundTrip, + serialized, + "helper should decrypt ciphertext encoded as an object with nonce", + ); + + const jsonPayload = JSON.stringify(structuredPayload); + const jsonRoundTrip = await decryptNip46PayloadWithKeys( + clientSecret, + remotePubkey, + jsonPayload, + ); + + assert.equal( + jsonRoundTrip, + serialized, + "helper should also decrypt JSON-serialized handshake payloads", + ); +});