aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-04 08:23:50 -0700
committerAdam Soltys <[email protected]>2026-09-04 08:24:04 -0700
commit7bdc0fb00f8f77075a449732971f4689d892e921 (patch)
tree39441dd778c5fc56dd1874559a529c0bb48eb16d /src/features
parent2d17fd652c3c2c56d6027c9271bf9b9854412677 (diff)
downloadcoinosv3-7bdc0fb00f8f77075a449732971f4689d892e921.tar.gz
coinosv3-7bdc0fb00f8f77075a449732971f4689d892e921.zip
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)
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js48
1 files changed, 39 insertions, 9 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