Files
Shade/packages/shade-crypto-web/src/worker-client.ts
Sterister e6fdf31b49
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.

Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
  Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
  bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
  shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
  non-realtime stack.

Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
  version 4.0.0 on /health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00

514 lines
17 KiB
TypeScript

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<T, 'id'>
: 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<WorkerCryptoProvider> {
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<typeof setTimeout> | 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<void> {
await this.ensureWorker();
await this.send({ method: 'init', protocolVersion: WORKER_PROTOCOL_VERSION });
}
/** Permanently terminate the worker. After this, every call rejects. */
async destroy(): Promise<void> {
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<void> {
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<Uint8Array> {
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<Uint8Array> {
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<boolean> {
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<Uint8Array> {
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<Uint8Array> {
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<Uint8Array> {
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<WorkerStreamSender> {
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<WorkerStreamReceiver> {
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<WorkerResult> {
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<WorkerResult>((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<void> {
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<string, unknown>)) {
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<Uint8Array> {
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<void> {
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<Uint8Array> {
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<void> {
if (this.destroyed) return;
this.destroyed = true;
await this.provider.send({ method: 'stream.destroyReceiver', receiverId: this.receiverId });
}
}