// tests/key-store.test.js // 测试 key-store.js:把不可导出的 CryptoKey 句柄存取到 IndexedDB // 运行:node --test tests/key-store.test.js // // 说明:用极简 IndexedDB shim 覆盖模块的异步流程与 API 语义; // 真实的 structured clone(CryptoKey 句柄持久化)语义由浏览器保证,Node 侧无法完整模拟。 const { describe, it, beforeEach } = require("node:test"); const assert = require("node:assert/strict"); function createFakeIndexedDB() { const databases = new Map(); function makeRequest(getResult) { const req = { result: undefined, error: null, onsuccess: null, onerror: null }; queueMicrotask(() => { try { req.result = getResult(); if (req.onsuccess) req.onsuccess(); } catch (err) { req.error = err; if (req.onerror) req.onerror(); } }); return req; } function makeDb() { const stores = new Map(); return { objectStoreNames: { contains: (n) => stores.has(n) }, createObjectStore: (n) => { stores.set(n, new Map()); return {}; }, transaction(storeName) { const data = stores.get(storeName); const tx = { error: null, onabort: null }; tx.objectStore = () => ({ put: (value, key) => makeRequest(() => { data.set(key, value); return key; }), get: (key) => makeRequest(() => data.get(key)), delete: (key) => makeRequest(() => { data.delete(key); return undefined; }), }); return tx; }, }; } return { open(name) { const req = { result: undefined, error: null, onsuccess: null, onerror: null, onupgradeneeded: null, onblocked: null, }; queueMicrotask(() => { let db = databases.get(name); const isNew = !db; if (isNew) { db = makeDb(); databases.set(name, db); } req.result = db; if (isNew && req.onupgradeneeded) req.onupgradeneeded(); if (req.onsuccess) req.onsuccess(); }); return req; }, }; } function loadKeyStore() { delete require.cache[require.resolve("../dist/key-store.js")]; return require("../dist/key-store.js"); } async function makeAesKey() { // extractable = false:这正是 vault 派生密钥的形态 return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); } describe("key-store - 密钥句柄存取", () => { let K; beforeEach(() => { global.indexedDB = createFakeIndexedDB(); K = loadKeyStore(); }); it("初始没有句柄时 loadKey 返回 null", async () => { assert.equal(await K.loadKey(), null); }); it("saveKey 后可以取回同一个密钥对象", async () => { const key = await makeAesKey(); await K.saveKey(key); const loaded = await K.loadKey(); assert.ok(loaded); assert.equal(loaded.algorithm.name, "AES-GCM"); }); it("取回的密钥仍不可导出(句柄而非原始字节)", async () => { const key = await makeAesKey(); await K.saveKey(key); const loaded = await K.loadKey(); assert.equal(loaded.extractable, false); await assert.rejects(() => crypto.subtle.exportKey("raw", loaded)); }); it("取回的密钥可以正常解密", async () => { const key = await makeAesKey(); const iv = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, key, new TextEncoder().encode("secret"), ); await K.saveKey(key); const loaded = await K.loadKey(); const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, loaded, ciphertext); assert.equal(new TextDecoder().decode(plain), "secret"); }); it("重复 saveKey 覆盖旧句柄", async () => { const first = await makeAesKey(); await K.saveKey(first); const iv = crypto.getRandomValues(new Uint8Array(12)); const payload = new TextEncoder().encode("hello"); const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, first, payload); const second = await makeAesKey(); await K.saveKey(second); const loaded = await K.loadKey(); // 取回的是第二个密钥,用旧密钥加密的数据解不开 await assert.rejects(() => crypto.subtle.decrypt({ name: "AES-GCM", iv }, loaded, ciphertext)); }); it("deleteKey 后取不到句柄(锁定即丢弃)", async () => { await K.saveKey(await makeAesKey()); await K.deleteKey(); assert.equal(await K.loadKey(), null); }); it("isAvailable 在支持 IndexedDB 时为 true", async () => { assert.equal(await K.isAvailable(), true); }); }); describe("key-store - 不支持 IndexedDB 时降级", () => { it("loadKey 返回 null 而不是抛错", async () => { delete global.indexedDB; const K = loadKeyStore(); assert.equal(await K.loadKey(), null); assert.equal(await K.isAvailable(), false); }); });