import type { CryptoProvider } from '@shade/core'; import { WORKER_PROTOCOL_VERSION, fromTransferable, toTransferableCopy, type WorkerRequest, type WorkerResponse, type WorkerResult, } from './worker-protocol.js'; /** Distributive omit of `id` from each variant of {@link WorkerRequest}. */ type WorkerRequestBody = WorkerRequest extends infer T ? T extends { id: number } ? Omit : never : never; /** * Minimal Worker shape we depend on. Lets the main-thread proxy work * against both browser `Worker` and Bun's `Worker` without dragging in * `lib.dom.d.ts`. */ export interface WorkerLike { postMessage(message: unknown, transfer?: ArrayBuffer[]): void; addEventListener( type: 'message', listener: (ev: { data: WorkerResponse }) => void, ): void; removeEventListener( type: 'message', listener: (ev: { data: WorkerResponse }) => void, ): void; addEventListener( type: 'error', listener: (ev: { message?: string; error?: unknown }) => void, ): void; terminate(): void; } export interface WorkerCryptoProviderOptions { /** * URL of the bundled worker entry. Required because every bundler * resolves worker URLs differently — supply yours and stop guessing. * * // Vite / Webpack 5 / Rspack: * workerUrl: new URL('@shade/crypto-web/worker', import.meta.url) */ workerUrl: URL | string; /** * How long the worker may stay idle before it self-terminates. Set to * `Infinity` to opt out (e.g. for SharedArrayBuffer / persistent UI). * Default: 30_000 ms — matches the V3.8 acceptance criterion. */ idleTimeoutMs?: number; /** * Override the worker factory. Useful in tests with `bun:test`'s * `Worker` global, or to inject a polyfill. */ spawn?: (url: URL | string) => WorkerLike; } /** * Public factory. Resolves once the worker has acknowledged the protocol * version handshake — so a stale bundle blows up here rather than in the * middle of an encrypt call. */ export async function createWorkerCryptoProvider( opts: WorkerCryptoProviderOptions, ): Promise { const provider = new WorkerCryptoProvider(opts); await provider.handshake(); return provider; } let SENDER_SEQ = 1; let RECEIVER_SEQ = 1; /** * `CryptoProvider` implementation that forwards every async call to a * dedicated Web Worker. Sync methods (`randomBytes`, `randomUint32`, * `constantTimeEqual`, `zeroize`) execute on the calling thread — they * are pure and instantaneous, so a worker round-trip would be silly. * * The worker is spawned on construction (lazy: see `createWorkerCryptoProvider`) * and terminated automatically after `idleTimeoutMs` of inactivity. * Subsequent calls re-spawn transparently. * * Stream sender/receiver state lives on the worker — the provider * exposes `createStreamSender` / `createStreamReceiver` factories that * return main-thread proxies (`WorkerStreamSender` / `WorkerStreamReceiver`). */ export class WorkerCryptoProvider implements CryptoProvider { private worker: WorkerLike | null = null; private nextRequestId = 1; private readonly inflight = new Map< number, { resolve: (r: WorkerResult) => void; reject: (e: Error) => void } >(); private idleTimer: ReturnType | null = null; private destroyed = false; private readonly idleTimeoutMs: number; constructor(private readonly opts: WorkerCryptoProviderOptions) { this.idleTimeoutMs = opts.idleTimeoutMs ?? 30_000; } // ─── lifecycle ─────────────────────────────────────────── /** Force-spawn + complete the protocol handshake. Idempotent. */ async handshake(): Promise { await this.ensureWorker(); await this.send({ method: 'init', protocolVersion: WORKER_PROTOCOL_VERSION }); } /** Permanently terminate the worker. After this, every call rejects. */ async destroy(): Promise { this.destroyed = true; this.terminateWorker(new Error('WorkerCryptoProvider destroyed')); } /** * Tear down the current worker and (lazily) spawn a fresh one. Use * after rotating identity keys so leaked-state worst case is bounded * by one rotation interval. */ async rotate(): Promise { this.terminateWorker(new Error('WorkerCryptoProvider rotated')); } // ─── async CryptoProvider methods ──────────────────────── async generateX25519KeyPair(): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }> { const r = await this.send({ method: 'crypto.generateX25519KeyPair' }); if (r.kind !== 'keypair') throw new Error('protocol: expected keypair'); return { publicKey: fromTransferable(r.publicKey), privateKey: fromTransferable(r.privateKey) }; } async x25519(privateKey: Uint8Array, publicKey: Uint8Array): Promise { const r = await this.send( { method: 'crypto.x25519', privateKey: toTransferableCopy(privateKey), publicKey: toTransferableCopy(publicKey), }, // No transferables from caller's owned buffers — we copied above. [], ); if (r.kind !== 'bytes') throw new Error('protocol: expected bytes'); return fromTransferable(r.bytes); } async generateEd25519KeyPair(): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }> { const r = await this.send({ method: 'crypto.generateEd25519KeyPair' }); if (r.kind !== 'keypair') throw new Error('protocol: expected keypair'); return { publicKey: fromTransferable(r.publicKey), privateKey: fromTransferable(r.privateKey) }; } async sign(privateKey: Uint8Array, message: Uint8Array): Promise { const r = await this.send({ method: 'crypto.sign', privateKey: toTransferableCopy(privateKey), message: toTransferableCopy(message), }); if (r.kind !== 'bytes') throw new Error('protocol: expected bytes'); return fromTransferable(r.bytes); } async verify( publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array, ): Promise { const r = await this.send({ method: 'crypto.verify', publicKey: toTransferableCopy(publicKey), message: toTransferableCopy(message), signature: toTransferableCopy(signature), }); if (r.kind !== 'verify') throw new Error('protocol: expected verify'); return r.valid; } async aesGcmEncrypt( key: Uint8Array, plaintext: Uint8Array, aad?: Uint8Array, ): Promise<{ ciphertext: Uint8Array; nonce: Uint8Array }> { const r = await this.send({ method: 'crypto.aesGcmEncrypt', key: toTransferableCopy(key), plaintext: toTransferableCopy(plaintext), aad: aad ? toTransferableCopy(aad) : null, }); if (r.kind !== 'aead-encrypt') throw new Error('protocol: expected aead-encrypt'); return { ciphertext: fromTransferable(r.ciphertext), nonce: fromTransferable(r.nonce), }; } async aesGcmDecrypt( key: Uint8Array, ciphertext: Uint8Array, nonce: Uint8Array, aad?: Uint8Array, ): Promise { const r = await this.send({ method: 'crypto.aesGcmDecrypt', key: toTransferableCopy(key), ciphertext: toTransferableCopy(ciphertext), nonce: toTransferableCopy(nonce), aad: aad ? toTransferableCopy(aad) : null, }); if (r.kind !== 'bytes') throw new Error('protocol: expected bytes'); return fromTransferable(r.bytes); } async hkdf( ikm: Uint8Array, salt: Uint8Array, info: Uint8Array, length: number, ): Promise { const r = await this.send({ method: 'crypto.hkdf', ikm: toTransferableCopy(ikm), salt: toTransferableCopy(salt), info: toTransferableCopy(info), length, }); if (r.kind !== 'bytes') throw new Error('protocol: expected bytes'); return fromTransferable(r.bytes); } async hmacSha256(key: Uint8Array, data: Uint8Array): Promise { const r = await this.send({ method: 'crypto.hmacSha256', key: toTransferableCopy(key), data: toTransferableCopy(data), }); if (r.kind !== 'bytes') throw new Error('protocol: expected bytes'); return fromTransferable(r.bytes); } // ─── sync — local execution (no round-trip) ────────────── randomBytes(length: number): Uint8Array { const buf = new Uint8Array(length); globalThis.crypto.getRandomValues(buf); return buf; } constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!; return diff === 0; } zeroize(buf: Uint8Array): void { buf.fill(0); } randomUint32(): number { const buf = this.randomBytes(4); return new DataView(buf.buffer, buf.byteOffset, 4).getUint32(0, false); } // ─── stream factories ──────────────────────────────────── async createStreamSender(opts: { streamId: Uint8Array; streamSecret: Uint8Array; laneId: number; startSeq?: number; }): Promise { const senderId = SENDER_SEQ++; await this.send({ method: 'stream.createSender', senderId, streamId: toTransferableCopy(opts.streamId), streamSecret: toTransferableCopy(opts.streamSecret), laneId: opts.laneId, startSeq: opts.startSeq ?? 0, }); return new WorkerStreamSender(this, senderId); } async createStreamReceiver(opts: { streamId: Uint8Array; streamSecret: Uint8Array; laneId: number; startSeq?: number; }): Promise { const receiverId = RECEIVER_SEQ++; await this.send({ method: 'stream.createReceiver', receiverId, streamId: toTransferableCopy(opts.streamId), streamSecret: toTransferableCopy(opts.streamSecret), laneId: opts.laneId, startSeq: opts.startSeq ?? 0, }); return new WorkerStreamReceiver(this, receiverId); } // ─── internals ─────────────────────────────────────────── /** @internal — used by `WorkerStreamSender` / `WorkerStreamReceiver`. */ async send( body: WorkerRequestBody, extraTransferables?: ArrayBuffer[], ): Promise { if (this.destroyed) throw new Error('WorkerCryptoProvider destroyed'); await this.ensureWorker(); const id = this.nextRequestId++; const req = { id, ...body } as WorkerRequest; // Auto-collect transferable buffers from the request payload — every // ArrayBuffer-typed field is fair game. const transferables = extraTransferables ?? collectArrayBuffers(req); return new Promise((resolve, reject) => { this.inflight.set(id, { resolve, reject }); try { this.worker!.postMessage(req, transferables); } catch (err) { this.inflight.delete(id); reject(err instanceof Error ? err : new Error(String(err))); return; } this.bumpIdleTimer(); }); } private async ensureWorker(): Promise { if (this.destroyed) throw new Error('WorkerCryptoProvider destroyed'); if (this.worker !== null) return; const spawn = this.opts.spawn ?? defaultSpawn; const w = spawn(this.opts.workerUrl); w.addEventListener('message', this.onMessage); w.addEventListener('error', this.onError); this.worker = w; } private readonly onMessage = (ev: { data: WorkerResponse }): void => { const res = ev.data; const pending = this.inflight.get(res.id); if (pending === undefined) return; this.inflight.delete(res.id); if (res.ok) pending.resolve(res.result); else { const err = new Error(res.error.message); err.name = res.error.name; pending.reject(err); } this.bumpIdleTimer(); }; private readonly onError = (ev: { message?: string; error?: unknown }): void => { const msg = ev.message ?? (ev.error instanceof Error ? ev.error.message : String(ev.error)); this.terminateWorker(new Error(`worker error: ${msg}`)); }; private bumpIdleTimer(): void { if (this.idleTimer !== null) clearTimeout(this.idleTimer); if (!isFinite(this.idleTimeoutMs)) return; if (this.inflight.size > 0) return; this.idleTimer = setTimeout(() => { // No outstanding work — recycle the worker. Calls after this // re-spawn lazily. this.terminateWorker(null); }, this.idleTimeoutMs); // Don't keep node-style event loops alive solely on this timer. const t = this.idleTimer as unknown as { unref?: () => void }; if (typeof t.unref === 'function') t.unref(); } private terminateWorker(reason: Error | null): void { if (this.idleTimer !== null) { clearTimeout(this.idleTimer); this.idleTimer = null; } const w = this.worker; this.worker = null; // Reject every in-flight request so callers don't hang. if (reason !== null) { for (const [, pending] of this.inflight) pending.reject(reason); } this.inflight.clear(); if (w !== null) { try { w.removeEventListener('message', this.onMessage); } catch { // ignore } try { w.terminate(); } catch { // ignore } } } } /** * Walk the request body, collecting every `ArrayBuffer` reference so we * can hand them to `postMessage(_, transfer)`. Cheap because the request * objects are flat — at most a handful of fields. */ function collectArrayBuffers(req: WorkerRequest): ArrayBuffer[] { const out: ArrayBuffer[] = []; for (const v of Object.values(req as Record)) { if (v instanceof ArrayBuffer) out.push(v); } return out; } function defaultSpawn(url: URL | string): WorkerLike { const ctor = (globalThis as unknown as { Worker?: new (u: URL | string, o?: unknown) => unknown }) .Worker; if (typeof ctor !== 'function') { throw new Error('Worker is not available in this runtime'); } return new ctor(url, { type: 'module' }) as WorkerLike; } /** * Main-thread handle on a `StreamSender` that lives in the worker. The * lane key never crosses thread boundaries — this proxy only ever ships * plaintext slices and receives wire bytes. */ export class WorkerStreamSender { private destroyed = false; constructor( private readonly provider: WorkerCryptoProvider, private readonly senderId: number, ) {} async encryptChunk( plaintext: Uint8Array, isLast: boolean, ): Promise<{ bytes: Uint8Array; seq: number }> { if (this.destroyed) throw new Error('WorkerStreamSender destroyed'); const r = await this.provider.send({ method: 'stream.encryptChunk', senderId: this.senderId, plaintext: toTransferableCopy(plaintext), isLast, }); if (r.kind !== 'chunk-encrypt') throw new Error('protocol: expected chunk-encrypt'); return { bytes: fromTransferable(r.bytes), seq: r.seq }; } async getLaneSha256(): Promise { const r = await this.provider.send({ method: 'stream.getSenderLaneSha256', senderId: this.senderId, }); if (r.kind !== 'bytes') throw new Error('protocol: expected bytes'); return fromTransferable(r.bytes); } async destroy(): Promise { if (this.destroyed) return; this.destroyed = true; await this.provider.send({ method: 'stream.destroySender', senderId: this.senderId }); } } export class WorkerStreamReceiver { private destroyed = false; constructor( private readonly provider: WorkerCryptoProvider, private readonly receiverId: number, ) {} async decryptChunk( wireBytes: Uint8Array, ): Promise<{ plaintext: Uint8Array; seq: number; isLast: boolean }> { if (this.destroyed) throw new Error('WorkerStreamReceiver destroyed'); const r = await this.provider.send({ method: 'stream.decryptChunk', receiverId: this.receiverId, wireBytes: toTransferableCopy(wireBytes), }); if (r.kind !== 'chunk-decrypt') throw new Error('protocol: expected chunk-decrypt'); return { plaintext: fromTransferable(r.plaintext), seq: r.seq, isLast: r.isLast, }; } async getLaneSha256(): Promise { const r = await this.provider.send({ method: 'stream.getReceiverLaneSha256', receiverId: this.receiverId, }); if (r.kind !== 'bytes') throw new Error('protocol: expected bytes'); return fromTransferable(r.bytes); } async destroy(): Promise { if (this.destroyed) return; this.destroyed = true; await this.provider.send({ method: 'stream.destroyReceiver', receiverId: this.receiverId }); } }