import { describe, test, expect } from 'bun:test'; import { readFileSync } from 'fs'; import { resolve } from 'path'; import { fromBase64, toBase64 } from '@shade/core'; import { buildAad, deriveFieldKey, deriveMasterKey, deriveNonce, deriveStorageKey, } from '../src/crypto/kdf.js'; import { aeadOpen, aeadSeal } from '../src/crypto/aead.js'; const VECTOR_PATH = resolve(__dirname, '../../../test-vectors/storage-encryption.json'); interface Vector { kdf: { scrypt: { passphrase: string; salt_hex: string; N: number; r: number; p: number; dkLen: number }; hkdf_storage_key: { master_key_hex: string }; hkdf_field_key: { storage_key_hex: string; samples: { table: string; column: string }[] }; deterministic_nonce: { samples: { table: string; pk: string }[] }; }; aead: { round_trips: { table: string; column: string; pk: string; plaintext_utf8: string }[] }; } function fromHex(hex: string): Uint8Array { const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); return out; } function toHex(bytes: Uint8Array): string { return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(''); } const vec: Vector = JSON.parse(readFileSync(VECTOR_PATH, 'utf-8')); describe('storage-encryption test vectors', () => { test('scrypt → masterKey is stable for the published parameters', async () => { const { passphrase, salt_hex, N, r, p, dkLen } = vec.kdf.scrypt; const out = await deriveMasterKey(passphrase, fromHex(salt_hex), { N, r, p, dkLen }); expect(out.length).toBe(dkLen); // Pin the result for cross-impl parity. expect(toHex(out)).toBe('aee2dc14f3a46c563f8906a9c8777f167c868dc06015a983fdf2dbba078a3597'); }); test('HKDF storageKey derivation matches pinned value', () => { const master = fromHex(vec.kdf.hkdf_storage_key.master_key_hex); const sk = deriveStorageKey(master); expect(sk.length).toBe(32); expect(toHex(sk)).toBe('059a250e15aa02952ab977f441e217cfcd4a6d9be8b51cf93d001a90eeb6accc'); }); test('HKDF fieldKey derivation is deterministic for known (table, column)', () => { // Use a fixed storageKey (different from the pinned one above so this // test can run independently). const sk = new Uint8Array(32).fill(0xAB); const fk = deriveFieldKey(sk, 'sessions', 'session'); expect(fk.length).toBe(32); // Pin: any change to the info-string format must update this value // *and* the Android implementation in lockstep. expect(toHex(fk)).toBe('cbe428b4e8be2d7c4cd707dbac7e02881f2da34ee5b00bdc9bc1ebf2f096087a'); }); test('deriveNonce is 12 bytes and stable for known inputs', () => { const k = new Uint8Array(32).fill(0xCD); const n = deriveNonce(k, 'sessions', 'alice'); expect(n.length).toBe(12); expect(toHex(n)).toBe('f72f291a2d3cd0ba652b60c5'); }); test('AAD templates encode (table, column, pk) verbatim', () => { const aad = buildAad('sessions', 'session', 'alice'); expect(new TextDecoder().decode(aad)).toBe('shade-aad-v1|sessions|session|alice'); }); test('AEAD round-trip matches advertised wire format', async () => { for (const sample of vec.aead.round_trips) { const sk = new Uint8Array(32).fill(0x01); const fk = deriveFieldKey(sk, sample.table, sample.column); const nonce = deriveNonce(fk, sample.table, sample.pk); const aad = buildAad(sample.table, sample.column, sample.pk); const pt = new TextEncoder().encode(sample.plaintext_utf8); const blob = await aeadSeal(fk, nonce, pt, aad); // Wire format: first 12 bytes are the nonce. expect(blob.subarray(0, 12)).toEqual(nonce); // Last 16 bytes are the GCM tag (we don't pin the tag, just length). expect(blob.length).toBe(12 + pt.length + 16); const opened = await aeadOpen(fk, blob, aad, nonce); expect(new TextDecoder().decode(opened)).toBe(sample.plaintext_utf8); } }); test('base64 helper round-trip (sanity)', () => { const b = new Uint8Array([1, 2, 3, 4, 5]); expect(fromBase64(toBase64(b))).toEqual(b); }); });