From b05e89839172722dd2c361e64a60936dc2f62389 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 19 Jan 2026 08:49:58 -0500 Subject: [PATCH] Add worker DM decrypt and persistence --- js/nostr/client.js | 40 ++++++ js/nostr/dmDecryptWorker.js | 210 ++++++++++++++++++++++++++++++ js/nostr/dmDecryptWorkerClient.js | 120 +++++++++++++++++ js/services/nostrService.js | 120 +++++++++++++++++ js/storage/dmDb.js | 19 +++ 5 files changed, 509 insertions(+) create mode 100644 js/nostr/dmDecryptWorker.js create mode 100644 js/nostr/dmDecryptWorkerClient.js diff --git a/js/nostr/client.js b/js/nostr/client.js index f58721c7..6a17e94e 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -107,6 +107,10 @@ import { shimLegacySimplePoolMethods, } from "./toolkit.js"; import { encryptNip04InWorker } from "./nip04WorkerClient.js"; +import { + decryptDmInWorker, + isDmDecryptWorkerSupported, +} from "./dmDecryptWorkerClient.js"; import { devLogger, userLogger } from "../utils/logger.js"; import { updateConversationFromMessage, writeMessages } from "../storage/dmDb.js"; import { @@ -4056,6 +4060,42 @@ export class NostrClient { typeof sessionActor.privateKey === "string" && sessionActor.privateKey ) { + const canUseWorker = isDmDecryptWorkerSupported(); + const sessionPrivateKey = sessionActor.privateKey; + + if (canUseWorker) { + addCandidate( + "nip44", + (targetPubkey, ciphertext) => + decryptDmInWorker({ + scheme: "nip44", + privateKey: sessionPrivateKey, + targetPubkey, + ciphertext, + }), + { + priority: -6, + source: "worker", + supportsGiftWrap: true, + }, + ); + + addCandidate( + "nip04", + (targetPubkey, ciphertext) => + decryptDmInWorker({ + scheme: "nip04", + privateKey: sessionPrivateKey, + targetPubkey, + ciphertext, + }), + { + priority: -4, + source: "worker", + }, + ); + } + if ( !this.sessionActorCipherClosures || this.sessionActorCipherClosuresPrivateKey !== sessionActor.privateKey diff --git a/js/nostr/dmDecryptWorker.js b/js/nostr/dmDecryptWorker.js new file mode 100644 index 00000000..caec3ec4 --- /dev/null +++ b/js/nostr/dmDecryptWorker.js @@ -0,0 +1,210 @@ +import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js"; +import { devLogger } from "../utils/logger.js"; + +const workerScope = typeof self !== "undefined" ? self : null; +const HEX64_REGEX = /^[0-9a-f]{64}$/i; + +const postResponse = (payload) => { + if (!workerScope || typeof workerScope.postMessage !== "function") { + return; + } + workerScope.postMessage(payload); +}; + +const normalizeScheme = (scheme) => { + if (typeof scheme !== "string") { + return "nip04"; + } + const normalized = scheme.trim().toLowerCase(); + if (!normalized) { + return "nip04"; + } + if (normalized === "nip44-v2") { + return "nip44_v2"; + } + if (normalized === "nip-44") { + return "nip44"; + } + if (normalized === "nip-04") { + return "nip04"; + } + return normalized; +}; + +const normalizePayload = (payload) => { + if (!payload || typeof payload !== "object") { + return null; + } + + const id = + typeof payload.id === "string" || typeof payload.id === "number" + ? payload.id + : null; + const scheme = normalizeScheme(payload.scheme); + const privateKey = + typeof payload.privateKey === "string" ? payload.privateKey.trim() : ""; + const targetPubkey = + typeof payload.targetPubkey === "string" ? payload.targetPubkey.trim() : ""; + const ciphertext = + typeof payload.ciphertext === "string" ? payload.ciphertext : ""; + + if (!id || !privateKey || !targetPubkey || !ciphertext) { + return null; + } + + return { + id, + scheme, + privateKey, + targetPubkey, + ciphertext, + }; +}; + +const resolveHexToBytes = (tools) => { + 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; + }; +}; + +const ensureNip04 = async () => { + const tools = (await ensureNostrTools()) || getCachedNostrTools(); + const nip04 = tools?.nip04; + if (!nip04 || typeof nip04.decrypt !== "function") { + throw new Error("nip04-unavailable"); + } + return nip04; +}; + +const ensureNip44 = async () => { + const tools = (await ensureNostrTools()) || getCachedNostrTools(); + if (!tools) { + throw new Error("nip44-unavailable"); + } + + const nip44 = tools.nip44 || null; + let decrypt = null; + let getConversationKey = null; + + if (nip44?.v2 && typeof nip44.v2 === "object") { + if (typeof nip44.v2.decrypt === "function") { + decrypt = nip44.v2.decrypt; + } + if (typeof nip44.v2?.utils?.getConversationKey === "function") { + getConversationKey = nip44.v2.utils.getConversationKey; + } + } + + if ((!decrypt || !getConversationKey) && nip44 && typeof nip44 === "object") { + if (typeof nip44.decrypt === "function") { + decrypt = nip44.decrypt; + } + if (typeof nip44.getConversationKey === "function") { + getConversationKey = nip44.getConversationKey; + } else if (typeof nip44.utils?.getConversationKey === "function") { + getConversationKey = nip44.utils.getConversationKey; + } + } + + if (!decrypt || !getConversationKey) { + throw new Error("nip44-unavailable"); + } + + return { + decrypt, + getConversationKey, + hexToBytes: resolveHexToBytes(tools), + }; +}; + +const handleMessage = async (event) => { + const payload = normalizePayload(event?.data); + if (!payload) { + postResponse({ + id: event?.data?.id ?? null, + ok: false, + error: { message: "invalid-payload" }, + }); + return; + } + + if (!HEX64_REGEX.test(payload.privateKey)) { + postResponse({ + id: payload.id, + ok: false, + error: { message: "invalid-private-key" }, + }); + return; + } + + try { + if (payload.scheme === "nip44" || payload.scheme === "nip44_v2") { + const nip44 = await ensureNip44(); + const privateKeyBytes = nip44.hexToBytes(payload.privateKey); + const conversationKey = nip44.getConversationKey( + privateKeyBytes, + payload.targetPubkey, + ); + const plaintext = await nip44.decrypt( + payload.ciphertext, + conversationKey, + ); + + postResponse({ + id: payload.id, + ok: true, + plaintext, + }); + return; + } + + const nip04 = await ensureNip04(); + const plaintext = await nip04.decrypt( + payload.privateKey, + payload.targetPubkey, + payload.ciphertext, + ); + + postResponse({ + id: payload.id, + ok: true, + plaintext, + }); + } catch (error) { + devLogger.warn("[dmDecryptWorker] Decryption failed", error); + postResponse({ + id: payload.id, + ok: false, + error: { + message: error?.message || "dm-worker-failed", + name: error?.name || "Error", + }, + }); + } +}; + +if (workerScope?.addEventListener) { + workerScope.addEventListener("message", (event) => { + void handleMessage(event); + }); +} diff --git a/js/nostr/dmDecryptWorkerClient.js b/js/nostr/dmDecryptWorkerClient.js new file mode 100644 index 00000000..794abb49 --- /dev/null +++ b/js/nostr/dmDecryptWorkerClient.js @@ -0,0 +1,120 @@ +import { devLogger } from "../utils/logger.js"; + +const DEFAULT_TIMEOUT_MS = 12000; + +let workerInstance = null; +let workerReady = false; +let requestId = 0; +const pending = new Map(); + +function attachWorkerListeners(worker) { + if (!worker || workerReady) { + return; + } + + worker.addEventListener("message", (event) => { + const data = event?.data || {}; + const id = data.id; + if (!pending.has(id)) { + return; + } + + const entry = pending.get(id); + pending.delete(id); + clearTimeout(entry.timeoutId); + + if (data.ok && typeof data.plaintext === "string") { + entry.resolve(data.plaintext); + return; + } + + const message = data?.error?.message || "dm-worker-error"; + const error = new Error(message); + error.name = data?.error?.name || "Error"; + entry.reject(error); + }); + + worker.addEventListener("error", (error) => { + devLogger.warn("[dmDecryptWorkerClient] Worker error", error); + for (const entry of pending.values()) { + clearTimeout(entry.timeoutId); + entry.reject( + error instanceof Error ? error : new Error("dm-worker-error"), + ); + } + pending.clear(); + }); + + workerReady = true; +} + +function ensureWorker() { + if (workerInstance) { + return workerInstance; + } + + if (typeof Worker === "undefined") { + return null; + } + + try { + workerInstance = new Worker( + new URL("./dmDecryptWorker.js", import.meta.url), + { + type: "module", + }, + ); + attachWorkerListeners(workerInstance); + } catch (error) { + devLogger.warn("[dmDecryptWorkerClient] Failed to create worker", error); + workerInstance = null; + } + + return workerInstance; +} + +export function isDmDecryptWorkerSupported() { + return typeof Worker !== "undefined"; +} + +export function decryptDmInWorker({ + scheme = "nip04", + privateKey, + targetPubkey, + ciphertext, + timeoutMs = DEFAULT_TIMEOUT_MS, +} = {}) { + const worker = ensureWorker(); + if (!worker) { + return Promise.reject(new Error("dm-worker-unavailable")); + } + + const trimmedPrivateKey = + typeof privateKey === "string" ? privateKey.trim() : ""; + const trimmedTarget = + typeof targetPubkey === "string" ? targetPubkey.trim() : ""; + const payloadCiphertext = typeof ciphertext === "string" ? ciphertext : ""; + const payloadScheme = typeof scheme === "string" ? scheme : ""; + + if (!trimmedPrivateKey || !trimmedTarget || !payloadCiphertext) { + return Promise.reject(new Error("dm-worker-invalid-input")); + } + + const id = `${Date.now()}-${requestId++}`; + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + pending.delete(id); + reject(new Error("dm-worker-timeout")); + }, timeoutMs); + + pending.set(id, { resolve, reject, timeoutId }); + worker.postMessage({ + id, + scheme: payloadScheme, + privateKey: trimmedPrivateKey, + targetPubkey: trimmedTarget, + ciphertext: payloadCiphertext, + }); + }); +} diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 223dc4c7..913eba62 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -9,6 +9,10 @@ import { saveDirectMessageSnapshot, clearDirectMessageSnapshot, } from "../directMessagesStore.js"; +import { + updateConversationFromMessage, + writeMessages, +} from "../storage/dmDb.js"; import { getVideosMap as getStoredVideosMap, setVideosMap as setStoredVideosMap, @@ -286,6 +290,93 @@ function resolveDirectMessageRemotePubkey(message, actorPubkey = "") { return ""; } +function buildConversationId(actorPubkey, remotePubkey) { + const normalizedActor = normalizeHexPubkey(actorPubkey); + const normalizedRemote = normalizeHexPubkey(remotePubkey); + + if (!normalizedActor || !normalizedRemote) { + return ""; + } + + return `dm:${[normalizedActor, normalizedRemote].sort().join(":")}`; +} + +function normalizeDirectMessageRecord(message, actorPubkey = "") { + if (!message || message.ok !== true) { + return null; + } + + const actor = normalizeHexPubkey( + actorPubkey || message.actorPubkey || "", + ); + const remote = resolveDirectMessageRemotePubkey(message, actor); + const conversationId = buildConversationId(actor, remote); + const eventId = + typeof message?.event?.id === "string" ? message.event.id : ""; + + if (!conversationId || !eventId) { + return null; + } + + const senderPubkey = normalizeHexPubkey( + message?.sender?.pubkey || + message?.message?.pubkey || + message?.event?.pubkey || + "", + ); + const direction = message.direction; + let receiverPubkey = ""; + + if (direction === "incoming") { + receiverPubkey = actor; + } else if (direction === "outgoing") { + receiverPubkey = remote; + } else if (senderPubkey) { + receiverPubkey = senderPubkey === actor ? remote : actor; + } + + const createdAt = sanitizeSnapshotTimestamp( + message?.message?.created_at ?? + message?.event?.created_at ?? + message?.timestamp, + ); + + const kind = + Number.isFinite(message?.message?.kind) + ? message.message.kind + : Number.isFinite(message?.event?.kind) + ? message.event.kind + : 0; + + const tags = Array.isArray(message?.message?.tags) + ? message.message.tags + : Array.isArray(message?.event?.tags) + ? message.event.tags + : []; + + const encryptionScheme = + typeof message?.scheme === "string" + ? message.scheme.trim() + : typeof message?.decryptor?.scheme === "string" + ? message.decryptor.scheme.trim() + : ""; + + return { + id: eventId, + conversation_id: conversationId, + sender_pubkey: senderPubkey || actor, + receiver_pubkey: receiverPubkey, + created_at: createdAt, + kind, + content: typeof message?.plaintext === "string" ? message.plaintext : "", + tags, + relay: typeof message?.event?.relay === "string" ? message.event.relay : "", + status: "published", + seen: direction !== "incoming", + encryption_scheme: encryptionScheme, + }; +} + function buildSnapshotFromMessages(messages, actorPubkey = "") { const normalizedActor = normalizeHexPubkey(actorPubkey); const threadMap = new Map(); @@ -510,6 +601,31 @@ export class NostrService { } } + async persistDirectMessageRecord(message, { actorPubkey = null } = {}) { + const record = normalizeDirectMessageRecord( + message, + actorPubkey || message?.actorPubkey || this.dmActorPubkey, + ); + if (!record) { + return null; + } + + try { + await writeMessages(record); + await updateConversationFromMessage(record, { + preview: extractSnapshotPreview(message), + unseenDelta: message?.direction === "incoming" ? 1 : 0, + downloadedUntil: sanitizeSnapshotTimestamp( + message?.timestamp ?? message?.event?.created_at ?? record.created_at, + ), + }); + return record; + } catch (error) { + userLogger.warn("[nostrService] Failed to persist DM record", error); + return null; + } + } + applyDirectMessage(message, { reason = "update", event = null } = {}) { if (!message || message.ok !== true) { return; @@ -575,6 +691,10 @@ export class NostrService { ); }); } + + if (!normalized.snapshot) { + void this.persistDirectMessageRecord(normalized, { actorPubkey: actor }); + } } async hydrateDirectMessagesFromStore({ diff --git a/js/storage/dmDb.js b/js/storage/dmDb.js index 7b1cc322..90ce359d 100644 --- a/js/storage/dmDb.js +++ b/js/storage/dmDb.js @@ -141,6 +141,14 @@ function normalizeMessage(raw) { ? statusRaw : ""; const seen = raw.seen === true; + const encryptionScheme = + typeof raw.encryption_scheme === "string" + ? raw.encryption_scheme.trim() + : typeof raw.encryptionScheme === "string" + ? raw.encryptionScheme.trim() + : typeof raw.scheme === "string" + ? raw.scheme.trim() + : ""; return { id, @@ -154,6 +162,7 @@ function normalizeMessage(raw) { relay: typeof raw.relay === "string" ? raw.relay : "", status, seen, + encryption_scheme: encryptionScheme, }; } @@ -179,6 +188,9 @@ function normalizeConversation(raw) { last_message_at: sanitizeTimestamp( raw.last_message_at ?? raw.lastMessageAt, ), + downloaded_until: sanitizeTimestamp( + raw.downloaded_until ?? raw.downloadedUntil, + ), last_message_preview: typeof raw.last_message_preview === "string" ? raw.last_message_preview @@ -347,6 +359,9 @@ export async function updateConversationFromMessage(message, options = {}) { Number.isFinite(Number(options.unseenDelta)) ? Number(options.unseenDelta) : 0; + const downloadedUntil = sanitizeTimestamp( + options.downloadedUntil ?? normalized.created_at, + ); try { const db = await openDmDb(); @@ -368,6 +383,10 @@ export async function updateConversationFromMessage(message, options = {}) { ...existing, created_at: existing.created_at || normalized.created_at, last_message_at: normalized.created_at, + downloaded_until: Math.max( + existing.downloaded_until || 0, + downloadedUntil, + ), last_message_preview: preview, unseen_count: Math.max( 0,