diff --git a/js/nostr.js b/js/nostr.js index 784edb56..a5dba67a 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -3857,6 +3857,61 @@ function normalizeNip46CiphertextPayload(payload) { } }; + const extractScalarString = (value) => { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed ? trimmed : ""; + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + if (Array.isArray(value)) { + const decoded = decodeBytesToUtf8(value); + if (decoded) { + return decoded; + } + + if (value.length === 1) { + return extractScalarString(value[0]); + } + + return ""; + } + + if (!value || typeof value !== "object") { + return ""; + } + + const decoded = decodeBytesToUtf8(value); + if (decoded) { + return decoded; + } + + const preferredKeys = [ + "ciphertext", + "cipher_text", + "payload", + "content", + "result", + "value", + "data", + "nonce", + "iv", + "secret", + "message", + ]; + + for (const key of preferredKeys) { + if (typeof value[key] === "string" && value[key].trim()) { + return value[key].trim(); + } + } + + return ""; + }; + const coerce = (value) => { if (typeof value === "string") { addCandidate(value); @@ -3888,6 +3943,23 @@ function normalizeNip46CiphertextPayload(payload) { return; } + const scalarEntries = []; + for (const entry of value) { + const scalar = extractScalarString(entry); + if (scalar) { + scalarEntries.push(scalar); + } + } + + if (scalarEntries.length >= 2) { + for (let i = 0; i < scalarEntries.length; i += 1) { + for (let j = i + 1; j < scalarEntries.length; j += 1) { + addCiphertextWithNonce(scalarEntries[i], scalarEntries[j]); + addCiphertextWithNonce(scalarEntries[j], scalarEntries[i]); + } + } + } + for (const entry of value) { coerce(entry); } diff --git a/tests/nostr.nip46.test.mjs b/tests/nostr.nip46.test.mjs index 44e398c3..449573b5 100644 --- a/tests/nostr.nip46.test.mjs +++ b/tests/nostr.nip46.test.mjs @@ -371,3 +371,49 @@ test( ); }, ); + +test( + "attemptDecryptNip46HandshakePayload handles array-encoded nip04 payloads", + async () => { + const nostrTools = await loadNostrTools(); + const { generateSecretKey, getPublicKey, nip04, utils } = nostrTools; + + const clientSecret = utils.bytesToHex(generateSecretKey()); + const clientPubkey = getPublicKey(clientSecret).toLowerCase(); + const remoteSecret = utils.bytesToHex(generateSecretKey()); + const remotePubkey = getPublicKey(remoteSecret).toLowerCase(); + + const payload = JSON.stringify({ id: "ack", result: "ack" }); + const ciphertext = nip04.encrypt(remoteSecret, clientPubkey, payload); + const [ciphertextPart, ivPart] = ciphertext.split("?iv="); + + assert.ok(ivPart, "nip04 encryption should emit an iv segment"); + + const encodedPayload = JSON.stringify([ciphertextPart, ivPart]); + + const { __testExports } = await import("../js/nostr.js"); + const { attemptDecryptNip46HandshakePayload } = __testExports; + + const result = await attemptDecryptNip46HandshakePayload({ + clientPrivateKey: clientSecret, + candidateRemotePubkeys: [remotePubkey], + ciphertext: encodedPayload, + }); + + assert.equal( + result.remotePubkey, + remotePubkey, + "helper should resolve the remote signer key for array payloads", + ); + assert.equal( + result.plaintext, + payload, + "helper should decrypt nip04 payloads serialized as arrays", + ); + assert.equal( + result.algorithm, + "nip04", + "helper should report the nip04 algorithm for nip04 ciphertext", + ); + }, +);