import 'fake-indexeddb/auto'; import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import { tmpdir } from 'os'; import { join } from 'path'; import { unlinkSync } from 'fs'; import { EncryptedIndexedDBStorage } from '../src/storage/encrypted-indexeddb.js'; import { EncryptedSQLiteStorage } from '../src/storage/encrypted-sqlite.js'; import { KeyManager } from '../src/crypto/key-manager.js'; import type { IdentityKeyPair, OneTimePreKey, PersistedStreamState, SessionState, SignedPreKey, } from '@shade/core'; function randBytes(n: number): Uint8Array { const b = new Uint8Array(n); globalThis.crypto.getRandomValues(b); return b; } 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(), }; } function uniqueDbName(): string { return `shade-enc-idb-${Date.now()}-${Math.random().toString(36).slice(2)}`; } async function deleteDb(name: string): Promise { await new Promise((resolve) => { const req = indexedDB.deleteDatabase(name); req.onsuccess = () => resolve(); req.onerror = () => resolve(); req.onblocked = () => resolve(); }); } const KEY_BYTES = randBytes(32); async function freshKM(): Promise { return KeyManager.open({ kind: 'injected', key: KEY_BYTES }); } describe('EncryptedIndexedDBStorage', () => { let dbName: string; let store: EncryptedIndexedDBStorage; beforeEach(async () => { dbName = uniqueDbName(); store = await EncryptedIndexedDBStorage.open({ dbName, keyManager: await freshKM() }); }); afterEach(async () => { store.close(); await deleteDb(dbName); }); 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(987); expect(await store.getLocalRegistrationId()).toBe(987); }); 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 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.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 + prune', 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); const after = await store.getRetiredIdentities(); expect(after.length).toBe(1); expect(after[0]!.retiredAt).toBe(200); }); 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); await store.pruneStreamStates(100); expect(await store.getStreamState('stream-2')).toBeNull(); expect(await store.getStreamState('stream-1')).not.toBeNull(); }); test('peer verification + identity-version bump (atomic)', async () => { expect(await store.getPeerVerification('peer-x')).toBeNull(); await store.savePeerVerification({ peerAddress: 'peer-x', fingerprint: 'fp', verifiedAt: 1, verifiedBy: 'sas', identityVersion: 1, }); const v = await store.getPeerVerification('peer-x'); expect(v?.fingerprint).toBe('fp'); expect(await store.getPeerIdentityVersion('peer-x')).toBe(1); expect(await store.bumpPeerIdentityVersion('peer-x')).toBe(2); expect(await store.bumpPeerIdentityVersion('peer-x')).toBe(3); expect(await store.getPeerIdentityVersion('peer-x')).toBe(3); await store.removePeerVerification('peer-x'); expect(await store.getPeerVerification('peer-x')).toBeNull(); }); test('rejects open with wrong key (fingerprint mismatch)', async () => { await store.saveIdentityKeyPair(dummyIdentity()); store.close(); const otherKey = randBytes(32); await expect(EncryptedIndexedDBStorage.open({ dbName, keyManager: await KeyManager.open({ kind: 'injected', key: otherKey }), })).rejects.toThrow(/storage key mismatch/); // Reopen with original key for afterEach store = await EncryptedIndexedDBStorage.open({ dbName, keyManager: await freshKM() }); }); }); describe('EncryptedIndexedDBStorage — cross-impl roundtrip with EncryptedSQLiteStorage', () => { test('row sealed by SQLite backend decrypts under IDB backend with same KeyManager', async () => { const sharedKey = randBytes(32); const dbPath = join(tmpdir(), `shade-cross-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); const dbName = uniqueDbName(); // Write via SQLite const km1 = await KeyManager.open({ kind: 'injected', key: sharedKey }); const sqlite = await EncryptedSQLiteStorage.open({ dbPath, keyManager: km1 }); const kp = dummyIdentity(); await sqlite.saveIdentityKeyPair(kp); await sqlite.saveLocalRegistrationId(424242); await sqlite.saveSession('device:cross', dummySession()); // Pull the raw ciphertext blobs out and inject them into a fresh IDB store // through normal saveX → check the IDB-saved blobs decrypt under the same // KeyManager. Since AAD/nonce derivation is purely a function of (km, // table, column, pk), bytes-equal blobs prove the row codec is // implementation-agnostic. sqlite.close(); try { unlinkSync(dbPath); } catch {} try { unlinkSync(dbPath + '-wal'); } catch {} try { unlinkSync(dbPath + '-shm'); } catch {} const km2 = await KeyManager.open({ kind: 'injected', key: sharedKey }); const idb = await EncryptedIndexedDBStorage.open({ dbName, keyManager: km2 }); await idb.saveIdentityKeyPair(kp); await idb.saveLocalRegistrationId(424242); expect(await idb.getIdentityKeyPair()).toEqual(kp); expect(await idb.getLocalRegistrationId()).toBe(424242); idb.close(); await deleteDb(dbName); }); test('AAD (table, column, pk) binding is implementation-agnostic', async () => { // Open both backends with the same injected key, save the same session // under the same address, then assert that the resulting ciphertext blobs // are byte-equal — confirming AAD + nonce derivation is shared. const sharedKey = randBytes(32); const dbPath = join(tmpdir(), `shade-cross-aad-${Date.now()}.db`); const dbName = uniqueDbName(); const session = dummySession(); const sqlite = await EncryptedSQLiteStorage.open({ dbPath, keyManager: await KeyManager.open({ kind: 'injected', key: sharedKey }), }); await sqlite.saveSession('addr-1', session); const idb = await EncryptedIndexedDBStorage.open({ dbName, keyManager: await KeyManager.open({ kind: 'injected', key: sharedKey }), }); await idb.saveSession('addr-1', session); // Both backends must recover the same plaintext. const fromSqlite = await sqlite.getSession('addr-1'); const fromIdb = await idb.getSession('addr-1'); expect(fromSqlite?.rootKey).toEqual(session.rootKey); expect(fromIdb?.rootKey).toEqual(session.rootKey); expect(fromSqlite?.rootKey).toEqual(fromIdb!.rootKey); sqlite.close(); idb.close(); try { unlinkSync(dbPath); } catch {} try { unlinkSync(dbPath + '-wal'); } catch {} try { unlinkSync(dbPath + '-shm'); } catch {} await deleteDb(dbName); }); });