110 lines
4.4 KiB
TypeScript
110 lines
4.4 KiB
TypeScript
|
|
import { describe, test, expect } from 'bun:test';
|
||
|
|
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||
|
|
import { decodeStreamChunk, encodeStreamChunk } from '@shade/proto';
|
||
|
|
import {
|
||
|
|
StreamSender,
|
||
|
|
StreamReceiver,
|
||
|
|
StreamDecryptionError,
|
||
|
|
StreamProtocolError,
|
||
|
|
generateStreamId,
|
||
|
|
generateStreamSecret,
|
||
|
|
} from '../src/index.js';
|
||
|
|
|
||
|
|
const crypto = new SubtleCryptoProvider();
|
||
|
|
|
||
|
|
async function pair() {
|
||
|
|
const streamId = generateStreamId(crypto);
|
||
|
|
const streamSecret = generateStreamSecret(crypto);
|
||
|
|
const sender = await StreamSender.create({ crypto, streamId, streamSecret, laneId: 0 });
|
||
|
|
const receiver = await StreamReceiver.create({ crypto, streamId, streamSecret, laneId: 0 });
|
||
|
|
return { sender, receiver };
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('Tamper detection', () => {
|
||
|
|
test('flipping a ciphertext byte → StreamDecryptionError', async () => {
|
||
|
|
const { sender, receiver } = await pair();
|
||
|
|
const { bytes } = await sender.encryptChunk(new TextEncoder().encode('payload'), false);
|
||
|
|
const env = decodeStreamChunk(bytes);
|
||
|
|
env.ciphertext[0] ^= 0x01;
|
||
|
|
const reencoded = encodeStreamChunk(env);
|
||
|
|
await expect(receiver.decryptChunk(reencoded)).rejects.toThrow(StreamDecryptionError);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('flipping the AEAD tag → StreamDecryptionError', async () => {
|
||
|
|
const { sender, receiver } = await pair();
|
||
|
|
const { bytes } = await sender.encryptChunk(new TextEncoder().encode('payload'), false);
|
||
|
|
const env = decodeStreamChunk(bytes);
|
||
|
|
env.ciphertext[env.ciphertext.length - 1] ^= 0x80;
|
||
|
|
await expect(receiver.decryptChunk(encodeStreamChunk(env))).rejects.toThrow(
|
||
|
|
StreamDecryptionError,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('tampering with isLast flag → StreamDecryptionError (AAD mismatch)', async () => {
|
||
|
|
const { sender, receiver } = await pair();
|
||
|
|
const { bytes } = await sender.encryptChunk(new TextEncoder().encode('payload'), false);
|
||
|
|
const env = decodeStreamChunk(bytes);
|
||
|
|
env.isLast = true;
|
||
|
|
await expect(receiver.decryptChunk(encodeStreamChunk(env))).rejects.toThrow(
|
||
|
|
StreamDecryptionError,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('tampering with the wire nonce → StreamProtocolError (deterministic check)', async () => {
|
||
|
|
const { sender, receiver } = await pair();
|
||
|
|
const { bytes } = await sender.encryptChunk(new TextEncoder().encode('payload'), false);
|
||
|
|
const env = decodeStreamChunk(bytes);
|
||
|
|
env.nonce[0] ^= 0x01;
|
||
|
|
await expect(receiver.decryptChunk(encodeStreamChunk(env))).rejects.toThrow(
|
||
|
|
StreamProtocolError,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('tampering with streamId → StreamProtocolError', async () => {
|
||
|
|
const { sender, receiver } = await pair();
|
||
|
|
const { bytes } = await sender.encryptChunk(new TextEncoder().encode('payload'), false);
|
||
|
|
const env = decodeStreamChunk(bytes);
|
||
|
|
env.streamId[0] ^= 0xff;
|
||
|
|
await expect(receiver.decryptChunk(encodeStreamChunk(env))).rejects.toThrow(
|
||
|
|
StreamProtocolError,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('routing a chunk to wrong-lane receiver → StreamProtocolError', async () => {
|
||
|
|
const streamId = generateStreamId(crypto);
|
||
|
|
const streamSecret = generateStreamSecret(crypto);
|
||
|
|
const sender0 = await StreamSender.create({ crypto, streamId, streamSecret, laneId: 0 });
|
||
|
|
const receiver1 = await StreamReceiver.create({ crypto, streamId, streamSecret, laneId: 1 });
|
||
|
|
const { bytes } = await sender0.encryptChunk(new TextEncoder().encode('payload'), false);
|
||
|
|
await expect(receiver1.decryptChunk(bytes)).rejects.toThrow(StreamProtocolError);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('non-empty AAD on wire → StreamProtocolError (reserved in v0.2.0)', async () => {
|
||
|
|
const { sender, receiver } = await pair();
|
||
|
|
const { bytes } = await sender.encryptChunk(new TextEncoder().encode('payload'), false);
|
||
|
|
const env = decodeStreamChunk(bytes);
|
||
|
|
env.aad = new Uint8Array([1, 2, 3]);
|
||
|
|
await expect(receiver.decryptChunk(encodeStreamChunk(env))).rejects.toThrow(
|
||
|
|
StreamProtocolError,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('different streamSecret → StreamDecryptionError', async () => {
|
||
|
|
const streamId = generateStreamId(crypto);
|
||
|
|
const sender = await StreamSender.create({
|
||
|
|
crypto,
|
||
|
|
streamId,
|
||
|
|
streamSecret: new Uint8Array(32).fill(1),
|
||
|
|
laneId: 0,
|
||
|
|
});
|
||
|
|
const receiver = await StreamReceiver.create({
|
||
|
|
crypto,
|
||
|
|
streamId,
|
||
|
|
streamSecret: new Uint8Array(32).fill(2),
|
||
|
|
laneId: 0,
|
||
|
|
});
|
||
|
|
const { bytes } = await sender.encryptChunk(new TextEncoder().encode('hi'), false);
|
||
|
|
await expect(receiver.decryptChunk(bytes)).rejects.toThrow(StreamDecryptionError);
|
||
|
|
});
|
||
|
|
});
|