From 821ae86cf5fc0e3840423a94277aabc7994015a6 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 21 Oct 2025 21:22:28 -0400 Subject: [PATCH] Add cipher helpers for private key signers --- js/nostr.js | 169 ++++++++++++++++++++++++ tests/nostr-private-key-signer.test.mjs | 78 +++++++++++ 2 files changed, 247 insertions(+) create mode 100644 tests/nostr-private-key-signer.test.mjs diff --git a/js/nostr.js b/js/nostr.js index 554f1b61..435ef6a5 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -3267,6 +3267,165 @@ function signEventWithPrivateKey(event, privateKey) { return { ...prepared, id, sig }; } +let loggedMissingCipherToolkit = false; +let loggedMissingNip04Cipher = false; +let loggedMissingNip44Cipher = false; + +async function createPrivateKeyCipherClosures(privateKey) { + const normalizedPrivateKey = + typeof privateKey === "string" && HEX64_REGEX.test(privateKey) + ? privateKey.toLowerCase() + : ""; + + if (!normalizedPrivateKey) { + return {}; + } + + const tools = (await ensureNostrTools()) || getCachedNostrTools(); + if (!tools) { + if (!loggedMissingCipherToolkit) { + loggedMissingCipherToolkit = true; + devLogger.warn( + "[nostr] nostr-tools bundle missing for private key cipher helpers.", + ); + } + return {}; + } + + const closures = {}; + + const normalizeTargetPubkey = (candidate) => { + const normalized = normalizeActorKey(candidate); + if (!normalized || !HEX64_REGEX.test(normalized)) { + throw new Error("A hex-encoded pubkey is required for encryption."); + } + return normalized; + }; + + const resolveHexToBytes = () => { + if (typeof tools?.utils?.hexToBytes === "function") { + return (value) => tools.utils.hexToBytes(value); + } + + return (value) => { + if (typeof value !== "string") { + throw new Error("Invalid hex input."); + } + const trimmed = value.trim(); + if (!trimmed || trimmed.length % 2 !== 0) { + throw new Error("Invalid hex input."); + } + + const bytes = new Uint8Array(trimmed.length / 2); + for (let index = 0; index < trimmed.length; index += 2) { + const byte = Number.parseInt(trimmed.slice(index, index + 2), 16); + if (Number.isNaN(byte)) { + throw new Error("Invalid hex input."); + } + bytes[index / 2] = byte; + } + return bytes; + }; + }; + + if ( + tools?.nip04?.encrypt && + typeof tools.nip04.encrypt === "function" && + tools?.nip04?.decrypt && + typeof tools.nip04.decrypt === "function" + ) { + closures.nip04Encrypt = async (targetPubkey, plaintext) => + tools.nip04.encrypt( + normalizedPrivateKey, + normalizeTargetPubkey(targetPubkey), + plaintext, + ); + + closures.nip04Decrypt = async (targetPubkey, ciphertext) => + tools.nip04.decrypt( + normalizedPrivateKey, + normalizeTargetPubkey(targetPubkey), + ciphertext, + ); + } else if (!loggedMissingNip04Cipher) { + loggedMissingNip04Cipher = true; + devLogger.warn( + "[nostr] nip04 helpers unavailable in nostr-tools bundle.", + ); + } + + const nip44 = tools?.nip44 || null; + let nip44Encrypt = null; + let nip44Decrypt = null; + let nip44GetConversationKey = null; + + if (nip44?.v2 && typeof nip44.v2 === "object") { + if (typeof nip44.v2.encrypt === "function") { + nip44Encrypt = nip44.v2.encrypt; + } + if (typeof nip44.v2.decrypt === "function") { + nip44Decrypt = nip44.v2.decrypt; + } + if (typeof nip44.v2?.utils?.getConversationKey === "function") { + nip44GetConversationKey = nip44.v2.utils.getConversationKey; + } + } + + if ((!nip44Encrypt || !nip44Decrypt) && nip44 && typeof nip44 === "object") { + if (typeof nip44.encrypt === "function") { + nip44Encrypt = nip44.encrypt; + } + if (typeof nip44.decrypt === "function") { + nip44Decrypt = nip44.decrypt; + } + if (!nip44GetConversationKey) { + if (typeof nip44.getConversationKey === "function") { + nip44GetConversationKey = nip44.getConversationKey; + } else if (typeof nip44.utils?.getConversationKey === "function") { + nip44GetConversationKey = nip44.utils.getConversationKey; + } + } + } + + if (nip44Encrypt && nip44Decrypt && nip44GetConversationKey) { + const hexToBytes = resolveHexToBytes(); + let cachedPrivateKeyBytes = null; + const getPrivateKeyBytes = () => { + if (!cachedPrivateKeyBytes) { + cachedPrivateKeyBytes = hexToBytes(normalizedPrivateKey); + } + return cachedPrivateKeyBytes; + }; + + const conversationKeyCache = new Map(); + const ensureConversationKey = (targetPubkey) => { + const normalizedTarget = normalizeTargetPubkey(targetPubkey); + const cached = conversationKeyCache.get(normalizedTarget); + if (cached) { + return cached; + } + + const privateKeyBytes = getPrivateKeyBytes(); + const derived = nip44GetConversationKey(privateKeyBytes, normalizedTarget); + conversationKeyCache.set(normalizedTarget, derived); + return derived; + }; + + closures.nip44Encrypt = async (targetPubkey, plaintext) => + nip44Encrypt(plaintext, ensureConversationKey(targetPubkey)); + + closures.nip44Decrypt = async (targetPubkey, ciphertext) => + nip44Decrypt(ciphertext, ensureConversationKey(targetPubkey)); + } else if (!loggedMissingNip44Cipher) { + loggedMissingNip44Cipher = true; + devLogger.warn( + "[nostr] nip44 helpers unavailable in nostr-tools bundle.", + ); + } + + return closures; +} + function cloneEventForCache(event) { if (!event || typeof event !== "object") { return null; @@ -5937,10 +6096,15 @@ export class NostrClient { persisted: persist === true, }; + const cipherClosures = await createPrivateKeyCipherClosures( + normalizedPrivateKey, + ); + setActiveSigner({ type: "nsec", pubkey: normalizedPubkey, signEvent: (event) => signEventWithPrivateKey(event, normalizedPrivateKey), + ...cipherClosures, }); if (persist) { @@ -6048,10 +6212,15 @@ export class NostrClient { persisted: true, }; + const cipherClosures = await createPrivateKeyCipherClosures( + normalizedPrivateKey, + ); + setActiveSigner({ type: "nsec", pubkey: normalizedPubkey, signEvent: (event) => signEventWithPrivateKey(event, normalizedPrivateKey), + ...cipherClosures, }); const storedPayload = { diff --git a/tests/nostr-private-key-signer.test.mjs b/tests/nostr-private-key-signer.test.mjs new file mode 100644 index 00000000..f2e7d154 --- /dev/null +++ b/tests/nostr-private-key-signer.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +test("registerPrivateKeySigner exposes nip04 helpers", 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 import( + "../js/nostr.js" + ); + + const privateKeyBytes = nostrTools.generateSecretKey(); + const privateKeyHex = nostrTools.utils.bytesToHex(privateKeyBytes); + const recipientKeyBytes = nostrTools.generateSecretKey(); + const recipientPrivateHex = nostrTools.utils.bytesToHex(recipientKeyBytes); + const recipientPubkey = nostrTools.getPublicKey(recipientKeyBytes); + + await nostrClient.registerPrivateKeySigner({ privateKey: privateKeyHex }); + const signer = getActiveSigner(); + + assert.ok(signer, "Signer should be registered"); + assert.equal(typeof signer.nip04Encrypt, "function"); + assert.equal(typeof signer.nip04Decrypt, "function"); + + const message = "bitvid nip04 smoke test"; + const outboundCipher = await signer.nip04Encrypt(recipientPubkey, message); + assert.equal(typeof outboundCipher, "string"); + const decryptedByRecipient = await nostrTools.nip04.decrypt( + recipientPrivateHex, + signer.pubkey, + outboundCipher, + ); + assert.equal(decryptedByRecipient, message); + + const inboundCipher = await nostrTools.nip04.encrypt( + recipientPrivateHex, + signer.pubkey, + message, + ); + const decryptedBySigner = await signer.nip04Decrypt( + recipientPubkey, + inboundCipher, + ); + assert.equal(decryptedBySigner, message); + + clearActiveSigner(); + } 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; + } + } +});