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

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

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

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

218 lines
7.3 KiB
TypeScript

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;
}