mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-02 10:52:27 +00:00
Add worker DM decrypt and persistence
This commit is contained in:
1 parent
761081883d
commit
b05e898391
5 files changed
+509
No files matched your search
@@ -107,6 +107,10 @@ import {
|
||||
shimLegacySimplePoolMethods,
|
||||
} from "./toolkit.js";
|
||||
import { encryptNip04InWorker } from "./nip04WorkerClient.js";
|
||||
import {
|
||||
decryptDmInWorker,
|
||||
isDmDecryptWorkerSupported,
|
||||
} from "./dmDecryptWorkerClient.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
import { updateConversationFromMessage, writeMessages } from "../storage/dmDb.js";
|
||||
import {
|
||||
@@ -4056,6 +4060,42 @@ export class NostrClient {
|
||||
typeof sessionActor.privateKey === "string" &&
|
||||
sessionActor.privateKey
|
||||
) {
|
||||
const canUseWorker = isDmDecryptWorkerSupported();
|
||||
const sessionPrivateKey = sessionActor.privateKey;
|
||||
|
||||
if (canUseWorker) {
|
||||
addCandidate(
|
||||
"nip44",
|
||||
(targetPubkey, ciphertext) =>
|
||||
decryptDmInWorker({
|
||||
scheme: "nip44",
|
||||
privateKey: sessionPrivateKey,
|
||||
targetPubkey,
|
||||
ciphertext,
|
||||
}),
|
||||
{
|
||||
priority: -6,
|
||||
source: "worker",
|
||||
supportsGiftWrap: true,
|
||||
},
|
||||
);
|
||||
|
||||
addCandidate(
|
||||
"nip04",
|
||||
(targetPubkey, ciphertext) =>
|
||||
decryptDmInWorker({
|
||||
scheme: "nip04",
|
||||
privateKey: sessionPrivateKey,
|
||||
targetPubkey,
|
||||
ciphertext,
|
||||
}),
|
||||
{
|
||||
priority: -4,
|
||||
source: "worker",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!this.sessionActorCipherClosures ||
|
||||
this.sessionActorCipherClosuresPrivateKey !== sessionActor.privateKey
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js";
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
const workerScope = typeof self !== "undefined" ? self : null;
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
|
||||
const postResponse = (payload) => {
|
||||
if (!workerScope || typeof workerScope.postMessage !== "function") {
|
||||
return;
|
||||
}
|
||||
workerScope.postMessage(payload);
|
||||
};
|
||||
|
||||
const normalizeScheme = (scheme) => {
|
||||
if (typeof scheme !== "string") {
|
||||
return "nip04";
|
||||
}
|
||||
const normalized = scheme.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return "nip04";
|
||||
}
|
||||
if (normalized === "nip44-v2") {
|
||||
return "nip44_v2";
|
||||
}
|
||||
if (normalized === "nip-44") {
|
||||
return "nip44";
|
||||
}
|
||||
if (normalized === "nip-04") {
|
||||
return "nip04";
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizePayload = (payload) => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id =
|
||||
typeof payload.id === "string" || typeof payload.id === "number"
|
||||
? payload.id
|
||||
: null;
|
||||
const scheme = normalizeScheme(payload.scheme);
|
||||
const privateKey =
|
||||
typeof payload.privateKey === "string" ? payload.privateKey.trim() : "";
|
||||
const targetPubkey =
|
||||
typeof payload.targetPubkey === "string" ? payload.targetPubkey.trim() : "";
|
||||
const ciphertext =
|
||||
typeof payload.ciphertext === "string" ? payload.ciphertext : "";
|
||||
|
||||
if (!id || !privateKey || !targetPubkey || !ciphertext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
scheme,
|
||||
privateKey,
|
||||
targetPubkey,
|
||||
ciphertext,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveHexToBytes = (tools) => {
|
||||
if (typeof tools?.utils?.hexToBytes === "function") {
|
||||
return (value) => tools.utils.hexToBytes(value);
|
||||
}
|
||||
|
||||
return (value) => {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("Invalid hex input.");
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length % 2 !== 0) {
|
||||
throw new Error("Invalid hex input.");
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(trimmed.length / 2);
|
||||
for (let index = 0; index < trimmed.length; index += 2) {
|
||||
const byte = Number.parseInt(trimmed.slice(index, index + 2), 16);
|
||||
if (Number.isNaN(byte)) {
|
||||
throw new Error("Invalid hex input.");
|
||||
}
|
||||
bytes[index / 2] = byte;
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
};
|
||||
|
||||
const ensureNip04 = async () => {
|
||||
const tools = (await ensureNostrTools()) || getCachedNostrTools();
|
||||
const nip04 = tools?.nip04;
|
||||
if (!nip04 || typeof nip04.decrypt !== "function") {
|
||||
throw new Error("nip04-unavailable");
|
||||
}
|
||||
return nip04;
|
||||
};
|
||||
|
||||
const ensureNip44 = async () => {
|
||||
const tools = (await ensureNostrTools()) || getCachedNostrTools();
|
||||
if (!tools) {
|
||||
throw new Error("nip44-unavailable");
|
||||
}
|
||||
|
||||
const nip44 = tools.nip44 || null;
|
||||
let decrypt = null;
|
||||
let getConversationKey = null;
|
||||
|
||||
if (nip44?.v2 && typeof nip44.v2 === "object") {
|
||||
if (typeof nip44.v2.decrypt === "function") {
|
||||
decrypt = nip44.v2.decrypt;
|
||||
}
|
||||
if (typeof nip44.v2?.utils?.getConversationKey === "function") {
|
||||
getConversationKey = nip44.v2.utils.getConversationKey;
|
||||
}
|
||||
}
|
||||
|
||||
if ((!decrypt || !getConversationKey) && nip44 && typeof nip44 === "object") {
|
||||
if (typeof nip44.decrypt === "function") {
|
||||
decrypt = nip44.decrypt;
|
||||
}
|
||||
if (typeof nip44.getConversationKey === "function") {
|
||||
getConversationKey = nip44.getConversationKey;
|
||||
} else if (typeof nip44.utils?.getConversationKey === "function") {
|
||||
getConversationKey = nip44.utils.getConversationKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (!decrypt || !getConversationKey) {
|
||||
throw new Error("nip44-unavailable");
|
||||
}
|
||||
|
||||
return {
|
||||
decrypt,
|
||||
getConversationKey,
|
||||
hexToBytes: resolveHexToBytes(tools),
|
||||
};
|
||||
};
|
||||
|
||||
const handleMessage = async (event) => {
|
||||
const payload = normalizePayload(event?.data);
|
||||
if (!payload) {
|
||||
postResponse({
|
||||
id: event?.data?.id ?? null,
|
||||
ok: false,
|
||||
error: { message: "invalid-payload" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!HEX64_REGEX.test(payload.privateKey)) {
|
||||
postResponse({
|
||||
id: payload.id,
|
||||
ok: false,
|
||||
error: { message: "invalid-private-key" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (payload.scheme === "nip44" || payload.scheme === "nip44_v2") {
|
||||
const nip44 = await ensureNip44();
|
||||
const privateKeyBytes = nip44.hexToBytes(payload.privateKey);
|
||||
const conversationKey = nip44.getConversationKey(
|
||||
privateKeyBytes,
|
||||
payload.targetPubkey,
|
||||
);
|
||||
const plaintext = await nip44.decrypt(
|
||||
payload.ciphertext,
|
||||
conversationKey,
|
||||
);
|
||||
|
||||
postResponse({
|
||||
id: payload.id,
|
||||
ok: true,
|
||||
plaintext,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nip04 = await ensureNip04();
|
||||
const plaintext = await nip04.decrypt(
|
||||
payload.privateKey,
|
||||
payload.targetPubkey,
|
||||
payload.ciphertext,
|
||||
);
|
||||
|
||||
postResponse({
|
||||
id: payload.id,
|
||||
ok: true,
|
||||
plaintext,
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn("[dmDecryptWorker] Decryption failed", error);
|
||||
postResponse({
|
||||
id: payload.id,
|
||||
ok: false,
|
||||
error: {
|
||||
message: error?.message || "dm-worker-failed",
|
||||
name: error?.name || "Error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (workerScope?.addEventListener) {
|
||||
workerScope.addEventListener("message", (event) => {
|
||||
void handleMessage(event);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 12000;
|
||||
|
||||
let workerInstance = null;
|
||||
let workerReady = false;
|
||||
let requestId = 0;
|
||||
const pending = new Map();
|
||||
|
||||
function attachWorkerListeners(worker) {
|
||||
if (!worker || workerReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
worker.addEventListener("message", (event) => {
|
||||
const data = event?.data || {};
|
||||
const id = data.id;
|
||||
if (!pending.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = pending.get(id);
|
||||
pending.delete(id);
|
||||
clearTimeout(entry.timeoutId);
|
||||
|
||||
if (data.ok && typeof data.plaintext === "string") {
|
||||
entry.resolve(data.plaintext);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = data?.error?.message || "dm-worker-error";
|
||||
const error = new Error(message);
|
||||
error.name = data?.error?.name || "Error";
|
||||
entry.reject(error);
|
||||
});
|
||||
|
||||
worker.addEventListener("error", (error) => {
|
||||
devLogger.warn("[dmDecryptWorkerClient] Worker error", error);
|
||||
for (const entry of pending.values()) {
|
||||
clearTimeout(entry.timeoutId);
|
||||
entry.reject(
|
||||
error instanceof Error ? error : new Error("dm-worker-error"),
|
||||
);
|
||||
}
|
||||
pending.clear();
|
||||
});
|
||||
|
||||
workerReady = true;
|
||||
}
|
||||
|
||||
function ensureWorker() {
|
||||
if (workerInstance) {
|
||||
return workerInstance;
|
||||
}
|
||||
|
||||
if (typeof Worker === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
workerInstance = new Worker(
|
||||
new URL("./dmDecryptWorker.js", import.meta.url),
|
||||
{
|
||||
type: "module",
|
||||
},
|
||||
);
|
||||
attachWorkerListeners(workerInstance);
|
||||
} catch (error) {
|
||||
devLogger.warn("[dmDecryptWorkerClient] Failed to create worker", error);
|
||||
workerInstance = null;
|
||||
}
|
||||
|
||||
return workerInstance;
|
||||
}
|
||||
|
||||
export function isDmDecryptWorkerSupported() {
|
||||
return typeof Worker !== "undefined";
|
||||
}
|
||||
|
||||
export function decryptDmInWorker({
|
||||
scheme = "nip04",
|
||||
privateKey,
|
||||
targetPubkey,
|
||||
ciphertext,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
const worker = ensureWorker();
|
||||
if (!worker) {
|
||||
return Promise.reject(new Error("dm-worker-unavailable"));
|
||||
}
|
||||
|
||||
const trimmedPrivateKey =
|
||||
typeof privateKey === "string" ? privateKey.trim() : "";
|
||||
const trimmedTarget =
|
||||
typeof targetPubkey === "string" ? targetPubkey.trim() : "";
|
||||
const payloadCiphertext = typeof ciphertext === "string" ? ciphertext : "";
|
||||
const payloadScheme = typeof scheme === "string" ? scheme : "";
|
||||
|
||||
if (!trimmedPrivateKey || !trimmedTarget || !payloadCiphertext) {
|
||||
return Promise.reject(new Error("dm-worker-invalid-input"));
|
||||
}
|
||||
|
||||
const id = `${Date.now()}-${requestId++}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error("dm-worker-timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
pending.set(id, { resolve, reject, timeoutId });
|
||||
worker.postMessage({
|
||||
id,
|
||||
scheme: payloadScheme,
|
||||
privateKey: trimmedPrivateKey,
|
||||
targetPubkey: trimmedTarget,
|
||||
ciphertext: payloadCiphertext,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
saveDirectMessageSnapshot,
|
||||
clearDirectMessageSnapshot,
|
||||
} from "../directMessagesStore.js";
|
||||
import {
|
||||
updateConversationFromMessage,
|
||||
writeMessages,
|
||||
} from "../storage/dmDb.js";
|
||||
import {
|
||||
getVideosMap as getStoredVideosMap,
|
||||
setVideosMap as setStoredVideosMap,
|
||||
@@ -286,6 +290,93 @@ function resolveDirectMessageRemotePubkey(message, actorPubkey = "") {
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildConversationId(actorPubkey, remotePubkey) {
|
||||
const normalizedActor = normalizeHexPubkey(actorPubkey);
|
||||
const normalizedRemote = normalizeHexPubkey(remotePubkey);
|
||||
|
||||
if (!normalizedActor || !normalizedRemote) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `dm:${[normalizedActor, normalizedRemote].sort().join(":")}`;
|
||||
}
|
||||
|
||||
function normalizeDirectMessageRecord(message, actorPubkey = "") {
|
||||
if (!message || message.ok !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const actor = normalizeHexPubkey(
|
||||
actorPubkey || message.actorPubkey || "",
|
||||
);
|
||||
const remote = resolveDirectMessageRemotePubkey(message, actor);
|
||||
const conversationId = buildConversationId(actor, remote);
|
||||
const eventId =
|
||||
typeof message?.event?.id === "string" ? message.event.id : "";
|
||||
|
||||
if (!conversationId || !eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const senderPubkey = normalizeHexPubkey(
|
||||
message?.sender?.pubkey ||
|
||||
message?.message?.pubkey ||
|
||||
message?.event?.pubkey ||
|
||||
"",
|
||||
);
|
||||
const direction = message.direction;
|
||||
let receiverPubkey = "";
|
||||
|
||||
if (direction === "incoming") {
|
||||
receiverPubkey = actor;
|
||||
} else if (direction === "outgoing") {
|
||||
receiverPubkey = remote;
|
||||
} else if (senderPubkey) {
|
||||
receiverPubkey = senderPubkey === actor ? remote : actor;
|
||||
}
|
||||
|
||||
const createdAt = sanitizeSnapshotTimestamp(
|
||||
message?.message?.created_at ??
|
||||
message?.event?.created_at ??
|
||||
message?.timestamp,
|
||||
);
|
||||
|
||||
const kind =
|
||||
Number.isFinite(message?.message?.kind)
|
||||
? message.message.kind
|
||||
: Number.isFinite(message?.event?.kind)
|
||||
? message.event.kind
|
||||
: 0;
|
||||
|
||||
const tags = Array.isArray(message?.message?.tags)
|
||||
? message.message.tags
|
||||
: Array.isArray(message?.event?.tags)
|
||||
? message.event.tags
|
||||
: [];
|
||||
|
||||
const encryptionScheme =
|
||||
typeof message?.scheme === "string"
|
||||
? message.scheme.trim()
|
||||
: typeof message?.decryptor?.scheme === "string"
|
||||
? message.decryptor.scheme.trim()
|
||||
: "";
|
||||
|
||||
return {
|
||||
id: eventId,
|
||||
conversation_id: conversationId,
|
||||
sender_pubkey: senderPubkey || actor,
|
||||
receiver_pubkey: receiverPubkey,
|
||||
created_at: createdAt,
|
||||
kind,
|
||||
content: typeof message?.plaintext === "string" ? message.plaintext : "",
|
||||
tags,
|
||||
relay: typeof message?.event?.relay === "string" ? message.event.relay : "",
|
||||
status: "published",
|
||||
seen: direction !== "incoming",
|
||||
encryption_scheme: encryptionScheme,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSnapshotFromMessages(messages, actorPubkey = "") {
|
||||
const normalizedActor = normalizeHexPubkey(actorPubkey);
|
||||
const threadMap = new Map();
|
||||
@@ -510,6 +601,31 @@ export class NostrService {
|
||||
}
|
||||
}
|
||||
|
||||
async persistDirectMessageRecord(message, { actorPubkey = null } = {}) {
|
||||
const record = normalizeDirectMessageRecord(
|
||||
message,
|
||||
actorPubkey || message?.actorPubkey || this.dmActorPubkey,
|
||||
);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await writeMessages(record);
|
||||
await updateConversationFromMessage(record, {
|
||||
preview: extractSnapshotPreview(message),
|
||||
unseenDelta: message?.direction === "incoming" ? 1 : 0,
|
||||
downloadedUntil: sanitizeSnapshotTimestamp(
|
||||
message?.timestamp ?? message?.event?.created_at ?? record.created_at,
|
||||
),
|
||||
});
|
||||
return record;
|
||||
} catch (error) {
|
||||
userLogger.warn("[nostrService] Failed to persist DM record", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
applyDirectMessage(message, { reason = "update", event = null } = {}) {
|
||||
if (!message || message.ok !== true) {
|
||||
return;
|
||||
@@ -575,6 +691,10 @@ export class NostrService {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!normalized.snapshot) {
|
||||
void this.persistDirectMessageRecord(normalized, { actorPubkey: actor });
|
||||
}
|
||||
}
|
||||
|
||||
async hydrateDirectMessagesFromStore({
|
||||
|
||||
@@ -141,6 +141,14 @@ function normalizeMessage(raw) {
|
||||
? statusRaw
|
||||
: "";
|
||||
const seen = raw.seen === true;
|
||||
const encryptionScheme =
|
||||
typeof raw.encryption_scheme === "string"
|
||||
? raw.encryption_scheme.trim()
|
||||
: typeof raw.encryptionScheme === "string"
|
||||
? raw.encryptionScheme.trim()
|
||||
: typeof raw.scheme === "string"
|
||||
? raw.scheme.trim()
|
||||
: "";
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -154,6 +162,7 @@ function normalizeMessage(raw) {
|
||||
relay: typeof raw.relay === "string" ? raw.relay : "",
|
||||
status,
|
||||
seen,
|
||||
encryption_scheme: encryptionScheme,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,6 +188,9 @@ function normalizeConversation(raw) {
|
||||
last_message_at: sanitizeTimestamp(
|
||||
raw.last_message_at ?? raw.lastMessageAt,
|
||||
),
|
||||
downloaded_until: sanitizeTimestamp(
|
||||
raw.downloaded_until ?? raw.downloadedUntil,
|
||||
),
|
||||
last_message_preview:
|
||||
typeof raw.last_message_preview === "string"
|
||||
? raw.last_message_preview
|
||||
@@ -347,6 +359,9 @@ export async function updateConversationFromMessage(message, options = {}) {
|
||||
Number.isFinite(Number(options.unseenDelta))
|
||||
? Number(options.unseenDelta)
|
||||
: 0;
|
||||
const downloadedUntil = sanitizeTimestamp(
|
||||
options.downloadedUntil ?? normalized.created_at,
|
||||
);
|
||||
|
||||
try {
|
||||
const db = await openDmDb();
|
||||
@@ -368,6 +383,10 @@ export async function updateConversationFromMessage(message, options = {}) {
|
||||
...existing,
|
||||
created_at: existing.created_at || normalized.created_at,
|
||||
last_message_at: normalized.created_at,
|
||||
downloaded_until: Math.max(
|
||||
existing.downloaded_until || 0,
|
||||
downloadedUntil,
|
||||
),
|
||||
last_message_preview: preview,
|
||||
unseen_count: Math.max(
|
||||
0,
|
||||
|
||||
Reference in new issue
Block a user