From a1ed04b22c7f5079ea62c38afbf6634c1dc39fd5 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:29:43 -0400 Subject: [PATCH] [dm] Fix NIP-17 send failing with nip17-keygen-failed (missing bytesToHex import) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sending a NIP-17 DM builds a kind-1059 gift wrap, which needs a fresh ephemeral keypair. generateEphemeralKeypair() hex-encodes the generated secret with bytesToHex — but bytesToHex was never imported in client.js, so the call threw ReferenceError. The try/catch swallowed it into privateKey="", keygen returned null, and every NIP-17 send failed with "We couldn't create secure wrapper keys for NIP-17 delivery." (nip17-keygen-failed). The seal (kind 13) signed fine because it uses the active signer; only the ephemeral keygen was broken. Build and lint don't parse JS, so the missing import wasn't caught. - Add bytesToHex to the canonical js/utils/hex.js and import it in client.js. - Add tests/nip17-send-gift-wrap.test.mjs: forces the NIP-17 path (useNip17 + relay hints, nip44-capable signer) and asserts a kind-1059 gift wrap is published. Reverting the import reproduces the exact nip17-keygen-failed error; the existing DM tests only covered the legacy kind-4/NIP-04 path (they delete nip44Encrypt), so this path had no coverage. Co-Authored-By: Claude Opus 4.8 --- js/nostr/client.js | 2 +- js/utils/hex.js | 10 ++ tests/nip17-send-gift-wrap.test.mjs | 151 ++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 tests/nip17-send-gift-wrap.test.mjs 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; + } +});