218 lines
7.3 KiB
TypeScript
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;
|
||
|
|
}
|