diff --git a/js/nostr/adapters/nip07Adapter.js b/js/nostr/adapters/nip07Adapter.js new file mode 100644 index 00000000..e9dd84c2 --- /dev/null +++ b/js/nostr/adapters/nip07Adapter.js @@ -0,0 +1,111 @@ +// js/nostr/adapters/nip07Adapter.js + +import { normalizeActorKey } from "../watchHistory.js"; + +function resolveNip44Module(extension) { + const nip44 = extension?.nip44 && typeof extension.nip44 === "object" ? extension.nip44 : null; + if (!nip44) { + return null; + } + + const v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null; + if (v2?.encrypt || v2?.decrypt) { + return v2; + } + + return nip44; +} + +export async function createNip07Adapter(extension) { + const resolvedExtension = extension || (typeof window !== "undefined" ? window?.nostr : null); + if (!resolvedExtension) { + return { + type: "nip07", + pubkey: "", + metadata: async () => null, + relays: async () => null, + signEvent: async () => { + throw new Error("NIP-07 extension unavailable."); + }, + requestPermissions: async () => ({ ok: false, error: new Error("extension-unavailable") }), + destroy: async () => {}, + canSign: () => false, + }; + } + + const getPublicKey = + typeof resolvedExtension.getPublicKey === "function" + ? resolvedExtension.getPublicKey.bind(resolvedExtension) + : null; + const pubkey = getPublicKey ? normalizeActorKey(await getPublicKey()) : ""; + + const nip04 = + resolvedExtension.nip04 && typeof resolvedExtension.nip04 === "object" + ? resolvedExtension.nip04 + : null; + const nip44 = resolveNip44Module(resolvedExtension); + + const nip04Encrypt = + nip04 && typeof nip04.encrypt === "function" ? nip04.encrypt.bind(nip04) : null; + const nip04Decrypt = + nip04 && typeof nip04.decrypt === "function" ? nip04.decrypt.bind(nip04) : null; + + const nip44Encrypt = + nip44 && typeof nip44.encrypt === "function" ? nip44.encrypt.bind(nip44) : null; + const nip44Decrypt = + nip44 && typeof nip44.decrypt === "function" ? nip44.decrypt.bind(nip44) : null; + + const requestPermissions = async (methods = []) => { + if (typeof resolvedExtension.requestPermissions === "function") { + return resolvedExtension.requestPermissions({ + permissions: Array.isArray(methods) ? methods : [], + }); + } + + if (typeof resolvedExtension.enable === "function") { + if (Array.isArray(methods) && methods.length) { + return resolvedExtension.enable({ permissions: methods.map((method) => ({ method })) }); + } + return resolvedExtension.enable(); + } + + return { ok: false, error: new Error("permission-request-unavailable") }; + }; + + const signer = { + type: "nip07", + pubkey, + metadata: async () => + typeof resolvedExtension.getMetadata === "function" + ? resolvedExtension.getMetadata() + : null, + relays: async () => + typeof resolvedExtension.getRelays === "function" + ? resolvedExtension.getRelays() + : null, + signEvent: async (event) => { + if (typeof resolvedExtension.signEvent !== "function") { + throw new Error("NIP-07 extension missing signEvent."); + } + return resolvedExtension.signEvent(event); + }, + requestPermissions, + destroy: async () => {}, + canSign: () => typeof resolvedExtension.signEvent === "function", + }; + + if (nip04Encrypt) { + signer.nip04Encrypt = nip04Encrypt; + } + if (nip04Decrypt) { + signer.nip04Decrypt = nip04Decrypt; + } + if (nip44Encrypt) { + signer.nip44Encrypt = nip44Encrypt; + } + if (nip44Decrypt) { + signer.nip44Decrypt = nip44Decrypt; + } + + return signer; +} diff --git a/js/nostr/adapters/nip46Adapter.js b/js/nostr/adapters/nip46Adapter.js new file mode 100644 index 00000000..0541ab6a --- /dev/null +++ b/js/nostr/adapters/nip46Adapter.js @@ -0,0 +1,66 @@ +// js/nostr/adapters/nip46Adapter.js + +import { Nip46RpcClient } from "../nip46Client.js"; +import { normalizeActorKey } from "../watchHistory.js"; + +function resolveClient(client) { + if (client instanceof Nip46RpcClient) { + return client; + } + return client?.nip46Client instanceof Nip46RpcClient ? client.nip46Client : null; +} + +export async function createNip46Adapter(client) { + const resolvedClient = resolveClient(client); + if (!resolvedClient) { + return { + type: "nip46", + pubkey: "", + metadata: async () => null, + relays: async () => null, + signEvent: async () => { + throw new Error("NIP-46 client unavailable."); + }, + requestPermissions: async () => ({ ok: false, error: new Error("client-unavailable") }), + destroy: async () => {}, + canSign: () => false, + }; + } + + let pubkey = normalizeActorKey(resolvedClient.userPubkey); + if (!pubkey) { + pubkey = normalizeActorKey(await resolvedClient.getUserPubkey()); + } + + const signer = { + type: "nip46", + pubkey, + metadata: async () => + resolvedClient.metadata && typeof resolvedClient.metadata === "object" + ? { ...resolvedClient.metadata } + : null, + relays: async () => (Array.isArray(resolvedClient.relays) ? [...resolvedClient.relays] : null), + signEvent: async (event) => resolvedClient.signEvent(event), + requestPermissions: async () => ({ ok: true }), + destroy: async () => { + resolvedClient.destroy(); + }, + canSign: () => + !resolvedClient.destroyed && typeof resolvedClient.signEvent === "function", + }; + + if (typeof resolvedClient.nip04Encrypt === "function") { + signer.nip04Encrypt = resolvedClient.nip04Encrypt.bind(resolvedClient); + } + if (typeof resolvedClient.nip04Decrypt === "function") { + signer.nip04Decrypt = resolvedClient.nip04Decrypt.bind(resolvedClient); + } + if (typeof resolvedClient.nip44Encrypt === "function") { + signer.nip44Encrypt = resolvedClient.nip44Encrypt.bind(resolvedClient); + } + if (typeof resolvedClient.nip44Decrypt === "function") { + signer.nip44Decrypt = resolvedClient.nip44Decrypt.bind(resolvedClient); + } + + return signer; +} diff --git a/js/nostr/adapters/nsecAdapter.js b/js/nostr/adapters/nsecAdapter.js new file mode 100644 index 00000000..c8f5f6e3 --- /dev/null +++ b/js/nostr/adapters/nsecAdapter.js @@ -0,0 +1,212 @@ +// js/nostr/adapters/nsecAdapter.js + +import { signEventWithPrivateKey } from "../publishHelpers.js"; +import { ensureNostrTools, getCachedNostrTools } from "../toolkit.js"; +import { normalizeActorKey } from "../watchHistory.js"; + +const HEX64_REGEX = /^[0-9a-f]{64}$/i; + +function normalizePrivateKey(privateKey) { + if (typeof privateKey !== "string") { + return ""; + } + const trimmed = privateKey.trim(); + if (!trimmed || !HEX64_REGEX.test(trimmed)) { + return ""; + } + return trimmed.toLowerCase(); +} + +async function resolvePublicKey(privateKey, pubkey) { + const normalizedPubkey = normalizeActorKey(pubkey); + if (normalizedPubkey && HEX64_REGEX.test(normalizedPubkey)) { + return normalizedPubkey; + } + + if (!privateKey) { + return ""; + } + + const tools = (await ensureNostrTools()) || getCachedNostrTools(); + if (tools?.getPublicKey && typeof tools.getPublicKey === "function") { + const derived = tools.getPublicKey(privateKey); + const normalized = normalizeActorKey(derived); + if (normalized && HEX64_REGEX.test(normalized)) { + return normalized; + } + } + + return ""; +} + +async function createPrivateKeyCipherClosures(privateKey) { + const normalizedPrivateKey = normalizePrivateKey(privateKey); + if (!normalizedPrivateKey) { + return {}; + } + + const tools = (await ensureNostrTools()) || getCachedNostrTools(); + if (!tools) { + 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, + ); + } + + 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)); + } + + return closures; +} + +export async function createNsecAdapter({ privateKey, pubkey } = {}) { + const normalizedPrivateKey = normalizePrivateKey(privateKey); + const resolvedPubkey = await resolvePublicKey(normalizedPrivateKey, pubkey); + const cipherClosures = await createPrivateKeyCipherClosures(normalizedPrivateKey); + + const signer = { + type: "nsec", + pubkey: resolvedPubkey, + metadata: async () => null, + relays: async () => null, + signEvent: async (event) => { + if (!normalizedPrivateKey) { + throw new Error("Missing private key for signing."); + } + return signEventWithPrivateKey(event, normalizedPrivateKey); + }, + requestPermissions: async () => ({ ok: true }), + destroy: async () => {}, + canSign: () => Boolean(normalizedPrivateKey), + }; + + if (typeof cipherClosures.nip04Encrypt === "function") { + signer.nip04Encrypt = cipherClosures.nip04Encrypt; + } + if (typeof cipherClosures.nip04Decrypt === "function") { + signer.nip04Decrypt = cipherClosures.nip04Decrypt; + } + if (typeof cipherClosures.nip44Encrypt === "function") { + signer.nip44Encrypt = cipherClosures.nip44Encrypt; + } + if (typeof cipherClosures.nip44Decrypt === "function") { + signer.nip44Decrypt = cipherClosures.nip44Decrypt; + } + + return signer; +} diff --git a/js/nostr/signer-interface.js b/js/nostr/signer-interface.js new file mode 100644 index 00000000..57093cc2 --- /dev/null +++ b/js/nostr/signer-interface.js @@ -0,0 +1,50 @@ +// js/nostr/signer-interface.js + +/** + * Canonical async signer interface used by bitvid. + * + * @typedef {Object} NostrSignerInterface + * @property {string} type + * Identifier for the signer implementation ("nsec", "nip07", "nip46", etc.). + * @property {string} pubkey + * Hex-encoded public key for the signer (lowercase). + * @property {() => Promise} metadata + * Returns signer metadata (name, url, image, etc.) when available. + * @property {() => Promise} relays + * Returns relay hints or preferences when available. + * @property {(event: object) => Promise} signEvent + * Signs a Nostr event payload and returns the signed event. + * @property {(targetPubkey: string, plaintext: string) => Promise} [nip04Encrypt] + * Optional NIP-04 encryption helper. + * @property {(targetPubkey: string, ciphertext: string) => Promise} [nip04Decrypt] + * Optional NIP-04 decryption helper. + * @property {(targetPubkey: string, plaintext: string) => Promise} [nip44Encrypt] + * Optional NIP-44 encryption helper. + * @property {(targetPubkey: string, ciphertext: string) => Promise} [nip44Decrypt] + * Optional NIP-44 decryption helper. + * @property {(methods?: string[]) => Promise} requestPermissions + * Requests signer permissions for the provided methods, when supported. + * @property {() => Promise} destroy + * Cleans up any resources associated with the signer. + * @property {() => boolean} canSign + * Returns true when signEvent is available and ready. + */ + +export const SIGNER_INTERFACE_FIELDS = Object.freeze({ + required: Object.freeze([ + "type", + "pubkey", + "metadata", + "relays", + "signEvent", + "requestPermissions", + "destroy", + "canSign", + ]), + optional: Object.freeze([ + "nip04Encrypt", + "nip04Decrypt", + "nip44Encrypt", + "nip44Decrypt", + ]), +});