Files
Shade/packages/shade-storage-encrypted/tests/test-vectors.test.ts
Sterister e6fdf31b49
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
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
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>
2026-05-03 18:35:35 +02:00

97 lines
4.0 KiB
TypeScript

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);
});
});