177 lines
6.4 KiB
TypeScript
177 lines
6.4 KiB
TypeScript
|
|
import { describe, test, expect } from 'bun:test';
|
||
|
|
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||
|
|
import {
|
||
|
|
StreamSender,
|
||
|
|
StreamReceiver,
|
||
|
|
StreamFinishedError,
|
||
|
|
generateStreamId,
|
||
|
|
generateStreamSecret,
|
||
|
|
} from '../src/index.js';
|
||
|
|
|
||
|
|
const crypto = new SubtleCryptoProvider();
|
||
|
|
|
||
|
|
function hex(b: Uint8Array): string {
|
||
|
|
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||
|
|
}
|
||
|
|
|
||
|
|
async function makePair(opts?: { laneId?: number; startSeq?: number }) {
|
||
|
|
const streamId = generateStreamId(crypto);
|
||
|
|
const streamSecret = generateStreamSecret(crypto);
|
||
|
|
const laneId = opts?.laneId ?? 0;
|
||
|
|
const sender = await StreamSender.create({
|
||
|
|
crypto,
|
||
|
|
streamId,
|
||
|
|
streamSecret,
|
||
|
|
laneId,
|
||
|
|
...(opts?.startSeq !== undefined ? { startSeq: opts.startSeq } : {}),
|
||
|
|
});
|
||
|
|
const receiver = await StreamReceiver.create({
|
||
|
|
crypto,
|
||
|
|
streamId,
|
||
|
|
streamSecret,
|
||
|
|
laneId,
|
||
|
|
...(opts?.startSeq !== undefined ? { startSeq: opts.startSeq } : {}),
|
||
|
|
});
|
||
|
|
return { sender, receiver, streamId, streamSecret };
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('Single-lane sender/receiver roundtrip', () => {
|
||
|
|
test('basic single-chunk transfer', async () => {
|
||
|
|
const { sender, receiver } = await makePair();
|
||
|
|
const plaintext = new TextEncoder().encode('hello shade');
|
||
|
|
const { bytes } = await sender.encryptChunk(plaintext, true);
|
||
|
|
const decrypted = await receiver.decryptChunk(bytes);
|
||
|
|
expect(new TextDecoder().decode(decrypted.plaintext)).toBe('hello shade');
|
||
|
|
expect(decrypted.seq).toBe(0);
|
||
|
|
expect(decrypted.isLast).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('multi-chunk transfer with monotonic seq', async () => {
|
||
|
|
const { sender, receiver } = await makePair();
|
||
|
|
const chunks = ['alpha', 'beta', 'gamma', 'delta'];
|
||
|
|
for (let i = 0; i < chunks.length; i++) {
|
||
|
|
const isLast = i === chunks.length - 1;
|
||
|
|
const { bytes, seq } = await sender.encryptChunk(
|
||
|
|
new TextEncoder().encode(chunks[i]!),
|
||
|
|
isLast,
|
||
|
|
);
|
||
|
|
expect(seq).toBe(i);
|
||
|
|
const dec = await receiver.decryptChunk(bytes);
|
||
|
|
expect(new TextDecoder().decode(dec.plaintext)).toBe(chunks[i]);
|
||
|
|
expect(dec.seq).toBe(i);
|
||
|
|
expect(dec.isLast).toBe(isLast);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('lane sha256 matches between sender and receiver', async () => {
|
||
|
|
const { sender, receiver } = await makePair();
|
||
|
|
const data = [
|
||
|
|
crypto.randomBytes(1024),
|
||
|
|
crypto.randomBytes(2048),
|
||
|
|
crypto.randomBytes(512),
|
||
|
|
];
|
||
|
|
for (let i = 0; i < data.length; i++) {
|
||
|
|
const { bytes } = await sender.encryptChunk(data[i]!, i === data.length - 1);
|
||
|
|
await receiver.decryptChunk(bytes);
|
||
|
|
}
|
||
|
|
expect(hex(sender.getLaneSha256Digest())).toBe(hex(receiver.getLaneSha256Digest()));
|
||
|
|
});
|
||
|
|
|
||
|
|
test('handles empty chunks', async () => {
|
||
|
|
const { sender, receiver } = await makePair();
|
||
|
|
const { bytes } = await sender.encryptChunk(new Uint8Array(0), true);
|
||
|
|
const dec = await receiver.decryptChunk(bytes);
|
||
|
|
expect(dec.plaintext.length).toBe(0);
|
||
|
|
expect(dec.isLast).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('ship-gate: ~10 MiB roundtrip preserves byte-for-byte content', async () => {
|
||
|
|
const { sender, receiver } = await makePair();
|
||
|
|
const total = 10 * 1024 * 1024;
|
||
|
|
const chunkSize = 256 * 1024;
|
||
|
|
const allBytes = crypto.randomBytes(total);
|
||
|
|
|
||
|
|
const reconstructed: Uint8Array[] = [];
|
||
|
|
for (let off = 0; off < total; off += chunkSize) {
|
||
|
|
const slice = allBytes.subarray(off, Math.min(off + chunkSize, total));
|
||
|
|
const isLast = off + chunkSize >= total;
|
||
|
|
const { bytes } = await sender.encryptChunk(slice, isLast);
|
||
|
|
const dec = await receiver.decryptChunk(bytes);
|
||
|
|
reconstructed.push(dec.plaintext);
|
||
|
|
}
|
||
|
|
|
||
|
|
let off = 0;
|
||
|
|
for (const piece of reconstructed) {
|
||
|
|
for (let i = 0; i < piece.length; i++) {
|
||
|
|
if (piece[i] !== allBytes[off + i]) {
|
||
|
|
throw new Error(`mismatch at byte ${off + i}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
off += piece.length;
|
||
|
|
}
|
||
|
|
expect(off).toBe(total);
|
||
|
|
expect(hex(sender.getLaneSha256Digest())).toBe(hex(receiver.getLaneSha256Digest()));
|
||
|
|
});
|
||
|
|
|
||
|
|
test('byte counters track encrypted/decrypted plaintext', async () => {
|
||
|
|
const { sender, receiver } = await makePair();
|
||
|
|
const a = crypto.randomBytes(100);
|
||
|
|
const b = crypto.randomBytes(250);
|
||
|
|
const { bytes: ab } = await sender.encryptChunk(a, false);
|
||
|
|
const { bytes: bb } = await sender.encryptChunk(b, true);
|
||
|
|
await receiver.decryptChunk(ab);
|
||
|
|
await receiver.decryptChunk(bb);
|
||
|
|
expect(sender.bytesSent).toBe(350n);
|
||
|
|
expect(receiver.bytesReceived).toBe(350n);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('finished flag set after isLast', async () => {
|
||
|
|
const { sender, receiver } = await makePair();
|
||
|
|
const { bytes } = await sender.encryptChunk(new Uint8Array(8), true);
|
||
|
|
expect(sender.isFinished).toBe(true);
|
||
|
|
await receiver.decryptChunk(bytes);
|
||
|
|
expect(receiver.isFinished).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('encryptChunk after finish throws StreamFinishedError', async () => {
|
||
|
|
const { sender } = await makePair();
|
||
|
|
await sender.encryptChunk(new Uint8Array(0), true);
|
||
|
|
await expect(sender.encryptChunk(new Uint8Array(0), false)).rejects.toThrow(
|
||
|
|
StreamFinishedError,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('decryptChunk after finish throws StreamFinishedError', async () => {
|
||
|
|
const { sender, receiver } = await makePair();
|
||
|
|
const { bytes: a } = await sender.encryptChunk(new Uint8Array(8), true);
|
||
|
|
await receiver.decryptChunk(a);
|
||
|
|
// Try to feed another chunk — sender wouldn't normally produce one, but
|
||
|
|
// simulate an attacker sending bytes after the legitimate isLast.
|
||
|
|
const sender2 = await StreamSender.create({
|
||
|
|
crypto,
|
||
|
|
streamId: (sender as unknown as { streamId: Uint8Array }).streamId,
|
||
|
|
streamSecret: new Uint8Array(32),
|
||
|
|
laneId: 0,
|
||
|
|
});
|
||
|
|
const { bytes: extra } = await sender2.encryptChunk(new Uint8Array(8), false);
|
||
|
|
await expect(receiver.decryptChunk(extra)).rejects.toThrow(StreamFinishedError);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('destroy zeroes the lane key (subsequent calls throw)', async () => {
|
||
|
|
const { sender, receiver } = await makePair();
|
||
|
|
sender.destroy();
|
||
|
|
receiver.destroy();
|
||
|
|
await expect(sender.encryptChunk(new Uint8Array(0), false)).rejects.toThrow(
|
||
|
|
StreamFinishedError,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('startSeq enables resume from arbitrary offset', async () => {
|
||
|
|
const { sender, receiver } = await makePair({ startSeq: 100 });
|
||
|
|
const { bytes, seq } = await sender.encryptChunk(new TextEncoder().encode('mid'), false);
|
||
|
|
expect(seq).toBe(100);
|
||
|
|
const dec = await receiver.decryptChunk(bytes);
|
||
|
|
expect(dec.seq).toBe(100);
|
||
|
|
});
|
||
|
|
});
|