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;