import { describe, test, expect } from 'bun:test'; import { encodeEnvelope, decodeEnvelope, encodeRatchetMessage, encodeStreamChunk, decodeStreamChunk, inspectEnvelopeType, TYPE_STREAM_CHUNK, } from '../src/index.js'; import type { StreamChunkWire } from '../src/index.js'; import type { PreKeyMessage, RatchetMessage, ShadeEnvelope } from '@shade/core'; function randBytes(n: number): Uint8Array { const buf = new Uint8Array(n); crypto.getRandomValues(buf); return buf; } function makeRatchetMessage(): RatchetMessage { return { dhPublicKey: randBytes(32), previousCounter: 42, counter: 7, ciphertext: randBytes(64), nonce: randBytes(12), }; } function makePreKeyMessage(): PreKeyMessage { return { registrationId: 12345, preKeyId: 100, signedPreKeyId: 1, ephemeralKey: randBytes(32), identityDHKey: randBytes(32), message: makeRatchetMessage(), }; } describe('Wire Format', () => { // ─── RatchetMessage ──────────────────────────────────────── describe('RatchetMessage', () => { test('encode/decode roundtrip', () => { const msg = makeRatchetMessage(); const envelope: ShadeEnvelope = { type: 'ratchet', content: msg, timestamp: Date.now(), senderAddress: 'alice', }; const encoded = encodeEnvelope(envelope); const decoded = decodeEnvelope(encoded); expect(decoded.type).toBe('ratchet'); const rm = decoded.content as RatchetMessage; expect(rm.dhPublicKey).toEqual(msg.dhPublicKey); expect(rm.previousCounter).toBe(42); expect(rm.counter).toBe(7); expect(rm.ciphertext).toEqual(msg.ciphertext); expect(rm.nonce).toEqual(msg.nonce); }); test('compact size (smaller than JSON)', () => { const msg = makeRatchetMessage(); const envelope: ShadeEnvelope = { type: 'ratchet', content: msg, timestamp: 0, senderAddress: '', }; const binary = encodeEnvelope(envelope); const json = new TextEncoder().encode(JSON.stringify(envelope)); // Binary should be significantly smaller expect(binary.length).toBeLessThan(json.length); }); }); // ─── PreKeyMessage ───────────────────────────────────────── describe('PreKeyMessage', () => { test('encode/decode roundtrip with preKeyId', () => { const msg = makePreKeyMessage(); const envelope: ShadeEnvelope = { type: 'prekey', content: msg, timestamp: Date.now(), senderAddress: 'alice', }; const encoded = encodeEnvelope(envelope); const decoded = decodeEnvelope(encoded); expect(decoded.type).toBe('prekey'); const pm = decoded.content as PreKeyMessage; expect(pm.registrationId).toBe(12345); expect(pm.preKeyId).toBe(100); expect(pm.signedPreKeyId).toBe(1); expect(pm.ephemeralKey).toEqual(msg.ephemeralKey); expect(pm.identityDHKey).toEqual(msg.identityDHKey); // Nested ratchet message expect(pm.message.dhPublicKey).toEqual(msg.message.dhPublicKey); expect(pm.message.counter).toBe(msg.message.counter); expect(pm.message.ciphertext).toEqual(msg.message.ciphertext); expect(pm.message.nonce).toEqual(msg.message.nonce); }); test('encode/decode roundtrip without preKeyId', () => { const msg = makePreKeyMessage(); msg.preKeyId = undefined; const envelope: ShadeEnvelope = { type: 'prekey', content: msg, timestamp: 0, senderAddress: '', }; const encoded = encodeEnvelope(envelope); const decoded = decodeEnvelope(encoded); const pm = decoded.content as PreKeyMessage; expect(pm.preKeyId).toBeUndefined(); }); }); // ─── Edge Cases ──────────────────────────────────────────── describe('edge cases', () => { test('empty ciphertext', () => { const msg: RatchetMessage = { dhPublicKey: randBytes(32), previousCounter: 0, counter: 0, ciphertext: new Uint8Array(0), nonce: randBytes(12), }; const encoded = encodeRatchetMessage(msg); const decoded = decodeEnvelope(encoded); expect((decoded.content as RatchetMessage).ciphertext.length).toBe(0); }); test('large ciphertext (10KB)', () => { const msg: RatchetMessage = { dhPublicKey: randBytes(32), previousCounter: 0, counter: 0, ciphertext: randBytes(10240), nonce: randBytes(12), }; const encoded = encodeRatchetMessage(msg); const decoded = decodeEnvelope(encoded); expect((decoded.content as RatchetMessage).ciphertext).toEqual(msg.ciphertext); }); test('max counter values', () => { const msg: RatchetMessage = { dhPublicKey: randBytes(32), previousCounter: 0xFFFFFFFF - 1, counter: 0xFFFFFFFF - 1, ciphertext: randBytes(16), nonce: randBytes(12), }; const encoded = encodeRatchetMessage(msg); const decoded = decodeEnvelope(encoded); const rm = decoded.content as RatchetMessage; expect(rm.previousCounter).toBe(0xFFFFFFFF - 1); expect(rm.counter).toBe(0xFFFFFFFF - 1); }); test('rejects unknown version', () => { const data = new Uint8Array([0xFF, 0x01]); expect(() => decodeEnvelope(data)).toThrow('Unknown version'); }); test('rejects unknown type', () => { const data = new Uint8Array([0x02, 0xFF]); expect(() => decodeEnvelope(data)).toThrow('Unknown type'); }); test('rejects too-short data', () => { expect(() => decodeEnvelope(new Uint8Array([0x02]))).toThrow('Too short'); expect(() => decodeEnvelope(new Uint8Array([]))).toThrow('Too short'); }); }); // ─── StreamChunk (type 0x11) ─────────────────────────────── describe('StreamChunk', () => { function makeChunk(overrides: Partial = {}): StreamChunkWire { return { streamId: randBytes(16), laneId: 0, seq: 0n, isLast: false, nonce: randBytes(12), aad: new Uint8Array(0), ciphertext: randBytes(64), ...overrides, }; } test('encode/decode roundtrip', () => { const c = makeChunk({ laneId: 7, seq: 42n, isLast: true }); const encoded = encodeStreamChunk(c); const decoded = decodeStreamChunk(encoded); expect(decoded.streamId).toEqual(c.streamId); expect(decoded.laneId).toBe(7); expect(decoded.seq).toBe(42n); expect(decoded.isLast).toBe(true); expect(decoded.nonce).toEqual(c.nonce); expect(decoded.aad.length).toBe(0); expect(decoded.ciphertext).toEqual(c.ciphertext); }); test('emits the correct type tag', () => { const encoded = encodeStreamChunk(makeChunk()); expect(encoded[0]).toBe(0x02); // version expect(encoded[1]).toBe(TYPE_STREAM_CHUNK); }); test('handles empty ciphertext', () => { const c = makeChunk({ ciphertext: new Uint8Array(0) }); const decoded = decodeStreamChunk(encodeStreamChunk(c)); expect(decoded.ciphertext.length).toBe(0); }); test('handles 16 MiB ciphertext (max chunk + tag)', () => { const c = makeChunk({ ciphertext: randBytes(16 * 1024 * 1024 + 16) }); const encoded = encodeStreamChunk(c); const decoded = decodeStreamChunk(encoded); expect(decoded.ciphertext.length).toBe(c.ciphertext.length); }); test('handles MAX_SEQ', () => { const max = 0xffff_ffff_ffff_ffffn; const c = makeChunk({ seq: max }); const decoded = decodeStreamChunk(encodeStreamChunk(c)); expect(decoded.seq).toBe(max); }); test('rejects wrong-length streamId', () => { expect(() => encodeStreamChunk(makeChunk({ streamId: randBytes(15) }))).toThrow(); }); test('rejects wrong-length nonce', () => { expect(() => encodeStreamChunk(makeChunk({ nonce: randBytes(11) }))).toThrow(); }); test('rejects out-of-range laneId', () => { expect(() => encodeStreamChunk(makeChunk({ laneId: 0x1_0000_0000 }))).toThrow(); expect(() => encodeStreamChunk(makeChunk({ laneId: -1 }))).toThrow(); }); test('decode rejects wrong type tag', () => { const valid = encodeStreamChunk(makeChunk()); const tampered = valid.slice(); tampered[1] = 0x02; // ratchet expect(() => decodeStreamChunk(tampered)).toThrow(); }); test('decode rejects wrong version', () => { const valid = encodeStreamChunk(makeChunk()); const tampered = valid.slice(); tampered[0] = 0x01; // bumped to 0x02 in v0.3.0; 0x01 is the legacy version expect(() => decodeStreamChunk(tampered)).toThrow(); }); test('decode rejects truncated body', () => { const valid = encodeStreamChunk(makeChunk()); expect(() => decodeStreamChunk(valid.slice(0, valid.length - 5))).toThrow(); }); test('decode rejects body extended beyond ctLen', () => { const valid = encodeStreamChunk(makeChunk()); const tampered = new Uint8Array(valid.length + 1); tampered.set(valid); expect(() => decodeStreamChunk(tampered)).toThrow(); }); test('inspectEnvelopeType identifies stream-chunk', () => { expect(inspectEnvelopeType(encodeStreamChunk(makeChunk()))).toBe('stream-chunk'); }); test('inspectEnvelopeType identifies ratchet/prekey/unknown', () => { const ratchet: ShadeEnvelope = { type: 'ratchet', content: makeRatchetMessage(), timestamp: 0, senderAddress: '', }; expect(inspectEnvelopeType(encodeEnvelope(ratchet))).toBe('ratchet'); expect(inspectEnvelopeType(new Uint8Array([0x02, 0x99]))).toBe('unknown'); expect(inspectEnvelopeType(new Uint8Array([]))).toBe('unknown'); }); }); });