diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 48 | ||||
| -rw-r--r-- | src/nostr.js | 28 | ||||
| -rw-r--r-- | src/verify-worker.js | 66 |
3 files changed, 117 insertions, 25 deletions
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 diff --git a/src/nostr.js b/src/nostr.js index 8b6bc7e..9fa892c 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -16,7 +16,7 @@ import { getPublicKey, finalizeEvent, generateSecretKey, verifyEvent } from 'nos import { decode as nip19decode, npubEncode, nsecEncode } from 'nostr-tools/nip19'; import { wrapEvent as nip17WrapEvent } from 'nostr-tools/nip17'; import { SimplePool } from 'nostr-tools/pool'; -import { randomBytes } from '@noble/hashes/utils'; +import { randomBytes, bytesToHex } from '@noble/hashes/utils'; import { base64urlnopad } from '@scure/base'; // One shared pool for all relay I/O — it manages connection lifecycles (and the @@ -71,6 +71,32 @@ function verifyEventsAsync(events) { // A single event → Promise<boolean>. Used to gate live subscription events. function verifyOneAsync(event) { return verifyEventsAsync([event]).then((r) => !!r[0]); } +// Heavier nip44 jobs ride the same worker: opening community wraps (decrypt +// under the symmetric stream key + seal verify per wrap) and unwrapping +// NIP-17 DMs when the raw identity key is in hand (two ECDH ops + a seal +// verify each) — the remaining boot secp256k1 after verification moved off. +// Resolves to the worker's results array, or null when the worker can't take +// the job (never created, died, stalled) — callers keep their time-sliced +// main-thread fallback, so a dead worker only changes WHERE the crypto runs. +function workerCrypto(msg, timeoutMs = 10_000) { + const w = verifyWorker(); + if (!w) return null; + return new Promise((resolve) => { + const reqId = ++_vwSeq; + const t = setTimeout(() => { _vwPending.delete(reqId); resolve(null); }, timeoutMs); + _vwPending.set(reqId, (r) => { clearTimeout(t); resolve(Array.isArray(r) ? r : null); }); + try { w.postMessage({ reqId, ...msg }); } + catch { clearTimeout(t); _vwPending.delete(reqId); resolve(null); } + }); +} +// convKey / sk are Uint8Array; hex crosses the thread boundary. +export function openWrapsOffthread(wraps, convKey) { + return workerCrypto({ op: 'openWraps', wraps, convKey: bytesToHex(convKey) }); +} +export function unwrapDMsOffthread(wraps, sk) { + return workerCrypto({ op: 'unwrapDMs', wraps, sk: bytesToHex(sk) }); +} + // Subscribe / publish on an explicit relay set, independent of the sync // feature's configuration. NWC needs this: the relays it advertises in a // connection URI must be exactly the ones it listens on, or a client's diff --git a/src/verify-worker.js b/src/verify-worker.js index 679c1a8..1f5203f 100644 --- a/src/verify-worker.js +++ b/src/verify-worker.js @@ -1,19 +1,55 @@ -// Off-main-thread nostr event verification. Schnorr signature checks (real -// secp256k1) on a relay backlog blocked the UI on boot / when a community -// opens — the carousel-stutter cause a CPU profile pinned to verifyEvent. This -// worker does that work on another thread; the main thread posts events and -// gets back a boolean per event, so the page never freezes on a burst. +// Off-main-thread nostr crypto. Schnorr signature checks (real secp256k1) on +// a relay backlog blocked the UI on boot / when a community opens — the +// carousel-stutter cause a CPU profile pinned to verifyEvent. This worker +// does that work on another thread; the main thread posts events and gets +// back a boolean per event, so the page never freezes on a burst. // -// Bundled to dist/verify-worker.js (see build.js) and loaded as a classic -// Worker. Same verifyEvent nostr-tools uses on the main thread, so semantics -// are identical — this only moves WHERE it runs. +// It also carries the other boot secp256k1 the profiler kept pinning: +// - 'openWraps': community (Concord) wraps — nip44 decrypt under the +// symmetric stream key + the seal's schnorr verify + hash checks. No +// secret crosses the thread boundary: the stream conversation key is +// group material every member derives. +// - 'unwrapDMs': NIP-17 gift wraps when the wallet holds the raw identity +// key (seed-derived / pasted nsec) — two ECDH ops + a seal verify each. +// Remote signers (bunker/extension) can't export their key, so their +// wraps never come here; the main thread keeps that path. +// +// Bundled to dist/verify-worker.js (see build.js). Same openWrap/unwrapDM/ +// verifyEvent the main thread uses, so semantics are identical — this only +// moves WHERE they run. import { verifyEvent } from 'nostr-tools/pure'; +import { hexToBytes } from '@noble/hashes/utils'; +import { openWrap } from './concord.js'; +import { unwrapDM } from './dm.js'; -self.onmessage = (e) => { - const { reqId, events } = e.data || {}; - if (reqId == null || !Array.isArray(events)) return; - const results = events.map((ev) => { - try { return verifyEvent(ev); } catch { return false; } - }); - self.postMessage({ reqId, results }); +self.onmessage = async (e) => { + const { reqId, events, op, wraps, convKey, sk } = e.data || {}; + if (reqId == null) return; + if (Array.isArray(events)) { + const results = events.map((ev) => { + try { return verifyEvent(ev); } catch { return false; } + }); + self.postMessage({ reqId, results }); + return; + } + if (op === 'openWraps' && Array.isArray(wraps)) { + let stream = null; + try { stream = { convKey: hexToBytes(convKey) }; } catch {} + const results = wraps.map((w) => { + try { return stream ? openWrap(w, stream) : null; } catch { return null; } + }); + self.postMessage({ reqId, results }); + return; + } + if (op === 'unwrapDMs' && Array.isArray(wraps)) { + let key = null; + try { key = hexToBytes(sk); } catch {} + const results = []; + for (const w of wraps) { + results.push(key ? await unwrapDM(w, key).catch(() => null) : null); + } + self.postMessage({ reqId, results }); + return; + } + self.postMessage({ reqId, results: null }); }; |
