diff --git a/apps/desktop/src/lib/cryptoWorker.ts b/apps/desktop/src/lib/cryptoWorker.ts new file mode 100644 index 0000000..0b057de --- /dev/null +++ b/apps/desktop/src/lib/cryptoWorker.ts @@ -0,0 +1,73 @@ +// Main-thread wrapper around the crypto Web Worker (Argon2id pwhash + +// sealed user-key open). Each unlock attempt spawns a fresh one-shot worker +// — workers are cheap and the pwhash is a one-time cost per login, so we +// avoid the bookkeeping needed for a persistent request queue. +// +// Falls back to inline (main-thread) `openUserKey` when the `Worker` +// constructor is unavailable (e.g. vitest's jsdom environment, strict CSPs). +// The fallback path is identical in semantics to the worker path; the only +// difference is whether it blocks the main thread. +// +// Why not a long-lived worker? The KDF cost dwarfs the spawn cost (~1-2 s +// vs. a few ms), and PIN-unlock happens at most once per session. Keeping a +// worker resident would also require a request-id correlation map which the +// decrypt.worker uses (because per-message decrypts are high-volume). + +import { openUserKey } from '@chat-app/shared/crypto'; + +import type { + OpenUserKeyInput, + OpenUserKeyResult, +} from '../workers/crypto.worker'; + +type WorkerResponse = + | { ok: true; result: OpenUserKeyResult } + | { ok: false; error: string }; + +export type { OpenUserKeyInput, OpenUserKeyResult }; + +export async function openUserKeyInWorker( + input: OpenUserKeyInput, +): Promise { + const worker = new Worker( + new URL('../workers/crypto.worker.ts', import.meta.url), + { type: 'module' }, + ); + try { + return await new Promise((resolve, reject) => { + worker.addEventListener('message', (ev: MessageEvent) => { + const msg = ev.data; + if (msg && msg.ok) resolve(msg.result); + else reject(new Error(msg?.error ?? 'crypto worker returned malformed response')); + }); + worker.addEventListener('error', (ev: ErrorEvent) => { + reject(new Error(ev.message || 'crypto worker error')); + }); + worker.postMessage({ op: 'openUserKey', input }); + }); + } finally { + worker.terminate(); + } +} + +// Public entry point: route through the worker when possible, fall back to +// the synchronous-on-main-thread path otherwise. Callers should prefer this +// over importing `openUserKey` directly so we get the worker speedup +// everywhere it's available. +export async function openUserKeyMaybeWorker( + input: OpenUserKeyInput, +): Promise { + if (typeof Worker === 'undefined') { + return openUserKey(input); + } + try { + const result = await openUserKeyInWorker(input); + return result.privateKey; + } catch (err) { + // If the worker spawn or message round-trip fails (e.g. CSP blocks + // module workers in some packaging modes), fall back to inline so the + // unlock still succeeds — just with a brief main-thread hitch. + console.warn('[cryptoWorker] worker path failed, falling back to inline', err); + return openUserKey(input); + } +} diff --git a/apps/desktop/src/lib/userIdentity.ts b/apps/desktop/src/lib/userIdentity.ts index 58177de..55083e6 100644 --- a/apps/desktop/src/lib/userIdentity.ts +++ b/apps/desktop/src/lib/userIdentity.ts @@ -4,10 +4,11 @@ import { } from '@chat-app/shared/auth'; import { generateRecoveryCode, generateUserKeyPair, getCryptoBackend, normalizeRecoveryCode, - openUserKey, sealUserKey, + sealUserKey, } from '@chat-app/shared/crypto'; import { migrateOwnLegacyBundles } from '@chat-app/shared/chat'; +import { openUserKeyMaybeWorker } from './cryptoWorker'; import { devLocalSecretStore } from './secretStore'; import { supabase } from './supabase'; @@ -71,7 +72,7 @@ export async function loadOrUnlockUserKey(p: UnlockParams): Promise {}); throw err; diff --git a/apps/desktop/src/workers/crypto.worker.ts b/apps/desktop/src/workers/crypto.worker.ts new file mode 100644 index 0000000..4034bfe --- /dev/null +++ b/apps/desktop/src/workers/crypto.worker.ts @@ -0,0 +1,78 @@ +// Web Worker — runs Argon2id pwhash + sealed user-key open off the main +// thread. PIN-unlock used to freeze the UI for ~1-2 s on mid-hardware while +// the moderate-preset KDF ran; pushing it here keeps the unlock screen +// responsive. +// +// The worker bundles its own libsodium-wrappers-sumo instance and registers +// it as the shared CryptoBackend inside this worker realm — there is no +// shared state with the main thread, so we initialise once per worker and +// re-use it across messages (the client wrapper currently spawns one-shot, +// but the worker is safe to keep alive too). +// +// Message protocol (one-shot RPC): +// request: { op: 'openUserKey', input: OpenUserKeyInput } +// response: { ok: true, result: { privateKey: Uint8Array } } +// | { ok: false, error: string } +// +// The private key bytes are transferred (zero-copy) back to the caller via +// the structured-clone Transferable list; the worker's view of the buffer is +// detached on transfer which also clears the only worker-side reference. + +/// + +import { setCryptoBackend, openUserKey } from '@chat-app/shared/crypto'; +import type { KdfParams } from '@chat-app/shared/crypto'; +import sodium from 'libsodium-wrappers-sumo'; + +import { createLibsodiumBackend } from '../lib/cryptoBackend'; + +export interface OpenUserKeyInput { + sealed: Uint8Array; + pin: string; + salt: Uint8Array; + kdfParams: KdfParams; +} + +export interface OpenUserKeyResult { + privateKey: Uint8Array; +} + +type WorkerRequest = { op: 'openUserKey'; input: OpenUserKeyInput }; +type WorkerResponse = + | { ok: true; result: OpenUserKeyResult } + | { ok: false; error: string }; + +let backendReady: Promise | null = null; + +async function ensureBackend(): Promise { + if (!backendReady) { + backendReady = (async () => { + await sodium.ready; + setCryptoBackend(await createLibsodiumBackend()); + })(); + } + return backendReady; +} + +self.addEventListener('message', (ev: MessageEvent) => { + const msg = ev.data; + void (async () => { + try { + if (!msg || msg.op !== 'openUserKey') { + throw new Error('unknown op: ' + String((msg as { op?: unknown })?.op)); + } + await ensureBackend(); + const privateKey = await openUserKey(msg.input); + const response: WorkerResponse = { ok: true, result: { privateKey } }; + const transfers: Transferable[] = []; + if (privateKey?.buffer instanceof ArrayBuffer) { + transfers.push(privateKey.buffer); + } + (self as unknown as Worker).postMessage(response, transfers); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const response: WorkerResponse = { ok: false, error: message }; + (self as unknown as Worker).postMessage(response); + } + })(); +});