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
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:
@@ -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';
|
||||
|
||||
@@ -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>();
|
||||
|
||||
513
packages/shade-crypto-web/src/worker-client.ts
Normal file
513
packages/shade-crypto-web/src/worker-client.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
165
packages/shade-crypto-web/src/worker-protocol.ts
Normal file
165
packages/shade-crypto-web/src/worker-protocol.ts
Normal 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);
|
||||
}
|
||||
217
packages/shade-crypto-web/src/worker-streams.ts
Normal file
217
packages/shade-crypto-web/src/worker-streams.ts
Normal 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;
|
||||
}
|
||||
231
packages/shade-crypto-web/src/worker.ts
Normal file
231
packages/shade-crypto-web/src/worker.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user