293 lines
10 KiB
TypeScript
293 lines
10 KiB
TypeScript
|
|
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<void> {
|
||
|
|
await new Promise<void>((resolve) => {
|
||
|
|
const req = indexedDB.deleteDatabase(name);
|
||
|
|
req.onsuccess = () => resolve();
|
||
|
|
req.onerror = () => resolve();
|
||
|
|
req.onblocked = () => resolve();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const KEY_BYTES = randBytes(32);
|
||
|
|
async function freshKM(): Promise<KeyManager> {
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
});
|