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