From 7bdc0fb00f8f77075a449732971f4689d892e921 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 4 Sep 2026 08:23:50 -0700 Subject: Move nip44 decryption off the main thread — the remaining boot secp256k1 after verification moved off (chunk-60dfb4nt in profiles). The verify worker grows two ops: 'openWraps' opens community (Concord) wraps given the symmetric stream conversation key — nip44 decrypt + the seal's schnorr verify + hash checks per wrap, no secret crossing the thread boundary since stream keys are group material; 'unwrapDMs' runs the full NIP-17 unwrap (two ECDH ops + seal verify) when the wallet holds the raw identity key. Remote signers can't export their key, so their DM path stays on the main thread, exactly as before. The time-sliced main-thread queues survive as fallbacks — a dead or stalled worker only changes WHERE the crypto runs (workerCrypto resolves null and callers re-enqueue), never whether chat catches up. Bonus: subChannel derives its stream key once per channel instead of once per wrap (groupKey does hkdf + ECDH per call — real curve work on a 200-wrap backfill) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz --- src/features/messages.js | 48 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 9 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index 79d9615..e246b78 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -15,7 +15,7 @@ import { subscribeOn, publishOn, queryOn, fetchNostrProfile, fetchInboxRelays, npubOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44, - PROFILE_RELAYS, + PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, } from '../nostr.js'; import { channelKey, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId, @@ -322,6 +322,21 @@ export function messagesFeature(ctx) { // Queue the decrypt work and run it time-sliced: ~8ms of work, then yield so // input and paint get a turn. Chat catches up a few frames later; the wallet // stays responsive. (Same pattern the DM inbox drain already uses.) + // Open one community wrap off the main thread when the crypto worker is + // up (the decrypt + seal verify per wrap is the remaining boot secp256k1 + // the profiler pinned); the time-sliced queue below stays as the fallback, + // so a dead worker only changes where the crypto runs, never whether chat + // catches up. The stream conversation key is group material every member + // derives — no secret crosses the thread boundary. + function openWrapBg(wrap, stream, cb) { + const job = openWrapsOffthread([wrap], stream.convKey); + if (!job) { enqueueRoomWork(() => cb(openWrap(wrap, stream))); return; } + job.then((r) => { + if (r) cb(r[0]); + else enqueueRoomWork(() => cb(openWrap(wrap, stream))); // worker died mid-job + }); + } + const roomWork = []; let roomWorking = false; function enqueueRoomWork(fn) { @@ -425,8 +440,7 @@ export function messagesFeature(ctx) { subscribeOn(room.relays, { kinds: [1059], authors: [room.control.pk], limit: 500 }, (wrap) => { if (seenWraps.has(wrap.id)) return; seenWraps.add(wrap.id); - enqueueRoomWork(() => { - const opened = openWrap(wrap, room.control); + openWrapBg(wrap, room.control, (opened) => { if (!opened || opened.rumor.kind !== 3308) return; room.controlEntries.push(opened); scheduleFold(); @@ -435,8 +449,7 @@ export function messagesFeature(ctx) { subscribeOn(room.relays, { kinds: [1059], authors: [room.guestbook.pk], limit: 500 }, (wrap) => { if (seenWraps.has(wrap.id)) return; seenWraps.add(wrap.id); - enqueueRoomWork(() => { - const opened = openWrap(wrap, room.guestbook); + openWrapBg(wrap, room.guestbook, (opened) => { if (!opened) return; room.guestEntries.push(opened); scheduleFold(); @@ -449,11 +462,13 @@ export function messagesFeature(ctx) { function subChannel(room, id) { if (room.subbed.has(id)) return; room.subbed.add(id); - allUnsubs.push(subscribeOn(room.relays, { kinds: [1059, 21059], authors: [room.chStream(id).pk], limit: 200 }, (wrap) => { + // derive the stream key once per channel, not once per wrap — groupKey + // does an hkdf + ECDH each call, real curve work on a 200-wrap backfill + const stream = room.chStream(id); + allUnsubs.push(subscribeOn(room.relays, { kinds: [1059, 21059], authors: [stream.pk], limit: 200 }, (wrap) => { if (seenWraps.has(wrap.id)) return; seenWraps.add(wrap.id); - enqueueRoomWork(() => { - const opened = openWrap(wrap, room.chStream(id)); + openWrapBg(wrap, stream, (opened) => { if (!opened) return; onChat(room, id, opened); }); @@ -1199,9 +1214,24 @@ export function messagesFeature(ctx) { return !login || !!(login.signer && login.signer.decryptFrom); } + // Raw-key unwraps (seed-derived / pasted nsec — the common case) run in + // the crypto worker: two ECDH ops + a seal verify per wrap is the boot + // secp256k1 that used to run here. Remote signers can't export their key, + // so their decryptFrom stays on the main thread, exactly as before. + async function unwrapDMAny(wrap, d) { + if (d instanceof Uint8Array) { + const job = unwrapDMsOffthread([wrap], d); + if (job) { + const r = await job; + if (r) return r[0]; // null here means "not ours", not "worker failed" + } + } + return unwrapDM(wrap, d).catch(() => null); + } + async function openInboxWrap(wrap) { for (const d of dmDecryptors()) { - const got = await unwrapDM(wrap, d).catch(() => null); + const got = await unwrapDMAny(wrap, d); if (!got) continue; if (got.rumor.kind === 14) { // unwrapDM judges "mine" against the key that DECRYPTED, but this -- cgit v1.2.3