安全与权限: - 站点访问权限改为 optional_host_permissions,保存配置时逐域名授权;移除 host_permissions 与 tabs,安装提示不再出现全站数据访问与浏览记录 - 主口令不再写入 storage.session,改用 IndexedDB 中不可导出的 CryptoKey 句柄恢复解锁,锁定即丢弃 - content script 注入范围只注册已授权域名,并随权限变化即时收敛 自动登录: - 支持 iframe 内的登录表单:脚本注入所有帧,逐帧按自身地址匹配配置 - 手动填充改为逐帧探测,定向发送到真正含密码框的帧 - 限流修正:只在真正触发提交后计数;判定登录成功后立即清零;达到上限时页面给出可见提示;重置判断只看启用中的配置 - SPA 重试改为 DOM 变更门控 + 退避,并单独监听已发现的 Shadow Root 工程化与文档: - 引入 ESLint(扁平配置)与 Prettier,CI 增加 lint 与 format:check - 信息类日志改为 debugLog(默认静默,chrome.storage.local.debugLog 开关) - 测试 123 → 128 用例(新增权限、iframe、限流相关用例) - README / PRIVACY / CHANGELOG 同步
170 lines
5.0 KiB
JavaScript
170 lines
5.0 KiB
JavaScript
// 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);
|
||
});
|
||
});
|