#!/usr/bin/env bun /** * Generate cross-platform test vectors from the TypeScript implementation. * * The output JSON files are loaded by BOTH the TypeScript and Kotlin test * suites. Any divergence between platforms fails CI immediately. * * Schema: every file is `{ "version": , "vectors": [...] }`. * Bump `VECTOR_FILE_VERSION` whenever the vector schema (NOT just values) * changes, so downstream consumers can fail loudly on mismatch. * * Usage: bun run scripts/generate-vectors.ts */ import { writeFileSync } from 'fs'; import { join } from 'path'; import { SubtleCryptoProvider } from '../packages/shade-crypto-web/src/index.js'; import { computeFingerprint } from '../packages/shade-core/src/fingerprint.js'; import { kdfChainKey, kdfRootKey, deriveInitialRootKey } from '../packages/shade-core/src/keys.js'; import { encodeEnvelope, encodeStreamChunk, decodeStreamChunk } from '../packages/shade-proto/src/index.js'; import type { StreamChunkWire } from '../packages/shade-proto/src/index.js'; import type { ShadeEnvelope, RatchetMessage, PreKeyMessage } from '../packages/shade-core/src/index.js'; import { deriveStreamKey, deriveLaneKey, buildChunkNonce, buildChunkAad, aesGcmEncryptWithNonce, } from '../packages/shade-streams/src/index.js'; const VECTOR_FILE_VERSION = 2; const crypto = new SubtleCryptoProvider(); const OUT_DIR = join(import.meta.dir, '..', 'test-vectors'); function hex(bytes: Uint8Array): string { return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); } function fromHex(str: string): Uint8Array { const bytes = new Uint8Array(str.length / 2); for (let i = 0; i < bytes.length; i++) { bytes[i] = parseInt(str.substring(i * 2, i * 2 + 2), 16); } return bytes; } interface Vector { description: string; [key: string]: unknown; } // AES-GCM with caller-supplied nonce. The CryptoProvider interface picks a // random nonce internally, so vector generation goes around it via SubtleCrypto // directly — same primitive `@shade/streams` already uses. async function aesGcmEncryptDeterministic( key: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array, aad: Uint8Array, ): Promise { const subtle = globalThis.crypto.subtle; const aesKey = await subtle.importKey( 'raw', key as unknown as ArrayBuffer, 'AES-GCM', false, ['encrypt'], ); const out = await subtle.encrypt( { name: 'AES-GCM', iv: nonce as unknown as ArrayBuffer, additionalData: aad as unknown as ArrayBuffer }, aesKey, plaintext as unknown as ArrayBuffer, ); return new Uint8Array(out); } // Mirror of `encodeHeader` in @shade/core/ratchet.ts — kept inline to avoid // exporting an internal symbol just for tests. function encodeRatchetHeader( dhPublicKey: Uint8Array, previousCounter: number, counter: number, ): Uint8Array { const buf = new Uint8Array(40); buf.set(dhPublicKey, 0); const view = new DataView(buf.buffer); view.setUint32(32, previousCounter, false); view.setUint32(36, counter, false); return buf; } // ─── HKDF vectors ─────────────────────────────────────────── async function generateHkdfVectors(): Promise { const cases = [ { ikm: '01'.repeat(32), salt: '02'.repeat(32), info: 'test', length: 32 }, { ikm: 'ab'.repeat(32), salt: '00'.repeat(32), info: 'ShadeRootRatchet', length: 64 }, { ikm: 'cd'.repeat(32), salt: '00'.repeat(32), info: 'ShadeX3DH', length: 32 }, ]; const vectors: Vector[] = []; for (const c of cases) { const out = await crypto.hkdf( fromHex(c.ikm), fromHex(c.salt), new TextEncoder().encode(c.info), c.length, ); vectors.push({ description: `HKDF-SHA256 with ikm=${c.ikm.slice(0, 8)}... info="${c.info}"`, ikm: c.ikm, salt: c.salt, info: c.info, length: c.length, output: hex(out), }); } return vectors; } // ─── KDF chain vectors ───────────────────────────────────── async function generateKdfChainVectors(): Promise { const rootKey = new Uint8Array(32).fill(0x11); const dhOutput = new Uint8Array(32).fill(0x22); const rootResult = await kdfRootKey(crypto, rootKey, dhOutput); const chainKey = new Uint8Array(32).fill(0x33); const chainResult = await kdfChainKey(crypto, chainKey); return [ { description: 'Root key ratchet: kdfRootKey', rootKey: hex(rootKey), dhOutput: hex(dhOutput), newRootKey: hex(rootResult.newRootKey), chainKey: hex(rootResult.chainKey), }, { description: 'Chain key ratchet: kdfChainKey', chainKey: hex(chainKey), newChainKey: hex(chainResult.newChainKey), messageKey: hex(chainResult.messageKey), }, ]; } // ─── X3DH initial root key ───────────────────────────────── async function generateX3DHVectors(): Promise { const secrets = [ new Uint8Array(32).fill(0xaa), new Uint8Array(32).fill(0xbb), new Uint8Array(32).fill(0xcc), ]; const rootKey3 = await deriveInitialRootKey(crypto, secrets); const secrets4 = [...secrets, new Uint8Array(32).fill(0xdd)]; const rootKey4 = await deriveInitialRootKey(crypto, secrets4); return [ { description: 'X3DH initial root key with 3 DH outputs (no one-time prekey)', secrets: secrets.map(hex), rootKey: hex(rootKey3), }, { description: 'X3DH initial root key with 4 DH outputs (with one-time prekey)', secrets: secrets4.map(hex), rootKey: hex(rootKey4), }, ]; } // ─── Fingerprint vectors ─────────────────────────────────── async function generateFingerprintVectors(): Promise { const cases = [ { sig: '01'.repeat(32), dh: '02'.repeat(32) }, { sig: 'ab'.repeat(32), dh: 'cd'.repeat(32) }, ]; const vectors: Vector[] = []; for (const c of cases) { const fp = await computeFingerprint(crypto, fromHex(c.sig), fromHex(c.dh)); vectors.push({ description: `Fingerprint for signing=${c.sig.slice(0, 8)}... dh=${c.dh.slice(0, 8)}...`, signingKey: c.sig, dhKey: c.dh, fingerprint: fp, }); } return vectors; } // ─── Wire format vectors ─────────────────────────────────── async function generateWireFormatVectors(): Promise { const ratchetMsg: RatchetMessage = { dhPublicKey: new Uint8Array(32).fill(0x11), previousCounter: 42, counter: 7, ciphertext: new Uint8Array(16).fill(0x22), nonce: new Uint8Array(12).fill(0x33), }; const envelopeRatchet: ShadeEnvelope = { type: 'ratchet', content: ratchetMsg, timestamp: 0, senderAddress: '', }; const bytesRatchet = encodeEnvelope(envelopeRatchet); const innerRatchet: RatchetMessage = { dhPublicKey: new Uint8Array(32).fill(0x44), previousCounter: 0, counter: 0, ciphertext: new Uint8Array(8).fill(0x55), nonce: new Uint8Array(12).fill(0x66), }; const preKeyMsgWithOTPK: PreKeyMessage = { registrationId: 0x12345678, preKeyId: 99, signedPreKeyId: 1, ephemeralKey: new Uint8Array(32).fill(0x77), identityDHKey: new Uint8Array(32).fill(0x88), message: innerRatchet, }; const envelopePreKey: ShadeEnvelope = { type: 'prekey', content: preKeyMsgWithOTPK, timestamp: 0, senderAddress: '', }; const bytesPreKey = encodeEnvelope(envelopePreKey); const preKeyMsgNoOTPK: PreKeyMessage = { registrationId: 1, preKeyId: undefined, signedPreKeyId: 1, ephemeralKey: new Uint8Array(32).fill(0x99), identityDHKey: new Uint8Array(32).fill(0xaa), message: innerRatchet, }; const bytesPreKeyNoOTPK = encodeEnvelope({ type: 'prekey', content: preKeyMsgNoOTPK, timestamp: 0, senderAddress: '', }); return [ { description: 'Wire format: RatchetMessage encoding (wire VERSION 0x02 — u32 length-prefixed)', kind: 'ratchet', message: { dhPublicKey: hex(ratchetMsg.dhPublicKey), previousCounter: ratchetMsg.previousCounter, counter: ratchetMsg.counter, ciphertext: hex(ratchetMsg.ciphertext), nonce: hex(ratchetMsg.nonce), }, encoded: hex(bytesRatchet), }, { description: 'Wire format: PreKeyMessage with one-time prekey (wire 0x02 type 0x01)', kind: 'prekey', message: { registrationId: preKeyMsgWithOTPK.registrationId, preKeyId: preKeyMsgWithOTPK.preKeyId ?? null, signedPreKeyId: preKeyMsgWithOTPK.signedPreKeyId, ephemeralKey: hex(preKeyMsgWithOTPK.ephemeralKey), identityDHKey: hex(preKeyMsgWithOTPK.identityDHKey), inner: { dhPublicKey: hex(innerRatchet.dhPublicKey), previousCounter: innerRatchet.previousCounter, counter: innerRatchet.counter, ciphertext: hex(innerRatchet.ciphertext), nonce: hex(innerRatchet.nonce), }, }, encoded: hex(bytesPreKey), }, { description: 'Wire format: PreKeyMessage without one-time prekey (preKeyId=null encoded as 0xFFFFFFFF)', kind: 'prekey', message: { registrationId: preKeyMsgNoOTPK.registrationId, preKeyId: null, signedPreKeyId: preKeyMsgNoOTPK.signedPreKeyId, ephemeralKey: hex(preKeyMsgNoOTPK.ephemeralKey), identityDHKey: hex(preKeyMsgNoOTPK.identityDHKey), inner: { dhPublicKey: hex(innerRatchet.dhPublicKey), previousCounter: innerRatchet.previousCounter, counter: innerRatchet.counter, ciphertext: hex(innerRatchet.ciphertext), nonce: hex(innerRatchet.nonce), }, }, encoded: hex(bytesPreKeyNoOTPK), }, ]; } // ─── Ratchet step vectors ────────────────────────────────── // // A ratchet "encrypt step" is fully deterministic given (rootKey, dhSendPriv, // dhRemotePub, plaintext, fixed nonce, counters). The vector records every // intermediate derivation so each implementation can verify byte-parity at // every layer (kdfRootKey → kdfChainKey → header AAD → AES-GCM ciphertext) and // also verify decrypt(ciphertext, nonce, aad, messageKey) === plaintext. async function generateRatchetStepVectors(): Promise { // Deterministic inputs const rootKey = new Uint8Array(32).fill(0xa1); const dhSendPriv = new Uint8Array(32).fill(0xb2); const dhSendPub = new Uint8Array(32).fill(0xb3); // not used in derivation, only AAD const dhRemotePub = new Uint8Array(32).fill(0xc4); const plaintext = new TextEncoder().encode('Shade ratchet roundtrip vector v1'); const fixedNonce = new Uint8Array(12).fill(0x5e); const previousCounter = 2; const counter = 0; // Step 1: DH between local send priv and remote pub const dhOutput = await crypto.x25519(dhSendPriv, dhRemotePub); // Step 2: kdfRootKey to advance root + get chain key const { newRootKey, chainKey } = await kdfRootKey(crypto, rootKey, dhOutput); // Step 3: kdfChainKey to derive messageKey const { newChainKey, messageKey } = await kdfChainKey(crypto, chainKey); // Step 4: Header AAD bytes const aad = encodeRatchetHeader(dhSendPub, previousCounter, counter); // Step 5: AES-GCM with deterministic nonce const ciphertext = await aesGcmEncryptDeterministic(messageKey, fixedNonce, plaintext, aad); return [ { description: 'Ratchet step: deterministic encrypt (kdfRootKey + kdfChainKey + AES-GCM with fixed nonce)', inputs: { rootKey: hex(rootKey), dhSendPrivateKey: hex(dhSendPriv), dhSendPublicKey: hex(dhSendPub), dhRemotePublicKey: hex(dhRemotePub), previousCounter, counter, plaintext: hex(plaintext), nonce: hex(fixedNonce), }, derived: { dhOutput: hex(dhOutput), newRootKey: hex(newRootKey), chainKey: hex(chainKey), newChainKey: hex(newChainKey), messageKey: hex(messageKey), aad: hex(aad), }, ciphertext: hex(ciphertext), }, ]; } // ─── Streams 0x11 vectors ────────────────────────────────── // // Covers the @shade/streams primitives that V3.5 §3 (M-Cross 3) requires // Kotlin to mirror byte-for-byte: HKDF labels with embedded NULs, u32-be // laneId encoding inside the lane-key info, deterministic (laneId, seq) // chunk nonces, the 29-byte chunk AAD, end-to-end chunk encrypt/decrypt, // and the wire 0x11 envelope encode/decode. async function generateStreamsVectors(): Promise { const streamSecret = new Uint8Array(32).fill(0xa1); const streamId = new Uint8Array(16).fill(0xb2); const streamKey = await deriveStreamKey(crypto, streamSecret, streamId); const laneIdsForKeys = [0, 1, 2, 0xffff_ffff]; const laneKeyVectors: Array<{ laneId: number; laneKey: string }> = []; for (const laneId of laneIdsForKeys) { const laneKey = await deriveLaneKey(crypto, streamKey, streamId, laneId); laneKeyVectors.push({ laneId, laneKey: hex(laneKey) }); } const noncePairs: Array<{ laneId: number; seq: bigint }> = [ { laneId: 0, seq: 0n }, { laneId: 0, seq: 1n }, { laneId: 1, seq: 0n }, { laneId: 0xffff_ffff, seq: 0xffff_ffff_ffff_fffen }, ]; const nonceVectors = noncePairs.map((p) => ({ laneId: p.laneId, seq: p.seq.toString(), nonce: hex(buildChunkNonce(p.laneId, p.seq)), })); const aadCases: Array<{ laneId: number; seq: bigint; isLast: boolean }> = [ { laneId: 0, seq: 0n, isLast: false }, { laneId: 1, seq: 7n, isLast: true }, { laneId: 0xffff_ffff, seq: 0xffff_ffff_ffff_fffen, isLast: false }, ]; const aadVectors = aadCases.map((c) => ({ laneId: c.laneId, seq: c.seq.toString(), isLast: c.isLast, aad: hex(buildChunkAad(streamId, c.laneId, c.seq, c.isLast)), })); // End-to-end chunk encrypt with lane 0, seq 0, isLast=true const laneId = 0; const seq = 0n; const isLast = true; const laneKey = await deriveLaneKey(crypto, streamKey, streamId, laneId); const nonce = buildChunkNonce(laneId, seq); const aad = buildChunkAad(streamId, laneId, seq, isLast); const plaintext = new TextEncoder().encode('Shade streams 0x11 chunk vector'); const ciphertext = await aesGcmEncryptWithNonce(laneKey, nonce, plaintext, aad); // Wire 0x11 envelope (extra-aad field = 0 bytes per current spec) const wire: StreamChunkWire = { streamId, laneId, seq, isLast, nonce, aad: new Uint8Array(0), ciphertext, }; const wireBytes = encodeStreamChunk(wire); // Sanity: roundtrip-decode locally so the recorded bytes are always parseable const decoded = decodeStreamChunk(wireBytes); if (hex(decoded.ciphertext) !== hex(ciphertext)) { throw new Error('streams wire 0x11 roundtrip diverged in generator'); } return [ { description: 'deriveStreamKey: HKDF(streamSecret, salt=streamId, info="shade-stream/v1\\0master")', streamSecret: hex(streamSecret), streamId: hex(streamId), streamKey: hex(streamKey), }, { description: 'deriveLaneKey: HKDF(streamKey, salt=streamId, info="shade-stream/v1\\0lane\\0" || u32_be(laneId))', streamKey: hex(streamKey), streamId: hex(streamId), lanes: laneKeyVectors, }, { description: 'buildChunkNonce(laneId, seq): u32_be(laneId) || u64_be(seq)', nonces: nonceVectors, }, { description: 'buildChunkAad(streamId, laneId, seq, isLast): streamId(16) || u32_be(laneId) || u64_be(seq) || u8(isLast)', streamId: hex(streamId), cases: aadVectors, }, { description: 'End-to-end chunk encrypt: AES-256-GCM(laneKey, nonce, plaintext, aad)', laneId, seq: seq.toString(), isLast, laneKey: hex(laneKey), nonce: hex(nonce), aad: hex(aad), plaintext: hex(plaintext), ciphertext: hex(ciphertext), }, { description: 'Wire 0x11 stream-chunk envelope encode/decode', streamId: hex(streamId), laneId, seq: seq.toString(), isLast, nonce: hex(nonce), extraAad: hex(new Uint8Array(0)), ciphertext: hex(ciphertext), encoded: hex(wireBytes), }, ]; } // ─── Backup format vectors ───────────────────────────────── // // Backup v1 derives an AES-256-GCM key from `(passphrase, salt)` via // HKDF-SHA256 with info `"ShadeBackupKey"`, then encrypts the payload. // The vector pins the HKDF output and an end-to-end encrypt/decrypt for // a known plaintext + fixed nonce. async function generateBackupVectors(): Promise { const passphrase = 'correct-horse-battery-staple'; const salt = new Uint8Array(32).fill(0xa5); const info = new TextEncoder().encode('ShadeBackupKey'); const backupKey = await crypto.hkdf( new TextEncoder().encode(passphrase), salt, info, 32, ); const plaintext = new TextEncoder().encode( JSON.stringify({ version: 1, identity: null, sessions: [] }), ); const fixedNonce = new Uint8Array(12).fill(0xc7); const ciphertext = await aesGcmEncryptDeterministic( backupKey, fixedNonce, plaintext, new Uint8Array(0), ); return [ { description: 'Backup v1: HKDF(passphrase_utf8, salt, info="ShadeBackupKey", 32) -> backupKey', passphrase, salt: hex(salt), info: 'ShadeBackupKey', backupKey: hex(backupKey), }, { description: 'Backup v1: AES-256-GCM(backupKey, plaintext, no AAD) with deterministic nonce', backupKey: hex(backupKey), nonce: hex(fixedNonce), plaintext: hex(plaintext), plaintextUtf8: new TextDecoder().decode(plaintext), ciphertext: hex(ciphertext), }, ]; } // ─── Group sender-keys vectors ───────────────────────────── // // Sender-key step pins three things: // 1. The 12-byte sender header AAD (`u16_be(gLen) || g || u16_be(sLen) || s || u32_be(iter)`) // 2. The chain-key advance (kdfChainKey) producing (newChainKey, messageKey) // 3. AES-256-GCM encrypt with deterministic nonce + Ed25519 signature // over `aad || ciphertext`. Ed25519 is deterministic so the signature // bytes are byte-parity-checkable cross-platform. async function generateGroupVectors(): Promise { // Static Ed25519 keypair (RFC 8032 §7.1 test vector 1) const signingPrivateKey = fromHex( '9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60', ); const signingPublicKey = fromHex( 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a', ); const groupId = 'group:42'; const senderAddress = 'alice@example.com'; const iteration = 5; const chainKey = new Uint8Array(32).fill(0x9b); const enc = new TextEncoder(); const gBytes = enc.encode(groupId); const sBytes = enc.encode(senderAddress); const aad = new Uint8Array(2 + gBytes.length + 2 + sBytes.length + 4); const aadView = new DataView(aad.buffer); let off = 0; aadView.setUint16(off, gBytes.length, false); off += 2; aad.set(gBytes, off); off += gBytes.length; aadView.setUint16(off, sBytes.length, false); off += 2; aad.set(sBytes, off); off += sBytes.length; aadView.setUint32(off, iteration, false); const { newChainKey, messageKey } = await kdfChainKey(crypto, chainKey); const fixedNonce = new Uint8Array(12).fill(0x7d); const plaintext = enc.encode('hello group'); const ciphertext = await aesGcmEncryptDeterministic(messageKey, fixedNonce, plaintext, aad); const signed = new Uint8Array(aad.length + ciphertext.length); signed.set(aad, 0); signed.set(ciphertext, aad.length); const signature = await crypto.sign(signingPrivateKey, signed); // Sanity: verify with the matching public key in the generator const ok = await crypto.verify(signingPublicKey, signed, signature); if (!ok) throw new Error('group sender-key signature verify failed in generator'); return [ { description: 'Sender header AAD: u16_be(gLen) || g || u16_be(sLen) || s || u32_be(iter)', groupId, senderAddress, iteration, aad: hex(aad), }, { description: 'Sender-key step: kdfChainKey + deterministic AES-GCM + Ed25519 sign(aad || ct)', chainKey: hex(chainKey), groupId, senderAddress, iteration, plaintext: hex(plaintext), nonce: hex(fixedNonce), signingPrivateKey: hex(signingPrivateKey), signingPublicKey: hex(signingPublicKey), newChainKey: hex(newChainKey), messageKey: hex(messageKey), aad: hex(aad), ciphertext: hex(ciphertext), signature: hex(signature), }, ]; } // ─── Storage-encryption HKDF parity ──────────────────────── // // `test-vectors/storage-encryption.json` already exists (V3.2). It pins // scrypt params + HKDF info templates + AAD templates. The Kotlin port // will need scrypt (likely via Bouncy Castle) before the full file can // be consumed; for now this generator emits a sub-vector covering only // the HKDF-storage-key + HKDF-field-key + deterministic-nonce derivations // — those Tink already supports. Bumps the `_ts_subset_version`. async function generateStorageEncryptionSubset(): Promise { const masterKey = new Uint8Array(32); for (let i = 0; i < 32; i++) masterKey[i] = i + 1; const storageInfo = new TextEncoder().encode('shade-storage-v1'); const storageKey = await crypto.hkdf(masterKey, new Uint8Array(0), storageInfo, 32); const fieldCases = [ { table: 'sessions', column: 'session' }, { table: 'identity', column: 'identity' }, { table: 'trusted_identities', column: 'trusted_identity' }, ]; const fieldKeys: Array<{ table: string; column: string; fieldKey: string }> = []; for (const c of fieldCases) { const info = new TextEncoder().encode(`shade-field-v1:${c.table}:${c.column}`); const k = await crypto.hkdf(storageKey, new Uint8Array(0), info, 32); fieldKeys.push({ table: c.table, column: c.column, fieldKey: hex(k) }); } const rowKey = new Uint8Array(32).fill(0xcd); const nonceCases = [ { table: 'sessions', pk: 'alice' }, { table: 'sessions', pk: 'bob' }, { table: 'identity', pk: '1' }, ]; const nonces: Array<{ table: string; pk: string; nonce: string }> = []; for (const c of nonceCases) { const info = new TextEncoder().encode(`shade-row-nonce-v1:${c.table}:${c.pk}`); const n = await crypto.hkdf(rowKey, new Uint8Array(0), info, 12); nonces.push({ table: c.table, pk: c.pk, nonce: hex(n) }); } return [ { description: 'Storage HKDF: storageKey = HKDF(masterKey, salt=0, info="shade-storage-v1", 32)', masterKey: hex(masterKey), storageKey: hex(storageKey), }, { description: 'Storage HKDF: fieldKey = HKDF(storageKey, salt=0, info="shade-field-v1:{table}:{column}", 32)', storageKey: hex(storageKey), fields: fieldKeys, }, { description: 'Storage HKDF: rowNonce = HKDF(rowKey, salt=0, info="shade-row-nonce-v1:{table}:{pk}", 12)', rowKey: hex(rowKey), nonces, }, ]; } async function main() { console.log('Generating cross-platform test vectors…'); const files: Array<[string, { vectors: Vector[] }]> = [ ['hkdf.json', { vectors: await generateHkdfVectors() }], ['kdf-chain.json', { vectors: await generateKdfChainVectors() }], ['x3dh.json', { vectors: await generateX3DHVectors() }], ['fingerprint.json', { vectors: await generateFingerprintVectors() }], ['wire-format.json', { vectors: await generateWireFormatVectors() }], ['ratchet-step.json', { vectors: await generateRatchetStepVectors() }], ['streams.json', { vectors: await generateStreamsVectors() }], ['backup.json', { vectors: await generateBackupVectors() }], ['group.json', { vectors: await generateGroupVectors() }], ['storage-hkdf.json', { vectors: await generateStorageEncryptionSubset() }], ]; for (const [name, data] of files) { const path = join(OUT_DIR, name); const versioned = { version: VECTOR_FILE_VERSION, ...data }; writeFileSync(path, JSON.stringify(versioned, null, 2) + '\n'); console.log(` ✓ ${name} (v${VECTOR_FILE_VERSION}, ${data.vectors.length} vectors)`); } console.log('Done.'); } main().catch((err) => { console.error(err); process.exit(1); });