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>
686 lines
24 KiB
TypeScript
686 lines
24 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.
|
|
*
|
|
* Schema: every file is `{ "version": <int>, "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<Uint8Array> {
|
|
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<Vector[]> {
|
|
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<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[]> {
|
|
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<Vector[]> {
|
|
// 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<Vector[]> {
|
|
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<Vector[]> {
|
|
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<Vector[]> {
|
|
// 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<Vector[]> {
|
|
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);
|
|
});
|