From ce396a1971b0d7ab9a8008688ae8e9c3d586a17e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 19 Jan 2026 08:22:46 -0500 Subject: [PATCH] Add signing adapters and DM composer hook --- js/auth/signingAdapter.js | 177 ++++++++++++++++++++++++++++++++++++++ js/ui/dm/AppShell.js | 2 + js/ui/dm/Composer.js | 31 ++++++- 3 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 js/auth/signingAdapter.js diff --git a/js/auth/signingAdapter.js b/js/auth/signingAdapter.js new file mode 100644 index 00000000..8c07f79e --- /dev/null +++ b/js/auth/signingAdapter.js @@ -0,0 +1,177 @@ +import { bytesToHex } from "../../vendor/crypto-helpers.bundle.min.js"; +import { signEventWithPrivateKey } from "../nostr/publishHelpers.js"; +import { ensureNostrTools, getCachedNostrTools } from "../nostr/toolkit.js"; +import { normalizeActorKey } from "../nostr/watchHistory.js"; + +const DEFAULT_TEST_PUBKEY = "f".repeat(64); +const DEFAULT_DEV_DISPLAY_NAME = "Ephemeral dev signer"; +const DEFAULT_TEST_DISPLAY_NAME = "Test signer"; + +function normalizePubkey(value) { + const normalized = typeof value === "string" ? normalizeActorKey(value) : ""; + return normalized || ""; +} + +function getSigningTimestamp() { + return Math.floor(Date.now() / 1000); +} + +/** + * @typedef {Object} SigningAdapter + * @property {() => Promise} getPubkey + * @property {() => Promise} getDisplayName + * @property {(unsignedEvent: object) => Promise} signEvent + * @property {(message: string) => Promise} signMessage + */ + +export function createNip07SigningAdapter({ extension } = {}) { + const resolvedExtension = + extension || + (typeof window !== "undefined" && window?.nostr ? window.nostr : null); + + const getPubkey = async () => { + if (!resolvedExtension || typeof resolvedExtension.getPublicKey !== "function") { + throw new Error("NIP-07 extension is unavailable."); + } + return normalizePubkey(await resolvedExtension.getPublicKey()); + }; + + const getDisplayName = async () => { + if (!resolvedExtension || typeof resolvedExtension.getMetadata !== "function") { + return ""; + } + const metadata = await resolvedExtension.getMetadata(); + if (!metadata || typeof metadata !== "object") { + return ""; + } + const displayName = + typeof metadata.display_name === "string" ? metadata.display_name.trim() : ""; + if (displayName) { + return displayName; + } + return typeof metadata.name === "string" ? metadata.name.trim() : ""; + }; + + const signEvent = async (event) => { + if (!resolvedExtension || typeof resolvedExtension.signEvent !== "function") { + throw new Error("NIP-07 extension is missing signEvent."); + } + return resolvedExtension.signEvent(event); + }; + + const signMessage = async (message) => { + if (!resolvedExtension || typeof resolvedExtension.signMessage !== "function") { + throw new Error("NIP-07 extension does not support signMessage."); + } + return resolvedExtension.signMessage(message); + }; + + return { + type: "nip07", + getPubkey, + getDisplayName, + signEvent, + signMessage, + }; +} + +export function createEphemeralDevSigningAdapter({ + displayName = DEFAULT_DEV_DISPLAY_NAME, + privateKey, +} = {}) { + let cachedPrivateKey = typeof privateKey === "string" ? privateKey.trim() : ""; + let cachedPubkey = ""; + + const ensurePrivateKey = () => { + if (cachedPrivateKey) { + return cachedPrivateKey; + } + const random = new Uint8Array(32); + if (typeof window !== "undefined" && window.crypto?.getRandomValues) { + window.crypto.getRandomValues(random); + } else if (typeof globalThis?.crypto?.getRandomValues === "function") { + globalThis.crypto.getRandomValues(random); + } else { + for (let i = 0; i < random.length; i += 1) { + random[i] = Math.floor(Math.random() * 256); + } + } + cachedPrivateKey = bytesToHex(random); + return cachedPrivateKey; + }; + + const ensurePubkey = async () => { + if (cachedPubkey) { + return cachedPubkey; + } + const tools = (await ensureNostrTools()) || getCachedNostrTools(); + if (!tools || typeof tools.getPublicKey !== "function") { + throw new Error("Nostr tools are required to derive the dev pubkey."); + } + cachedPubkey = normalizePubkey(tools.getPublicKey(ensurePrivateKey())); + if (!cachedPubkey) { + throw new Error("Failed to derive dev pubkey."); + } + return cachedPubkey; + }; + + const signEvent = async (event) => + signEventWithPrivateKey({ ...event, pubkey: await ensurePubkey() }, ensurePrivateKey()); + + const signMessage = async (message) => { + const signed = signEventWithPrivateKey( + { + kind: 27235, + created_at: getSigningTimestamp(), + tags: [["u", "bitvid:signing-adapter"]], + content: typeof message === "string" ? message : String(message ?? ""), + pubkey: await ensurePubkey(), + }, + ensurePrivateKey(), + ); + if (!signed || typeof signed.sig !== "string") { + throw new Error("Failed to sign message."); + } + return signed.sig; + }; + + return { + type: "dev", + getPubkey: ensurePubkey, + getDisplayName: async () => displayName, + signEvent, + signMessage, + }; +} + +export function createTestSigningAdapter({ + pubkey = DEFAULT_TEST_PUBKEY, + displayName = DEFAULT_TEST_DISPLAY_NAME, + signEvent, + signMessage, +} = {}) { + const normalizedPubkey = normalizePubkey(pubkey) || DEFAULT_TEST_PUBKEY; + + const signEventImpl = + typeof signEvent === "function" + ? signEvent + : async (event) => ({ + ...event, + pubkey: normalizedPubkey || event.pubkey, + id: "test-event-id", + sig: "test-event-sig", + }); + + const signMessageImpl = + typeof signMessage === "function" + ? signMessage + : async (message) => `signed:${message}`; + + return { + type: "test", + getPubkey: async () => normalizedPubkey, + getDisplayName: async () => displayName, + signEvent: signEventImpl, + signMessage: signMessageImpl, + }; +} diff --git a/js/ui/dm/AppShell.js b/js/ui/dm/AppShell.js index 3a804cce..e917a8e9 100644 --- a/js/ui/dm/AppShell.js +++ b/js/ui/dm/AppShell.js @@ -77,6 +77,7 @@ export class AppShell { threadErrorType = "", composerState = "idle", notifications = [], + signingAdapter = null, onSelectConversation, onSendMessage, } = {}) { @@ -125,6 +126,7 @@ export class AppShell { Composer({ document: doc, state: composerState, + signingAdapter, onSend: onSendMessage, }), ); diff --git a/js/ui/dm/Composer.js b/js/ui/dm/Composer.js index 05f3f54d..5fead6f4 100644 --- a/js/ui/dm/Composer.js +++ b/js/ui/dm/Composer.js @@ -14,6 +14,7 @@ export function Composer({ placeholder = "Write a message…", state = "idle", privacyMode: initialPrivacyMode = "standard", + signingAdapter = null, onSend, } = {}) { if (!doc) { @@ -88,11 +89,35 @@ export function Composer({ form.appendChild(actions); form.appendChild(status); + const handleSubmit = async () => { + if (typeof onSend !== "function") { + return; + } + + const payload = { privacyMode, attachments: [] }; + if (signingAdapter) { + try { + if (typeof signingAdapter.getPubkey === "function") { + payload.pubkey = await signingAdapter.getPubkey(); + } + if (typeof signingAdapter.getDisplayName === "function") { + payload.displayName = await signingAdapter.getDisplayName(); + } + if (typeof signingAdapter.signMessage === "function") { + payload.signature = await signingAdapter.signMessage(textarea.value); + } + } catch (error) { + payload.signingError = + error instanceof Error ? error.message : "Signing failed."; + } + } + + onSend(textarea.value, payload); + }; + form.addEventListener("submit", (event) => { event.preventDefault(); - if (typeof onSend === "function") { - onSend(textarea.value, { privacyMode, attachments: [] }); - } + void handleSubmit(); }); const updatePrivacyLabel = () => {