Files
bitvid/js/dmDecryptor.js
T
google-labs-jules[bot]andPR0M3TH3AN 56ffdb693d Refactor decryptLegacyDm to use ordered parallel race for decryptors.
Replaces Promise.any with raceOrdered utility to respect decryptor preference order while maintaining parallel execution.
Adds regression test for order preference.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-26 17:45:31 +00:00

702 lines
17 KiB
JavaScript

// 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,
});
}
function raceOrdered(promises) {
if (!promises || promises.length === 0) {
return Promise.reject(new AggregateError([], "No promises to race"));
}
return new Promise((resolve, reject) => {
let pendingCount = promises.length;
const errors = new Array(promises.length);
const results = new Array(promises.length);
const states = new Array(promises.length).fill("pending");
let scanIndex = 0;
promises.forEach((p, i) => {
Promise.resolve(p).then(
(val) => {
states[i] = "fulfilled";
results[i] = val;
if (i === scanIndex) {
resolve(val);
}
},
(err) => {
states[i] = "rejected";
errors[i] = err;
pendingCount--;
if (i === scanIndex) {
while (scanIndex < promises.length) {
if (states[scanIndex] === "fulfilled") {
resolve(results[scanIndex]);
return;
}
if (states[scanIndex] === "rejected") {
scanIndex++;
} else {
return;
}
}
}
if (pendingCount === 0 && scanIndex >= promises.length) {
reject(new AggregateError(errors, "All promises rejected"));
}
},
);
});
});
}
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 raceOrdered(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);
}