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