db59e3f658
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>
74 lines
2.8 KiB
TypeScript
74 lines
2.8 KiB
TypeScript
// 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);
|
|
}
|
|
}
|