205 lines
6.4 KiB
JavaScript
205 lines
6.4 KiB
JavaScript
/**
|
||
* 加密存储模块:主口令 + AES-GCM 加密整库。
|
||
*
|
||
* 存储布局:
|
||
* chrome.storage.local:
|
||
* - vaultMeta: { salt(base64), iterations, version } 口令派生参数
|
||
* - encryptedVault: { iv(base64), ciphertext(base64) } AES-GCM 密文
|
||
* chrome.storage.session(仅解锁后存在,内存中、不落盘):
|
||
* - environments: 解密后的环境数组
|
||
* - activeEnvId: 当前激活环境 id
|
||
*
|
||
* 主口令不存储。派生的 CryptoKey 仅在解锁后存放于 background SW 内存中,
|
||
* 用于每次 session 数据变化时重新加密写回 local。
|
||
*
|
||
* 同时在浏览器(Service Worker / 扩展页面)和 Node 测试环境下可用。
|
||
*/
|
||
(function (root, factory) {
|
||
if (typeof module === "object" && module.exports) {
|
||
module.exports = factory({
|
||
getCrypto: () => globalThis.crypto,
|
||
getStorage: (area) => {
|
||
const c = typeof chrome !== "undefined" ? chrome : null;
|
||
return c?.storage?.[area] || null;
|
||
},
|
||
});
|
||
} else {
|
||
const api = factory({
|
||
getCrypto: () => crypto,
|
||
getStorage: (area) => chrome.storage[area],
|
||
});
|
||
root.CryptoStore = api;
|
||
}
|
||
})(typeof globalThis !== "undefined" ? globalThis : this, function ({ getCrypto, getStorage }) {
|
||
const VAULT_META_KEY = "vaultMeta";
|
||
const ENCRYPTED_VAULT_KEY = "encryptedVault";
|
||
const SESSION_ENV_KEY = "environments";
|
||
const SESSION_ACTIVE_KEY = "activeEnvId";
|
||
const PBKDF2_ITERATIONS = 200000;
|
||
const VAULT_VERSION = 1;
|
||
|
||
function b64encode(buf) {
|
||
const bytes = new Uint8Array(buf);
|
||
let s = "";
|
||
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
||
return btoa(s);
|
||
}
|
||
|
||
function b64decode(str) {
|
||
const s = atob(str);
|
||
const bytes = new Uint8Array(s.length);
|
||
for (let i = 0; i < s.length; i++) bytes[i] = s.charCodeAt(i);
|
||
return bytes;
|
||
}
|
||
|
||
function randomBytes(n) {
|
||
const arr = new Uint8Array(n);
|
||
getCrypto().getRandomValues(arr);
|
||
return arr;
|
||
}
|
||
|
||
/** 从口令派生 AES-GCM 密钥 */
|
||
async function deriveKey(password, salt, iterations) {
|
||
const enc = new TextEncoder();
|
||
const keyMaterial = await getCrypto().subtle.importKey(
|
||
"raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]
|
||
);
|
||
return getCrypto().subtle.deriveKey(
|
||
{ name: "PBKDF2", salt, iterations, hash: "SHA-256" },
|
||
keyMaterial,
|
||
{ name: "AES-GCM", length: 256 },
|
||
false,
|
||
["encrypt", "decrypt"]
|
||
);
|
||
}
|
||
|
||
/** 加密任意 JSON 对象,返回 { iv, ciphertext } base64 */
|
||
async function encryptJson(obj, key) {
|
||
const enc = new TextEncoder();
|
||
const iv = randomBytes(12);
|
||
const ciphertext = await getCrypto().subtle.encrypt(
|
||
{ name: "AES-GCM", iv }, key, enc.encode(JSON.stringify(obj))
|
||
);
|
||
return { iv: b64encode(iv), ciphertext: b64encode(ciphertext) };
|
||
}
|
||
|
||
/** 解密为 JSON 对象,口令错误/数据损坏抛错 */
|
||
async function decryptJson({ iv, ciphertext }, key) {
|
||
const dec = new TextDecoder();
|
||
const plain = await getCrypto().subtle.decrypt(
|
||
{ name: "AES-GCM", iv: b64decode(iv) }, key, b64decode(ciphertext)
|
||
);
|
||
return JSON.parse(dec.decode(plain));
|
||
}
|
||
|
||
async function getLocal(key) {
|
||
return (await getStorage("local").get(key))[key];
|
||
}
|
||
|
||
/** 是否已创建过口令 */
|
||
async function hasVault() {
|
||
const meta = await getLocal(VAULT_META_KEY);
|
||
const cipher = await getLocal(ENCRYPTED_VAULT_KEY);
|
||
return !!(meta && cipher);
|
||
}
|
||
|
||
/** 是否处于解锁状态(session 中有明文环境数据) */
|
||
async function isUnlocked() {
|
||
const data = await getStorage("session").get(SESSION_ENV_KEY);
|
||
return !!data[SESSION_ENV_KEY];
|
||
}
|
||
|
||
/**
|
||
* 首次创建口令:生成盐,用口令加密初始环境,写入 local。
|
||
* 同时把环境写入 session,视为已解锁。返回派生密钥(供调用方内存持有)。
|
||
*/
|
||
async function setupVault(password, environments) {
|
||
if (!password) throw new Error("口令不能为空");
|
||
const salt = randomBytes(16);
|
||
const key = await deriveKey(password, salt, PBKDF2_ITERATIONS);
|
||
const cipher = await encryptJson(environments || [], key);
|
||
const meta = {
|
||
version: VAULT_VERSION,
|
||
iterations: PBKDF2_ITERATIONS,
|
||
salt: b64encode(salt),
|
||
};
|
||
await getStorage("local").set({
|
||
[VAULT_META_KEY]: meta,
|
||
[ENCRYPTED_VAULT_KEY]: cipher,
|
||
});
|
||
const envs = environments || [];
|
||
await getStorage("session").set({
|
||
[SESSION_ENV_KEY]: envs,
|
||
[SESSION_ACTIVE_KEY]: envs[0]?.id || null,
|
||
});
|
||
return key;
|
||
}
|
||
|
||
/**
|
||
* 解锁:用口令解密 local 密文,写入 session。
|
||
* 返回 { environments, key },口令错误抛错。
|
||
*/
|
||
async function unlock(password) {
|
||
const meta = await getLocal(VAULT_META_KEY);
|
||
const cipher = await getLocal(ENCRYPTED_VAULT_KEY);
|
||
if (!meta || !cipher) throw new Error("尚未设置口令");
|
||
const key = await deriveKey(password, b64decode(meta.salt), meta.iterations);
|
||
let environments;
|
||
try {
|
||
environments = await decryptJson(cipher, key);
|
||
} catch {
|
||
throw new Error("口令错误");
|
||
}
|
||
const active = (await getStorage("session").get(SESSION_ACTIVE_KEY))[SESSION_ACTIVE_KEY]
|
||
|| environments[0]?.id || null;
|
||
await getStorage("session").set({
|
||
[SESSION_ENV_KEY]: environments,
|
||
[SESSION_ACTIVE_KEY]: active,
|
||
});
|
||
return { environments, key };
|
||
}
|
||
|
||
/** 锁定:清除 session 中的明文 */
|
||
async function lock() {
|
||
await getStorage("session").remove([SESSION_ENV_KEY, SESSION_ACTIVE_KEY]);
|
||
}
|
||
|
||
/** 读取 vaultMeta(供 background 派生密钥用) */
|
||
async function getVaultMeta() {
|
||
return getLocal(VAULT_META_KEY);
|
||
}
|
||
|
||
/** 读取加密 vault 密文 */
|
||
async function getEncryptedVault() {
|
||
return getLocal(ENCRYPTED_VAULT_KEY);
|
||
}
|
||
|
||
/** 用给定密钥加密 environments 并写回 local(background 内存密钥调用) */
|
||
async function persistEncrypted(environments, key) {
|
||
const cipher = await encryptJson(environments, key);
|
||
await getStorage("local").set({ [ENCRYPTED_VAULT_KEY]: cipher });
|
||
}
|
||
|
||
return {
|
||
VAULT_META_KEY,
|
||
ENCRYPTED_VAULT_KEY,
|
||
SESSION_ENV_KEY,
|
||
SESSION_ACTIVE_KEY,
|
||
PBKDF2_ITERATIONS,
|
||
hasVault,
|
||
isUnlocked,
|
||
setupVault,
|
||
unlock,
|
||
lock,
|
||
deriveKey,
|
||
encryptJson,
|
||
decryptJson,
|
||
getVaultMeta,
|
||
getEncryptedVault,
|
||
persistEncrypted,
|
||
b64encode,
|
||
b64decode,
|
||
randomBytes,
|
||
};
|
||
});
|