diff --git a/js/nostr/client.js b/js/nostr/client.js index e7fa1fd2..c1452801 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -173,7 +173,7 @@ import { readStoredSessionActorEntry, isSessionActor, } from "./sessionActor.js"; -import { HEX64_REGEX, normalizeHexHash } from "../utils/hex.js"; +import { HEX64_REGEX, normalizeHexHash, bytesToHex } from "../utils/hex.js"; import { NIP46_RPC_KIND, NIP46_HANDSHAKE_TIMEOUT_MS, diff --git a/js/utils/hex.js b/js/utils/hex.js index 49307eb0..247ead81 100644 --- a/js/utils/hex.js +++ b/js/utils/hex.js @@ -12,6 +12,16 @@ export function normalizeHexString(value) { export const normalizeHexId = normalizeHexString; export const normalizeHexPubkey = normalizeHexString; +// Lowercase hex encoding of a byte array (e.g. a generated secret key). +export function bytesToHex(bytes) { + if (!bytes) { + return ""; + } + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + export function normalizeHexHash(value) { if (value === undefined || value === null) { return ""; diff --git a/tests/nip17-send-gift-wrap.test.mjs b/tests/nip17-send-gift-wrap.test.mjs new file mode 100644 index 00000000..56ef3beb --- /dev/null +++ b/tests/nip17-send-gift-wrap.test.mjs @@ -0,0 +1,151 @@ +// Sending a NIP-17 DM (kind 1059 gift wrap) must generate an ephemeral wrapper +// keypair. That keygen hex-encodes the generated secret via bytesToHex; a +// missing import made bytesToHex throw (swallowed into "") so keygen returned +// null and every NIP-17 send failed with `nip17-keygen-failed`. The legacy +// (kind 4 / NIP-04) send tests delete nip44Encrypt, so they never exercised +// this path. This test forces the NIP-17 path and asserts a gift wrap is built +// and published. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-nip17-send-gift-wrap +// given: "a nip44-capable signer and known DM relays" +// when: "sendDirectMessage is called with useNip17:true" +// then: "it resolves ok and publishes a kind-1059 gift wrap (no keygen failure)" +// observable_outcomes: +// - "result.ok === true (not { error: 'nip17-keygen-failed' })" +// - "a kind 1059 gift-wrap event is published to the recipient relay" +// determinism_controls: +// - "real nostr-tools injected; pool.publish/list stubbed; relay hints supplied" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +import assert from "node:assert/strict"; +import test, { after } from "node:test"; + +const RELAY_URL = "wss://relay.nip17.test"; + +after(() => { + setTimeout(() => process.exit(0), 100); +}); + +test("sendDirectMessage (NIP-17) builds and publishes a kind-1059 gift wrap", 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 }; + + try { + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = canonicalTools; + globalThis.NostrTools = canonicalTools; + globalThis.nostrToolsReady = Promise.resolve({ ok: true, value: canonicalTools }); + + const [{ nostrClient }, { getActiveSigner, clearActiveSigner }] = + await Promise.all([ + import("../js/nostrClientFacade.js"), + import("../js/nostr/client.js"), + ]); + + const previousPool = nostrClient.pool; + const previousRelays = nostrClient.relays; + const previousPubkey = nostrClient.pubkey; + + const publishInvocations = []; + nostrClient.pool = { + publish(urls, event) { + publishInvocations.push({ urls, event }); + return { + on(eventName, handler) { + if (eventName === "ok") setTimeout(() => handler(), 0); + return true; + }, + }; + }, + list() { + return Promise.resolve([]); + }, + }; + nostrClient.relays = [RELAY_URL]; + nostrClient.readRelays = [RELAY_URL]; + nostrClient.writeRelays = [RELAY_URL]; + + const senderSecret = canonicalTools.generateSecretKey(); + const senderPubkey = canonicalTools.getPublicKey(senderSecret); + const recipientSecret = canonicalTools.generateSecretKey(); + const recipientPubkey = canonicalTools.getPublicKey(recipientSecret); + + await nostrClient.registerPrivateKeySigner({ + privateKey: canonicalTools.utils.bytesToHex(senderSecret), + pubkey: senderPubkey, + }); + nostrClient.pubkey = senderPubkey; + + const signer = getActiveSigner(); + // Keep nip44 (so the NIP-17 path is taken) and give it a working impl. + signer.signEvent = (event) => { + const finalized = canonicalTools.finalizeEvent(event, senderSecret); + return { ...event, id: finalized.id, sig: finalized.sig }; + }; + signer.nip44Encrypt = async (peerPubkey, plaintext) => { + const key = canonicalTools.nip44.v2.utils.getConversationKey( + senderSecret, + peerPubkey, + ); + return canonicalTools.nip44.v2.encrypt(plaintext, key); + }; + + const result = await nostrClient.sendDirectMessage( + recipientPubkey, + "nip17 gift wrap smoke test", + { + useNip17: true, + recipientRelayHints: [RELAY_URL], + senderRelayHints: [RELAY_URL], + }, + ); + + assert.equal( + result.ok, + true, + `NIP-17 send should succeed, got: ${JSON.stringify(result)}`, + ); + assert.notEqual( + result.error, + "nip17-keygen-failed", + "ephemeral wrapper keygen must not fail", + ); + + const giftWraps = publishInvocations.filter( + ({ event }) => event?.kind === 1059, + ); + assert.ok( + giftWraps.length >= 1, + "at least one kind-1059 gift wrap should be published", + ); + // A gift wrap is signed by a fresh ephemeral key, never the sender's. + assert.notEqual( + giftWraps[0].event.pubkey, + senderPubkey, + "gift wrap must be authored by the ephemeral wrapper key, not the sender", + ); + + signer.signEvent = undefined; + signer.nip44Encrypt = undefined; + clearActiveSigner(); + nostrClient.pool = previousPool; + nostrClient.relays = previousRelays; + nostrClient.pubkey = previousPubkey; + } 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; + } +});