release(v4.0.0): Shade GA — V3.x consolidation + audit prep
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
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>
This commit is contained in:
96
packages/shade-storage-encrypted/tests/test-vectors.test.ts
Normal file
96
packages/shade-storage-encrypted/tests/test-vectors.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user