release(v4.0.0): Shade GA — V3.x consolidation + audit prep
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

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>
This commit is contained in:
2026-05-03 18:35:35 +02:00
parent 8b055912b7
commit e6fdf31b49
298 changed files with 37909 additions and 256 deletions

View File

@@ -1,2 +1,24 @@
export { SubtleCryptoProvider } from './provider.js';
export { MemoryStorage } from './memory-storage.js';
// ─── Web Workers crypto (V3.8) ────────────────────────────
export {
createWorkerCryptoProvider,
WorkerCryptoProvider,
WorkerStreamSender,
WorkerStreamReceiver,
} from './worker-client.js';
export type {
WorkerCryptoProviderOptions,
WorkerLike,
} from './worker-client.js';
export {
createEncryptStream,
createDecryptStream,
DEFAULT_STREAM_CHUNK_SIZE,
} from './worker-streams.js';
export type {
CreateEncryptStreamOptions,
CreateDecryptStreamOptions,
} from './worker-streams.js';
export { WORKER_PROTOCOL_VERSION } from './worker-protocol.js';

View File

@@ -1,4 +1,4 @@
import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState, RetiredIdentity, PersistedStreamState } from '@shade/core';
import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState, RetiredIdentity, PersistedStreamState, PeerVerification } from '@shade/core';
import { constantTimeEqual } from '@shade/core';
/**
@@ -104,6 +104,34 @@ export class MemoryStorage implements StorageProvider {
this.retiredIdentities = this.retiredIdentities.filter((r) => r.retiredAt >= olderThan);
}
// ─── Peer verifications (V3.3) ────────────────────────────
private peerVerifications = new Map<string, PeerVerification>();
private peerIdentityVersions = new Map<string, number>();
async savePeerVerification(v: PeerVerification): Promise<void> {
this.peerVerifications.set(v.peerAddress, { ...v });
}
async getPeerVerification(address: string): Promise<PeerVerification | null> {
const v = this.peerVerifications.get(address);
return v ? { ...v } : null;
}
async removePeerVerification(address: string): Promise<void> {
this.peerVerifications.delete(address);
}
async getPeerIdentityVersion(address: string): Promise<number> {
return this.peerIdentityVersions.get(address) ?? 1;
}
async bumpPeerIdentityVersion(address: string): Promise<number> {
const next = (this.peerIdentityVersions.get(address) ?? 1) + 1;
this.peerIdentityVersions.set(address, next);
return next;
}
// ─── Stream-transfer resume state (v0.2.0) ────────────────
private streamStates = new Map<string, PersistedStreamState>();

View File

@@ -0,0 +1,513 @@
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 });
}
}

View File

@@ -0,0 +1,165 @@
/**
* Wire protocol between `WorkerCryptoProvider` (main thread) and the
* worker entry (`worker.ts`).
*
* Shape:
* main → worker: WorkerRequest
* worker → main: WorkerResponse (matched by `id`)
*
* Binary inputs are passed as `ArrayBuffer` (transferable) — never
* `Uint8Array` — so we can hand them off without copying. The worker
* wraps them in `Uint8Array` for use, and the response transfers result
* buffers the same way.
*
* Versioning: `__protocolVersion` is bumped on any breaking change.
* `worker-init` echoes the version so a mismatched bundle aborts
* deterministically instead of silently producing garbage.
*/
export const WORKER_PROTOCOL_VERSION = 1;
export type WorkerRequest =
// ─── lifecycle ────────────────────────────────────────────
| { id: number; method: 'init'; protocolVersion: number }
| { id: number; method: 'ping' }
// ─── crypto.* — generic CryptoProvider ────────────────────
| { id: number; method: 'crypto.generateX25519KeyPair' }
| { id: number; method: 'crypto.x25519'; privateKey: ArrayBuffer; publicKey: ArrayBuffer }
| { id: number; method: 'crypto.generateEd25519KeyPair' }
| { id: number; method: 'crypto.sign'; privateKey: ArrayBuffer; message: ArrayBuffer }
| {
id: number;
method: 'crypto.verify';
publicKey: ArrayBuffer;
message: ArrayBuffer;
signature: ArrayBuffer;
}
| {
id: number;
method: 'crypto.aesGcmEncrypt';
key: ArrayBuffer;
plaintext: ArrayBuffer;
aad: ArrayBuffer | null;
}
| {
id: number;
method: 'crypto.aesGcmDecrypt';
key: ArrayBuffer;
ciphertext: ArrayBuffer;
nonce: ArrayBuffer;
aad: ArrayBuffer | null;
}
| {
id: number;
method: 'crypto.hkdf';
ikm: ArrayBuffer;
salt: ArrayBuffer;
info: ArrayBuffer;
length: number;
}
| { id: number; method: 'crypto.hmacSha256'; key: ArrayBuffer; data: ArrayBuffer }
// ─── stream.* — host StreamSender / StreamReceiver ────────
| {
id: number;
method: 'stream.createSender';
senderId: number;
streamId: ArrayBuffer;
streamSecret: ArrayBuffer;
laneId: number;
startSeq: number;
}
| {
id: number;
method: 'stream.encryptChunk';
senderId: number;
plaintext: ArrayBuffer;
isLast: boolean;
}
| { id: number; method: 'stream.getSenderLaneSha256'; senderId: number }
| { id: number; method: 'stream.destroySender'; senderId: number }
| {
id: number;
method: 'stream.createReceiver';
receiverId: number;
streamId: ArrayBuffer;
streamSecret: ArrayBuffer;
laneId: number;
startSeq: number;
}
| {
id: number;
method: 'stream.decryptChunk';
receiverId: number;
wireBytes: ArrayBuffer;
}
| { id: number; method: 'stream.getReceiverLaneSha256'; receiverId: number }
| { id: number; method: 'stream.destroyReceiver'; receiverId: number };
export type WorkerResponse =
| { id: number; ok: true; result: WorkerResult }
| {
id: number;
ok: false;
error: { name: string; message: string; code?: string };
};
export type WorkerResult =
| { kind: 'ack' } // void/init/destroy
| { kind: 'pong' }
| { kind: 'keypair'; publicKey: ArrayBuffer; privateKey: ArrayBuffer }
| { kind: 'bytes'; bytes: ArrayBuffer }
| { kind: 'aead-encrypt'; ciphertext: ArrayBuffer; nonce: ArrayBuffer }
| { kind: 'verify'; valid: boolean }
| { kind: 'chunk-encrypt'; bytes: ArrayBuffer; seq: number }
| { kind: 'chunk-decrypt'; plaintext: ArrayBuffer; seq: number; isLast: boolean };
/**
* Pull every transferable `ArrayBuffer` out of a result so the runtime
* can hand ownership to the receiving thread. Order doesn't matter; the
* structured-clone algorithm matches buffers by reference.
*/
export function transferablesOf(result: WorkerResult): ArrayBuffer[] {
switch (result.kind) {
case 'keypair':
return [result.publicKey, result.privateKey];
case 'bytes':
return [result.bytes];
case 'aead-encrypt':
return [result.ciphertext, result.nonce];
case 'chunk-encrypt':
return [result.bytes];
case 'chunk-decrypt':
return [result.plaintext];
default:
return [];
}
}
/**
* Wrap a `Uint8Array` as an `ArrayBuffer` suitable for transfer. If the
* view doesn't span its underlying buffer, copy into a fresh one so we
* never transfer slack the caller still owns.
*/
export function toTransferable(u: Uint8Array): ArrayBuffer {
if (u.byteOffset === 0 && u.byteLength === u.buffer.byteLength) {
return u.buffer as ArrayBuffer;
}
const copy = new Uint8Array(u.byteLength);
copy.set(u);
return copy.buffer;
}
/**
* Like `toTransferable`, but always copies. Use when the original buffer
* must remain valid on the calling thread (e.g. when the caller owns a
* key the worker should not mutate).
*/
export function toTransferableCopy(u: Uint8Array): ArrayBuffer {
const copy = new Uint8Array(u.byteLength);
copy.set(u);
return copy.buffer;
}
export function fromTransferable(buf: ArrayBuffer): Uint8Array {
return new Uint8Array(buf);
}

View File

@@ -0,0 +1,217 @@
import type {
WorkerCryptoProvider,
WorkerStreamReceiver,
WorkerStreamSender,
} from './worker-client.js';
/** Default plaintext chunk size — 256 KiB. Matches `@shade/transfer`. */
export const DEFAULT_STREAM_CHUNK_SIZE = 256 * 1024;
export interface CreateEncryptStreamOptions {
provider: WorkerCryptoProvider;
streamId: Uint8Array;
streamSecret: Uint8Array;
laneId?: number;
/**
* Plaintext bytes per AEAD chunk. Smaller = lower latency per chunk +
* more postMessage overhead; larger = higher per-chunk RAM in the
* worker. Default 256 KiB.
*/
chunkSize?: number;
/**
* First sequence number this sender will emit. Default 0.
* Use for resume.
*/
startSeq?: number;
/** First seq this receiver will accept; defaults to 0. */
signal?: AbortSignal;
}
export interface CreateDecryptStreamOptions {
provider: WorkerCryptoProvider;
streamId: Uint8Array;
streamSecret: Uint8Array;
laneId?: number;
startSeq?: number;
signal?: AbortSignal;
}
/**
* Build a `TransformStream<Uint8Array, Uint8Array>` that encrypts every
* passing byte as a stream-chunk wire envelope. The actual AEAD work
* happens in the worker — the main thread only buffers, slices, and
* forwards.
*
* Output: one wire chunk per `enqueue`. Concatenation is the responsibility
* of the downstream consumer (typically an HTTP-shipping `TransformStream`).
*/
export function createEncryptStream(opts: CreateEncryptStreamOptions): {
stream: TransformStream<Uint8Array, Uint8Array>;
/** Promise that resolves to the final lane sha256 once the stream finishes. */
laneSha256: Promise<Uint8Array>;
} {
const chunkSize = opts.chunkSize ?? DEFAULT_STREAM_CHUNK_SIZE;
if (chunkSize <= 0) throw new Error('chunkSize must be positive');
// Plaintext slices accumulate here until we have at least `chunkSize`
// bytes (so we emit fixed-size chunks except for the very last one).
let pending: Uint8Array = new Uint8Array(0);
let sender: WorkerStreamSender | null = null;
let resolveLaneSha: (b: Uint8Array) => void;
let rejectLaneSha: (e: Error) => void;
const laneSha256 = new Promise<Uint8Array>((res, rej) => {
resolveLaneSha = res;
rejectLaneSha = rej;
});
// Cast to `Transformer<I,O>` because some TS lib versions still ship
// the pre-2023 shape without `cancel`. Runtime supports it (Bun, all
// modern browsers).
const transformer = {
async start(): Promise<void> {
sender = await opts.provider.createStreamSender({
streamId: opts.streamId,
streamSecret: opts.streamSecret,
laneId: opts.laneId ?? 0,
startSeq: opts.startSeq ?? 0,
});
},
async transform(
chunk: Uint8Array,
controller: TransformStreamDefaultController<Uint8Array>,
): Promise<void> {
if (sender === null) throw new Error('encryptStream: sender not initialized');
if (chunk.byteLength === 0) return;
pending = concat(pending, chunk);
// Emit complete chunks. Hold back the trailing partial — we don't
// know yet whether it's the last one (which gets isLast=true).
while (pending.byteLength >= chunkSize) {
const slice = pending.subarray(0, chunkSize);
const rest = pending.subarray(chunkSize);
const out = await sender.encryptChunk(slice, false);
controller.enqueue(out.bytes);
// Detach `rest` from the larger backing buffer so it can be GCed.
pending = new Uint8Array(rest);
}
},
async flush(controller: TransformStreamDefaultController<Uint8Array>): Promise<void> {
if (sender === null) throw new Error('encryptStream: sender not initialized');
try {
// Always emit a final chunk with isLast=true. Even if `pending`
// is empty: receivers rely on a trailing isLast envelope to
// mark stream completion.
const out = await sender.encryptChunk(pending, true);
controller.enqueue(out.bytes);
pending = new Uint8Array(0);
const sha = await sender.getLaneSha256();
resolveLaneSha(sha);
} catch (err) {
rejectLaneSha(err instanceof Error ? err : new Error(String(err)));
throw err;
} finally {
await sender.destroy();
sender = null;
}
},
async cancel(reason: unknown): Promise<void> {
try {
if (sender !== null) await sender.destroy();
} finally {
sender = null;
rejectLaneSha(reason instanceof Error ? reason : new Error(String(reason)));
}
},
};
const stream = new TransformStream<Uint8Array, Uint8Array>(
transformer as unknown as Transformer<Uint8Array, Uint8Array>,
);
if (opts.signal) {
const abort = (): void => {
stream.writable.abort(opts.signal!.reason).catch(() => {});
};
if (opts.signal.aborted) abort();
else opts.signal.addEventListener('abort', abort, { once: true });
}
return { stream, laneSha256 };
}
/**
* Build a `TransformStream<Uint8Array, Uint8Array>` that decrypts wire
* stream-chunk envelopes back into plaintext. The input chunks must be
* complete envelopes — the caller is responsible for framing on the wire
* (one envelope per write).
*/
export function createDecryptStream(opts: CreateDecryptStreamOptions): {
stream: TransformStream<Uint8Array, Uint8Array>;
/** Promise that resolves to the final lane sha256 once decryption finishes. */
laneSha256: Promise<Uint8Array>;
} {
let receiver: WorkerStreamReceiver | null = null;
let resolveLaneSha: (b: Uint8Array) => void;
let rejectLaneSha: (e: Error) => void;
const laneSha256 = new Promise<Uint8Array>((res, rej) => {
resolveLaneSha = res;
rejectLaneSha = rej;
});
const transformer = {
async start(): Promise<void> {
receiver = await opts.provider.createStreamReceiver({
streamId: opts.streamId,
streamSecret: opts.streamSecret,
laneId: opts.laneId ?? 0,
startSeq: opts.startSeq ?? 0,
});
},
async transform(
chunk: Uint8Array,
controller: TransformStreamDefaultController<Uint8Array>,
): Promise<void> {
if (receiver === null) throw new Error('decryptStream: receiver not initialized');
const dec = await receiver.decryptChunk(chunk);
if (dec.plaintext.byteLength > 0) controller.enqueue(dec.plaintext);
if (dec.isLast) {
const sha = await receiver.getLaneSha256();
resolveLaneSha(sha);
}
},
async flush(): Promise<void> {
if (receiver !== null) await receiver.destroy();
receiver = null;
},
async cancel(reason: unknown): Promise<void> {
try {
if (receiver !== null) await receiver.destroy();
} finally {
receiver = null;
rejectLaneSha(reason instanceof Error ? reason : new Error(String(reason)));
}
},
};
const stream = new TransformStream<Uint8Array, Uint8Array>(
transformer as unknown as Transformer<Uint8Array, Uint8Array>,
);
if (opts.signal) {
const abort = (): void => {
stream.writable.abort(opts.signal!.reason).catch(() => {});
};
if (opts.signal.aborted) abort();
else opts.signal.addEventListener('abort', abort, { once: true });
}
return { stream, laneSha256 };
}
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
if (a.byteLength === 0) return b;
if (b.byteLength === 0) return a;
const out = new Uint8Array(a.byteLength + b.byteLength);
out.set(a, 0);
out.set(b, a.byteLength);
return out;
}

View File

@@ -0,0 +1,231 @@
/**
* Dedicated Web Worker entry. Bundle this as a module worker:
*
* // Vite / modern Webpack / Rspack
* const w = new Worker(new URL('@shade/crypto-web/worker', import.meta.url),
* { type: 'module' });
*
* The main thread talks to this worker via the protocol in
* `worker-protocol.ts`. All heavy crypto (AES-GCM, HKDF, HMAC, X25519,
* Ed25519) and stream state (per-lane keys + seq counters + running
* sha256) live here so the main thread is never blocked.
*
* Lifecycle: every request is one `postMessage` round-trip. Sender /
* receiver state is keyed by numeric ids handed in by the main thread —
* the worker never invents ids. `destroy*` calls zeroize lane keys.
*/
import { StreamSender, StreamReceiver } from '@shade/streams';
import { SubtleCryptoProvider } from './provider.js';
import {
WORKER_PROTOCOL_VERSION,
fromTransferable,
toTransferable,
transferablesOf,
type WorkerRequest,
type WorkerResponse,
type WorkerResult,
} from './worker-protocol.js';
interface DedicatedWorkerScope {
postMessage(data: unknown, transfer?: ArrayBuffer[]): void;
addEventListener(
type: 'message',
listener: (ev: { data: WorkerRequest }) => void,
): void;
}
const scope = globalThis as unknown as DedicatedWorkerScope;
const provider = new SubtleCryptoProvider();
const senders = new Map<number, StreamSender>();
const receivers = new Map<number, StreamReceiver>();
scope.addEventListener('message', (ev) => {
void handle(ev.data);
});
async function handle(req: WorkerRequest): Promise<void> {
try {
const result = await dispatch(req);
const transfer = transferablesOf(result);
const res: WorkerResponse = { id: req.id, ok: true, result };
scope.postMessage(res, transfer);
} catch (err) {
const error = err instanceof Error
? { name: err.name, message: err.message }
: { name: 'Error', message: String(err) };
const res: WorkerResponse = { id: req.id, ok: false, error };
scope.postMessage(res);
}
}
async function dispatch(req: WorkerRequest): Promise<WorkerResult> {
switch (req.method) {
case 'init': {
if (req.protocolVersion !== WORKER_PROTOCOL_VERSION) {
throw new Error(
`worker protocol version mismatch: main=${req.protocolVersion} worker=${WORKER_PROTOCOL_VERSION}`,
);
}
return { kind: 'ack' };
}
case 'ping':
return { kind: 'pong' };
// ─── crypto.* ─────────────────────────────────────────
case 'crypto.generateX25519KeyPair': {
const kp = await provider.generateX25519KeyPair();
return {
kind: 'keypair',
publicKey: toTransferable(kp.publicKey),
privateKey: toTransferable(kp.privateKey),
};
}
case 'crypto.x25519': {
const out = await provider.x25519(
fromTransferable(req.privateKey),
fromTransferable(req.publicKey),
);
return { kind: 'bytes', bytes: toTransferable(out) };
}
case 'crypto.generateEd25519KeyPair': {
const kp = await provider.generateEd25519KeyPair();
return {
kind: 'keypair',
publicKey: toTransferable(kp.publicKey),
privateKey: toTransferable(kp.privateKey),
};
}
case 'crypto.sign': {
const sig = await provider.sign(
fromTransferable(req.privateKey),
fromTransferable(req.message),
);
return { kind: 'bytes', bytes: toTransferable(sig) };
}
case 'crypto.verify': {
const valid = await provider.verify(
fromTransferable(req.publicKey),
fromTransferable(req.message),
fromTransferable(req.signature),
);
return { kind: 'verify', valid };
}
case 'crypto.aesGcmEncrypt': {
const out = await provider.aesGcmEncrypt(
fromTransferable(req.key),
fromTransferable(req.plaintext),
req.aad ? fromTransferable(req.aad) : undefined,
);
return {
kind: 'aead-encrypt',
ciphertext: toTransferable(out.ciphertext),
nonce: toTransferable(out.nonce),
};
}
case 'crypto.aesGcmDecrypt': {
const out = await provider.aesGcmDecrypt(
fromTransferable(req.key),
fromTransferable(req.ciphertext),
fromTransferable(req.nonce),
req.aad ? fromTransferable(req.aad) : undefined,
);
return { kind: 'bytes', bytes: toTransferable(out) };
}
case 'crypto.hkdf': {
const out = await provider.hkdf(
fromTransferable(req.ikm),
fromTransferable(req.salt),
fromTransferable(req.info),
req.length,
);
return { kind: 'bytes', bytes: toTransferable(out) };
}
case 'crypto.hmacSha256': {
const out = await provider.hmacSha256(
fromTransferable(req.key),
fromTransferable(req.data),
);
return { kind: 'bytes', bytes: toTransferable(out) };
}
// ─── stream.* (sender) ────────────────────────────────
case 'stream.createSender': {
if (senders.has(req.senderId)) {
throw new Error(`senderId ${req.senderId} already exists`);
}
const sender = await StreamSender.create({
crypto: provider,
streamId: fromTransferable(req.streamId),
streamSecret: fromTransferable(req.streamSecret),
laneId: req.laneId,
startSeq: req.startSeq,
});
senders.set(req.senderId, sender);
return { kind: 'ack' };
}
case 'stream.encryptChunk': {
const sender = senders.get(req.senderId);
if (sender === undefined) throw new Error(`unknown senderId ${req.senderId}`);
const chunk = await sender.encryptChunk(fromTransferable(req.plaintext), req.isLast);
return {
kind: 'chunk-encrypt',
bytes: toTransferable(chunk.bytes),
seq: chunk.seq,
};
}
case 'stream.getSenderLaneSha256': {
const sender = senders.get(req.senderId);
if (sender === undefined) throw new Error(`unknown senderId ${req.senderId}`);
return { kind: 'bytes', bytes: toTransferable(sender.getLaneSha256Digest()) };
}
case 'stream.destroySender': {
const sender = senders.get(req.senderId);
if (sender !== undefined) {
sender.destroy();
senders.delete(req.senderId);
}
return { kind: 'ack' };
}
// ─── stream.* (receiver) ──────────────────────────────
case 'stream.createReceiver': {
if (receivers.has(req.receiverId)) {
throw new Error(`receiverId ${req.receiverId} already exists`);
}
const receiver = await StreamReceiver.create({
crypto: provider,
streamId: fromTransferable(req.streamId),
streamSecret: fromTransferable(req.streamSecret),
laneId: req.laneId,
startSeq: req.startSeq,
});
receivers.set(req.receiverId, receiver);
return { kind: 'ack' };
}
case 'stream.decryptChunk': {
const receiver = receivers.get(req.receiverId);
if (receiver === undefined) throw new Error(`unknown receiverId ${req.receiverId}`);
const dec = await receiver.decryptChunk(fromTransferable(req.wireBytes));
return {
kind: 'chunk-decrypt',
plaintext: toTransferable(dec.plaintext),
seq: dec.seq,
isLast: dec.isLast,
};
}
case 'stream.getReceiverLaneSha256': {
const receiver = receivers.get(req.receiverId);
if (receiver === undefined) throw new Error(`unknown receiverId ${req.receiverId}`);
return { kind: 'bytes', bytes: toTransferable(receiver.getLaneSha256Digest()) };
}
case 'stream.destroyReceiver': {
const receiver = receivers.get(req.receiverId);
if (receiver !== undefined) {
receiver.destroy();
receivers.delete(req.receiverId);
}
return { kind: 'ack' };
}
}
}