// js/dmDecryptor.js import { normalizeActorKey } from "./nostr/watchHistory.js"; import { HEX64_REGEX } from "./utils/hex.js"; const SUPPORTED_KINDS = new Set([4, 1059]); const GIFT_WRAP_KIND = 1059; function cloneEvent(event) { if (!event || typeof event !== "object") { return null; } const cloned = { ...event }; if (Array.isArray(event.tags)) { cloned.tags = event.tags.map((tag) => Array.isArray(tag) ? [...tag] : tag, ); } return cloned; } function normalizeCreatedAt(value) { const numeric = Number(value); if (!Number.isFinite(numeric) || numeric <= 0) { return null; } return Math.floor(numeric); } function normalizeInnerMessage(event) { if (!event || typeof event !== "object") { return null; } const normalized = cloneEvent(event) || {}; normalized.content = typeof event.content === "string" ? event.content : ""; if (Number.isFinite(event.kind)) { normalized.kind = event.kind; } const createdAt = normalizeCreatedAt(event.created_at); if (createdAt !== null) { normalized.created_at = createdAt; } else if ("created_at" in normalized) { delete normalized.created_at; } if (Array.isArray(event.tags)) { normalized.tags = event.tags.map((tag) => Array.isArray(tag) ? [...tag] : tag, ); } return normalized; } function normalizeHex(candidate) { if (typeof candidate !== "string") { return ""; } const trimmed = candidate.trim().toLowerCase(); if (!HEX64_REGEX.test(trimmed)) { return ""; } return trimmed; } function normalizeScheme(scheme) { if (typeof scheme !== "string") { return ""; } const normalized = scheme.trim().toLowerCase(); if (!normalized) { return ""; } if (normalized === "nip44_v2" || normalized === "nip44-v2") { return "nip44_v2"; } if (normalized === "nip44" || normalized === "nip-44") { return "nip44"; } if (normalized === "nip04" || normalized === "nip-04") { return "nip04"; } return normalized; } function parseEncryptionHints(rawTags) { const tags = Array.isArray(rawTags) ? rawTags : []; const algorithms = []; for (const tag of tags) { if (!Array.isArray(tag) || tag.length < 2) { continue; } if (tag[0] !== "encrypted") { continue; } for (let index = 1; index < tag.length; index += 1) { const value = typeof tag[index] === "string" ? tag[index].trim() : ""; if (!value) { continue; } const parts = value.split(/\s+/).filter(Boolean); for (const part of parts) { const normalized = normalizeScheme(part); if (normalized && !algorithms.includes(normalized)) { algorithms.push(normalized); } } } } return { algorithms }; } function collectRelayHints(tag) { const hints = []; if (!Array.isArray(tag)) { return hints; } for (let index = 2; index < tag.length; index += 1) { const candidate = typeof tag[index] === "string" ? tag[index].trim() : ""; if (candidate) { hints.push(candidate); } } return hints; } function collectRecipients(rawTags) { const tags = Array.isArray(rawTags) ? rawTags : []; const recipients = []; const seen = new Map(); for (const tag of tags) { if (!Array.isArray(tag) || tag[0] !== "p") { continue; } const pubkey = normalizeHex(tag[1]); if (!pubkey) { continue; } const hints = collectRelayHints(tag); if (seen.has(pubkey)) { const existing = seen.get(pubkey); for (const hint of hints) { if (!existing.relayHints.includes(hint)) { existing.relayHints.push(hint); } } continue; } const recipient = { pubkey, relayHints: hints, role: "recipient", }; seen.set(pubkey, recipient); recipients.push(recipient); } return recipients; } function ensureDecryptCandidates(rawCandidates) { if (!Array.isArray(rawCandidates)) { return []; } return rawCandidates .map((candidate) => { if (!candidate || typeof candidate !== "object") { return null; } const decrypt = candidate.decrypt; if (typeof decrypt !== "function") { return null; } const scheme = normalizeScheme(candidate.scheme) || ""; const supportsGiftWrap = candidate.supportsGiftWrap === true || scheme.startsWith("nip44"); const priority = Number.isFinite(candidate.priority) ? candidate.priority : 0; return { scheme: scheme || "", decrypt, priority, supportsGiftWrap, source: candidate.source || "", }; }) .filter(Boolean); } function orderDecryptors(candidates, hints, { preferGiftWrap = false } = {}) { if (!candidates.length) { return []; } const algorithms = Array.isArray(hints?.algorithms) ? hints.algorithms : []; const algorithmRanks = new Map(); algorithms.forEach((algorithm, index) => { if (!algorithmRanks.has(algorithm)) { algorithmRanks.set(algorithm, index); } }); const baseSchemeRank = (scheme) => { if (scheme === "nip44_v2" || scheme === "nip44") { return 0; } if (scheme === "nip04") { return 5; } return 10; }; const desiredSchemeRank = (scheme) => { if (algorithmRanks.has(scheme)) { return algorithmRanks.get(scheme); } if (scheme === "nip44" && algorithmRanks.has("nip44_v2")) { return algorithmRanks.get("nip44_v2"); } if (scheme === "nip44_v2" && algorithmRanks.has("nip44")) { return algorithmRanks.get("nip44"); } return baseSchemeRank(scheme); }; return [...candidates].sort((a, b) => { if (a.priority !== b.priority) { return a.priority - b.priority; } const aRank = desiredSchemeRank(a.scheme); const bRank = desiredSchemeRank(b.scheme); if (aRank !== bRank) { return aRank - bRank; } if (preferGiftWrap) { const aWrapScore = a.supportsGiftWrap ? 0 : 1; const bWrapScore = b.supportsGiftWrap ? 0 : 1; if (aWrapScore !== bWrapScore) { return aWrapScore - bWrapScore; } } return 0; }); } function deriveDirection(actorPubkey, senderPubkey, recipients) { const normalizedActor = normalizeHex(actorPubkey); if (!normalizedActor) { return "unknown"; } if (normalizedActor === normalizeHex(senderPubkey)) { return "outgoing"; } if (Array.isArray(recipients)) { for (const recipient of recipients) { if (normalizeHex(recipient?.pubkey) === normalizedActor) { return "incoming"; } } } return "unknown"; } function buildDecryptResult({ ok, event, message, plaintext, recipients, senderPubkey, actorPubkey, decryptor, scheme, envelope = null, errors = [], }) { const normalizedScheme = normalizeScheme(scheme || decryptor?.scheme || ""); const messageTimestamp = message && Number.isFinite(message.created_at) ? message.created_at : null; const eventTimestamp = event && Number.isFinite(event.created_at) ? event.created_at : null; const timestamp = messageTimestamp ?? eventTimestamp ?? Date.now() / 1000; return { ok, event: cloneEvent(event), message: message ? cloneEvent(message) : null, plaintext: typeof plaintext === "string" ? plaintext : null, recipients: Array.isArray(recipients) ? recipients : [], sender: senderPubkey ? { pubkey: senderPubkey, relayHints: [], role: "sender", } : null, actorPubkey: normalizeHex(actorPubkey), decryptor: decryptor ? { scheme: normalizeScheme(decryptor.scheme) || normalizedScheme || "", source: decryptor.source || "", } : { scheme: normalizedScheme || "", source: "" }, scheme: normalizedScheme, encryption_scheme: normalizedScheme, envelope, direction: deriveDirection(actorPubkey, senderPubkey, recipients), timestamp: Number.isFinite(timestamp) ? timestamp : Date.now() / 1000, errors, }; } function parseEventJson(serialized, stage) { if (typeof serialized !== "string" || !serialized.trim()) { const error = new Error("Decrypted payload was empty."); error.code = "empty-payload"; error.stage = stage; throw error; } try { const parsed = JSON.parse(serialized); if (!parsed || typeof parsed !== "object") { const error = new Error("Decrypted payload was not a valid JSON event."); error.code = "invalid-json"; error.stage = stage; throw error; } return parsed; } catch (error) { const failure = new Error("Failed to parse decrypted payload as JSON."); failure.code = "json-parse-failed"; failure.stage = stage; failure.cause = error; throw failure; } } async function decryptGiftWrap(event, decryptors, actorPubkey) { const ciphertext = typeof event?.content === "string" ? event.content : ""; const wrapPubkey = normalizeHex(event?.pubkey); if (!ciphertext || !wrapPubkey) { return buildDecryptResult({ ok: false, event, actorPubkey, errors: [ { stage: "wrap", error: new Error("Gift wrap event is missing ciphertext or pubkey."), }, ], }); } const errors = []; const attemptUnwrap = async (decryptor) => { try { const sealSerialized = await decryptor.decrypt(wrapPubkey, ciphertext, { event, stage: "wrap", }); const seal = parseEventJson(sealSerialized, "wrap"); const sealCiphertext = typeof seal?.content === "string" ? seal.content : ""; const sealPubkey = normalizeHex(seal?.pubkey); if (!sealCiphertext || !sealPubkey) { const error = new Error( "Gift wrap seal is missing ciphertext or pubkey.", ); error.code = "invalid-seal"; error.stage = "seal"; throw error; } const rumorSerialized = await decryptor.decrypt( sealPubkey, sealCiphertext, { event: seal, stage: "seal", }, ); const rumor = parseEventJson(rumorSerialized, "rumor"); const normalizedRumor = normalizeInnerMessage(rumor); const plaintext = typeof normalizedRumor?.content === "string" ? normalizedRumor.content : ""; const senderPubkey = normalizeHex(normalizedRumor?.pubkey) || sealPubkey; const recipients = collectRecipients(normalizedRumor?.tags); return buildDecryptResult({ ok: true, event, message: normalizedRumor, plaintext, recipients, senderPubkey, actorPubkey, decryptor, scheme: decryptor.scheme || "nip44", envelope: { wrap: cloneEvent(event), seal: cloneEvent(seal), }, }); } catch (error) { throw { scheme: decryptor.scheme || "", source: decryptor.source || "", stage: error?.stage || "wrap", error, }; } }; try { return await Promise.any(decryptors.map(attemptUnwrap)); } catch (aggregateError) { const aggregatedErrors = aggregateError.errors || []; errors.push(...aggregatedErrors); } return buildDecryptResult({ ok: false, event, actorPubkey, errors, }); } async function decryptLegacyDm(event, decryptors, actorPubkey) { const ciphertext = typeof event?.content === "string" ? event.content : ""; const senderPubkey = normalizeHex(event?.pubkey); const hints = parseEncryptionHints(event?.tags); const recipients = collectRecipients(event?.tags); const normalizedActor = normalizeHex(actorPubkey); if (!ciphertext || !senderPubkey) { return buildDecryptResult({ ok: false, event, actorPubkey, errors: [ { stage: "content", error: new Error("Direct message is missing ciphertext or pubkey."), }, ], }); } const ordered = orderDecryptors(decryptors, hints); const errors = []; const remoteCandidates = []; const seenRemotes = new Set(); const registerRemote = (candidate) => { const normalized = normalizeHex(candidate); if (!normalized || seenRemotes.has(normalized)) { return; } seenRemotes.add(normalized); remoteCandidates.push(normalized); }; if (normalizedActor && normalizedActor === senderPubkey) { for (const recipient of recipients) { registerRemote(recipient?.pubkey); } } registerRemote(senderPubkey); for (const recipient of recipients) { registerRemote(recipient?.pubkey); } const attemptDecryption = async (decryptor, remotePubkey) => { try { const plaintext = await decryptor.decrypt(remotePubkey, ciphertext, { event, stage: "content", remotePubkey, }); if (typeof plaintext === "string") { const resolvedScheme = normalizeScheme(hints.algorithms?.[0]) || normalizeScheme(decryptor.scheme) || ""; return buildDecryptResult({ ok: true, event, message: { ...cloneEvent(event), content: plaintext, }, plaintext, recipients, senderPubkey, actorPubkey, decryptor, scheme: resolvedScheme, }); } throw new Error("Decrypted content was not a valid string."); } catch (error) { throw { scheme: decryptor.scheme || "", source: decryptor.source || "", stage: "content", remotePubkey, error, }; } }; const attempts = []; for (const decryptor of ordered) { for (const remotePubkey of remoteCandidates) { attempts.push(attemptDecryption(decryptor, remotePubkey)); } } // Race all decryption attempts to return the first successful result // This significantly reduces latency compared to sequential attempts try { return await Promise.any(attempts); } catch (aggregateError) { const aggregatedErrors = aggregateError.errors || []; errors.push(...aggregatedErrors); return buildDecryptResult({ ok: false, event, actorPubkey, errors, }); } } export async function decryptDM(event, context = {}) { if (!event || typeof event !== "object") { return { ok: false, event: null, actorPubkey: normalizeHex(context?.actorPubkey), errors: [ { stage: "input", error: new Error("A valid event is required for DM decryption."), }, ], }; } const kind = Number.isFinite(event.kind) ? event.kind : null; if (!SUPPORTED_KINDS.has(kind)) { return { ok: false, event: cloneEvent(event), actorPubkey: normalizeHex(context?.actorPubkey), errors: [ { stage: "input", error: new Error("Unsupported DM event kind."), }, ], }; } const actorPubkey = normalizeActorKey(context?.actorPubkey); const decryptors = ensureDecryptCandidates(context?.decryptors); if (!decryptors.length) { return { ok: false, event: cloneEvent(event), actorPubkey, errors: [ { stage: "decryptor", error: new Error("No decryptors are available for DM payloads."), }, ], }; } if (kind === GIFT_WRAP_KIND) { const giftWrapDecryptors = orderDecryptors(decryptors, null, { preferGiftWrap: true, }).filter((candidate) => candidate.supportsGiftWrap); if (!giftWrapDecryptors.length) { return { ok: false, event: cloneEvent(event), actorPubkey, errors: [ { stage: "decryptor", error: new Error("Gift wrap events require a NIP-44 decryptor."), }, ], }; } return decryptGiftWrap(event, giftWrapDecryptors, actorPubkey); } return decryptLegacyDm(event, decryptors, actorPubkey); }