Files
Shade/scripts/generate-vectors.ts
Sterister 4bf9307548
Some checks failed
Test / test (push) Has been cancelled
feat(android): M-Cross 1-3 — Kotlin module + cross-platform test vectors
Phase C complete: Shade now has a Kotlin implementation with byte-for-byte
compatibility to the TypeScript core, verified by shared test vectors.

M-Cross 1: shade-android Kotlin module
- build.gradle.kts with Tink, EncryptedSharedPreferences, kotlinx.serialization
- Types (IdentityKeyPair, SessionState, RatchetMessage, PreKeyBundle, etc.)
- CryptoProvider interface
- TinkProvider implementation (X25519, Ed25519, AES-GCM, HKDF, HMAC)
- KDF chain functions (kdfRootKey, kdfChainKey, deriveInitialRootKey)
  with the same info strings and salts as @shade/core
- Fingerprint (safety number) computation matching TS exactly
- X3DH protocol: identity gen, signed prekey gen, OTPK gen, bundle processing
- Double Ratchet: initSenderSession, initReceiverSession, ratchetEncrypt,
  ratchetDecrypt, DH ratchet step, skipped key cache
- Wire format matching @shade/proto byte-for-byte
- StorageProvider interface + MemoryStorage impl
- High-level ShadeSessionManager mirroring @shade/core's API

M-Cross 2: Cross-platform test vectors
- scripts/generate-vectors.ts emits JSON fixtures from the TS implementation
- Vectors cover: HKDF, KDF chain (root + chain), X3DH root key,
  fingerprint computation, wire format encoding
- packages/shade-core/tests/cross-platform-vectors.test.ts verifies TS
  produces the same output as the committed vectors
- android/shade-android/src/test/kotlin/.../CrossPlatformVectorTest.kt
  loads the SAME JSON and verifies Kotlin produces identical bytes

M-Cross 3: Nova Android migration plan
- android/shade-android/MIGRATION-NOVA.md — concrete steps to replace
  Nova's static PushKeyStore AES with Shade sessions
- Phase 1 (dual-write) / Phase 2 (switch reads) / Phase 3 (deprecate)
- Smoke test recipe for end-to-end TS → Kotlin push flow

251 tests passing on the TS side. Kotlin tests run via Gradle when
the Android SDK is available; the vectors guarantee they'll pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:45:38 +02:00

200 lines
6.4 KiB
TypeScript

#!/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.
*
* Usage: bun run scripts/generate-vectors.ts
*/
import { writeFileSync } from 'fs';
import { join } from 'path';
import { SubtleCryptoProvider, MemoryStorage } 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, decodeEnvelope } from '../packages/shade-proto/src/index.js';
import type { ShadeEnvelope, RatchetMessage } from '../packages/shade-core/src/index.js';
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]: any;
}
// ─── HKDF vectors ───────────────────────────────────────────
async function generateHkdfVectors(): Promise<Vector[]> {
const vectors: Vector[] = [];
// Known inputs → expected outputs
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 },
];
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<Vector[]> {
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<Vector[]> {
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<Vector[]> {
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<Vector[]> {
// Deterministic inputs
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);
return [
{
description: 'Wire format: RatchetMessage encoding',
message: {
dhPublicKey: hex(ratchetMsg.dhPublicKey),
previousCounter: ratchetMsg.previousCounter,
counter: ratchetMsg.counter,
ciphertext: hex(ratchetMsg.ciphertext),
nonce: hex(ratchetMsg.nonce),
},
encoded: hex(bytesRatchet),
},
];
}
async function main() {
console.log('Generating cross-platform test vectors…');
const files: Array<[string, any]> = [
['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() }],
];
for (const [name, data] of files) {
const path = join(OUT_DIR, name);
writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
console.log(`${name} (${data.vectors.length} vectors)`);
}
console.log('Done.');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});