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

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:
2026-05-03 18:35:35 +02:00
parent 8b055912b7
commit e6fdf31b49
298 changed files with 37909 additions and 256 deletions

View File

@@ -0,0 +1,63 @@
import { describe, test, expect } from 'bun:test';
import { aeadOpen, aeadSeal, AEAD_NONCE_LEN, AEAD_TAG_LEN } from '../src/crypto/aead.js';
const TEXT = new TextEncoder();
describe('AEAD — basic seal/open', () => {
const key = new Uint8Array(32).fill(0xAA);
const nonce = new Uint8Array(AEAD_NONCE_LEN).fill(0x55);
const aad = TEXT.encode('shade-aad-v1|sessions|session|alice');
const pt = TEXT.encode('hello shade');
test('round-trips', async () => {
const blob = await aeadSeal(key, nonce, pt, aad);
expect(blob.length).toBe(AEAD_NONCE_LEN + pt.length + AEAD_TAG_LEN);
const opened = await aeadOpen(key, blob, aad);
expect(opened).toEqual(pt);
});
test('blob carries the nonce in the prefix', async () => {
const blob = await aeadSeal(key, nonce, pt, aad);
expect(blob.subarray(0, AEAD_NONCE_LEN)).toEqual(nonce);
});
test('rejects wrong key', async () => {
const blob = await aeadSeal(key, nonce, pt, aad);
const wrong = new Uint8Array(32).fill(0xBB);
await expect(aeadOpen(wrong, blob, aad)).rejects.toThrow();
});
test('rejects wrong AAD', async () => {
const blob = await aeadSeal(key, nonce, pt, aad);
await expect(aeadOpen(key, blob, TEXT.encode('different aad'))).rejects.toThrow();
});
test('rejects flipped ciphertext bit', async () => {
const blob = await aeadSeal(key, nonce, pt, aad);
const tampered = new Uint8Array(blob);
tampered[AEAD_NONCE_LEN + 2]! ^= 0x01;
await expect(aeadOpen(key, tampered, aad)).rejects.toThrow();
});
test('rejects flipped tag bit', async () => {
const blob = await aeadSeal(key, nonce, pt, aad);
const tampered = new Uint8Array(blob);
tampered[blob.length - 1]! ^= 0x01;
await expect(aeadOpen(key, tampered, aad)).rejects.toThrow();
});
test('rejects flipped nonce bit (mismatch with expected)', async () => {
const blob = await aeadSeal(key, nonce, pt, aad);
const tampered = new Uint8Array(blob);
tampered[1]! ^= 0x01;
await expect(aeadOpen(key, tampered, aad, nonce)).rejects.toThrow(/nonce mismatch/);
});
test('rejects too-short blob', async () => {
await expect(aeadOpen(key, new Uint8Array(10), aad)).rejects.toThrow();
});
test('rejects nonce of wrong size on seal', async () => {
await expect(aeadSeal(key, new Uint8Array(8), pt, aad)).rejects.toThrow();
});
});

View File

@@ -0,0 +1,230 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { tmpdir } from 'os';
import { join } from 'path';
import { unlinkSync } from 'fs';
import { Database } from 'bun:sqlite';
import { EncryptedSQLiteStorage } from '../src/storage/encrypted-sqlite.js';
import { KeyManager } from '../src/crypto/key-manager.js';
import type { IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState, PersistedStreamState } from '@shade/core';
function randBytes(n: number): Uint8Array {
const b = new Uint8Array(n);
globalThis.crypto.getRandomValues(b);
return b;
}
function tempDb(): string {
return join(tmpdir(), `shade-enc-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
}
const KEY_BYTES = randBytes(32);
async function freshKM(): Promise<KeyManager> {
return KeyManager.open({ kind: 'injected', key: KEY_BYTES });
}
function dummyIdentity(): IdentityKeyPair {
return {
signingPublicKey: randBytes(32),
signingPrivateKey: randBytes(32),
dhPublicKey: randBytes(32),
dhPrivateKey: randBytes(32),
};
}
function dummySignedPreKey(id: number): SignedPreKey {
return {
keyId: id,
keyPair: { publicKey: randBytes(32), privateKey: randBytes(32) },
signature: randBytes(64),
timestamp: Date.now(),
};
}
function dummyOTP(id: number): OneTimePreKey {
return {
keyId: id,
keyPair: { publicKey: randBytes(32), privateKey: randBytes(32) },
};
}
function dummySession(): SessionState {
return {
remoteIdentityKey: randBytes(32),
rootKey: randBytes(32),
sendChain: { chainKey: randBytes(32), counter: 0 },
receiveChain: { chainKey: randBytes(32), counter: 0 },
dhSend: { publicKey: randBytes(32), privateKey: randBytes(32) },
dhReceive: randBytes(32),
previousSendCounter: 0,
skippedKeys: new Map(),
};
}
describe('EncryptedSQLiteStorage', () => {
let dbPath: string;
let store: EncryptedSQLiteStorage;
beforeEach(async () => {
dbPath = tempDb();
store = await EncryptedSQLiteStorage.open({ dbPath, keyManager: await freshKM() });
});
afterEach(() => {
store.close();
try { unlinkSync(dbPath); } catch {}
try { unlinkSync(dbPath + '-wal'); } catch {}
try { unlinkSync(dbPath + '-shm'); } catch {}
});
test('identity round-trip', async () => {
expect(await store.getIdentityKeyPair()).toBeNull();
const kp = dummyIdentity();
await store.saveIdentityKeyPair(kp);
const got = await store.getIdentityKeyPair();
expect(got).toEqual(kp);
});
test('registrationId round-trip', async () => {
expect(await store.getLocalRegistrationId()).toBe(0);
await store.saveLocalRegistrationId(12345);
expect(await store.getLocalRegistrationId()).toBe(12345);
});
test('signed prekey round-trip + remove', async () => {
expect(await store.getSignedPreKey(7)).toBeNull();
const k = dummySignedPreKey(7);
await store.saveSignedPreKey(k);
const got = await store.getSignedPreKey(7);
expect(got?.keyId).toBe(7);
expect(got?.keyPair.privateKey).toEqual(k.keyPair.privateKey);
await store.removeSignedPreKey(7);
expect(await store.getSignedPreKey(7)).toBeNull();
});
test('one-time prekey round-trip + count + remove', async () => {
expect(await store.getOneTimePreKeyCount()).toBe(0);
await store.saveOneTimePreKey(dummyOTP(1));
await store.saveOneTimePreKey(dummyOTP(2));
expect(await store.getOneTimePreKeyCount()).toBe(2);
expect(await store.getOneTimePreKey(1)).not.toBeNull();
await store.removeOneTimePreKey(1);
expect(await store.getOneTimePreKey(1)).toBeNull();
expect(await store.getOneTimePreKeyCount()).toBe(1);
});
test('session round-trip + remove', async () => {
const s = dummySession();
await store.saveSession('device:abc', s);
const got = await store.getSession('device:abc');
expect(got?.rootKey).toEqual(s.rootKey);
await store.removeSession('device:abc');
expect(await store.getSession('device:abc')).toBeNull();
});
test('TOFU + trust check', async () => {
const ik = randBytes(32);
expect(await store.isTrustedIdentity('peer-1', ik)).toBe(true); // TOFU
await store.saveTrustedIdentity('peer-1', ik);
expect(await store.isTrustedIdentity('peer-1', ik)).toBe(true);
expect(await store.isTrustedIdentity('peer-1', randBytes(32))).toBe(false);
});
test('retired identities are sorted DESC', async () => {
await store.addRetiredIdentity({ keyPair: dummyIdentity(), retiredAt: 100 });
await store.addRetiredIdentity({ keyPair: dummyIdentity(), retiredAt: 200 });
const list = await store.getRetiredIdentities();
expect(list.length).toBe(2);
expect(list[0]!.retiredAt).toBe(200);
await store.pruneRetiredIdentities(150);
expect((await store.getRetiredIdentities()).length).toBe(1);
});
test('stream-state round-trip + listActive + prune', async () => {
const s: PersistedStreamState = {
streamId: 'stream-1',
direction: 'send',
peerAddress: 'device:bob',
status: 'active',
metadataJson: '{"name":"file.bin"}',
partitionJson: '[]',
laneStateJson: '[]',
ioDescriptorJson: '{"path":"/tmp/x"}',
secretEnc: randBytes(32),
secretNonce: randBytes(12),
createdAt: 1,
updatedAt: 2,
};
await store.saveStreamState(s);
const got = await store.getStreamState('stream-1');
expect(got).toEqual(s);
const active = await store.listActiveStreamStates();
expect(active.length).toBe(1);
expect((await store.listActiveStreamStates('receive')).length).toBe(0);
await store.saveStreamState({ ...s, streamId: 'stream-2', status: 'finished', updatedAt: 50 });
expect((await store.listActiveStreamStates()).length).toBe(1); // only stream-1 still active
await store.pruneStreamStates(100);
expect(await store.getStreamState('stream-2')).toBeNull();
expect(await store.getStreamState('stream-1')).not.toBeNull(); // active rows untouched
});
test('rejects open with wrong key (fingerprint mismatch)', async () => {
await store.saveIdentityKeyPair(dummyIdentity());
store.close();
const otherKey = randBytes(32);
await expect(EncryptedSQLiteStorage.open({
dbPath,
keyManager: await KeyManager.open({ kind: 'injected', key: otherKey }),
})).rejects.toThrow(/storage key mismatch/);
// Reopen with original key for afterEach
store = await EncryptedSQLiteStorage.open({ dbPath, keyManager: await freshKM() });
});
});
describe('EncryptedSQLiteStorage — tamper detection', () => {
test('flipped ciphertext byte → decrypt fails', async () => {
const dbPath = tempDb();
const store = await EncryptedSQLiteStorage.open({ dbPath, keyManager: await freshKM() });
await store.saveIdentityKeyPair(dummyIdentity());
store.close();
// Tamper with the ciphertext directly via raw SQLite.
const db = new Database(dbPath);
const row = db.prepare('SELECT ciphertext FROM identity_enc WHERE id = 1').get() as { ciphertext: Uint8Array };
const ct = new Uint8Array(row.ciphertext);
ct[ct.length - 1]! ^= 0x01;
db.prepare('UPDATE identity_enc SET ciphertext = ? WHERE id = 1').run(ct);
db.close();
const reopened = await EncryptedSQLiteStorage.open({ dbPath, keyManager: await freshKM() });
await expect(reopened.getIdentityKeyPair()).rejects.toThrow();
reopened.close();
try { unlinkSync(dbPath); } catch {}
try { unlinkSync(dbPath + '-wal'); } catch {}
try { unlinkSync(dbPath + '-shm'); } catch {}
});
test('row swap (sessions) → decrypt fails due to AAD mismatch', async () => {
const dbPath = tempDb();
const store = await EncryptedSQLiteStorage.open({ dbPath, keyManager: await freshKM() });
await store.saveSession('alice', dummySession());
await store.saveSession('bob', dummySession());
store.close();
// Swap the ciphertexts.
const db = new Database(dbPath);
const aliceRow = db.prepare('SELECT ciphertext FROM sessions_enc WHERE address = ?').get('alice') as { ciphertext: Uint8Array };
const bobRow = db.prepare('SELECT ciphertext FROM sessions_enc WHERE address = ?').get('bob') as { ciphertext: Uint8Array };
db.prepare('UPDATE sessions_enc SET ciphertext = ? WHERE address = ?').run(bobRow.ciphertext, 'alice');
db.prepare('UPDATE sessions_enc SET ciphertext = ? WHERE address = ?').run(aliceRow.ciphertext, 'bob');
db.close();
const reopened = await EncryptedSQLiteStorage.open({ dbPath, keyManager: await freshKM() });
await expect(reopened.getSession('alice')).rejects.toThrow();
await expect(reopened.getSession('bob')).rejects.toThrow();
reopened.close();
try { unlinkSync(dbPath); } catch {}
try { unlinkSync(dbPath + '-wal'); } catch {}
try { unlinkSync(dbPath + '-shm'); } catch {}
});
});

View File

@@ -0,0 +1,107 @@
import { describe, test, expect } from 'bun:test';
import {
buildAad, deriveFieldKey, deriveMasterKey, deriveNonce, deriveStorageKey,
hkdfDerive, DEFAULT_SCRYPT,
} from '../src/crypto/kdf.js';
describe('KDF — masterKey', () => {
const salt = new Uint8Array(16).fill(0x42);
test('deriveMasterKey is deterministic for the same input', async () => {
const fast = { ...DEFAULT_SCRYPT, N: 1 << 10 };
const a = await deriveMasterKey('correct-horse-battery-staple', salt, fast);
const b = await deriveMasterKey('correct-horse-battery-staple', salt, fast);
expect(a).toEqual(b);
expect(a.length).toBe(32);
});
test('different passphrase → different masterKey', async () => {
const fast = { ...DEFAULT_SCRYPT, N: 1 << 10 };
const a = await deriveMasterKey('alpha', salt, fast);
const b = await deriveMasterKey('beta', salt, fast);
expect(a).not.toEqual(b);
});
test('different salt → different masterKey', async () => {
const fast = { ...DEFAULT_SCRYPT, N: 1 << 10 };
const otherSalt = new Uint8Array(16).fill(0x43);
const a = await deriveMasterKey('p', salt, fast);
const b = await deriveMasterKey('p', otherSalt, fast);
expect(a).not.toEqual(b);
});
test('NFKC-normalises passphrase', async () => {
const fast = { ...DEFAULT_SCRYPT, N: 1 << 10 };
// U+00E9 (é, single codepoint) vs U+0065 U+0301 (e + combining acute)
const composed = await deriveMasterKey('café', salt, fast);
const decomposed = await deriveMasterKey('café', salt, fast);
expect(composed).toEqual(decomposed);
});
test('rejects empty passphrase', async () => {
await expect(deriveMasterKey('', salt)).rejects.toThrow(/non-empty/);
});
test('rejects too-short salt', async () => {
await expect(deriveMasterKey('p', new Uint8Array(8))).rejects.toThrow(/at least 16/);
});
});
describe('KDF — derivation chain', () => {
test('storageKey is HKDF("shade-storage-v1")', () => {
const master = new Uint8Array(32).fill(7);
const sk = deriveStorageKey(master);
const expected = hkdfDerive(master, 'shade-storage-v1', 32);
expect(sk).toEqual(expected);
expect(sk.length).toBe(32);
});
test('fieldKey changes per (table, column)', () => {
const sk = new Uint8Array(32).fill(9);
const a = deriveFieldKey(sk, 'sessions', 'session');
const b = deriveFieldKey(sk, 'sessions', 'identity');
const c = deriveFieldKey(sk, 'identity', 'session');
expect(a).not.toEqual(b);
expect(a).not.toEqual(c);
expect(b).not.toEqual(c);
});
test('nonce is deterministic per (rowKey, table, pk)', () => {
const k = new Uint8Array(32).fill(11);
const n1 = deriveNonce(k, 'sessions', 'alice');
const n2 = deriveNonce(k, 'sessions', 'alice');
expect(n1).toEqual(n2);
expect(n1.length).toBe(12);
});
test('nonce changes when pk changes', () => {
const k = new Uint8Array(32).fill(11);
const n1 = deriveNonce(k, 'sessions', 'alice');
const n2 = deriveNonce(k, 'sessions', 'bob');
expect(n1).not.toEqual(n2);
});
test('nonce changes when table changes', () => {
const k = new Uint8Array(32).fill(11);
const n1 = deriveNonce(k, 'sessions', 'alice');
const n2 = deriveNonce(k, 'identity', 'alice');
expect(n1).not.toEqual(n2);
});
});
describe('KDF — AAD binding', () => {
test('buildAad encodes (table, column, pk)', () => {
const aad = buildAad('sessions', 'session', 'alice');
expect(new TextDecoder().decode(aad)).toBe('shade-aad-v1|sessions|session|alice');
});
test('AAD differs for any change in binding tuple', () => {
const a = buildAad('sessions', 'session', 'alice');
const b = buildAad('sessions', 'session', 'bob');
const c = buildAad('sessions', 'trust', 'alice');
const d = buildAad('identity', 'session', 'alice');
expect(a).not.toEqual(b);
expect(a).not.toEqual(c);
expect(a).not.toEqual(d);
});
});

View File

@@ -0,0 +1,192 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { tmpdir } from 'os';
import { join } from 'path';
import { unlinkSync, existsSync, readdirSync } from 'fs';
import { Database } from 'bun:sqlite';
import { SQLiteStorage } from '@shade/storage-sqlite';
import { EncryptedSQLiteStorage } from '../src/storage/encrypted-sqlite.js';
import { KeyManager } from '../src/crypto/key-manager.js';
import { migrateSqliteToEncrypted, rotateSqliteEncryptionKey } from '../src/migrate/migrate-sqlite.js';
function randBytes(n: number): Uint8Array {
const b = new Uint8Array(n);
globalThis.crypto.getRandomValues(b);
return b;
}
function tempDb(): string {
return join(tmpdir(), `shade-migrate-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
}
function dummyIdentity() {
return {
signingPublicKey: randBytes(32),
signingPrivateKey: randBytes(32),
dhPublicKey: randBytes(32),
dhPrivateKey: randBytes(32),
};
}
function dummySignedPreKey(id: number) {
return {
keyId: id,
keyPair: { publicKey: randBytes(32), privateKey: randBytes(32) },
signature: randBytes(64),
timestamp: Date.now(),
};
}
function dummySession() {
return {
remoteIdentityKey: randBytes(32),
rootKey: randBytes(32),
sendChain: { chainKey: randBytes(32), counter: 0 },
receiveChain: { chainKey: randBytes(32), counter: 0 },
dhSend: { publicKey: randBytes(32), privateKey: randBytes(32) },
dhReceive: randBytes(32),
previousSendCounter: 0,
skippedKeys: new Map<string, Uint8Array>(),
};
}
describe('migrateSqliteToEncrypted', () => {
let dbPath: string;
beforeEach(() => {
dbPath = tempDb();
});
afterEach(() => {
for (const f of [dbPath, dbPath + '-wal', dbPath + '-shm']) {
try { unlinkSync(f); } catch {}
}
// Clean up any .bak files left in the temp dir.
const dir = tmpdir();
for (const name of readdirSync(dir)) {
if (name.startsWith(`shade-migrate-`) && name.includes('.bak.')) {
try { unlinkSync(join(dir, name)); } catch {}
}
}
});
test('migrates a populated unencrypted DB', async () => {
const id = dummyIdentity();
const sk = dummySignedPreKey(1);
const sess = dummySession();
const src = new SQLiteStorage(dbPath);
await src.saveIdentityKeyPair(id);
await src.saveLocalRegistrationId(99);
await src.saveSignedPreKey(sk);
await src.saveSession('alice', sess);
await src.saveTrustedIdentity('alice', id.dhPublicKey);
await src.addRetiredIdentity({ keyPair: dummyIdentity(), retiredAt: 1234 });
src.close();
const masterKey = randBytes(32);
const km = await KeyManager.open({ kind: 'injected', key: masterKey });
const report = await migrateSqliteToEncrypted({ dbPath, keyManager: km, backup: false });
expect(report.identity).toBe(1);
expect(report.config).toBe(1);
expect(report.signedPrekeys).toBe(1);
expect(report.sessions).toBe(1);
expect(report.trustedIdentities).toBe(1);
expect(report.retiredIdentities).toBe(1);
// Verify we can read everything back with the same masterKey.
const km2 = await KeyManager.open({ kind: 'injected', key: masterKey });
const enc = await EncryptedSQLiteStorage.open({ dbPath, keyManager: km2 });
expect(await enc.getIdentityKeyPair()).not.toBeNull();
expect(await enc.getLocalRegistrationId()).toBe(99);
expect(await enc.getSession('alice')).not.toBeNull();
expect(await enc.getSignedPreKey(1)).not.toBeNull();
expect((await enc.getRetiredIdentities()).length).toBe(1);
enc.close();
});
test('--dry-run leaves DB unchanged', async () => {
const src = new SQLiteStorage(dbPath);
await src.saveIdentityKeyPair(dummyIdentity());
await src.saveSession('alice', dummySession());
src.close();
const km = await KeyManager.open({ kind: 'injected', key: randBytes(32) });
const report = await migrateSqliteToEncrypted({ dbPath, keyManager: km, dryRun: true, backup: false });
expect(report.dryRun).toBe(true);
// Original tables still present and populated.
const db = new Database(dbPath);
const idCount = (db.prepare('SELECT COUNT(*) as c FROM identity').get() as { c: number }).c;
expect(idCount).toBe(1);
const sessCount = (db.prepare('SELECT COUNT(*) as c FROM sessions').get() as { c: number }).c;
expect(sessCount).toBe(1);
db.close();
});
test('drops unencrypted tables after successful migration', async () => {
const src = new SQLiteStorage(dbPath);
await src.saveIdentityKeyPair(dummyIdentity());
src.close();
const km = await KeyManager.open({ kind: 'injected', key: randBytes(32) });
await migrateSqliteToEncrypted({ dbPath, keyManager: km, backup: false });
const db = new Database(dbPath);
const r = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='identity'").get();
expect(r).toBeNull();
db.close();
});
test('produces .bak file when backup enabled', async () => {
const src = new SQLiteStorage(dbPath);
await src.saveIdentityKeyPair(dummyIdentity());
src.close();
const km = await KeyManager.open({ kind: 'injected', key: randBytes(32) });
const report = await migrateSqliteToEncrypted({ dbPath, keyManager: km, backup: true });
expect(report.backupPath).toBeDefined();
expect(existsSync(report.backupPath!)).toBe(true);
try { unlinkSync(report.backupPath!); } catch {}
});
});
describe('rotateSqliteEncryptionKey', () => {
test('re-keys all rows; old key no longer opens DB', async () => {
const dbPath = tempDb();
const oldKeyBytes = randBytes(32);
const newKeyBytes = randBytes(32);
const oldKm = await KeyManager.open({ kind: 'injected', key: oldKeyBytes });
const store = await EncryptedSQLiteStorage.open({ dbPath, keyManager: oldKm });
await store.saveIdentityKeyPair(dummyIdentity());
await store.saveSession('alice', dummySession());
await store.saveSignedPreKey(dummySignedPreKey(1));
store.close();
const oldKmAgain = await KeyManager.open({ kind: 'injected', key: oldKeyBytes });
const newKm = await KeyManager.open({ kind: 'injected', key: newKeyBytes });
const result = await rotateSqliteEncryptionKey({
dbPath,
oldKeyManager: oldKmAgain,
newKeyManager: newKm,
});
expect(result.rowsRotated).toBeGreaterThan(0);
// Old key is rejected.
const oldKmOnceMore = await KeyManager.open({ kind: 'injected', key: oldKeyBytes });
await expect(EncryptedSQLiteStorage.open({ dbPath, keyManager: oldKmOnceMore }))
.rejects.toThrow(/storage key mismatch/);
// New key works.
const newKmAgain = await KeyManager.open({ kind: 'injected', key: newKeyBytes });
const reopened = await EncryptedSQLiteStorage.open({ dbPath, keyManager: newKmAgain });
expect(await reopened.getIdentityKeyPair()).not.toBeNull();
expect(await reopened.getSession('alice')).not.toBeNull();
reopened.close();
for (const f of [dbPath, dbPath + '-wal', dbPath + '-shm']) {
try { unlinkSync(f); } catch {}
}
});
});

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