350 lines
15 KiB
TypeScript
350 lines
15 KiB
TypeScript
/**
|
||
* AriaEngine — 全库 AES-256-GCM 加密测试
|
||
*
|
||
* 覆盖:
|
||
* 1. EncryptedBackend 单元:读写往返 / writeMany / delete / clear / 密文落盘验证
|
||
* 2. AriaEngine 集成:密码往返(open → 写入 → close → 同密码重开数据完整)
|
||
* 3. 错误密码 → ARIA_DECRYPT_ERROR
|
||
* 4. 明文旧库 + 加密配置 → ARIA_ENCRYPT_CONFIG_ERROR
|
||
* 5. 加密库 + 无密码 → ARIA_ENCRYPT_REQUIRED
|
||
* 6. clearAll 后可换新密码重建
|
||
* 7. 元数据(迁移版本)加密往返
|
||
*/
|
||
import { AriaEngine } from '../../src/engine/aria/index';
|
||
import { EncryptedBackend } from '../../src/engine/aria/store/encrypted_backend';
|
||
import { MemoryBackend } from '../../src/engine/aria/store/backend';
|
||
import { createSchema } from '../../src/table/schema';
|
||
import 'fake-indexeddb/auto';
|
||
|
||
let idbCounter = 0;
|
||
function uniqueDB(): string {
|
||
return `enc-${Date.now()}-${++idbCounter}-${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
const SCHEMA = () => createSchema('users', {
|
||
id: { type: 'string', primaryKey: true },
|
||
name: { type: 'string' },
|
||
secret: { type: 'string' },
|
||
});
|
||
|
||
// ===================================================================
|
||
// EncryptedBackend 单元
|
||
// ===================================================================
|
||
describe('AriaEngine — EncryptedBackend 单元', () => {
|
||
it('open 创建 keymeta,读写往返完整', async () => {
|
||
const inner = new MemoryBackend();
|
||
const backend = new EncryptedBackend(inner, 's3cret-password');
|
||
await backend.open('enc-unit-1');
|
||
|
||
// keymeta 已创建
|
||
const keymeta = await inner.read('__aria_keymeta');
|
||
expect(keymeta).not.toBeNull();
|
||
|
||
// 读写往返
|
||
const payload = new TextEncoder().encode('hello encrypted world').buffer;
|
||
await backend.write('k1', payload);
|
||
const back = await backend.read('k1');
|
||
expect(back).not.toBeNull();
|
||
expect(new TextDecoder().decode(back)).toBe('hello encrypted world');
|
||
|
||
// 底层是密文(非明文)
|
||
const raw = await inner.read('k1');
|
||
const rawStr = new TextDecoder().decode(raw);
|
||
expect(rawStr).not.toContain('hello encrypted world');
|
||
|
||
await backend.close();
|
||
});
|
||
|
||
it('writeMany / deleteMany 往返', async () => {
|
||
const inner = new MemoryBackend();
|
||
const backend = new EncryptedBackend(inner, 'pw');
|
||
await backend.open('enc-unit-2');
|
||
|
||
const enc = (s: string) => new TextEncoder().encode(s).buffer;
|
||
await backend.writeMany({ a: enc('AAA'), b: enc('BBB'), c: enc('CCC') });
|
||
expect(new TextDecoder().decode(await backend.read('a'))).toBe('AAA');
|
||
expect(new TextDecoder().decode(await backend.read('c'))).toBe('CCC');
|
||
|
||
await backend.deleteMany(['a', 'c']);
|
||
expect(await backend.exists('a')).toBe(false);
|
||
expect(await backend.exists('b')).toBe(true);
|
||
|
||
await backend.delete('b');
|
||
expect(await backend.exists('b')).toBe(false);
|
||
expect(await backend.listKeys()).toEqual(['__aria_keymeta']);
|
||
await backend.close();
|
||
});
|
||
|
||
it('每个 value 独立随机 IV(相同明文两次加密密文不同)', async () => {
|
||
const inner = new MemoryBackend();
|
||
const backend = new EncryptedBackend(inner, 'pw');
|
||
await backend.open('enc-unit-3');
|
||
|
||
const payload = new TextEncoder().encode('same plaintext').buffer;
|
||
await backend.write('x', payload);
|
||
await backend.write('y', payload);
|
||
const rawX = await inner.read('x');
|
||
const rawY = await inner.read('y');
|
||
// Jest toEqual 对 ArrayBuffer 不比较内容 → 字节级比较
|
||
expect(new Uint8Array(rawX as ArrayBuffer)).not.toEqual(new Uint8Array(rawY as ArrayBuffer));
|
||
// 但 IV 前 12 字节后的长度一致(密文长度 = 明文 + GCM tag 16B)
|
||
expect((rawX as ArrayBuffer).byteLength).toBe((rawY as ArrayBuffer).byteLength);
|
||
|
||
// 但解密一致
|
||
expect(new TextDecoder().decode(await backend.read('x'))).toBe('same plaintext');
|
||
expect(new TextDecoder().decode(await backend.read('y'))).toBe('same plaintext');
|
||
await backend.close();
|
||
});
|
||
|
||
it('clear 清空全部数据但保留密钥元数据(库身份保留)', async () => {
|
||
const inner = new MemoryBackend();
|
||
const backend = new EncryptedBackend(inner, 'pw');
|
||
await backend.open('enc-unit-4');
|
||
await backend.write('k', new TextEncoder().encode('v').buffer);
|
||
expect((await backend.listKeys()).length).toBe(2);
|
||
await backend.clear();
|
||
// 数据清空,keymeta 保留
|
||
expect(await backend.listKeys()).toEqual(['__aria_keymeta']);
|
||
expect(await backend.exists('k')).toBe(false);
|
||
await backend.close();
|
||
|
||
// 重新打开:keymeta 仍在,同密码可用
|
||
const backend2 = new EncryptedBackend(inner, 'pw');
|
||
await backend2.open('enc-unit-4');
|
||
await backend2.write('k2', new TextEncoder().encode('v2').buffer);
|
||
expect(new TextDecoder().decode(await backend2.read('k2'))).toBe('v2');
|
||
|
||
// 换密码被拒(keymeta 与旧密码绑定)——注意:MemoryBackend close 清空 store,
|
||
// 因此在 close 前验证(backend2 仍持有 inner)
|
||
const backend3 = new EncryptedBackend(inner, 'other-pw');
|
||
await expect(backend3.open('enc-unit-4')).rejects.toMatchObject({ code: 'ARIA_DECRYPT_ERROR' });
|
||
await backend2.close();
|
||
});
|
||
|
||
it('明文旧库(有数据无 keymeta)+ 加密 → ARIA_ENCRYPT_CONFIG_ERROR', async () => {
|
||
const inner = new MemoryBackend();
|
||
await inner.open('enc-unit-5');
|
||
await inner.write('__aria_schemas', new TextEncoder().encode('{}').buffer);
|
||
|
||
const backend = new EncryptedBackend(inner, 'pw');
|
||
await expect(backend.open('enc-unit-5')).rejects.toMatchObject({ code: 'ARIA_ENCRYPT_CONFIG_ERROR' });
|
||
});
|
||
|
||
it('空密码配置直接抛错', () => {
|
||
const inner = new MemoryBackend();
|
||
try {
|
||
new EncryptedBackend(inner, '');
|
||
fail('should throw');
|
||
} catch (error) {
|
||
expect((error as { code?: string }).code).toBe('ARIA_ENCRYPT_CONFIG_ERROR');
|
||
}
|
||
});
|
||
|
||
it('getInner / isCryptoReady 访问器', async () => {
|
||
const inner = new MemoryBackend();
|
||
const backend = new EncryptedBackend(inner, 'pw');
|
||
await backend.open('enc-unit-6');
|
||
expect(backend.getInner()).toBe(inner);
|
||
expect(backend.isCryptoReady()).toBe(true);
|
||
await backend.close();
|
||
expect(backend.isCryptoReady()).toBe(false);
|
||
});
|
||
|
||
it('密文块被篡改 → 读取抛 ARIA_DECRYPT_ERROR(GCM 认证失败)', async () => {
|
||
const inner = new MemoryBackend();
|
||
const backend = new EncryptedBackend(inner, 'pw');
|
||
await backend.open('enc-unit-7');
|
||
await backend.write('k', new TextEncoder().encode('integrity-check').buffer);
|
||
|
||
// 篡改密文一个字节(绕过 IV 区)
|
||
const raw = (await inner.read('k')) as ArrayBuffer;
|
||
const buf = new Uint8Array(raw);
|
||
buf[16] ^= 0xff;
|
||
await inner.write('k', buf.buffer as ArrayBuffer);
|
||
|
||
await expect(backend.read('k')).rejects.toMatchObject({ code: 'ARIA_DECRYPT_ERROR' });
|
||
await backend.close();
|
||
});
|
||
|
||
it('过短密文块 → 抛 ARIA_DECRYPT_ERROR', async () => {
|
||
const inner = new MemoryBackend();
|
||
const backend = new EncryptedBackend(inner, 'pw');
|
||
await backend.open('enc-unit-8');
|
||
await inner.write('short', new Uint8Array(4).buffer); // 不足 IV(12) + tag
|
||
await expect(backend.read('short')).rejects.toMatchObject({ code: 'ARIA_DECRYPT_ERROR' });
|
||
await backend.close();
|
||
});
|
||
});
|
||
|
||
// ===================================================================
|
||
// AriaEngine 集成 — 加密往返
|
||
// ===================================================================
|
||
describe('AriaEngine — 全库加密(集成)', () => {
|
||
it('加密库:写入 → close → 同密码重开数据完整', async () => {
|
||
const dbName = uniqueDB();
|
||
const engine = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'master-pass' } });
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(SCHEMA());
|
||
await engine.insert('users', [
|
||
{ id: '1', name: 'Alice', secret: 'top-secret-a' },
|
||
{ id: '2', name: 'Bob', secret: 'top-secret-b' },
|
||
]);
|
||
await engine.setMeta('__metona_version', '3');
|
||
await engine.close();
|
||
|
||
// 重开(同密码)
|
||
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'master-pass' } });
|
||
await engine2.open(dbName, 1);
|
||
const rows = await engine2.find('users', { table: 'users' });
|
||
expect(rows).toHaveLength(2);
|
||
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
|
||
expect(byId['1'].secret).toBe('top-secret-a');
|
||
expect(byId['2'].secret).toBe('top-secret-b');
|
||
expect(await engine2.getMeta('__metona_version')).toBe('3');
|
||
await engine2.close();
|
||
});
|
||
|
||
it('加密库:错误密码重开 → ARIA_DECRYPT_ERROR', async () => {
|
||
const dbName = uniqueDB();
|
||
const engine = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'right-pass' } });
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(SCHEMA());
|
||
await engine.insert('users', [{ id: '1', name: 'A', secret: 'x' }]);
|
||
await engine.close();
|
||
|
||
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'wrong-pass' } });
|
||
await expect(engine2.open(dbName, 1)).rejects.toMatchObject({ code: 'ARIA_DECRYPT_ERROR' });
|
||
});
|
||
|
||
it('加密库:无密码打开 → ARIA_ENCRYPT_REQUIRED', async () => {
|
||
const dbName = uniqueDB();
|
||
const engine = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'pw' } });
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(SCHEMA());
|
||
await engine.close();
|
||
|
||
const engine2 = new AriaEngine({ storageBackend: 'indexeddb' });
|
||
await expect(engine2.open(dbName, 1)).rejects.toMatchObject({ code: 'ARIA_ENCRYPT_REQUIRED' });
|
||
});
|
||
|
||
it('明文库:加密配置打开 → ARIA_ENCRYPT_CONFIG_ERROR', async () => {
|
||
const dbName = uniqueDB();
|
||
const engine = new AriaEngine({ storageBackend: 'indexeddb' });
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(SCHEMA());
|
||
await engine.insert('users', [{ id: '1', name: 'A', secret: 'plain' }]);
|
||
await engine.close();
|
||
|
||
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'pw' } });
|
||
await expect(engine2.open(dbName, 1)).rejects.toMatchObject({ code: 'ARIA_ENCRYPT_CONFIG_ERROR' });
|
||
});
|
||
|
||
it('加密库:底层存储全部为密文(SSTable/Schema/WAL 均不可见明文)', async () => {
|
||
const dbName = uniqueDB();
|
||
const engine = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'pw' } });
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(SCHEMA());
|
||
await engine.insert('users', [{ id: '1', name: 'Alice', secret: 'needle-in-cipher' }]);
|
||
await (engine as any).lsm.flush();
|
||
// 强制落盘后 WAL 仍有记录(checkpoint 截断前)→ 全量扫描检查
|
||
const backend = (engine as any).backend as EncryptedBackend;
|
||
const inner = backend.getInner();
|
||
const keys = await inner.listKeys();
|
||
expect(keys.length).toBeGreaterThan(0);
|
||
|
||
// 任何一个底层 key 的内容都不包含明文数据(需解密才可见)
|
||
for (const key of keys) {
|
||
if (key === '__aria_keymeta') continue;
|
||
const raw = await inner.read(key);
|
||
const str = new TextDecoder().decode(raw as ArrayBuffer);
|
||
expect(str).not.toContain('needle-in-cipher');
|
||
expect(str).not.toContain('Alice');
|
||
expect(str).not.toContain('users');
|
||
}
|
||
await engine.close();
|
||
});
|
||
|
||
it('加密 + 压缩组合:往返完整(压缩层先压,加密层后加密)', async () => {
|
||
const dbName = uniqueDB();
|
||
const engine = new AriaEngine({
|
||
storageBackend: 'indexeddb',
|
||
compression: true,
|
||
encryption: { password: 'pw-compress' },
|
||
memtableSizeThreshold: 64 * 1024 * 1024,
|
||
});
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(SCHEMA());
|
||
const rows = [] as Record<string, unknown>[];
|
||
for (let i = 0; i < 200; i++) {
|
||
rows.push({ id: `u-${i}`, name: `User${i}`, secret: '这是需要加密的敏感内容'.repeat(5) });
|
||
}
|
||
await engine.insert('users', rows);
|
||
await (engine as any).lsm.flush();
|
||
await engine.close();
|
||
|
||
const engine2 = new AriaEngine({
|
||
storageBackend: 'indexeddb',
|
||
compression: true,
|
||
encryption: { password: 'pw-compress' },
|
||
});
|
||
await engine2.open(dbName, 1);
|
||
expect(await engine2.count('users')).toBe(200);
|
||
const one = await engine2.find('users', { table: 'users', where: { id: 'u-42' } });
|
||
expect(one[0].secret).toBe('这是需要加密的敏感内容'.repeat(5));
|
||
await engine2.close();
|
||
});
|
||
|
||
it('加密库:clearAll 保留密钥(库身份),同密码重开重建,新密码被拒', async () => {
|
||
const dbName = uniqueDB();
|
||
const engine = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'old-pw' } });
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(SCHEMA());
|
||
await engine.insert('users', [{ id: '1', name: 'A', secret: 'x' }]);
|
||
await engine.clearAll();
|
||
await engine.close();
|
||
|
||
// clearAll 保留 keymeta → 换新密码会被拒绝
|
||
const engineWrong = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'new-pw' } });
|
||
await expect(engineWrong.open(dbName, 1)).rejects.toMatchObject({ code: 'ARIA_DECRYPT_ERROR' });
|
||
|
||
// 同密码重开可重建
|
||
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', encryption: { password: 'old-pw' } });
|
||
await engine2.open(dbName, 1);
|
||
await engine2.createTable(SCHEMA());
|
||
await engine2.insert('users', [{ id: '1', name: 'A', secret: 'new' }]);
|
||
expect(await engine2.count('users')).toBe(1);
|
||
await engine2.close();
|
||
});
|
||
|
||
it('加密库:WAL 崩溃恢复路径可用(含加密 WAL)', async () => {
|
||
const dbName = uniqueDB();
|
||
const engine = new AriaEngine({
|
||
storageBackend: 'indexeddb',
|
||
encryption: { password: 'pw' },
|
||
walSyncMode: 'full',
|
||
checkpointInterval: 100000,
|
||
});
|
||
await engine.open(dbName, 1);
|
||
await engine.createTable(SCHEMA());
|
||
// 写入后不 flush 不 checkpoint,直接"崩溃"(close 会 checkpoint,这里模拟崩溃:直接断 backend)
|
||
for (let i = 0; i < 10; i++) {
|
||
await engine.insert('users', [{ id: `w-${i}`, name: `W${i}`, secret: `s${i}` }]);
|
||
}
|
||
// 模拟崩溃:不 close,直接断开(真实崩溃时 WAL 留在存储中)
|
||
await (engine as any).backend.close();
|
||
(engine as any).opened = false;
|
||
|
||
// 重开:WAL 重放(加密 WAL 解密后解析)
|
||
const engine2 = new AriaEngine({
|
||
storageBackend: 'indexeddb',
|
||
encryption: { password: 'pw' },
|
||
walSyncMode: 'full',
|
||
checkpointInterval: 100000,
|
||
});
|
||
await engine2.open(dbName, 1);
|
||
const rows = await engine2.find('users', { table: 'users' });
|
||
expect(rows).toHaveLength(10);
|
||
await engine2.close();
|
||
});
|
||
});
|