2026-09-17 23:00:47 +08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-18 00:05:20 +08:00
|
|
|
|
function makeDb() {
|
2026-09-17 23:00:47 +08:00
|
|
|
|
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 = () => ({
|
2026-09-18 00:05:20 +08:00
|
|
|
|
put: (value, key) =>
|
|
|
|
|
|
makeRequest(() => {
|
|
|
|
|
|
data.set(key, value);
|
|
|
|
|
|
return key;
|
|
|
|
|
|
}),
|
2026-09-17 23:00:47 +08:00
|
|
|
|
get: (key) => makeRequest(() => data.get(key)),
|
2026-09-18 00:05:20 +08:00
|
|
|
|
delete: (key) =>
|
|
|
|
|
|
makeRequest(() => {
|
|
|
|
|
|
data.delete(key);
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}),
|
2026-09-17 23:00:47 +08:00
|
|
|
|
});
|
|
|
|
|
|
return tx;
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
open(name) {
|
|
|
|
|
|
const req = {
|
2026-09-18 00:05:20 +08:00
|
|
|
|
result: undefined,
|
|
|
|
|
|
error: null,
|
|
|
|
|
|
onsuccess: null,
|
|
|
|
|
|
onerror: null,
|
|
|
|
|
|
onupgradeneeded: null,
|
|
|
|
|
|
onblocked: null,
|
2026-09-17 23:00:47 +08:00
|
|
|
|
};
|
|
|
|
|
|
queueMicrotask(() => {
|
|
|
|
|
|
let db = databases.get(name);
|
|
|
|
|
|
const isNew = !db;
|
|
|
|
|
|
if (isNew) {
|
2026-09-18 00:05:20 +08:00
|
|
|
|
db = makeDb();
|
2026-09-17 23:00:47 +08:00
|
|
|
|
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,
|
2026-09-18 00:05:20 +08:00
|
|
|
|
new TextEncoder().encode("secret"),
|
2026-09-17 23:00:47 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|