Files
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

193 lines
7.0 KiB
TypeScript

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