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>
193 lines
7.0 KiB
TypeScript
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 {}
|
|
}
|
|
});
|
|
});
|