219 lines
7.6 KiB
TypeScript
219 lines
7.6 KiB
TypeScript
|
|
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();
|
||
|
|
});
|
||
|
|
});
|