diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index f8f7ddb3..cc0149b3 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -152,6 +152,16 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca | Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', ]` entries | Empty content | | Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', ]` entries | Empty content | +### Direct messages + +bitvid now consumes both legacy direct messages (kind `4`) and modern gift-wrap envelopes (kind `1059`). The `js/dmDecryptor.js` helper unwraps these events by: + +* checking `['encrypted']` hints on kind `4` events to prioritize `nip44` ciphertext before falling back to `nip04` +* unwrapping kind `1059` gift wraps with nested `nip44` decrypt operations until the inner rumor payload is available +* returning a normalized payload that carries sender metadata, recipient relay hints, the decrypted plaintext, and derived timestamps for sorting + +`NostrClient` exposes `listDirectMessages()` and `subscribeDirectMessages()` APIs that hydrate decryptors lazily (preferring extension-provided helpers) and cache results in an LRU keyed by event id. `NostrService` mirrors the normalized messages via `getDirectMessages()` and emits updates as new events arrive so the UI can render private conversations without reimplementing the unwrap logic. + The `isPrivate` flag in Content Schema v3 marks cards that should stay off shared or public grids. Clients should suppress these events for everyone except the owner, even though the payload stays in clear text for compatibility. If you introduce a new Nostr feature, add its schema to diff --git a/js/dmDecryptor.js b/js/dmDecryptor.js new file mode 100644 index 00000000..85e809b4 --- /dev/null +++ b/js/dmDecryptor.js @@ -0,0 +1,562 @@ +// js/dmDecryptor.js + +import { normalizeActorKey } from "./nostr/watchHistory.js"; + +const HEX64_REGEX = /^[0-9a-f]{64}$/; + +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 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 timestampCandidates = []; + if (message && Number.isFinite(message.created_at)) { + timestampCandidates.push(message.created_at); + } + if (event && Number.isFinite(event.created_at)) { + timestampCandidates.push(event.created_at); + } + const timestamp = timestampCandidates.length + ? Math.max(...timestampCandidates) + : 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) || scheme || "", + source: decryptor.source || "", + } + : { scheme: scheme || "", source: "" }, + scheme: normalizeScheme(scheme || decryptor?.scheme || ""), + 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 = []; + + for (const decryptor of decryptors) { + 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 plaintext = typeof rumor?.content === "string" ? rumor.content : ""; + const senderPubkey = normalizeHex(rumor?.pubkey) || sealPubkey; + const recipients = collectRecipients(rumor?.tags); + + return buildDecryptResult({ + ok: true, + event, + message: rumor, + plaintext, + recipients, + senderPubkey, + actorPubkey, + decryptor, + scheme: decryptor.scheme || "nip44", + envelope: { + wrap: cloneEvent(event), + seal: cloneEvent(seal), + }, + }); + } catch (error) { + errors.push({ + scheme: decryptor.scheme || "", + source: decryptor.source || "", + stage: error?.stage || "wrap", + error, + }); + } + } + + 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); + + 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 = []; + + for (const decryptor of ordered) { + try { + const plaintext = await decryptor.decrypt(senderPubkey, ciphertext, { + event, + stage: "content", + }); + + if (typeof plaintext === "string") { + const recipients = collectRecipients(event?.tags); + + return buildDecryptResult({ + ok: true, + event, + message: { + ...cloneEvent(event), + content: plaintext, + }, + plaintext, + recipients, + senderPubkey, + actorPubkey, + decryptor, + scheme: decryptor.scheme || hints.algorithms?.[0] || "", + }); + } + } catch (error) { + errors.push({ + scheme: decryptor.scheme || "", + source: decryptor.source || "", + stage: "content", + error, + }); + } + } + + 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); +} + +export const __testUtils = { + cloneEvent, + normalizeHex, + parseEncryptionHints, + collectRecipients, + orderDecryptors, +}; + diff --git a/js/nostr/client.js b/js/nostr/client.js index cb99c473..60f0198c 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -602,6 +602,91 @@ function cloneEventForCache(event) { return cloned; } +const DM_DECRYPT_CACHE_LIMIT = 256; +const DM_EVENT_KINDS = Object.freeze([4, 1059]); + +class SimpleLruCache { + constructor(limit = 100) { + const resolvedLimit = Number.isFinite(limit) && limit > 0 ? limit : 100; + this.limit = Math.floor(resolvedLimit); + this.map = new Map(); + } + + get(key) { + if (!this.map.has(key)) { + return undefined; + } + + const value = this.map.get(key); + this.map.delete(key); + this.map.set(key, value); + return value; + } + + set(key, value) { + if (key === undefined || key === null) { + return; + } + + if (this.map.has(key)) { + this.map.delete(key); + } + this.map.set(key, value); + + while (this.map.size > this.limit) { + const oldestKey = this.map.keys().next().value; + this.map.delete(oldestKey); + } + } + + clear() { + this.map.clear(); + } +} + +function buildDmFilters(actorPubkey, { since, until, limit } = {}) { + const normalizedActor = normalizeActorKey(actorPubkey); + const filters = []; + + const normalizedLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : undefined; + const normalizedSince = Number.isFinite(since) ? Math.floor(since) : undefined; + const normalizedUntil = Number.isFinite(until) ? Math.floor(until) : undefined; + + const baseFilterPayload = (kind) => { + const payload = { kinds: [kind] }; + if (normalizedLimit !== undefined) { + payload.limit = normalizedLimit; + } + if (normalizedSince !== undefined) { + payload.since = normalizedSince; + } + if (normalizedUntil !== undefined) { + payload.until = normalizedUntil; + } + return payload; + }; + + if (normalizedActor) { + const authorFilter = baseFilterPayload(4); + authorFilter.authors = [normalizedActor]; + filters.push(authorFilter); + + const directFilter = baseFilterPayload(4); + directFilter["#p"] = [normalizedActor]; + filters.push(directFilter); + + const giftWrapFilter = baseFilterPayload(1059); + giftWrapFilter["#p"] = [normalizedActor]; + filters.push(giftWrapFilter); + } else { + const fallbackFilter = baseFilterPayload(DM_EVENT_KINDS[0]); + fallbackFilter.kinds = DM_EVENT_KINDS.slice(); + filters.push(fallbackFilter); + } + + return filters; +} + const EXTENSION_MIME_MAP = { mp4: "video/mp4", @@ -795,6 +880,11 @@ export class NostrClient { }, }, }); + this.dmDecryptCache = new SimpleLruCache(DM_DECRYPT_CACHE_LIMIT); + this.dmDecryptor = null; + this.dmDecryptorPromise = null; + this.sessionActorCipherClosures = null; + this.sessionActorCipherClosuresPrivateKey = null; this.countUnsupportedRelays = new Set(); this.nip46Client = null; this.remoteSignerListeners = new Set(); @@ -2472,6 +2562,8 @@ export class NostrClient { const cipherClosures = await createPrivateKeyCipherClosures( normalizedPrivateKey, ); + this.sessionActorCipherClosures = cipherClosures || null; + this.sessionActorCipherClosuresPrivateKey = normalizedPrivateKey; setActiveSigner({ type: "nsec", @@ -2588,6 +2680,8 @@ export class NostrClient { const cipherClosures = await createPrivateKeyCipherClosures( normalizedPrivateKey, ); + this.sessionActorCipherClosures = cipherClosures || null; + this.sessionActorCipherClosuresPrivateKey = normalizedPrivateKey; setActiveSigner({ type: "nsec", @@ -2631,6 +2725,8 @@ export class NostrClient { if (forceRenew) { this.sessionActor = null; + this.sessionActorCipherClosures = null; + this.sessionActorCipherClosuresPrivateKey = null; this.clearStoredSessionActor(); } else if ( this.sessionActor && @@ -2653,6 +2749,8 @@ export class NostrClient { const minted = this.mintSessionActor(); if (minted) { this.sessionActor = minted; + this.sessionActorCipherClosures = null; + this.sessionActorCipherClosuresPrivateKey = null; this.persistSessionActor(minted); return minted.pubkey; } @@ -3205,6 +3303,11 @@ export class NostrClient { clearActiveSigner(); const previousSessionActor = this.sessionActor; this.sessionActor = null; + this.sessionActorCipherClosures = null; + this.sessionActorCipherClosuresPrivateKey = null; + this.clearDmDecryptCache(); + this.dmDecryptor = null; + this.dmDecryptorPromise = null; if (this.nip46Client) { Promise.resolve() @@ -3238,6 +3341,357 @@ export class NostrClient { devLogger.log("User logged out."); } + getDmDecryptCacheLimit() { + return DM_DECRYPT_CACHE_LIMIT; + } + + clearDmDecryptCache() { + if (this.dmDecryptCache) { + this.dmDecryptCache.clear(); + } + } + + async ensureDmDecryptor() { + if (this.dmDecryptor) { + return this.dmDecryptor; + } + + if (!this.dmDecryptorPromise) { + this.dmDecryptorPromise = import("../dmDecryptor.js") + .then((module) => { + if (!module || typeof module.decryptDM !== "function") { + throw new Error("DM decryptor module is unavailable."); + } + this.dmDecryptor = module.decryptDM; + return this.dmDecryptor; + }) + .catch((error) => { + this.dmDecryptorPromise = null; + throw error; + }); + } + + return this.dmDecryptorPromise; + } + + async buildDmDecryptContext(actorPubkeyInput = null) { + let normalizedActor = normalizeActorKey(actorPubkeyInput); + if (!normalizedActor && typeof this.pubkey === "string" && this.pubkey) { + normalizedActor = normalizeActorKey(this.pubkey); + } + if ( + !normalizedActor && + this.sessionActor && + typeof this.sessionActor.pubkey === "string" + ) { + normalizedActor = normalizeActorKey(this.sessionActor.pubkey); + } + + const decryptors = []; + const seen = new Set(); + + const addCandidate = (scheme, decrypt, options = {}) => { + if (typeof decrypt !== "function") { + return; + } + + const normalizedScheme = typeof scheme === "string" ? scheme : ""; + const key = `${normalizedScheme}:${options.source || ""}`; + if (seen.has(key)) { + return; + } + + decryptors.push({ + scheme: normalizedScheme, + decrypt, + priority: Number.isFinite(options.priority) ? options.priority : 0, + source: options.source || "", + supportsGiftWrap: options.supportsGiftWrap === true, + }); + seen.add(key); + }; + + let activeSigner = null; + if (normalizedActor) { + activeSigner = resolveActiveSigner(normalizedActor); + } + if (!activeSigner && this.pubkey) { + activeSigner = resolveActiveSigner(this.pubkey); + } + if (!activeSigner) { + activeSigner = getActiveSigner(); + } + + if (activeSigner) { + if (typeof activeSigner.nip44Decrypt === "function") { + addCandidate( + "nip44", + activeSigner.nip44Decrypt.bind(activeSigner), + { + priority: -20, + source: activeSigner.type || "signer", + supportsGiftWrap: true, + }, + ); + } + if (typeof activeSigner.nip04Decrypt === "function") { + addCandidate( + "nip04", + activeSigner.nip04Decrypt.bind(activeSigner), + { + priority: -10, + source: activeSigner.type || "signer", + }, + ); + } + } + + const sessionActor = this.sessionActor; + if ( + sessionActor && + typeof sessionActor.privateKey === "string" && + sessionActor.privateKey + ) { + if ( + !this.sessionActorCipherClosures || + this.sessionActorCipherClosuresPrivateKey !== sessionActor.privateKey + ) { + const closures = await createPrivateKeyCipherClosures( + sessionActor.privateKey, + ); + this.sessionActorCipherClosures = closures || null; + this.sessionActorCipherClosuresPrivateKey = sessionActor.privateKey; + } + + const closures = this.sessionActorCipherClosures || {}; + if (typeof closures.nip44Decrypt === "function") { + addCandidate("nip44", closures.nip44Decrypt, { + priority: -5, + source: "session-actor", + supportsGiftWrap: true, + }); + } + if (typeof closures.nip04Decrypt === "function") { + addCandidate("nip04", closures.nip04Decrypt, { + priority: 0, + source: "session-actor", + }); + } + } + + return { actorPubkey: normalizedActor, decryptors }; + } + + async decryptDirectMessageEvent(event, { actorPubkey } = {}) { + const eventId = typeof event?.id === "string" ? event.id : ""; + if (eventId) { + const cached = this.dmDecryptCache.get(eventId); + if (cached) { + return cached; + } + } + + const decryptDM = await this.ensureDmDecryptor(); + const context = await this.buildDmDecryptContext(actorPubkey); + + let result; + try { + result = await decryptDM(event, context); + } catch (error) { + devLogger.warn("[nostr] DM decryptor threw unexpectedly.", { + error, + eventId, + }); + throw error; + } + + if (result?.ok && eventId) { + this.dmDecryptCache.set(eventId, result); + } + + return result; + } + + async listDirectMessages(actorPubkeyInput = null, options = {}) { + if (!this.pool) { + await this.ensurePool(); + } + + const context = await this.buildDmDecryptContext(actorPubkeyInput); + if (!context.decryptors.length) { + throw new Error("DM decryption helpers are unavailable."); + } + + const relayCandidates = Array.isArray(options.relays) + ? options.relays + : Array.isArray(this.readRelays) && this.readRelays.length + ? this.readRelays + : this.relays; + const relays = sanitizeRelayList(relayCandidates); + const relaysToUse = relays.length ? relays : Array.from(DEFAULT_RELAY_URLS); + + const filters = buildDmFilters( + context.actorPubkey || actorPubkeyInput, + options, + ); + + let events = []; + try { + events = await this.pool.list(relaysToUse, filters); + } catch (error) { + devLogger.warn("[nostr] Failed to list DM events.", error); + throw error; + } + + const deduped = new Map(); + for (const event of Array.isArray(events) ? events : []) { + if (!event || typeof event !== "object") { + continue; + } + const key = + typeof event.id === "string" && event.id + ? event.id + : `${event.kind || ""}:${event.pubkey || ""}:${event.created_at || ""}`; + if (!deduped.has(key)) { + deduped.set(key, event); + } + } + + const messages = []; + for (const event of deduped.values()) { + try { + const decrypted = await this.decryptDirectMessageEvent(event, { + actorPubkey: context.actorPubkey || actorPubkeyInput, + }); + if (decrypted?.ok) { + messages.push(decrypted); + } + } catch (error) { + devLogger.warn("[nostr] Failed to decrypt DM event during list.", { + error, + id: event?.id || null, + }); + } + } + + messages.sort((a, b) => (b?.timestamp || 0) - (a?.timestamp || 0)); + return messages; + } + + subscribeDirectMessages(actorPubkeyInput = null, options = {}) { + if (!this.pool) { + throw new Error("nostr pool is not initialized"); + } + + const relayCandidates = Array.isArray(options.relays) + ? options.relays + : Array.isArray(this.readRelays) && this.readRelays.length + ? this.readRelays + : this.relays; + const relays = sanitizeRelayList(relayCandidates); + const relaysToUse = relays.length ? relays : Array.from(DEFAULT_RELAY_URLS); + + const filters = buildDmFilters(actorPubkeyInput, options); + const subscription = this.pool.sub(relaysToUse, filters); + const seenIds = new Set(); + + const contextPromise = this.buildDmDecryptContext(actorPubkeyInput); + + subscription.on("event", (event) => { + if (!event || typeof event !== "object") { + return; + } + + const eventId = typeof event.id === "string" ? event.id : null; + if (eventId) { + if (seenIds.has(eventId)) { + return; + } + seenIds.add(eventId); + } + + if (typeof options.onEvent === "function") { + try { + options.onEvent(event); + } catch (error) { + devLogger.warn("[nostr] DM onEvent handler threw.", error); + } + } + + if (options.skipDecrypt === true) { + return; + } + + contextPromise + .then((context) => { + if (!context.decryptors.length) { + if (typeof options.onFailure === "function") { + options.onFailure( + { ok: false, event }, + { event, reason: "no-decryptors" }, + ); + } + return; + } + + return this.decryptDirectMessageEvent(event, { + actorPubkey: context.actorPubkey || actorPubkeyInput, + }) + .then((result) => { + if (result?.ok) { + if (typeof options.onMessage === "function") { + options.onMessage(result, { event }); + } + } else if (typeof options.onFailure === "function") { + options.onFailure(result, { event }); + } + }) + .catch((error) => { + if (typeof options.onError === "function") { + options.onError(error, { event }); + } else { + devLogger.warn( + "[nostr] Failed to decrypt DM event from subscription.", + { + error, + id: eventId, + }, + ); + } + }); + }) + .catch((error) => { + if (typeof options.onError === "function") { + options.onError(error, { event }); + } else { + devLogger.warn("[nostr] Failed to prepare DM decrypt context.", error); + } + }); + }); + + if (typeof options.onEose === "function") { + subscription.on("eose", () => { + try { + options.onEose(); + } catch (error) { + devLogger.warn("[nostr] DM onEose handler threw.", error); + } + }); + } + + const originalUnsub = + typeof subscription.unsub === "function" + ? subscription.unsub.bind(subscription) + : () => {}; + + subscription.unsub = () => { + seenIds.clear(); + return originalUnsub(); + }; + + return subscription; + } + async sendDirectMessage(targetNpub, message, actorPubkeyOverride = null) { const trimmedTarget = typeof targetNpub === "string" ? targetNpub.trim() : ""; const trimmedMessage = typeof message === "string" ? message.trim() : ""; diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 01dbb1ee..2a180e49 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -118,6 +118,9 @@ export class NostrService { this.initialLoadPromise = null; this.initialLoadResolved = false; this.initialLoadResolve = null; + this.dmMessages = []; + this.dmMessageIndex = new Map(); + this.dmSubscription = null; try { if (this.moderationService && typeof this.moderationService.setNostrClient === "function") { this.moderationService.setNostrClient(this.nostrClient); @@ -180,6 +183,257 @@ export class NostrService { return this.ensureInitialLoadDeferred(); } + resolveActiveDmActor() { + if ( + this.nostrClient && + typeof this.nostrClient.pubkey === "string" && + this.nostrClient.pubkey + ) { + return this.nostrClient.pubkey; + } + + if ( + this.nostrClient?.sessionActor && + typeof this.nostrClient.sessionActor.pubkey === "string" + ) { + return this.nostrClient.sessionActor.pubkey; + } + + return ""; + } + + getDirectMessages() { + return Array.isArray(this.dmMessages) ? [...this.dmMessages] : []; + } + + clearDirectMessages({ emit = true } = {}) { + this.dmMessages = []; + this.dmMessageIndex = new Map(); + if (typeof this.nostrClient?.clearDmDecryptCache === "function") { + try { + this.nostrClient.clearDmDecryptCache(); + } catch (error) { + userLogger.warn("[nostrService] Failed to clear DM decrypt cache", error); + } + } + if (emit) { + this.emit("directMessages:cleared", {}); + this.emit("directMessages:updated", { messages: [] }); + } + } + + applyDirectMessage(message, { reason = "update", event = null } = {}) { + if (!message || message.ok !== true) { + return; + } + + const eventId = message?.event?.id; + if (typeof eventId !== "string" || !eventId) { + return; + } + + const normalized = { + ...message, + timestamp: + Number.isFinite(message?.timestamp) + ? message.timestamp + : Number.isFinite(message?.message?.created_at) + ? message.message.created_at + : Number.isFinite(message?.event?.created_at) + ? message.event.created_at + : Date.now() / 1000, + }; + + this.dmMessageIndex.set(eventId, normalized); + const existingIndex = this.dmMessages.findIndex( + (entry) => entry?.event?.id === eventId, + ); + if (existingIndex >= 0) { + this.dmMessages[existingIndex] = normalized; + } else { + this.dmMessages.push(normalized); + } + + this.dmMessages.sort((a, b) => (b?.timestamp || 0) - (a?.timestamp || 0)); + + this.emit("directMessages:message", { message: normalized, reason, event }); + this.emit("directMessages:updated", { + messages: this.getDirectMessages(), + reason, + }); + } + + async loadDirectMessages({ actorPubkey, relays, ...options } = {}) { + const activeActor = actorPubkey || this.resolveActiveDmActor(); + const normalizedActor = normalizeHexPubkey(activeActor); + if (!normalizedActor) { + return []; + } + + let messages = []; + try { + messages = await this.nostrClient.listDirectMessages(normalizedActor, { + relays, + ...options, + }); + } catch (error) { + userLogger.warn("[nostrService] Failed to load direct messages", error); + return []; + } + + const collected = []; + const index = new Map(); + + for (const message of Array.isArray(messages) ? messages : []) { + if (!message || message.ok !== true) { + continue; + } + + const eventId = message?.event?.id; + if (typeof eventId !== "string" || !eventId) { + continue; + } + + const normalized = { + ...message, + timestamp: + Number.isFinite(message?.timestamp) + ? message.timestamp + : Number.isFinite(message?.message?.created_at) + ? message.message.created_at + : Number.isFinite(message?.event?.created_at) + ? message.event.created_at + : Date.now() / 1000, + }; + + index.set(eventId, normalized); + collected.push(normalized); + } + + collected.sort((a, b) => (b?.timestamp || 0) - (a?.timestamp || 0)); + this.dmMessageIndex = index; + this.dmMessages = collected; + + const snapshot = this.getDirectMessages(); + + this.emit("directMessages:loaded", { + messages: snapshot, + actorPubkey: normalizedActor, + }); + this.emit("directMessages:updated", { + messages: snapshot, + reason: "load", + }); + + return snapshot; + } + + ensureDirectMessageSubscription({ actorPubkey, relays, ...handlers } = {}) { + if (this.dmSubscription) { + return this.dmSubscription; + } + + const activeActor = actorPubkey || this.resolveActiveDmActor(); + const normalizedActor = normalizeHexPubkey(activeActor); + if (!normalizedActor) { + return null; + } + + try { + const subscription = this.nostrClient.subscribeDirectMessages( + normalizedActor, + { + relays, + onEvent: handlers.onEvent, + onMessage: (message, context = {}) => { + this.applyDirectMessage(message, { + reason: "subscription", + event: context.event || null, + }); + if (typeof handlers.onMessage === "function") { + try { + handlers.onMessage(message, context); + } catch (error) { + userLogger.warn( + "[nostrService] DM onMessage handler threw", + error, + ); + } + } + }, + onFailure: (payload, context) => { + this.emit("directMessages:failure", { failure: payload, context }); + if (typeof handlers.onFailure === "function") { + try { + handlers.onFailure(payload, context); + } catch (error) { + userLogger.warn( + "[nostrService] DM onFailure handler threw", + error, + ); + } + } + }, + onError: (error, context) => { + this.emit("directMessages:error", { error, context }); + if (typeof handlers.onError === "function") { + try { + handlers.onError(error, context); + } catch (handlerError) { + userLogger.warn( + "[nostrService] DM onError handler threw", + handlerError, + ); + } + } else { + userLogger.warn( + "[nostrService] Direct message subscription error", + error, + ); + } + }, + onEose: () => { + this.emit("directMessages:eose", { actorPubkey: normalizedActor }); + if (typeof handlers.onEose === "function") { + try { + handlers.onEose(); + } catch (error) { + userLogger.warn( + "[nostrService] DM onEose handler threw", + error, + ); + } + } + }, + }, + ); + + this.dmSubscription = subscription; + this.emit("directMessages:subscribed", { subscription }); + return subscription; + } catch (error) { + userLogger.warn( + "[nostrService] Failed to subscribe to direct messages", + error, + ); + return null; + } + } + + stopDirectMessageSubscription() { + if (this.dmSubscription && typeof this.dmSubscription.unsub === "function") { + try { + this.dmSubscription.unsub(); + } catch (error) { + userLogger.warn( + "[nostrService] Failed to unsubscribe from direct messages", + error, + ); + } + } + this.dmSubscription = null; + } + getModerationService() { if (!this.moderationService) { return null; diff --git a/tests/dm-decryptor.test.mjs b/tests/dm-decryptor.test.mjs new file mode 100644 index 00000000..1df77d77 --- /dev/null +++ b/tests/dm-decryptor.test.mjs @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { decryptDM } from "../js/dmDecryptor.js"; + +test("decryptDM handles kind 4 events with nip04 ciphertext", async () => { + const nostrTools = await import("nostr-tools"); + const { generateSecretKey, getPublicKey, nip04 } = nostrTools; + + const senderSecret = generateSecretKey(); + const recipientSecret = generateSecretKey(); + + const senderPubkey = getPublicKey(senderSecret); + const recipientPubkey = getPublicKey(recipientSecret); + + const message = "Hello from kind 4"; + const ciphertext = await nip04.encrypt(senderSecret, recipientPubkey, message); + + const event = { + id: "kind4-event", + kind: 4, + created_at: Math.floor(Date.now() / 1000), + pubkey: senderPubkey, + tags: [["p", recipientPubkey]], + content: ciphertext, + }; + + const context = { + actorPubkey: recipientPubkey, + decryptors: [ + { + scheme: "nip04", + priority: 0, + decrypt: (remotePubkey, cipher) => + nip04.decrypt(recipientSecret, remotePubkey, cipher), + }, + ], + }; + + const result = await decryptDM(event, context); + + assert.equal(result.ok, true, "decryptDM should succeed for kind 4 event"); + assert.equal(result.plaintext, message, "plaintext should match original message"); + assert.equal(result.scheme, "nip04", "scheme should resolve to nip04"); + assert.equal(result.sender.pubkey, senderPubkey, "sender pubkey should match event"); + assert.equal(result.recipients.length, 1, "recipient metadata should be populated"); + assert.equal(result.recipients[0].pubkey, recipientPubkey); + assert.equal(result.direction, "incoming", "direction should be incoming for recipient"); +}); + +test("decryptDM unwraps kind 1059 gift wraps with nip44", async () => { + const nostrTools = await import("nostr-tools"); + const { generateSecretKey, getPublicKey, nip44, nip59 } = nostrTools; + + const senderSecret = generateSecretKey(); + const recipientSecret = generateSecretKey(); + + const senderPubkey = getPublicKey(senderSecret); + const recipientPubkey = getPublicKey(recipientSecret); + + const baseEvent = { + kind: 14, + content: "Gift wrapped hello", + tags: [["p", recipientPubkey]], + created_at: Math.floor(Date.now() / 1000), + }; + + const wrap = nip59.wrapEvent(baseEvent, senderSecret, recipientPubkey); + + const nip44Module = nip44.v2 ?? nip44; + const getConversationKey = + typeof nip44Module?.getConversationKey === "function" + ? nip44Module.getConversationKey.bind(nip44Module) + : typeof nip44Module?.utils?.getConversationKey === "function" + ? nip44Module.utils.getConversationKey.bind(nip44Module.utils) + : null; + const decryptFn = + typeof nip44Module?.decrypt === "function" + ? nip44Module.decrypt.bind(nip44Module) + : typeof nip44?.v2?.decrypt === "function" + ? nip44.v2.decrypt.bind(nip44.v2) + : null; + + assert.ok(getConversationKey && decryptFn, "nip44 helpers should be available for tests"); + + const context = { + actorPubkey: recipientPubkey, + decryptors: [ + { + scheme: "nip44_v2", + priority: 0, + supportsGiftWrap: true, + decrypt: (remotePubkey, cipher) => { + const conversationKey = getConversationKey(recipientSecret, remotePubkey); + return decryptFn(cipher, conversationKey); + }, + }, + ], + }; + + const result = await decryptDM(wrap, context); + + assert.equal(result.ok, true, "decryptDM should succeed for gift wrap event"); + assert.equal( + result.plaintext, + baseEvent.content, + "plaintext should match unwrap payload", + ); + assert.equal(result.sender.pubkey, senderPubkey, "sender should match rumor pubkey"); + assert.equal(result.scheme, "nip44_v2", "scheme should report nip44 variant"); + assert.equal(result.direction, "incoming", "direction should be incoming for recipient"); + assert.ok(result.envelope && result.envelope.seal, "seal metadata should be provided"); + assert.equal( + result.recipients[0].pubkey, + recipientPubkey, + "recipient metadata should reflect unwrap target", + ); +}); + +test("decryptDM returns failure when decryptors are unavailable", async () => { + const event = { id: "unhandled", kind: 4, content: "", pubkey: "" }; + + const result = await decryptDM(event, { decryptors: [] }); + + assert.equal(result.ok, false, "decryptDM should report failure"); + assert.ok(Array.isArray(result.errors), "failure payload should include errors"); +});