231 lines
7.0 KiB
TypeScript
231 lines
7.0 KiB
TypeScript
|
|
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();
|
||
|
|
});
|
||
|
|
});
|