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,12 +1,27 @@
|
||||
{
|
||||
"name": "@shade/crypto-web",
|
||||
"version": "0.3.0",
|
||||
"version": "4.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./worker": {
|
||||
"types": "./src/worker.ts",
|
||||
"default": "./src/worker.ts"
|
||||
},
|
||||
"./worker-protocol": {
|
||||
"types": "./src/worker-protocol.ts",
|
||||
"default": "./src/worker-protocol.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@shade/core": "workspace:*"
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/streams": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
218
packages/shade-crypto-web/tests/worker-provider.test.ts
Normal file
218
packages/shade-crypto-web/tests/worker-provider.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { describe, expect, test, afterEach } from 'bun:test';
|
||||
import {
|
||||
createWorkerCryptoProvider,
|
||||
SubtleCryptoProvider,
|
||||
WorkerCryptoProvider,
|
||||
} from '../src/index.js';
|
||||
|
||||
const WORKER_URL = new URL('../src/worker.ts', import.meta.url);
|
||||
|
||||
const subtle = new SubtleCryptoProvider();
|
||||
let provider: WorkerCryptoProvider | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (provider) {
|
||||
await provider.destroy();
|
||||
provider = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function makeProvider(idleTimeoutMs = 30_000): Promise<WorkerCryptoProvider> {
|
||||
provider = await createWorkerCryptoProvider({
|
||||
workerUrl: WORKER_URL,
|
||||
idleTimeoutMs,
|
||||
});
|
||||
return provider;
|
||||
}
|
||||
|
||||
describe('WorkerCryptoProvider — roundtrip and parity', () => {
|
||||
test('handshake completes', async () => {
|
||||
const p = await makeProvider();
|
||||
expect(p).toBeInstanceOf(WorkerCryptoProvider);
|
||||
});
|
||||
|
||||
test('AES-GCM encrypt → worker, decrypt locally — produces same plaintext', async () => {
|
||||
const p = await makeProvider();
|
||||
const key = subtle.randomBytes(32);
|
||||
const plaintext = new TextEncoder().encode('hello shade workers — large enough payload');
|
||||
|
||||
const enc = await p.aesGcmEncrypt(key, plaintext);
|
||||
expect(enc.nonce.length).toBe(12);
|
||||
|
||||
// Decrypt with the local SubtleCryptoProvider — proves wire compatibility
|
||||
const dec = await subtle.aesGcmDecrypt(key, enc.ciphertext, enc.nonce);
|
||||
expect(dec).toEqual(plaintext);
|
||||
});
|
||||
|
||||
test('AES-GCM with AAD round-trips through worker', async () => {
|
||||
const p = await makeProvider();
|
||||
const key = subtle.randomBytes(32);
|
||||
const plaintext = subtle.randomBytes(1024);
|
||||
const aad = subtle.randomBytes(16);
|
||||
|
||||
const enc = await p.aesGcmEncrypt(key, plaintext, aad);
|
||||
const dec = await p.aesGcmDecrypt(key, enc.ciphertext, enc.nonce, aad);
|
||||
expect(dec).toEqual(plaintext);
|
||||
});
|
||||
|
||||
test('AES-GCM decrypt rejects tampered ciphertext', async () => {
|
||||
const p = await makeProvider();
|
||||
const key = subtle.randomBytes(32);
|
||||
const plaintext = new TextEncoder().encode('untampered');
|
||||
const enc = await p.aesGcmEncrypt(key, plaintext);
|
||||
enc.ciphertext[0]! ^= 0x01;
|
||||
await expect(p.aesGcmDecrypt(key, enc.ciphertext, enc.nonce)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('HKDF parity with SubtleCryptoProvider', async () => {
|
||||
const p = await makeProvider();
|
||||
const ikm = subtle.randomBytes(32);
|
||||
const salt = subtle.randomBytes(16);
|
||||
const info = new TextEncoder().encode('test info');
|
||||
|
||||
const a = await p.hkdf(ikm, salt, info, 64);
|
||||
const b = await subtle.hkdf(ikm, salt, info, 64);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
test('HMAC-SHA256 parity with SubtleCryptoProvider', async () => {
|
||||
const p = await makeProvider();
|
||||
const key = subtle.randomBytes(32);
|
||||
const data = subtle.randomBytes(256);
|
||||
|
||||
const a = await p.hmacSha256(key, data);
|
||||
const b = await subtle.hmacSha256(key, data);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
test('X25519 DH agrees with SubtleCryptoProvider', async () => {
|
||||
const p = await makeProvider();
|
||||
const alice = await p.generateX25519KeyPair();
|
||||
const bob = await subtle.generateX25519KeyPair();
|
||||
|
||||
const ab = await p.x25519(alice.privateKey, bob.publicKey);
|
||||
const ba = await subtle.x25519(bob.privateKey, alice.publicKey);
|
||||
expect(ab).toEqual(ba);
|
||||
});
|
||||
|
||||
test('Ed25519 sign in worker, verify locally', async () => {
|
||||
const p = await makeProvider();
|
||||
const kp = await p.generateEd25519KeyPair();
|
||||
const msg = new TextEncoder().encode('please sign me');
|
||||
|
||||
const sig = await p.sign(kp.privateKey, msg);
|
||||
expect(await subtle.verify(kp.publicKey, msg, sig)).toBe(true);
|
||||
});
|
||||
|
||||
test('Ed25519 verify rejects tampered signature', async () => {
|
||||
const p = await makeProvider();
|
||||
const kp = await subtle.generateEd25519KeyPair();
|
||||
const msg = new TextEncoder().encode('msg');
|
||||
const sig = await subtle.sign(kp.privateKey, msg);
|
||||
sig[0]! ^= 0x01;
|
||||
expect(await p.verify(kp.publicKey, msg, sig)).toBe(false);
|
||||
});
|
||||
|
||||
test('local sync helpers do not round-trip', async () => {
|
||||
const p = await makeProvider();
|
||||
const a = p.randomBytes(16);
|
||||
expect(a.length).toBe(16);
|
||||
expect(p.constantTimeEqual(a, a)).toBe(true);
|
||||
expect(p.constantTimeEqual(a, new Uint8Array(16))).toBe(false);
|
||||
expect(typeof p.randomUint32()).toBe('number');
|
||||
});
|
||||
|
||||
test('errors from worker propagate as rejected promises', async () => {
|
||||
const p = await makeProvider();
|
||||
const wrongKey = subtle.randomBytes(32);
|
||||
const ct = subtle.randomBytes(48);
|
||||
const nonce = subtle.randomBytes(12);
|
||||
await expect(p.aesGcmDecrypt(wrongKey, ct, nonce)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('parallel calls do not interleave incorrectly', async () => {
|
||||
const p = await makeProvider();
|
||||
const key = subtle.randomBytes(32);
|
||||
|
||||
const inputs = Array.from({ length: 16 }, (_, i) =>
|
||||
new TextEncoder().encode(`payload-${i}-${'x'.repeat(50 * i)}`),
|
||||
);
|
||||
|
||||
const encs = await Promise.all(inputs.map((pt) => p.aesGcmEncrypt(key, pt)));
|
||||
const decs = await Promise.all(
|
||||
encs.map((e) => p.aesGcmDecrypt(key, e.ciphertext, e.nonce)),
|
||||
);
|
||||
decs.forEach((d, i) => expect(d).toEqual(inputs[i]!));
|
||||
});
|
||||
|
||||
test('after destroy(), calls reject', async () => {
|
||||
const p = await makeProvider();
|
||||
await p.destroy();
|
||||
await expect(p.aesGcmEncrypt(subtle.randomBytes(32), new Uint8Array(8))).rejects.toThrow(
|
||||
/destroyed/,
|
||||
);
|
||||
provider = null;
|
||||
});
|
||||
|
||||
test('rotate() respawns transparently', async () => {
|
||||
const p = await makeProvider();
|
||||
const key = subtle.randomBytes(32);
|
||||
await p.aesGcmEncrypt(key, new Uint8Array(8));
|
||||
await p.rotate();
|
||||
const out = await p.aesGcmEncrypt(key, new TextEncoder().encode('still works'));
|
||||
const dec = await subtle.aesGcmDecrypt(key, out.ciphertext, out.nonce);
|
||||
expect(new TextDecoder().decode(dec)).toBe('still works');
|
||||
});
|
||||
|
||||
test('idle-timeout terminates worker but next call respawns', async () => {
|
||||
const p = await makeProvider(120);
|
||||
const key = subtle.randomBytes(32);
|
||||
await p.aesGcmEncrypt(key, new Uint8Array(8));
|
||||
// Wait for the idle timer to fire.
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
// Next call should still succeed — proves respawn works.
|
||||
const out = await p.aesGcmEncrypt(key, new TextEncoder().encode('respawned'));
|
||||
const dec = await subtle.aesGcmDecrypt(key, out.ciphertext, out.nonce);
|
||||
expect(new TextDecoder().decode(dec)).toBe('respawned');
|
||||
});
|
||||
|
||||
test('configureWorkerCrypto throws on protocol mismatch', async () => {
|
||||
// Spawn with a fake "spawn" that returns a worker echoing the wrong version.
|
||||
const fakeProvider = new WorkerCryptoProvider({
|
||||
workerUrl: WORKER_URL,
|
||||
spawn: () => {
|
||||
type Listener = (ev: { data: unknown }) => void;
|
||||
const listeners: Listener[] = [];
|
||||
return {
|
||||
postMessage(msg: unknown): void {
|
||||
const m = msg as { id: number; method: string };
|
||||
if (m.method === 'init') {
|
||||
setTimeout(() => {
|
||||
for (const l of listeners) {
|
||||
l({
|
||||
data: {
|
||||
id: m.id,
|
||||
ok: false,
|
||||
error: { name: 'Error', message: 'protocol version mismatch' },
|
||||
},
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
addEventListener(type: string, listener: Listener): void {
|
||||
if (type === 'message') listeners.push(listener);
|
||||
},
|
||||
removeEventListener(): void {
|
||||
// no-op
|
||||
},
|
||||
terminate(): void {
|
||||
// no-op
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
await expect(fakeProvider.handshake()).rejects.toThrow(/protocol/);
|
||||
await fakeProvider.destroy();
|
||||
});
|
||||
});
|
||||
230
packages/shade-crypto-web/tests/worker-streams.test.ts
Normal file
230
packages/shade-crypto-web/tests/worker-streams.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import {
|
||||
createDecryptStream,
|
||||
createEncryptStream,
|
||||
createWorkerCryptoProvider,
|
||||
SubtleCryptoProvider,
|
||||
WorkerCryptoProvider,
|
||||
} from '../src/index.js';
|
||||
|
||||
const WORKER_URL = new URL('../src/worker.ts', import.meta.url);
|
||||
const subtle = new SubtleCryptoProvider();
|
||||
let provider: WorkerCryptoProvider | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (provider) {
|
||||
await provider.destroy();
|
||||
provider = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function makeProvider(): Promise<WorkerCryptoProvider> {
|
||||
provider = await createWorkerCryptoProvider({ workerUrl: WORKER_URL });
|
||||
return provider;
|
||||
}
|
||||
|
||||
async function readAll(rs: ReadableStream<Uint8Array>): Promise<Uint8Array> {
|
||||
const reader = rs.getReader();
|
||||
const parts: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
parts.push(value);
|
||||
total += value.byteLength;
|
||||
}
|
||||
const out = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const p of parts) {
|
||||
out.set(p, off);
|
||||
off += p.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function streamFromChunks(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
|
||||
let i = 0;
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (i < chunks.length) controller.enqueue(chunks[i++]!);
|
||||
else controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('encryptStream / decryptStream — round-trip', () => {
|
||||
test('round-trips small payload exactly', async () => {
|
||||
const p = await makeProvider();
|
||||
const streamId = subtle.randomBytes(16);
|
||||
const streamSecret = subtle.randomBytes(32);
|
||||
const plaintext = new TextEncoder().encode('hello stream');
|
||||
|
||||
const enc = await createEncryptStream({
|
||||
provider: p,
|
||||
streamId,
|
||||
streamSecret,
|
||||
chunkSize: 1024,
|
||||
});
|
||||
const wireBytes = await readAll(
|
||||
streamFromChunks([plaintext]).pipeThrough(enc.stream),
|
||||
);
|
||||
|
||||
// Frame: each enqueue is one wire envelope. We can't trivially split
|
||||
// a concatenated buffer back into envelopes, but we know how many
|
||||
// chunks were emitted (len/chunkSize, plus the final isLast). Easier
|
||||
// path: collect them as separate writes through a side channel.
|
||||
const chunks: Uint8Array[] = [];
|
||||
await streamFromChunks([plaintext])
|
||||
.pipeThrough(
|
||||
(
|
||||
await createEncryptStream({
|
||||
provider: p,
|
||||
streamId,
|
||||
streamSecret,
|
||||
chunkSize: 1024,
|
||||
})
|
||||
).stream,
|
||||
)
|
||||
.pipeTo(
|
||||
new WritableStream<Uint8Array>({
|
||||
write(c) {
|
||||
chunks.push(c);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const dec = await createDecryptStream({ provider: p, streamId, streamSecret });
|
||||
const recovered = await readAll(streamFromChunks(chunks).pipeThrough(dec.stream));
|
||||
expect(recovered).toEqual(plaintext);
|
||||
expect(wireBytes.byteLength).toBeGreaterThan(plaintext.byteLength); // overhead
|
||||
});
|
||||
|
||||
test('round-trips multi-chunk payload with sha256 parity', async () => {
|
||||
const p = await makeProvider();
|
||||
const streamId = subtle.randomBytes(16);
|
||||
const streamSecret = subtle.randomBytes(32);
|
||||
const total = 750 * 1024; // 750 KiB → forces 3+ chunks at 256 KiB
|
||||
const plaintext = subtle.randomBytes(total);
|
||||
const expectedSha = sha256(plaintext);
|
||||
|
||||
const enc = await createEncryptStream({
|
||||
provider: p,
|
||||
streamId,
|
||||
streamSecret,
|
||||
chunkSize: 256 * 1024,
|
||||
});
|
||||
|
||||
const wireChunks: Uint8Array[] = [];
|
||||
await streamFromChunks([plaintext])
|
||||
.pipeThrough(enc.stream)
|
||||
.pipeTo(
|
||||
new WritableStream<Uint8Array>({
|
||||
write(c) {
|
||||
wireChunks.push(c);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// 750 KiB / 256 KiB = 2 full chunks + 1 final (238 KiB, isLast=true)
|
||||
expect(wireChunks.length).toBe(3);
|
||||
|
||||
const senderLaneSha = await enc.laneSha256;
|
||||
expect(senderLaneSha).toEqual(expectedSha);
|
||||
|
||||
const dec = await createDecryptStream({
|
||||
provider: p,
|
||||
streamId,
|
||||
streamSecret,
|
||||
});
|
||||
const recovered = await readAll(streamFromChunks(wireChunks).pipeThrough(dec.stream));
|
||||
expect(recovered).toEqual(plaintext);
|
||||
expect(await dec.laneSha256).toEqual(expectedSha);
|
||||
});
|
||||
|
||||
test('fragmented input produces same output as single-shot', async () => {
|
||||
const p = await makeProvider();
|
||||
const streamId = subtle.randomBytes(16);
|
||||
const streamSecret = subtle.randomBytes(32);
|
||||
const plaintext = subtle.randomBytes(50_000);
|
||||
|
||||
async function run(parts: Uint8Array[]): Promise<Uint8Array[]> {
|
||||
const wire: Uint8Array[] = [];
|
||||
const e = await createEncryptStream({
|
||||
provider: p!,
|
||||
streamId,
|
||||
streamSecret,
|
||||
chunkSize: 8 * 1024,
|
||||
});
|
||||
await streamFromChunks(parts)
|
||||
.pipeThrough(e.stream)
|
||||
.pipeTo(new WritableStream({ write: (c) => void wire.push(c) }));
|
||||
return wire;
|
||||
}
|
||||
|
||||
const single = await run([plaintext]);
|
||||
const split = await run([
|
||||
plaintext.subarray(0, 17_000),
|
||||
plaintext.subarray(17_000, 33_000),
|
||||
plaintext.subarray(33_000),
|
||||
]);
|
||||
expect(split.length).toBe(single.length);
|
||||
for (let i = 0; i < single.length; i++) {
|
||||
// Same chunk size, same lane key, same seq — wire bytes match
|
||||
// byte-for-byte (deterministic nonces + AEAD).
|
||||
expect(split[i]).toEqual(single[i]!);
|
||||
}
|
||||
});
|
||||
|
||||
test('100 KiB stream end-to-end completes', async () => {
|
||||
const p = await makeProvider();
|
||||
const streamId = subtle.randomBytes(16);
|
||||
const streamSecret = subtle.randomBytes(32);
|
||||
const plaintext = subtle.randomBytes(100 * 1024);
|
||||
|
||||
const enc = await createEncryptStream({
|
||||
provider: p,
|
||||
streamId,
|
||||
streamSecret,
|
||||
chunkSize: 16 * 1024,
|
||||
});
|
||||
const wire: Uint8Array[] = [];
|
||||
await streamFromChunks([plaintext])
|
||||
.pipeThrough(enc.stream)
|
||||
.pipeTo(new WritableStream({ write: (c) => void wire.push(c) }));
|
||||
|
||||
const dec = await createDecryptStream({ provider: p, streamId, streamSecret });
|
||||
const out = await readAll(streamFromChunks(wire).pipeThrough(dec.stream));
|
||||
expect(out).toEqual(plaintext);
|
||||
expect(await dec.laneSha256).toEqual(await enc.laneSha256);
|
||||
});
|
||||
|
||||
test('decryptStream rejects out-of-order chunks', async () => {
|
||||
const p = await makeProvider();
|
||||
const streamId = subtle.randomBytes(16);
|
||||
const streamSecret = subtle.randomBytes(32);
|
||||
const plaintext = subtle.randomBytes(20_000);
|
||||
|
||||
const enc = await createEncryptStream({
|
||||
provider: p,
|
||||
streamId,
|
||||
streamSecret,
|
||||
chunkSize: 4 * 1024,
|
||||
});
|
||||
const wire: Uint8Array[] = [];
|
||||
await streamFromChunks([plaintext])
|
||||
.pipeThrough(enc.stream)
|
||||
.pipeTo(new WritableStream({ write: (c) => void wire.push(c) }));
|
||||
|
||||
expect(wire.length).toBeGreaterThan(2);
|
||||
// Swap first and second chunk
|
||||
[wire[0], wire[1]] = [wire[1]!, wire[0]!];
|
||||
|
||||
const dec = await createDecryptStream({ provider: p, streamId, streamSecret });
|
||||
await expect(
|
||||
streamFromChunks(wire).pipeThrough(dec.stream).pipeTo(
|
||||
new WritableStream({ write() {} }),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user