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>
514 lines
17 KiB
TypeScript
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 });
|
|
}
|
|
}
|
|
|