From d8dcdf2b404186c405fad8315432f11ff7b9c7c1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 21 Oct 2025 21:57:13 -0400 Subject: [PATCH] Fix direct message encryption fallback and add regression test --- docs/qa.md | 1 + js/nostr.js | 79 +++++++++++-- tests/nostr-send-direct-message.test.mjs | 134 +++++++++++++++++++++++ 3 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 tests/nostr-send-direct-message.test.mjs diff --git a/docs/qa.md b/docs/qa.md index a80afac4..f46a7b70 100644 --- a/docs/qa.md +++ b/docs/qa.md @@ -47,6 +47,7 @@ Automated regression coverage now exercises these flows on the unified design-sy - Sign in with a new nsec or mnemonic, opt into remembering it, and verify the passphrase prompt blocks submission until both passphrase fields match. - Reload the page, select "Unlock saved key", enter the passphrase, and confirm the account unlocks without re-entering the secret. - Publish a video while logged in with the direct-key provider and confirm the resulting event is signed (relays accept it) and the UI reports success. + - While still signed in via the direct-key provider, send a direct message to a second account and confirm it decrypts successfully on the recipient before release. - Log out and ensure the session clears decrypted key material; stored keys should require the passphrase to unlock again. Document findings (pass/fail notes plus relevant screenshots or logs) so they can be attached to release or PR notes. diff --git a/js/nostr.js b/js/nostr.js index 67858093..7b9d6c8a 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -8959,10 +8959,6 @@ export class NostrClient { return { ok: false, error: "sign-event-unavailable" }; } - if (typeof signer.nip04Encrypt !== "function") { - return { ok: false, error: "nip04-unavailable" }; - } - if (shouldRequestExtensionPermissions(signer)) { const permissionResult = await this.ensureExtensionPermissions( DEFAULT_NIP07_PERMISSION_METHODS, @@ -9002,11 +8998,78 @@ export class NostrClient { return { ok: false, error: "invalid-target" }; } + const encryptionCandidates = []; + if (typeof signer.nip44Encrypt === "function") { + encryptionCandidates.push({ + scheme: "nip44", + encrypt: signer.nip44Encrypt, + }); + } + if (typeof signer.nip04Encrypt === "function") { + encryptionCandidates.push({ + scheme: "nip04", + encrypt: signer.nip04Encrypt, + }); + } + + const encryptionErrors = []; let ciphertext = ""; - try { - ciphertext = await signer.nip04Encrypt(targetHex, trimmedMessage); - } catch (error) { - return { ok: false, error: "encryption-failed", details: error }; + + for (const candidate of encryptionCandidates) { + try { + const encrypted = await candidate.encrypt(targetHex, trimmedMessage); + if (typeof encrypted === "string" && encrypted) { + ciphertext = encrypted; + break; + } + } catch (error) { + encryptionErrors.push({ scheme: candidate.scheme, error }); + } + } + + if (!ciphertext) { + const normalizedActorHex = normalizeActorKey(actorHex); + const sessionActor = this.sessionActor; + const sessionMatchesActor = + sessionActor && + typeof sessionActor.pubkey === "string" && + sessionActor.pubkey.toLowerCase() === normalizedActorHex && + typeof sessionActor.privateKey === "string" && + sessionActor.privateKey; + + if (sessionMatchesActor) { + try { + const tools = (await ensureNostrTools()) || getCachedNostrTools(); + if (tools?.nip04 && typeof tools.nip04.encrypt === "function") { + const encrypted = await tools.nip04.encrypt( + sessionActor.privateKey, + targetHex, + trimmedMessage, + ); + if (typeof encrypted === "string" && encrypted) { + ciphertext = encrypted; + } + } else { + encryptionErrors.push({ + scheme: "nip04", + error: new Error("nostr-tools nip04 helpers unavailable"), + }); + } + } catch (error) { + encryptionErrors.push({ scheme: "nip04", error }); + } + } + } + + if (!ciphertext) { + const details = + encryptionErrors.length === 1 + ? encryptionErrors[0].error + : encryptionErrors.map((entry) => ({ + scheme: entry.scheme, + error: entry.error, + })); + return { ok: false, error: "encryption-failed", details }; } const event = { diff --git a/tests/nostr-send-direct-message.test.mjs b/tests/nostr-send-direct-message.test.mjs new file mode 100644 index 00000000..d50eed7c --- /dev/null +++ b/tests/nostr-send-direct-message.test.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const RELAY_URL = "wss://relay.unit.test"; + +test("sendDirectMessage succeeds with private key signer and no extension", async () => { + const previousCanonical = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__; + const previousNostrTools = globalThis.NostrTools; + const previousReady = globalThis.nostrToolsReady; + + const nostrTools = await import("nostr-tools"); + const canonicalTools = { ...nostrTools }; + if (typeof canonicalTools.signEvent !== "function") { + canonicalTools.signEvent = (event, privateKey) => { + const finalized = canonicalTools.finalizeEvent(event, privateKey); + return finalized.sig; + }; + } + + try { + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = canonicalTools; + globalThis.NostrTools = canonicalTools; + globalThis.nostrToolsReady = Promise.resolve({ + ok: true, + value: canonicalTools, + }); + + const { nostrClient, getActiveSigner, clearActiveSigner } = await import( + "../js/nostr.js" + ); + + const previousPool = nostrClient.pool; + const previousRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const previousPubkey = nostrClient.pubkey; + const previousSessionActor = nostrClient.sessionActor; + const previousLockedSessionActor = nostrClient.lockedSessionActor; + + const publishInvocations = []; + nostrClient.pool = { + publish(urls, event) { + publishInvocations.push({ urls, event }); + return { + on(eventName, handler) { + if (eventName === "ok") { + setTimeout(() => handler(), 0); + } + return true; + }, + }; + }, + }; + nostrClient.relays = [RELAY_URL]; + + const senderPrivateKey = canonicalTools.utils.bytesToHex( + canonicalTools.generateSecretKey(), + ); + const senderPubkey = canonicalTools.getPublicKey(senderPrivateKey); + const recipientSecret = canonicalTools.generateSecretKey(); + const recipientPrivateKey = canonicalTools.utils.bytesToHex(recipientSecret); + const recipientPubkey = canonicalTools.getPublicKey(recipientSecret); + + await nostrClient.registerPrivateKeySigner({ + privateKey: senderPrivateKey, + pubkey: senderPubkey, + }); + nostrClient.pubkey = senderPubkey; + + const signer = getActiveSigner(); + const originalSignEvent = signer?.signEvent; + const originalNip04 = signer?.nip04Encrypt; + const originalNip44 = signer?.nip44Encrypt; + if (signer) { + signer.signEvent = (event) => { + const finalized = canonicalTools.finalizeEvent(event, senderPrivateKey); + return { ...event, id: finalized.id, sig: finalized.sig }; + }; + delete signer.nip04Encrypt; + delete signer.nip44Encrypt; + } + + const message = "bitvid direct message smoke test"; + const result = await nostrClient.sendDirectMessage(recipientPubkey, message); + + assert.deepEqual(result, { ok: true }, "sendDirectMessage should resolve successfully"); + assert.equal(publishInvocations.length, 1, "exactly one relay publish should be attempted"); + + const [{ urls, event }] = publishInvocations; + assert.deepEqual(urls, [RELAY_URL], "publish should target the configured relay"); + assert.equal(event.kind, 4, "event kind should be 4 for direct messages"); + assert.equal(event.pubkey, senderPubkey, "event should be authored by the sender"); + assert.equal(typeof event.content, "string", "ciphertext should be a string"); + assert.notEqual(event.content, "", "ciphertext should not be empty"); + + const decrypted = await canonicalTools.nip04.decrypt( + recipientPrivateKey, + senderPubkey, + event.content, + ); + assert.equal(decrypted, message, "recipient should decrypt the original message"); + + if (signer) { + signer.signEvent = originalSignEvent; + signer.nip04Encrypt = originalNip04; + signer.nip44Encrypt = originalNip44; + } + + clearActiveSigner(); + nostrClient.pool = previousPool; + nostrClient.relays = previousRelays; + nostrClient.pubkey = previousPubkey; + nostrClient.sessionActor = previousSessionActor; + nostrClient.lockedSessionActor = previousLockedSessionActor; + } finally { + if (previousCanonical === undefined) { + delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__; + } else { + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = previousCanonical; + } + + if (previousNostrTools === undefined) { + delete globalThis.NostrTools; + } else { + globalThis.NostrTools = previousNostrTools; + } + + if (previousReady === undefined) { + delete globalThis.nostrToolsReady; + } else { + globalThis.nostrToolsReady = previousReady; + } + } +});