aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js48
-rw-r--r--src/nostr.js28
-rw-r--r--src/verify-worker.js66
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 });
};