diff --git a/js/dmDecryptor.js b/js/dmDecryptor.js index 85e809b4..b6c47298 100644 --- a/js/dmDecryptor.js +++ b/js/dmDecryptor.js @@ -419,6 +419,8 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) { const ciphertext = typeof event?.content === "string" ? event.content : ""; const senderPubkey = normalizeHex(event?.pubkey); const hints = parseEncryptionHints(event?.tags); + const recipients = collectRecipients(event?.tags); + const normalizedActor = normalizeHex(actorPubkey); if (!ciphertext || !senderPubkey) { return buildDecryptResult({ @@ -437,38 +439,63 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) { const ordered = orderDecryptors(decryptors, hints); const errors = []; + const remoteCandidates = []; + const seenRemotes = new Set(); + const registerRemote = (candidate) => { + const normalized = normalizeHex(candidate); + if (!normalized || seenRemotes.has(normalized)) { + return; + } + seenRemotes.add(normalized); + remoteCandidates.push(normalized); + }; + + if (normalizedActor && normalizedActor === senderPubkey) { + for (const recipient of recipients) { + registerRemote(recipient?.pubkey); + } + } + + registerRemote(senderPubkey); + + for (const recipient of recipients) { + registerRemote(recipient?.pubkey); + } + for (const decryptor of ordered) { - try { - const plaintext = await decryptor.decrypt(senderPubkey, ciphertext, { - event, - stage: "content", - }); - - if (typeof plaintext === "string") { - const recipients = collectRecipients(event?.tags); - - return buildDecryptResult({ - ok: true, + for (const remotePubkey of remoteCandidates) { + try { + const plaintext = await decryptor.decrypt(remotePubkey, ciphertext, { event, - message: { - ...cloneEvent(event), - content: plaintext, - }, - plaintext, - recipients, - senderPubkey, - actorPubkey, - decryptor, - scheme: decryptor.scheme || hints.algorithms?.[0] || "", + stage: "content", + remotePubkey, + }); + + if (typeof plaintext === "string") { + return buildDecryptResult({ + ok: true, + event, + message: { + ...cloneEvent(event), + content: plaintext, + }, + plaintext, + recipients, + senderPubkey, + actorPubkey, + decryptor, + scheme: decryptor.scheme || hints.algorithms?.[0] || "", + }); + } + } catch (error) { + errors.push({ + scheme: decryptor.scheme || "", + source: decryptor.source || "", + stage: "content", + remotePubkey, + error, }); } - } catch (error) { - errors.push({ - scheme: decryptor.scheme || "", - source: decryptor.source || "", - stage: "content", - error, - }); } } diff --git a/tests/dm-decryptor.test.mjs b/tests/dm-decryptor.test.mjs index 1df77d77..a1c12e75 100644 --- a/tests/dm-decryptor.test.mjs +++ b/tests/dm-decryptor.test.mjs @@ -48,6 +48,49 @@ test("decryptDM handles kind 4 events with nip04 ciphertext", async () => { assert.equal(result.direction, "incoming", "direction should be incoming for recipient"); }); +test("decryptDM prefers recipient pubkeys when actor is the sender", async () => { + const nostrTools = await import("nostr-tools"); + const { generateSecretKey, getPublicKey, nip04 } = nostrTools; + + const senderSecret = generateSecretKey(); + const recipientSecret = generateSecretKey(); + + const senderPubkey = getPublicKey(senderSecret); + const recipientPubkey = getPublicKey(recipientSecret); + + const message = "Hello from the author"; + const ciphertext = await nip04.encrypt(senderSecret, recipientPubkey, message); + + const event = { + id: "outgoing-kind4", + kind: 4, + created_at: Math.floor(Date.now() / 1000), + pubkey: senderPubkey, + tags: [["p", recipientPubkey]], + content: ciphertext, + }; + + const context = { + actorPubkey: senderPubkey, + decryptors: [ + { + scheme: "nip04", + priority: 0, + decrypt: (remotePubkey, cipher) => + nip04.decrypt(senderSecret, remotePubkey, cipher), + }, + ], + }; + + const result = await decryptDM(event, context); + + assert.equal(result.ok, true, "decryptDM should succeed for outgoing event"); + assert.equal(result.plaintext, message, "plaintext should match outgoing message"); + assert.equal(result.recipients.length, 1, "recipient metadata should remain intact"); + assert.equal(result.recipients[0].pubkey, recipientPubkey); + assert.equal(result.direction, "outgoing", "direction should be outgoing for the author"); +}); + test("decryptDM unwraps kind 1059 gift wraps with nip44", async () => { const nostrTools = await import("nostr-tools"); const { generateSecretKey, getPublicKey, nip44, nip59 } = nostrTools;