perf(P6B.T6): offload Argon2 + userKey unseal to Web Worker

PIN-unlock used to freeze the renderer for ~1-2 s on mid-hardware while
the moderate-preset Argon2id KDF + sealed-key secretbox open ran on the
main thread. Push that work into a Vite-bundled ESM Web Worker so the
unlock screen stays responsive.

The worker (apps/desktop/src/workers/crypto.worker.ts) bundles its own
libsodium-wrappers-sumo instance and registers a fresh CryptoBackend
inside the worker realm. Client wrapper (apps/desktop/src/lib/cryptoWorker.ts)
spawns a one-shot worker per unlock — workers are cheap, PIN-unlock is
once-per-session, and one-shot avoids the request-id bookkeeping that the
existing decrypt.worker needs for high-volume per-message decrypts.

Falls back to inline main-thread openUserKey when the Worker constructor
is unavailable (vitest's jsdom) or when worker spawn / round-trip fails
(strict CSP). All 14 desktop + 71 shared tests still pass — the existing
loadOrUnlockUserKey test exercises the inline-fallback branch.

Private key bytes are transferred (zero-copy) back to the main thread,
detaching the worker-side ArrayBuffer view on transfer.

Build emits crypto.worker-<hash>.js (~2.5 MB, mostly libsodium WASM glue
duplicated from the main bundle). Acceptable trade-off for the unblocked
UI; a future change could lazy-load libsodium on the main thread to drop
the duplication.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-05-17 00:05:00 +02:00
parent 6c6828006b
commit db59e3f658
3 changed files with 154 additions and 2 deletions
+73
View File
@@ -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<OpenUserKeyResult> {
const worker = new Worker(
new URL('../workers/crypto.worker.ts', import.meta.url),
{ type: 'module' },
);
try {
return await new Promise<OpenUserKeyResult>((resolve, reject) => {
worker.addEventListener('message', (ev: MessageEvent<WorkerResponse>) => {
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<Uint8Array> {
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);
}
}
+3 -2
View File
@@ -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<UnlockOutcom
if (!sealed || !salt) throw new Error('no recovery blob configured');
let priv: Uint8Array;
try {
priv = await openUserKey({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
priv = await openUserKeyMaybeWorker({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
} catch (err) {
await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {});
throw err;
+78
View File
@@ -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.
/// <reference lib="webworker" />
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<void> | null = null;
async function ensureBackend(): Promise<void> {
if (!backendReady) {
backendReady = (async () => {
await sodium.ready;
setCryptoBackend(await createLibsodiumBackend());
})();
}
return backendReady;
}
self.addEventListener('message', (ev: MessageEvent<WorkerRequest>) => {
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);
}
})();
});