mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-06 12:52:26 +00:00
Add DM decryptor and service integrations
This commit is contained in:
1 parent
8d441128b0
commit
a534e20c10
5 files changed
+1407
No files matched your search
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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() : "";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user