Files
Shade/packages/shade-crypto-web/tests/worker-provider.test.ts

219 lines
7.6 KiB
TypeScript
Raw Normal View History

import { describe, expect, test, afterEach } from 'bun:test';
import {
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(idleTimeoutMs = 30_000): Promise<WorkerCryptoProvider> {
provider = await createWorkerCryptoProvider({
workerUrl: WORKER_URL,
idleTimeoutMs,
});
return provider;
}
describe('WorkerCryptoProvider — roundtrip and parity', () => {
test('handshake completes', async () => {
const p = await makeProvider();
expect(p).toBeInstanceOf(WorkerCryptoProvider);
});
test('AES-GCM encrypt → worker, decrypt locally — produces same plaintext', async () => {
const p = await makeProvider();
const key = subtle.randomBytes(32);
const plaintext = new TextEncoder().encode('hello shade workers — large enough payload');
const enc = await p.aesGcmEncrypt(key, plaintext);
expect(enc.nonce.length).toBe(12);
// Decrypt with the local SubtleCryptoProvider — proves wire compatibility
const dec = await subtle.aesGcmDecrypt(key, enc.ciphertext, enc.nonce);
expect(dec).toEqual(plaintext);
});
test('AES-GCM with AAD round-trips through worker', async () => {
const p = await makeProvider();
const key = subtle.randomBytes(32);
const plaintext = subtle.randomBytes(1024);
const aad = subtle.randomBytes(16);
const enc = await p.aesGcmEncrypt(key, plaintext, aad);
const dec = await p.aesGcmDecrypt(key, enc.ciphertext, enc.nonce, aad);
expect(dec).toEqual(plaintext);
});
test('AES-GCM decrypt rejects tampered ciphertext', async () => {
const p = await makeProvider();
const key = subtle.randomBytes(32);
const plaintext = new TextEncoder().encode('untampered');
const enc = await p.aesGcmEncrypt(key, plaintext);
enc.ciphertext[0]! ^= 0x01;
await expect(p.aesGcmDecrypt(key, enc.ciphertext, enc.nonce)).rejects.toThrow();
});
test('HKDF parity with SubtleCryptoProvider', async () => {
const p = await makeProvider();
const ikm = subtle.randomBytes(32);
const salt = subtle.randomBytes(16);
const info = new TextEncoder().encode('test info');
const a = await p.hkdf(ikm, salt, info, 64);
const b = await subtle.hkdf(ikm, salt, info, 64);
expect(a).toEqual(b);
});
test('HMAC-SHA256 parity with SubtleCryptoProvider', async () => {
const p = await makeProvider();
const key = subtle.randomBytes(32);
const data = subtle.randomBytes(256);
const a = await p.hmacSha256(key, data);
const b = await subtle.hmacSha256(key, data);
expect(a).toEqual(b);
});
test('X25519 DH agrees with SubtleCryptoProvider', async () => {
const p = await makeProvider();
const alice = await p.generateX25519KeyPair();
const bob = await subtle.generateX25519KeyPair();
const ab = await p.x25519(alice.privateKey, bob.publicKey);
const ba = await subtle.x25519(bob.privateKey, alice.publicKey);
expect(ab).toEqual(ba);
});
test('Ed25519 sign in worker, verify locally', async () => {
const p = await makeProvider();
const kp = await p.generateEd25519KeyPair();
const msg = new TextEncoder().encode('please sign me');
const sig = await p.sign(kp.privateKey, msg);
expect(await subtle.verify(kp.publicKey, msg, sig)).toBe(true);
});
test('Ed25519 verify rejects tampered signature', async () => {
const p = await makeProvider();
const kp = await subtle.generateEd25519KeyPair();
const msg = new TextEncoder().encode('msg');
const sig = await subtle.sign(kp.privateKey, msg);
sig[0]! ^= 0x01;
expect(await p.verify(kp.publicKey, msg, sig)).toBe(false);
});
test('local sync helpers do not round-trip', async () => {
const p = await makeProvider();
const a = p.randomBytes(16);
expect(a.length).toBe(16);
expect(p.constantTimeEqual(a, a)).toBe(true);
expect(p.constantTimeEqual(a, new Uint8Array(16))).toBe(false);
expect(typeof p.randomUint32()).toBe('number');
});
test('errors from worker propagate as rejected promises', async () => {
const p = await makeProvider();
const wrongKey = subtle.randomBytes(32);
const ct = subtle.randomBytes(48);
const nonce = subtle.randomBytes(12);
await expect(p.aesGcmDecrypt(wrongKey, ct, nonce)).rejects.toThrow();
});
test('parallel calls do not interleave incorrectly', async () => {
const p = await makeProvider();
const key = subtle.randomBytes(32);
const inputs = Array.from({ length: 16 }, (_, i) =>
new TextEncoder().encode(`payload-${i}-${'x'.repeat(50 * i)}`),
);
const encs = await Promise.all(inputs.map((pt) => p.aesGcmEncrypt(key, pt)));
const decs = await Promise.all(
encs.map((e) => p.aesGcmDecrypt(key, e.ciphertext, e.nonce)),
);
decs.forEach((d, i) => expect(d).toEqual(inputs[i]!));
});
test('after destroy(), calls reject', async () => {
const p = await makeProvider();
await p.destroy();
await expect(p.aesGcmEncrypt(subtle.randomBytes(32), new Uint8Array(8))).rejects.toThrow(
/destroyed/,
);
provider = null;
});
test('rotate() respawns transparently', async () => {
const p = await makeProvider();
const key = subtle.randomBytes(32);
await p.aesGcmEncrypt(key, new Uint8Array(8));
await p.rotate();
const out = await p.aesGcmEncrypt(key, new TextEncoder().encode('still works'));
const dec = await subtle.aesGcmDecrypt(key, out.ciphertext, out.nonce);
expect(new TextDecoder().decode(dec)).toBe('still works');
});
test('idle-timeout terminates worker but next call respawns', async () => {
const p = await makeProvider(120);
const key = subtle.randomBytes(32);
await p.aesGcmEncrypt(key, new Uint8Array(8));
// Wait for the idle timer to fire.
await new Promise((r) => setTimeout(r, 250));
// Next call should still succeed — proves respawn works.
const out = await p.aesGcmEncrypt(key, new TextEncoder().encode('respawned'));
const dec = await subtle.aesGcmDecrypt(key, out.ciphertext, out.nonce);
expect(new TextDecoder().decode(dec)).toBe('respawned');
});
test('configureWorkerCrypto throws on protocol mismatch', async () => {
// Spawn with a fake "spawn" that returns a worker echoing the wrong version.
const fakeProvider = new WorkerCryptoProvider({
workerUrl: WORKER_URL,
spawn: () => {
type Listener = (ev: { data: unknown }) => void;
const listeners: Listener[] = [];
return {
postMessage(msg: unknown): void {
const m = msg as { id: number; method: string };
if (m.method === 'init') {
setTimeout(() => {
for (const l of listeners) {
l({
data: {
id: m.id,
ok: false,
error: { name: 'Error', message: 'protocol version mismatch' },
},
});
}
}, 0);
}
},
addEventListener(type: string, listener: Listener): void {
if (type === 'message') listeners.push(listener);
},
removeEventListener(): void {
// no-op
},
terminate(): void {
// no-op
},
};
},
});
await expect(fakeProvider.handshake()).rejects.toThrow(/protocol/);
await fakeProvider.destroy();
});
});