release(v4.0.0): Shade GA — V3.x consolidation + audit prep
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers byte-for-byte. The version bump is semantic: audit-cycle complete, opt-in surface fully exposed, threat model refreshed for every new surface. Highlights: - All 24 @shade/* packages bumped to 4.0.0 in lockstep. - CHANGELOG 4.0.0 section is the canonical manifest of what landed. - THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12 Web-Worker boundary) + residual-risks table refreshed. - OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox, bridge, observer, /metrics, /healthz, /ready. - MIGRATION 0.3.x → 4.0 documented + smoke-tested against shade migrate-storage on a real SQLite DB. - docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer. - scripts/soak.ts harness for the GA-stable 2-week soak window. - All V*.md plans archived under docs/archive/ with Status: Done. - Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen non-realtime stack. Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green. Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports version 4.0.0 on /health. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
218
packages/shade-crypto-web/tests/worker-provider.test.ts
Normal file
218
packages/shade-crypto-web/tests/worker-provider.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user