From 456416ae66893ed421f7d8f5e9e58b1ebf804dd0 Mon Sep 17 00:00:00 2001 From: PIKACHUIM Date: Sun, 20 Sep 2026 15:48:38 +0800 Subject: [PATCH 1/2] feat(db): make at-rest field encryption optional via DB_CIPHER MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB_CIPHER 新增:none(默认,不加密)/ aes-256-gcm(HKDF,enc:v2:)/ aes-256-gcm-pbkdf2(历史 enc:v1:)/ aes-256-cbc-hmac(enc:v3:) - 解密改为按密文前缀自动识别算法,与当前配置无关:换算法或关掉加密都不会 让既有数据不可读,旧密文会在下一次保存时自动迁移为明文 - DB_CIPHER=none 只表示不加密数据库字段:未提供 JWT_SECRET 时仍会生成并 持久化共享密钥,保证 JWT 令牌在多实例/冷启动间一致 - /api/public/env_check 暴露 config.db_cipher,并同步更新 README、wrangler vars、Deploy 按钮绑定说明与 .env.example 文案 - 修复 db_cache.test.ts 的既有失败:storage 缺少 driver/mount_path 会被 ensureDefaultStorages 过滤掉,导致断言在 storages[0] 上抛错 Co-Authored-By: CodeBuddy --- .dev.vars.example | 4 +- .env.example | 4 +- README.md | 33 +- package.json | 6 +- scripts/env-check.mjs | 3 +- src/backend/internal/model/db.ts | 267 +++++++++++---- src/backend/internal/model/db_cache.test.ts | 43 ++- src/backend/internal/model/db_cipher.test.ts | 340 +++++++++++++++++++ src/backend/internal/model/store/backend.ts | 39 +++ src/backend/pkg/crypto.ts | 303 +++++++++++++++++ src/backend/server/middlewares.ts | 7 +- src/backend/server/public.ts | 16 +- wrangler.jsonc | 16 +- 13 files changed, 997 insertions(+), 84 deletions(-) create mode 100644 src/backend/internal/model/db_cipher.test.ts diff --git a/.dev.vars.example b/.dev.vars.example index 2ba1b4be..276f2cbe 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -18,8 +18,8 @@ # ── 必填 ──────────────────────────────────────────────────────────────────── -# JWT 签名密钥,同时用于字段加密(网盘凭据、2FA 密钥)与定时任务鉴权。 -# 要求 >=16 字符;推荐用 `openssl rand -hex 32` 生成。 +# JWT 签名密钥,同时用作可选的字段加密密钥(DB_CIPHER 非 none 时,见 wrangler.jsonc) +# 与定时任务鉴权。要求 >=16 字符;推荐用 `openssl rand -hex 32` 生成。 JWT_SECRET= # ── 选填 ──────────────────────────────────────────────────────────────────── diff --git a/.env.example b/.env.example index d5bff6d7..54ab7470 100644 --- a/.env.example +++ b/.env.example @@ -16,8 +16,8 @@ # ── 必填 ──────────────────────────────────────────────────────────────────── -# JWT 签名密钥,同时用于字段加密(网盘凭据、2FA 密钥)与定时任务鉴权。 -# 要求 >=16 字符;推荐用 `openssl rand -hex 32` 生成。 +# JWT 签名密钥,同时用作可选的字段加密密钥(DB_CIPHER 非 none 时,见 wrangler.jsonc) +# 与定时任务鉴权。要求 >=16 字符;推荐用 `openssl rand -hex 32` 生成。 JWT_SECRET= # ── 选填 ──────────────────────────────────────────────────────────────────── diff --git a/README.md b/README.md index ac0d83be..434d739f 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,14 @@ pnpm run deploy:worker - `do`:Cloudflare Durable Objects(SQLite) - `mysql`:MySQL(仅 Node.js 容器) +**DB_CIPHER**(敏感字段落盘算法,**默认不加密**) +- `none`(默认):不加密,敏感字段与普通 JSON 一样明文落盘 +- `aes-256-gcm`:HKDF-SHA256 派生一把 AES-256-GCM 密钥,每次读/写只派生一次 + (既有加密部署写入的 `enc:v2:` 形态,开销最低,开启加密时推荐使用) +- `aes-256-gcm-pbkdf2`:AES-256-GCM,密钥由 PBKDF2-SHA256(10 万次迭代)逐字段派生 + (历史 `enc:v1:` envelope,抗弱口令但开销大,仅建议用于兼容) +- `aes-256-cbc-hmac`:AES-256-CBC + HMAC-SHA256(Encrypt-then-MAC,非 GCM 族) + **推荐配置组合:** ```bash # Cloudflare Workers + D1(推荐) @@ -205,8 +213,29 @@ CF_API_KEY=your_api_token > 非法「驱动 × 格式」组合(如 `DB_FORMAT=sql` + `DB_DRIVER=kv`)同样只报错, > 不会自动改驱动或格式。 +**关于 DB_CIPHER 的补充说明:** +- 加密只作用于 `storages[].addition`(网盘凭据)、敏感 `settings`、`users[].password`、 + `users[].otp_secret`;内存中始终为明文,其余逻辑(驱动、路径解析、管理接口)不受影响。 +- **`none` 只表示「不加密数据库字段」,不影响其它任何行为**:JWT 令牌签名仍需一把 + 跨实例/跨冷启动一致的共享密钥,若未通过环境变量 `JWT_SECRET` 提供,安装向导仍会 + 自动生成并持久化 `openlist_encryption_secret`(与加密是否启用无关)。 +- 密文带版本前缀(`enc:v1:` / `enc:v2:` / `enc:v3:`),**读取时按前缀自动识别算法**, + 与当前配置无关。因此: + - 从加密切回 `none`(或升级后不再配置 `DB_CIPHER`):既有密文仍能正常解密, + 并在**下一次配置保存**时自动转为明文(逐字段迁移,无需任何手动步骤); + - 更换算法:既有密文按旧算法解开,下次写入按新算法落盘; + - 既有的明文数据(无前缀)原样返回,升级不会丢数据。 +- 加密算法选择是**正交的一维**,不改变 `DB_DRIVER` / `DB_FORMAT` 的语义。 +- 取值无法识别时回退 `none` 并在日志告警(不会静默启用某个算法); + `/api/public/env_check` 的 `config.db_cipher` 会回显当前生效值。 +- 三种算法均基于 WebCrypto,在 Cloudflare Workers / EdgeOne Node 云函数 / Node.js 上行为一致。 + **向后兼容:** - `DB_DRIVER=json` 自动转换为 `DB_FORMAT=map` + 自动检测驱动 +- 未配置 `DB_CIPHER` 时**不再对敏感字段加密**(旧版本默认加密)。升级已有部署时: + 存储中的 `enc:v1:`(PBKDF2)与 `enc:v2:`(HKDF)密文仍会按前缀自动解密、保持可读, + 并在**下一次配置保存**时自动转为明文;如需继续加密,显式设置 `DB_CIPHER` 即可 + (`DB_CIPHER=aes-256-gcm` 与既有加密部署的写入形态一致)。 **表名对齐(仅 SQL 格式):** `sql` 格式采用列式表,命名策略与 Go 后端的 GORM 一致(snake_case + 复数表名 + 前缀): @@ -224,7 +253,9 @@ CF_API_KEY=your_api_token #### 安全配置 -- `JWT_SECRET`:JWT 令牌签名密钥(必填),**同时用于数据加密与定时任务鉴权** +- `JWT_SECRET`:JWT 令牌签名密钥(必填),**同时用作可选的字段加密密钥**与定时任务鉴权 +- `DB_CIPHER`:敏感字段落盘算法(可选,默认 `none` 不加密):`none` / + `aes-256-gcm`(历史默认)/ `aes-256-gcm-sha256` / `aes-256-cbc-hmac` - `ADMIN_PASS`:初始管理员密码(可选,设置后跳过安装向导自动初始化 admin) #### 其他配置 diff --git a/package.json b/package.json index eb843344..d3dfc610 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ }, "DB_DRIVER": { "description": "Storage driver: `auto` (default), `blob`, `cfkv`, `kv`, `d1`, `do`, `mysql`. Leave as `auto` unless you need to force one. An explicitly configured driver is never replaced automatically: if it is unavailable, the app reports a configuration error (including which driver auto-detection would have picked) instead of writing to another backend. Invalid driver/format pairs are reported the same way." + }, + "DB_CIPHER": { + "description": "At-rest cipher for sensitive fields (drive credentials, 2FA secrets, password hashes): `none` (default, no encryption), `aes-256-gcm` (HKDF-derived AES-256-GCM key, derived once per read/write — the `enc:v2:` envelope written by existing encrypted deployments), `aes-256-gcm-pbkdf2` (legacy `enc:v1:` envelope, PBKDF2 per field), `aes-256-cbc-hmac` (AES-256-CBC + HMAC-SHA256, encrypt-then-MAC). Ciphertexts carry an `enc:vN:` prefix and are decrypted by prefix on read, so switching back to `none` (or changing algorithms) never makes existing data unreadable — it only changes how newly written data is stored (legacy ciphertext is migrated to plaintext on the next save). This setting does NOT affect the shared secret: with `none`, a secret is still generated and persisted during setup when JWT_SECRET is not provided, because JWT signing needs it." } } }, @@ -51,10 +54,11 @@ "test:drivers": "tsx --test \"src/backend/drivers/**/*.test.ts\"", "test:server": "tsx --test \"src/backend/server/*.test.ts\"", "test:store": "tsx --test src/backend/internal/model/store/store.test.ts", + "test:model": "tsx --test \"src/backend/internal/model/*.test.ts\"", "test:regress": "tsx scripts/_regress.mjs", "test:deploy": "node scripts/test-deploy.js", "env:check": "tsx scripts/env-check.mjs", - "test:all": "npm run test:189 && npm run test:drivers && npm run test:server && npm run test:store && npm run test:regress", + "test:all": "npm run test:189 && npm run test:drivers && npm run test:server && npm run test:store && npm run test:model && npm run test:regress", "sls:deploy": "serverless deploy", "sls:package": "serverless package", "sls:info": "serverless info", diff --git a/scripts/env-check.mjs b/scripts/env-check.mjs index afea045e..d369ab49 100644 --- a/scripts/env-check.mjs +++ b/scripts/env-check.mjs @@ -40,7 +40,8 @@ async function probe(name, env) { ) console.log( ` config : format=${d.config.db_format}->${d.config.resolved_format} ` + - `driver=${d.config.db_driver}->${d.config.resolved_driver}`, + `driver=${d.config.db_driver}->${d.config.resolved_driver} ` + + `cipher=${d.config.db_cipher}`, ) console.log( ` storage : available=${d.storage.available} memory=${d.storage.memory} connected=${d.storage.connected}`, diff --git a/src/backend/internal/model/db.ts b/src/backend/internal/model/db.ts index 04008902..aacac66e 100644 --- a/src/backend/internal/model/db.ts +++ b/src/backend/internal/model/db.ts @@ -1,8 +1,9 @@ import { - decrypt, - decryptConfigValue, - deriveConfigEncryptionKey, - encryptConfigValue, + cipherPrefix, + createFieldCipher, + detectCipherPrefix, + isSealedCiphertext, + type FieldCipher, } from "../../pkg/crypto" import { generateSecret, @@ -10,7 +11,7 @@ import { setJsonEnvCtx, writePersistedSecret, } from "./store/json" -import { getStoreBackend } from "./store/backend" +import { getStoreBackend, readCipher } from "./store/backend" // 保持外部(middlewares.ts / router.ts / admin.ts)对 getKvBinding / getKvStatus // 的既有引用不变,从 json 后端 re-export。 @@ -1104,6 +1105,15 @@ export const __resetDbCacheForTest = () => { dbTrusted = false dbLastLoadError = null dbWriteBlocked = false + // 加密相关的缓存与一次性告警复位: + // 用例可能先后使用不同密钥/不同 DB_CIPHER,缓存串味会让「首次告警」「换钥后 + // 可解」这类断言依赖执行顺序。 + cachedEncryptionKey = null + cachedFromEnv = false + encryptionKeyWarned = false + unsealKeyWarned = false + sealKeyMissingWarned = false + plaintextMigrationLogged = false } /** 仅供测试:注入统计型存储后端。 */ @@ -1160,7 +1170,28 @@ const loadDb = async (envCtx?: any) => { backend = await storeBackendLoader(activeEnv) const persisted = await backend.load(activeEnv) if (persisted) { - await unsealDb(persisted, await getEncryptionKey(activeEnv)) + // 只在数据里确实存在密文时才构造解密器(才需要解析密钥): + // - DB_CIPHER=none(默认)且历史数据全为明文 → 完全不需要密钥, + // 省掉一次持久化读取,也不会误报「缺少加密密钥」; + // - 存在 enc:vN: 密文(旧部署 / 曾开启加密)→ 仍按前缀解密, + // 因此「关掉加密」不会让既有数据读不出来。 + const hadSealed = hasSealedValues(persisted) + if ( + hadSealed && + readCipher(activeEnv) === "none" && + !plaintextMigrationLogged + ) { + plaintextMigrationLogged = true + console.log( + "[DB] DB_CIPHER=none: existing encrypted values were decrypted; the " + + "next save will write them back as PLAINTEXT (per-field migration, " + + "no explicit step needed). Set DB_CIPHER to keep them encrypted.", + ) + } + const fieldCipher = hadSealed + ? await resolveFieldCipher(activeEnv, { needDecrypt: true }) + : null + await unsealDb(persisted, fieldCipher) memoryDb = persisted ensureDefaultSettings(memoryDb) ensureDefaultStorages(memoryDb) @@ -1269,24 +1300,23 @@ export const getDb = async (envCtx?: any) => { * warn-and-continue behavior, so unpersisted deployments are not broken by it. */ // ============================================================ -// 静态加密(At-rest encryption) +// 静态加密(At-rest encryption)—— 可选,由 DB_CIPHER 控制 +// // 修复 H-1:网盘 token/secret/OTP 等敏感字段此前以明文 JSON 落 KV/Blob。 // 这里在「持久化边界」做字段级加密(落盘前 seal、读盘后 unseal),内存中 // 始终保持明文,因此 resolvePath / parseAddition / 各驱动 / admin 接口均无需 -// 改动。密钥统一取 JWT_SECRET(签名与加密共用);未配置时跳过加密, -// 保持既有部署(无密钥)向后兼容。已存在的明文数据不带前缀,unseal 时原样 -// 返回,不会因升级而丢失。 +// 改动。密钥统一取 JWT_SECRET(签名与加密共用)。 +// +// 与历史实现的差别: +// - **默认不加密**(DB_CIPHER=none)。加密是可选能力,而不是强制行为。 +// - 算法由 DB_CIPHER 选择,见 pkg/crypto.ts 的 DbCipher: +// aes-256-gcm(HKDF,低成本,推荐) / aes-256-gcm-pbkdf2(历史 envelope) +// / aes-256-cbc-hmac(Encrypt-then-MAC) +// - **解密永远由密文前缀驱动**(`enc:vN:`),与当前配置无关。于是: +// 关掉加密 → 既有密文仍能解开,并在下次写入时转为明文(自动迁移); +// 换算法 → 既有密文按旧算法解开,下次写入按新算法落盘; +// 明文数据 → 不带前缀,原样返回,升级不丢数据。 // ============================================================ -const LEGACY_ENCRYPTION_PREFIX = "enc:v1:" -const ENCRYPTION_PREFIX = "enc:v2:" - -function isSealedValue(value: string): boolean { - return ( - value.startsWith(ENCRYPTION_PREFIX) || - value.startsWith(LEGACY_ENCRYPTION_PREFIX) - ) -} - const SENSITIVE_SETTING_KEYS = new Set([ "token", "sso_client_secret", @@ -1298,6 +1328,12 @@ const SENSITIVE_SETTING_KEYS = new Set([ ]) let encryptionKeyWarned = false +/** 一次性告警:数据中存在密文但拿不到密钥(避免每次加载都刷屏) */ +let unsealKeyWarned = false +/** 一次性告警:配置了 DB_CIPHER 却没有密钥,只能明文落盘 */ +let sealKeyMissingWarned = false +/** 一次性提示:旧部署的密文在 DB_CIPHER=none 下会被自动迁移为明文 */ +let plaintextMigrationLogged = false /** * 字段加密密钥的持久化键名。 @@ -1404,7 +1440,8 @@ async function getEncryptionKey(envCtx?: any): Promise { encryptionKeyWarned = true console.error( "[DB] No encryption key available: set JWT_SECRET. " + - "Sensitive fields would otherwise be written in plaintext.", + "Encrypted fields cannot be decrypted and new sensitive fields will be " + + "stored in plaintext until a key becomes available.", ) } return null @@ -1440,7 +1477,12 @@ export async function isEncryptionReady(envCtx?: any): Promise { } /** - * 初始化阶段确保字段加密密钥存在。只在 setup 流程中调用。 + * 初始化阶段确保**共享密钥**存在(JWT 签名 + DB_CIPHER 启用时的字段加密)。 + * 只在 setup 中调用。 + * + * 为什么 DB_CIPHER=none 时同样要生成:这把密钥同时充当 JWT 令牌的签名密钥 + * (middlewares.ts 的 getJwtSecret 会复用本槽位)。多实例 / 冷启动必须共用同一把, + * 否则令牌会随机失效。`none` 只表示「不加密数据库字段」,与密钥是否存在无关。 * * 行为(与 getEncryptionKey 使用完全相同的优先级,避免加解密分裂): * 1. 环境变量已配置 → 直接采用,不写持久化(尊重运维配置) @@ -1570,26 +1612,48 @@ export async function ensureEncryptionSecret( } } -async function sealValue(value: string, key: CryptoKey): Promise { - if (!value) return value - if (isSealedValue(value)) return value // idempotent - return ENCRYPTION_PREFIX + (await encryptConfigValue(value, key)) +/** + * 加密单个字段(`seal`)。 + * + * - 未启用加密(fieldCipher 为 null)或未取到密钥:原样返回明文。 + * - 已有任意版本前缀:视为已加密(幂等),避免重复加密 —— 双重加密会在下次读取 + * 时只解开一层、仍然呈现密文,表现为「密码看起来还是乱码」的诡异故障。 + */ +async function sealValue( + value: string, + fieldCipher: FieldCipher | null, +): Promise { + if (!value || !fieldCipher || fieldCipher.cipher === "none") return value + if (isSealedCiphertext(value)) return value // idempotent + return cipherPrefix(fieldCipher.cipher) + (await fieldCipher.encrypt(value)) } +/** + * 解密单个字段(`unseal`)。 + * + * 算法由**密文前缀**决定,而不是当前 DB_CIPHER —— 这保证了「换算法 / 关加密」 + * 不会让既有密文无法读取。无前缀(明文)直接返回。 + */ async function unsealValue( value: string, - secret: string, - configKey: () => Promise, + fieldCipher: FieldCipher | null, ): Promise { - if (!value || !isSealedValue(value)) return value - try { - if (value.startsWith(ENCRYPTION_PREFIX)) { - return await decryptConfigValue( - value.slice(ENCRYPTION_PREFIX.length), - await configKey(), + if (!value || !isSealedCiphertext(value)) return value + if (!fieldCipher) { + // 有密文但拿不到密钥:保持原值,绝不丢数据,但必须显式告警 —— + // 否则表现为「密码/凭据看起来是乱码」而无任何线索。 + if (!unsealKeyWarned) { + unsealKeyWarned = true + console.error( + "[DB] Sealed values found in storage but no encryption key is " + + "available; they will be left as-is (set JWT_SECRET, or restore the " + + "original openlist_encryption_secret).", ) } - return await decrypt(value.slice(LEGACY_ENCRYPTION_PREFIX.length), secret) + return value + } + try { + return await fieldCipher.decrypt(value) } catch (e) { console.warn( "[DB] Failed to decrypt a sealed secret (wrong JWT_SECRET?):", @@ -1599,11 +1663,66 @@ async function unsealValue( } } -async function sealDb(data: any, key: string | null): Promise { - if (!key || !data) return data +/** + * 数据中是否存在本模块产生的密文(任意版本)。 + * + * 用途:`DB_CIPHER=none` 且数据全为明文时,加载路径**完全不需要**解析密钥 —— + * 既能避免一次额外的持久化读取,也避免误报「缺少加密密钥」。 + */ +function hasSealedValues(data: any): boolean { + if (!data) return false + for (const s of data.storages || []) { + if (s && detectCipherPrefix(s.addition)) return true + } + for (const st of data.settings || []) { + if ( + st && + SENSITIVE_SETTING_KEYS.has(st.key) && + detectCipherPrefix(st.value) + ) { + return true + } + } + for (const u of data.users || []) { + if ( + u && + (detectCipherPrefix(u.otp_secret) || detectCipherPrefix(u.password)) + ) { + return true + } + } + return false +} + +/** + * 依据 `DB_CIPHER` 与共享密钥构造字段加解密器(一次 save / load 复用一个实例)。 + * + * @param opts.needDecrypt 读取路径专用:即使当前 `DB_CIPHER=none`,只要数据里存在 + * 历史密文(`enc:vN:`)就仍然需要密钥去解开它 —— 这正是「关掉加密后旧数据 + * 依旧可读、并在下次保存时自动转为明文」所依赖的分支。 + * 此时返回的对象写入算法是 `none`(seal 侧本就不会调用它)。 + * @returns null 表示「不需要加密/解密」或「需要但拿不到密钥」。后者由调用方告警。 + */ +async function resolveFieldCipher( + env: any, + opts?: { needDecrypt?: boolean }, +): Promise { + const cipher = readCipher(env) + if (cipher === "none" && !opts?.needDecrypt) return null + const secret = await getEncryptionKey(env) + if (!secret) return null + return createFieldCipher(cipher, secret) +} + +async function sealDb( + data: any, + fieldCipher: FieldCipher | null, +): Promise { + // 未启用加密:原样落盘(不做无意义的深拷贝) + if (!fieldCipher || fieldCipher.cipher === "none" || !data) return data const copy = JSON.parse(JSON.stringify(data)) - // Derive once per save. All fields still receive independent random GCM IVs. - const configKey = await deriveConfigEncryptionKey(key) + // 密钥派生由 fieldCipher 内部完成并缓存:一次 save 只派生一次(v2 的 HKDF key), + // 每个字段仍然各自使用独立随机 IV。 // 1. 加密存储配置中的 addition 字段(网盘凭据) for (const s of copy.storages || []) { @@ -1611,14 +1730,14 @@ async function sealDb(data: any, key: string | null): Promise { const str = typeof s.addition === "string" ? s.addition : JSON.stringify(s.addition) if (str && str !== "{}") { - s.addition = await sealValue(str, configKey) + s.addition = await sealValue(str, fieldCipher) } } // 2. 加密敏感的系统设置 for (const st of copy.settings || []) { if (st && SENSITIVE_SETTING_KEYS.has(st.key) && st.value) { - st.value = await sealValue(String(st.value), configKey) + st.value = await sealValue(String(st.value), fieldCipher) } } @@ -1626,11 +1745,11 @@ async function sealDb(data: any, key: string | null): Promise { for (const u of copy.users || []) { // OTP 密钥 if (u && u.otp_secret) { - u.otp_secret = await sealValue(String(u.otp_secret), configKey) + u.otp_secret = await sealValue(String(u.otp_secret), fieldCipher) } // 密码二次加密(defense-in-depth,即使已哈希也加密存储) if (u && u.password) { - u.password = await sealValue(String(u.password), configKey) + u.password = await sealValue(String(u.password), fieldCipher) } } @@ -1640,17 +1759,16 @@ async function sealDb(data: any, key: string | null): Promise { /** * 解密并发上限。 * - * v2 密文只需一次快速密钥派生;旧 v1 密文仍需 PBKDF2(10 万次迭代),并在 + * v2/v3 密文只需一次快速密钥派生;旧 v1 密文仍需 PBKDF2(10 万次迭代),并在 * 下次保存时自动迁移。限制并发可避免旧数据字段很多时产生 CPU/内存峰值。 */ const UNSEAL_CONCURRENCY = 16 -async function unsealDb(data: any, key: string | null): Promise { - if (!key || !data) return - - // v2 的派生结果在本次加载中共享;纯 v1 数据不会做这次派生。 - let configKey: Promise | null = null - const getConfigKey = () => (configKey ||= deriveConfigEncryptionKey(key)) +async function unsealDb( + data: any, + fieldCipher: FieldCipher | null, +): Promise { + if (!fieldCipher || !data) return // 并行解密(带并发上限): // @@ -1661,19 +1779,17 @@ async function unsealDb(data: any, key: string | null): Promise { // 这里先**同步收集 thunk**(不在收集阶段就把解密全部发起),再按 // UNSEAL_CONCURRENCY 分批 await:既拿到并行带来的墙钟收益,又避免字段极多 // (如数千用户)时一次性并发过多造成 CPU/内存峰值。 + // + // 密钥派生(v2 的 HKDF)由 fieldCipher 内部缓存,本次加载只发生一次。 const tasks: Array<() => Promise> = [] - // 1. 解密存储配置 + // 1. 解密存储配置(算法由密文前缀决定,与当前 DB_CIPHER 无关) for (const s of data.storages || []) { - if ( - s && - typeof s.addition === "string" && - isSealedValue(s.addition) - ) { + if (s && typeof s.addition === "string" && isSealedCiphertext(s.addition)) { const target = s - const cipher = target.addition + const sealedValue = target.addition tasks.push(async () => { - target.addition = await unsealValue(cipher, key, getConfigKey) + target.addition = await unsealValue(sealedValue, fieldCipher) }) } } @@ -1684,12 +1800,12 @@ async function unsealDb(data: any, key: string | null): Promise { st && SENSITIVE_SETTING_KEYS.has(st.key) && typeof st.value === "string" && - isSealedValue(st.value) + isSealedCiphertext(st.value) ) { const target = st - const cipher = target.value + const sealedValue = target.value tasks.push(async () => { - target.value = await unsealValue(cipher, key, getConfigKey) + target.value = await unsealValue(sealedValue, fieldCipher) }) } } @@ -1698,19 +1814,19 @@ async function unsealDb(data: any, key: string | null): Promise { for (const u of data.users || []) { if (!u) continue // OTP 密钥 - if (typeof u.otp_secret === "string" && isSealedValue(u.otp_secret)) { + if (typeof u.otp_secret === "string" && isSealedCiphertext(u.otp_secret)) { const target = u - const cipher = target.otp_secret + const sealedValue = target.otp_secret tasks.push(async () => { - target.otp_secret = await unsealValue(cipher, key, getConfigKey) + target.otp_secret = await unsealValue(sealedValue, fieldCipher) }) } // 密码解密 - if (typeof u.password === "string" && isSealedValue(u.password)) { + if (typeof u.password === "string" && isSealedCiphertext(u.password)) { const target = u - const cipher = target.password + const sealedValue = target.password tasks.push(async () => { - target.password = await unsealValue(cipher, key, getConfigKey) + target.password = await unsealValue(sealedValue, fieldCipher) }) } } @@ -1785,11 +1901,22 @@ export const saveDb = async ( } try { - // 落盘前对敏感字段做静态加密(H-1),内存中的 data 保持明文 + // 落盘前对敏感字段做静态加密(可选,见 DB_CIPHER),内存中的 data 保持明文。 + // cipher=none(默认)时直接按明文落盘,不解析密钥(也不需要密钥)。 + const cipher = readCipher(activeEnv) console.log( - `[DB] saveDb: sealing and persisting to ${backend.name}, storages=${data.storages?.length || 0}`, + `[DB] saveDb: cipher=${cipher}, persisting to ${backend.name}, storages=${data.storages?.length || 0}`, ) - const sealed = await sealDb(data, await getEncryptionKey(activeEnv)) + const fieldCipher = cipher === "none" ? null : await resolveFieldCipher(activeEnv) + if (cipher !== "none" && !fieldCipher && !sealKeyMissingWarned) { + // 只在首次告警:密钥缺失会持续到密钥可用为止,逐次写入都打印只会刷屏。 + sealKeyMissingWarned = true + console.error( + `[DB] DB_CIPHER=${cipher} but no encryption key is available; ` + + "sensitive fields will be stored in PLAINTEXT. Set JWT_SECRET.", + ) + } + const sealed = await sealDb(data, fieldCipher) console.log( `[DB] saveDb: sealed data size=${JSON.stringify(sealed).length} bytes`, ) diff --git a/src/backend/internal/model/db_cache.test.ts b/src/backend/internal/model/db_cache.test.ts index 93015c34..a56c7c51 100644 --- a/src/backend/internal/model/db_cache.test.ts +++ b/src/backend/internal/model/db_cache.test.ts @@ -134,13 +134,25 @@ test("saveDb: sensitive fields use the low-CPU v2 envelope and remain readable", const { backend, getData } = createCountingBackend({ ...SAMPLE, users: [{ id: 1, username: "admin", password: "password-hash" }], - storages: [{ id: 1, addition: '{"refresh_token":"secret"}' }], + // 注意:storage 必须带 driver / mount_path —— 否则会被 ensureDefaultStorages() + // 当作损坏条目过滤掉(storages 变为空数组),断言在 storages[0] 上就会炸。 + storages: [ + { + id: 1, + driver: "local", + mount_path: "/drive", + addition: '{"refresh_token":"secret"}', + }, + ], }) __setStoreBackendLoaderForTest(async () => backend) const env = { DB_DRIVER: "counting", JWT_SECRET: "test-config-encryption-secret", + // 加密是可选的:默认 DB_CIPHER=none(明文落盘),本用例显式开启加密, + // 以锁定 #69 的低 CPU v2 envelope 行为(见下方「默认不加密」用例)。 + DB_CIPHER: "aes-256-gcm", } const plain = await getDb(env) await saveDb(plain, env) @@ -157,6 +169,35 @@ test("saveDb: sensitive fields use the low-CPU v2 envelope and remain readable", assert.equal(reloaded.users[0].password, plain.users[0].password) }) +test("saveDb: without DB_CIPHER sensitive fields are written as plaintext", async () => { + __resetDbCacheForTest() + const { backend, getData } = createCountingBackend({ + ...SAMPLE, + users: [{ id: 1, username: "admin", password: "password-hash" }], + storages: [ + { + id: 1, + driver: "local", + mount_path: "/drive", + addition: '{"refresh_token":"secret"}', + }, + ], + }) + __setStoreBackendLoaderForTest(async () => backend) + + // 不配置 DB_CIPHER(默认 none):即便提供了 JWT_SECRET 也不加密数据库字段。 + const env = { + DB_DRIVER: "counting", + JWT_SECRET: "test-config-encryption-secret", + } + const plain = await getDb(env) + await saveDb(plain, env) + + const persisted = getData() + assert.equal(persisted.storages[0].addition, '{"refresh_token":"secret"}') + assert.equal(persisted.users[0].password, "password-hash") +}) + test("getDb: 五个无参 getter 复用同一份缓存快照", async () => { __resetDbCacheForTest() const { backend, stats } = createCountingBackend({ diff --git a/src/backend/internal/model/db_cipher.test.ts b/src/backend/internal/model/db_cipher.test.ts new file mode 100644 index 00000000..c675a018 --- /dev/null +++ b/src/backend/internal/model/db_cipher.test.ts @@ -0,0 +1,340 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { + DB_CIPHER_VALUES, + cipherPrefix, + createFieldCipher, + deriveConfigEncryptionKey, + detectCipherPrefix, + encrypt, + encryptConfigValue, + isSealedCiphertext, + resolveDbCipher, +} from "../../pkg/crypto" +import { readCipher } from "./store/backend" +import { memoryDriver } from "./store/driver/memory" +import { mapFormat } from "./store/format/map" + +/** + * DB_CIPHER 回归测试。 + * + * 锁定三条核心不变量: + * 1. **默认不加密**(DB_CIPHER 缺省 = none):敏感字段明文落盘; + * 2. **解密由密文前缀驱动**,与当前配置无关:换算法 / 关加密都不会让既有密文 + * 无法读取(否则升级会把「登录密码」变成谁也解不开的乱码); + * 3. **DB_CIPHER=none 不影响 JWT 共享密钥**:密钥的生成与持久化照旧。 + */ + +const SECRET = "test-shared-secret-0123456789abcdef" + +/** 三个真实算法(none 之外) */ +const SECRET_CIPHERS = [ + "aes-256-gcm", + "aes-256-gcm-pbkdf2", + "aes-256-cbc-hmac", +] as const + +function envFor(cipher?: string): any { + const env: any = { + DB_DRIVER: "memory", + DB_FORMAT: "map", + JWT_SECRET: SECRET, + } + if (cipher !== undefined) env.DB_CIPHER = cipher + return env +} + +function sampleDb() { + return { + settings: [ + { key: "site_title", value: "OpenList" }, + { key: "token", value: "tok-123" }, + ], + users: [ + { + id: 1, + username: "admin", + role: 2, + permission: 0, + base_path: "/", + disabled: false, + password: "hash-abc", + otp_secret: "OTP-SECRET", + }, + ], + storages: [ + { + id: 1, + mount_path: "/drive", + driver: "local", + addition: JSON.stringify({ token: "drive-token" }), + }, + ], + shares: [], + metas: [], + plugins: [], + } +} + +const rawUser = (raw: any) => raw.users.find((u: any) => u.username === "admin") +const rawSetting = (raw: any, key: string) => + raw.settings.find((s: any) => s.key === key) +/** 读取**存储中的原始内容**(未经 unseal),用于断言落盘形态 */ +const rawStored = (env: any) => mapFormat.load(memoryDriver, env) + +// ─── 配置解析 ──────────────────────────────────────────────────────────────── + +test("readCipher:默认 none,别名可用,非法值回退 none", () => { + assert.equal(readCipher({}), "none", "缺省必须为 none(默认不加密)") + assert.equal(readCipher({ DB_CIPHER: "" }), "none") + assert.equal(readCipher({ DB_CIPHER: " none " }), "none") + assert.equal(readCipher({ DB_CIPHER: "off" }), "none") + + assert.equal(readCipher({ DB_CIPHER: "AES-256-GCM" }), "aes-256-gcm") + assert.equal(readCipher({ DB_CIPHER: "gcm" }), "aes-256-gcm") + assert.equal(readCipher({ DB_CIPHER: "v2" }), "aes-256-gcm") + assert.equal(readCipher({ DB_CIPHER: "pbkdf2" }), "aes-256-gcm-pbkdf2") + assert.equal(readCipher({ DB_CIPHER: "v1" }), "aes-256-gcm-pbkdf2") + assert.equal(readCipher({ DB_CIPHER: "v3" }), "aes-256-cbc-hmac") + + // 拼错不得静默启用某个算法 + assert.equal(readCipher({ DB_CIPHER: "rot13" }), "none") + assert.equal(resolveDbCipher("rot13").known, false) + assert.equal(resolveDbCipher("aes-256-gcm").known, true) + + for (const cipher of DB_CIPHER_VALUES) { + assert.equal(readCipher({ DB_CIPHER: cipher }), cipher) + } +}) + +test("密文前缀与算法一一对应(v1=PBKDF2 / v2=HKDF / v3=CBC-HMAC)", () => { + assert.equal(cipherPrefix("none"), "") + assert.equal(cipherPrefix("aes-256-gcm-pbkdf2"), "enc:v1:") + assert.equal(cipherPrefix("aes-256-gcm"), "enc:v2:") + assert.equal(cipherPrefix("aes-256-cbc-hmac"), "enc:v3:") + + assert.equal(detectCipherPrefix("hash-abc"), null) + assert.equal(detectCipherPrefix("enc:v9:abc"), null, "未知版本不得被识别") + assert.equal(detectCipherPrefix("enc:v1:aa:bb:cc")?.cipher, "aes-256-gcm-pbkdf2") + assert.equal(detectCipherPrefix("enc:v2:aa:bb")?.cipher, "aes-256-gcm") + assert.equal(detectCipherPrefix("enc:v3:aa:bb:cc")?.cipher, "aes-256-cbc-hmac") + assert.equal(isSealedCiphertext("enc:v2:aa:bb"), true) + assert.equal(isSealedCiphertext("plain"), false) +}) + +// ─── 算法层 ────────────────────────────────────────────────────────────────── + +test("各算法加密→解密往返一致(含空串与多字节)", async () => { + const samples = [ + "", + "a", + "hash-abc", + "中文 / emoji 😀 / 换行\n换行", + "x".repeat(5000), + ] + for (const cipher of SECRET_CIPHERS) { + const fc = await createFieldCipher(cipher, SECRET) + for (const plain of samples) { + const body = await fc.encrypt(plain) + const sealed = cipherPrefix(cipher) + body + assert.notEqual(body, plain, `${cipher} 必须产生密文`) + assert.equal(detectCipherPrefix(sealed)?.cipher, cipher) + assert.equal(await fc.decrypt(sealed), plain) + } + } +}) + +test("解密由前缀驱动:一种写入算法能解开全部历史版本的密文", async () => { + // 三种算法分别由不同的写入器产生,再用同一个(写 v3 的)解密器读取。 + const v1 = "enc:v1:" + (await encrypt("v1-secret", SECRET)) + const v2 = + "enc:v2:" + + (await encryptConfigValue( + "v2-secret", + await deriveConfigEncryptionKey(SECRET), + )) + const v3fc = await createFieldCipher("aes-256-cbc-hmac", SECRET) + const v3 = "enc:v3:" + (await v3fc.encrypt("v3-secret")) + + const reader = await createFieldCipher("aes-256-cbc-hmac", SECRET) + assert.equal(await reader.decrypt(v1), "v1-secret") + assert.equal(await reader.decrypt(v2), "v2-secret") + assert.equal(await reader.decrypt(v3), "v3-secret") + // 明文原样返回 + assert.equal(await reader.decrypt("plain-value"), "plain-value") + + // #69 写入的 v2 密文必须与「写 v2 的实现」互相兼容(同一密钥、同一 HKDF 参数) + const v2fc = await createFieldCipher("aes-256-gcm", SECRET) + const v2Fresh = await v2fc.encrypt("v2-fresh") + assert.equal( + await v2fc.decrypt("enc:v2:" + v2Fresh), + "v2-fresh", + "v2 写入/读取必须与 #69 的 envelope 自洽", + ) +}) + +test("密钥不匹配时必须解密失败,而不是返回乱码", async () => { + const wrong = "another-secret-0123456789abcdefgh" + for (const cipher of SECRET_CIPHERS) { + const fc = await createFieldCipher(cipher, SECRET) + const sealed = cipherPrefix(cipher) + (await fc.encrypt("secret-value")) + // 同密钥正常 + assert.equal(await fc.decrypt(sealed), "secret-value") + // 换密钥必须抛错(AES-GCM 认证失败 / CBC-HMAC 验签失败) + const wrongFc = await createFieldCipher(cipher, wrong) + await assert.rejects( + () => wrongFc.decrypt(sealed), + `换密钥后 ${cipher} 必须解密失败`, + ) + } +}) + +test("v3 为 Encrypt-then-MAC:篡改密文必须被完整性校验拦下", async () => { + const fc = await createFieldCipher("aes-256-cbc-hmac", SECRET) + const prefix = cipherPrefix("aes-256-cbc-hmac") + const parts = (await fc.encrypt("secret-value")).split(":") + const flipped = parts[1][0] === "0" ? "1" : "0" + const tampered = `${prefix}${parts[0]}:${flipped}${parts[1].slice(1)}:${parts[2]}` + await assert.rejects(() => fc.decrypt(tampered)) +}) + +// ─── 持久化边界 ────────────────────────────────────────────────────────────── + +test("DB_CIPHER 缺省:敏感字段明文落盘(默认不加密)", async () => { + const db = await import("./db") + db.__resetDbCacheForTest() + const env = envFor() // 不设置 DB_CIPHER + + assert.equal(await db.saveDb(sampleDb(), env, { force: true }), true) + + const raw: any = await rawStored(env) + assert.equal(rawUser(raw).password, "hash-abc") + assert.equal(rawUser(raw).otp_secret, "OTP-SECRET") + assert.equal(rawSetting(raw, "token").value, "tok-123") + assert.equal(raw.storages[0].addition, JSON.stringify({ token: "drive-token" })) + assert.equal(isSealedCiphertext(rawUser(raw).password), false) + + // 读回同样为明文 + db.__resetDbCacheForTest() + const loaded: any = await db.getDb(env) + assert.equal(rawUser(loaded).password, "hash-abc") +}) + +test("DB_CIPHER 非 none:敏感字段按所选算法落盘,读回自动解密", async () => { + for (const cipher of SECRET_CIPHERS) { + const db = await import("./db") + db.__resetDbCacheForTest() + const env = envFor(cipher) + + assert.equal(await db.saveDb(sampleDb(), env, { force: true }), true) + + const raw: any = await rawStored(env) + for (const sealed of [ + rawUser(raw).password, + rawUser(raw).otp_secret, + rawSetting(raw, "token").value, + raw.storages[0].addition, + ]) { + assert.equal(detectCipherPrefix(sealed)?.cipher, cipher, `${cipher} 应落盘为密文`) + } + assert.notEqual(rawUser(raw).password, "hash-abc") + // 非敏感设置不应被加密(避免无谓开销) + assert.equal(rawSetting(raw, "site_title").value, "OpenList") + + // 冷启动重新加载(绕过内存快照)→ 必须自动解密 + db.__resetDbCacheForTest() + const loaded: any = await db.getDb(env) + assert.equal(rawUser(loaded).password, "hash-abc", `${cipher} 读回必须是明文`) + assert.equal(rawUser(loaded).otp_secret, "OTP-SECRET") + assert.equal(rawSetting(loaded, "token").value, "tok-123") + assert.equal( + loaded.storages[0].addition, + JSON.stringify({ token: "drive-token" }), + ) + } +}) + +test("旧部署迁移:DB_CIPHER=none 仍能读加密数据,并在下次保存转为明文", async () => { + const db = await import("./db") + + // 1) 模拟 #69 的既有部署:DB_CIPHER 未配置时期望写入 v2 密文 + const legacyEnv = envFor("aes-256-gcm") + db.__resetDbCacheForTest() + assert.equal(await db.saveDb(sampleDb(), legacyEnv, { force: true }), true) + const legacyRaw: any = await rawStored(legacyEnv) + assert.equal(isSealedCiphertext(legacyRaw.users[0].password), true) + assert.equal( + detectCipherPrefix(legacyRaw.users[0].password)?.cipher, + "aes-256-gcm", + ) + + // 2) 升级后默认 DB_CIPHER=none:同一份存储必须仍可解密(否则登录直接坏掉) + const plainEnv = envFor() + db.__resetDbCacheForTest() + const loaded: any = await db.getDb(plainEnv) + assert.equal(rawUser(loaded).password, "hash-abc", "关掉加密后旧密文必须仍能解开") + assert.equal(rawUser(loaded).otp_secret, "OTP-SECRET") + + // 3) 下一次保存 → 自动转为明文(逐字段迁移,无需手动步骤) + assert.equal(await db.saveDb(loaded, plainEnv), true) + const raw: any = await rawStored(plainEnv) + assert.equal(rawUser(raw).password, "hash-abc") + assert.equal(isSealedCiphertext(rawUser(raw).password), false) +}) + +test("旧部署迁移:PBKDF2(enc:v1:)密文在 none 下同样可读", async () => { + const db = await import("./db") + const env = envFor() + + // 直接向存储写入一份「历史 v1 加密」的库(模拟 pre-#69 的部署数据) + const legacy = sampleDb() + legacy.users[0].password = "enc:v1:" + (await encrypt("hash-abc", SECRET)) + legacy.users[0].otp_secret = "enc:v1:" + (await encrypt("OTP-SECRET", SECRET)) + legacy.settings[1].value = "enc:v1:" + (await encrypt("tok-123", SECRET)) + await mapFormat.save(legacy, memoryDriver, env) + + db.__resetDbCacheForTest() + const loaded: any = await db.getDb(env) + assert.equal(rawUser(loaded).password, "hash-abc") + assert.equal(rawUser(loaded).otp_secret, "OTP-SECRET") + assert.equal(rawSetting(loaded, "token").value, "tok-123") +}) + +test("更换算法:旧密文按前缀解密,新写入使用新算法", async () => { + const db = await import("./db") + + const v2Env = envFor("aes-256-gcm") + db.__resetDbCacheForTest() + assert.equal(await db.saveDb(sampleDb(), v2Env, { force: true }), true) + + const v3Env = envFor("aes-256-cbc-hmac") + db.__resetDbCacheForTest() + const loaded: any = await db.getDb(v3Env) + assert.equal(rawUser(loaded).password, "hash-abc", "旧 v2 密文必须能被 v3 配置读取") + + assert.equal(await db.saveDb(loaded, v3Env), true) + const raw: any = await rawStored(v3Env) + assert.equal( + detectCipherPrefix(rawUser(raw).password)?.cipher, + "aes-256-cbc-hmac", + ) + assert.equal( + detectCipherPrefix(rawUser(raw).otp_secret)?.cipher, + "aes-256-cbc-hmac", + ) +}) + +test("DB_CIPHER=none 不影响共享密钥的生成与持久化(JWT 仍需密钥)", async () => { + const db = await import("./db") + const env = { + DB_DRIVER: "memory", + DB_FORMAT: "map", + DB_CIPHER: "none", + } + db.__resetDbCacheForTest() + + const key = await db.ensureEncryptionSecret(env) + assert.equal(typeof key, "string", "无 JWT_SECRET 时必须自动生成共享密钥") + assert.ok((key as string).length >= 16) + assert.equal(await db.isEncryptionReady(env), true, "生成后任意实例都能读到密钥") +}) diff --git a/src/backend/internal/model/store/backend.ts b/src/backend/internal/model/store/backend.ts index a8ceeb8e..fff8f8e8 100644 --- a/src/backend/internal/model/store/backend.ts +++ b/src/backend/internal/model/store/backend.ts @@ -25,6 +25,12 @@ import { memoryDriver } from "./driver/memory" import { mapFormat } from "./format/map" import { keyFormat } from "./format/key" import { sqlFormat } from "./format/sql" +import { + DB_CIPHER_VALUES, + DEFAULT_DB_CIPHER, + resolveDbCipher, + type DbCipher, +} from "../../../pkg/crypto" /** * 读取环境变量(支持 process.env 和 env 对象)。 @@ -78,6 +84,39 @@ export function readFormat(env?: any): StorageFormat { return readEnv("DB_FORMAT", "map", env) as StorageFormat } +/** + * 读取数据库字段加密算法(DB_CIPHER)。 + * + * 契约(与 DB_DRIVER / DB_FORMAT 同为「正交的一维」):只决定**敏感字段如何 + * 落盘**,不改变存储位置与数据组织方式。 + * + * none(默认) - 不加密,敏感字段与普通 JSON 一样明文落盘 + * aes-256-gcm - HKDF-SHA256 派生一把 AES-256-GCM 密钥,每次 + * 读/写只派生一次(低成本,推荐开启加密时使用) + * aes-256-gcm-pbkdf2 - AES-256-GCM,密钥由 PBKDF2-SHA256(10 万次) 派生 + * (历史 envelope,逐字段派生、开销大) + * aes-256-cbc-hmac - AES-256-CBC + HMAC-SHA256(Encrypt-then-MAC) + * + * 为什么默认 none:加密会让「共享库给 Go 后端 / 直接用 SQL 查询」变得不可读, + * 且读取时需逐字段解密(详见 #69 的 CPU 优化)。需要静态加密的部署显式配置即可; + * 历史密文带 `enc:vN:` 前缀,读取时按前缀自动解密,因此从加密切回 none + * **不会导致数据不可读**,只会在下次写入时转为明文(自动迁移)。 + * + * 取值无法识别时告警并回退 none(而不是静默启用某个算法)。注意:读取时 + * 解密算法由密文前缀决定,与本函数取值无关 —— 拼错变量只会影响**新写入**。 + */ +export function readCipher(env?: any): DbCipher { + const raw = readEnv("DB_CIPHER", DEFAULT_DB_CIPHER, env) + const { cipher, known } = resolveDbCipher(raw) + if (!known) { + console.warn( + `[DB] Unknown DB_CIPHER "${raw}"; falling back to "${DEFAULT_DB_CIPHER}". ` + + `Valid values: ${DB_CIPHER_VALUES.join(", ")}.`, + ) + } + return cipher +} + /** * 是否处于 Serverless / Worker 类运行环境。 * diff --git a/src/backend/pkg/crypto.ts b/src/backend/pkg/crypto.ts index 726b45c1..26810d7e 100644 --- a/src/backend/pkg/crypto.ts +++ b/src/backend/pkg/crypto.ts @@ -356,6 +356,309 @@ export async function aesCbcEncryptBase64( return btoa(binary) } +// ─── 静态加密算法选择(DB_CIPHER)─────────────────────────────────────────── +// +// 设计要点: +// +// 1. **算法由环境变量选择,历史密文由前缀识别**。写入时按所选算法加版本前缀 +// (`enc:v1:` / `enc:v2:` / `enc:v3:`),读取时只看前缀、完全不依赖当前配置, +// 因此「换算法」「关掉加密」都不会让既有密文变成乱码 —— 旧密文会在本次读取时 +// 按旧算法解开,并在下一次写入时按新算法(或明文)重新落盘。 +// +// 2. `none` 是默认值:不加密直接落盘。 +// +// 3. 三种算法都基于同一把密钥(JWT_SECRET / 持久化的共享密钥): +// - v1 aes-256-gcm-pbkdf2 PBKDF2-SHA256(10 万次) 逐字段派生(历史 envelope) +// - v2 aes-256-gcm HKDF-SHA256 派生一把 AES key,每次读写只派生一次 +// (#69 引入的低 CPU 方案;既有部署默认写这种) +// - v3 aes-256-cbc-hmac AES-256-CBC + HMAC-SHA256(Encrypt-then-MAC,非 GCM 族) +// +// 4. v3 采用「长度前缀」包裹明文:WebCrypto 的 AES-CBC 在部分运行时(Node 的 +// OpenSSL 后端)会自动做/去 PKCS#7 padding,而 CF Workers/浏览器不会。 +// 把真实长度写进密文头部(4 字节大端),解密后只取该长度即可,从而在任何 +// 运行时都得到一致结果(多余 padding 与额外整块 padding 都会被忽略)。 + +export type DbCipher = + | "none" + | "aes-256-gcm" + | "aes-256-gcm-pbkdf2" + | "aes-256-cbc-hmac" + +/** DB_CIPHER 的默认值:不加密(向后兼容「明文落盘」的既有部署) */ +export const DEFAULT_DB_CIPHER: DbCipher = "none" + +/** 可选算法(文档与错误提示用,顺序即推荐顺序) */ +export const DB_CIPHER_VALUES: DbCipher[] = [ + "none", + "aes-256-gcm", + "aes-256-gcm-pbkdf2", + "aes-256-cbc-hmac", +] + +/** + * 别名映射(全部小写、去空白后匹配)。 + * + * 允许别名是为了让 `DB_CIPHER=AES-256-GCM`、`gcm`、`v2` 这类习惯写法都能工作, + * 避免用户因为大小写或短名不同而静默退回 `none`(那会让「以为开了加密」的部署 + * 实际明文落盘)。 + */ +const DB_CIPHER_ALIASES: Record = { + none: "none", + off: "none", + no: "none", + plain: "none", + plaintext: "none", + "aes-256-gcm": "aes-256-gcm", + "aes-gcm": "aes-256-gcm", + aesgcm: "aes-256-gcm", + "aes-256-gcm-hkdf": "aes-256-gcm", + "gcm-hkdf": "aes-256-gcm", + hkdf: "aes-256-gcm", + gcm: "aes-256-gcm", + v2: "aes-256-gcm", + "aes-256-gcm-pbkdf2": "aes-256-gcm-pbkdf2", + "aes-gcm-pbkdf2": "aes-256-gcm-pbkdf2", + pbkdf2: "aes-256-gcm-pbkdf2", + legacy: "aes-256-gcm-pbkdf2", + v1: "aes-256-gcm-pbkdf2", + "aes-256-cbc-hmac": "aes-256-cbc-hmac", + "aes-cbc-hmac": "aes-256-cbc-hmac", + "cbc-hmac": "aes-256-cbc-hmac", + "aes-256-cbc": "aes-256-cbc-hmac", + cbc: "aes-256-cbc-hmac", + v3: "aes-256-cbc-hmac", +} + +/** + * 解析 DB_CIPHER 取值。 + * + * @returns `known=false` 表示取值无法识别(调用方应告警,但仍拿到安全默认值 `none`, + * 绝不因为拼错变量而悄悄启用一把谁也不知道的算法) + */ +export function resolveDbCipher(raw: unknown): { + cipher: DbCipher + known: boolean +} { + const key = String(raw ?? "") + .trim() + .toLowerCase() + if (!key) return { cipher: DEFAULT_DB_CIPHER, known: true } + const hit = DB_CIPHER_ALIASES[key] + if (hit) return { cipher: hit, known: true } + return { cipher: DEFAULT_DB_CIPHER, known: false } +} + +/** 算法 → 密文版本号(前缀中的数字) */ +const CIPHER_VERSION: Record, number> = { + "aes-256-gcm-pbkdf2": 1, + "aes-256-gcm": 2, + "aes-256-cbc-hmac": 3, +} + +/** 密文版本号 → 算法 */ +const VERSION_CIPHER: Record> = { + 1: "aes-256-gcm-pbkdf2", + 2: "aes-256-gcm", + 3: "aes-256-cbc-hmac", +} + +/** 密文前缀正则:`enc:v<数字>:` */ +const CIPHER_PREFIX_RE = /^enc:v(\d+):/ + +/** 算法对应的密文前缀(`none` 无前缀) */ +export function cipherPrefix(cipher: DbCipher): string { + if (cipher === "none") return "" + return `enc:v${CIPHER_VERSION[cipher]}:` +} + +/** + * 识别密文前缀。**只看前缀,不看当前配置** —— 这是「换算法/关加密不丢数据」的关键。 + * + * @returns 命中的算法与前缀(调用方用 `prefix.length` 切出密文主体);明文返回 null + */ +export function detectCipherPrefix( + value: unknown, +): { cipher: Exclude; prefix: string } | null { + if (typeof value !== "string") return null + const m = CIPHER_PREFIX_RE.exec(value) + if (!m) return null + const cipher = VERSION_CIPHER[Number(m[1])] + if (!cipher) return null + return { cipher, prefix: m[0] } +} + +/** 该值是否为本模块产生的密文(任意版本) */ +export function isSealedCiphertext(value: unknown): boolean { + return detectCipherPrefix(value) !== null +} + +// ── v3:AES-256-CBC + HMAC-SHA256(Encrypt-then-MAC)──────────────────────── + +const V3_ENC_INFO = "openlist-db-cipher-v3-enc" +const V3_MAC_INFO = "openlist-db-cipher-v3-mac" + +function bytesToHex(bytes: Uint8Array): string { + let out = "" + for (const b of bytes) out += b.toString(16).padStart(2, "0") + return out +} + +function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const out = new Uint8Array(a.length + b.length) + out.set(a, 0) + out.set(b, a.length) + return out +} + +/** 定长(时间无关)比较:用于校验 HMAC,避免比较短路泄露信息。 */ +function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false + let diff = 0 + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i] + return diff === 0 +} + +/** 明文包裹:4 字节大端长度 + 原文(用于解密后精确切出原文,见上方说明 4) */ +function wrapWithLength(bytes: Uint8Array): Uint8Array { + const head = new Uint8Array(4) + new DataView(head.buffer).setUint32(0, bytes.length, false) + return concatBytes(head, bytes) +} + +function unwrapWithLength(bytes: Uint8Array): Uint8Array { + if (bytes.length < 4) throw new Error("Invalid encrypted data format") + const len = new DataView(bytes.buffer, bytes.byteOffset, 4).getUint32(0, false) + if (len > bytes.length - 4) throw new Error("Invalid encrypted data length") + return bytes.slice(4, 4 + len) +} + +/** SHA-256 派生一把 AES-CBC 密钥(v3 用;与 encrypt/decrypt 的 PBKDF2 无关) */ +async function deriveSha256AesCbcKey(secret: string, info: string) { + const digest = await crypto.subtle.digest("SHA-256", toBytes(`${info}|${secret}`)) + return crypto.subtle.importKey("raw", digest, { name: "AES-CBC" }, false, [ + "encrypt", + "decrypt", + ]) +} + +async function hmacSha256RawKey(secret: string, info: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", toBytes(`${info}|${secret}`)) + return crypto.subtle.importKey( + "raw", + digest, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ) +} + +async function aesCbcHmacEncrypt( + data: string, + secret: string, +): Promise { + const iv = crypto.getRandomValues(new Uint8Array(16)) + const padded = pkcs7Pad(wrapWithLength(toBytes(data) as Uint8Array)) + const encKey = await deriveSha256AesCbcKey(secret, V3_ENC_INFO) + const cipherBuf = await crypto.subtle.encrypt( + { name: "AES-CBC", iv }, + encKey, + padded as any, + ) + const cipherBytes = new Uint8Array(cipherBuf) + const macKey = await hmacSha256RawKey(secret, V3_MAC_INFO) + const mac = new Uint8Array( + await crypto.subtle.sign("HMAC", macKey, concatBytes(iv, cipherBytes) as any), + ) + return `${bytesToHex(iv)}:${bytesToHex(cipherBytes)}:${bytesToHex(mac)}` +} + +async function aesCbcHmacDecrypt( + body: string, + secret: string, +): Promise { + const parts = body.split(":") + if (parts.length !== 3) throw new Error("Invalid encrypted data format") + const iv = fromHex(parts[0]) + const cipherBytes = fromHex(parts[1]) + const mac = fromHex(parts[2]) + + // Encrypt-then-MAC:先验签再解密,避免把未经认证的数据送进解密器。 + const macKey = await hmacSha256RawKey(secret, V3_MAC_INFO) + const expect = new Uint8Array( + await crypto.subtle.sign("HMAC", macKey, concatBytes(iv, cipherBytes) as any), + ) + if (!timingSafeEqual(expect, mac)) { + throw new Error("Encrypted data failed integrity check (wrong key?)") + } + + const encKey = await deriveSha256AesCbcKey(secret, V3_ENC_INFO) + const plain = await crypto.subtle.decrypt( + { name: "AES-CBC", iv: iv as any }, + encKey, + cipherBytes as any, + ) + return new TextDecoder().decode(unwrapWithLength(new Uint8Array(plain))) +} + +/** + * 字段加解密器:把「写入算法」和「共享密钥」绑定成一个对象,供 db.ts 在 + * 一次 save / load 内复用(避免逐字段重复派生密钥)。 + * + * 关键约定:`encrypt()` 用**写入算法**,`decrypt()` 用**密文前缀**识别算法。 + * 因此历史 v1 密文、#69 写入的 v2 密文、本实现新增的 v3 密文都能读; + * 而「关掉加密 / 换算法」只影响新写入的内容。 + */ +export interface FieldCipher { + /** 写入时使用的算法(`none` 不会构造本对象) */ + cipher: DbCipher + /** 按写入算法加密,返回**不含前缀**的密文主体 */ + encrypt(value: string): Promise + /** 解密(入参是**含前缀**的完整密文),按前缀自动选择算法 */ + decrypt(sealed: string): Promise +} + +export async function createFieldCipher( + cipher: DbCipher, + secret: string, +): Promise { + // v2 的密钥派生结果在本次读写中共享;纯 v1/v3 数据不会触发这次派生。 + let v2Key: Promise | null = null + const getV2Key = () => (v2Key ||= deriveConfigEncryptionKey(secret)) + + return { + cipher, + async encrypt(value: string): Promise { + switch (cipher) { + case "none": + return value + case "aes-256-gcm": + return await encryptConfigValue(value, await getV2Key()) + case "aes-256-gcm-pbkdf2": + return await encrypt(value, secret) + case "aes-256-cbc-hmac": + return await aesCbcHmacEncrypt(value, secret) + default: + throw new Error(`Unsupported DB_CIPHER: ${String(cipher)}`) + } + }, + async decrypt(sealed: string): Promise { + const hit = detectCipherPrefix(sealed) + if (!hit) return sealed + const body = sealed.slice(hit.prefix.length) + switch (hit.cipher) { + case "aes-256-gcm": + return await decryptConfigValue(body, await getV2Key()) + case "aes-256-gcm-pbkdf2": + return await decrypt(body, secret) + case "aes-256-cbc-hmac": + return await aesCbcHmacDecrypt(body, secret) + default: + throw new Error(`Unsupported ciphertext: ${String(hit.cipher)}`) + } + }, + } +} + /** PKCS#7 padding for AES-CBC (block size 16). */ function pkcs7Pad(pt: Uint8Array, blockSize = 16): Uint8Array { const padLen = blockSize - (pt.length % blockSize) diff --git a/src/backend/server/middlewares.ts b/src/backend/server/middlewares.ts index 48bcfacf..747bea25 100644 --- a/src/backend/server/middlewares.ts +++ b/src/backend/server/middlewares.ts @@ -114,15 +114,16 @@ export async function getJwtSecret(c?: Context | any): Promise { return cached as string } - // 2a. 复用 setup 自动生成的加密密钥(openlist_encryption_secret)。 + // 2a. 复用 setup 自动生成的共享密钥(openlist_encryption_secret)。 // // 为什么必须放在这里:`ensureEncryptionSecret()` 在 setup 阶段把自动生成的 // 密钥写进 **openlist_encryption_secret**,而本函数历史实现只找 // **openlist_jwt_secret** —— 两个槽位名不同。于是「自动生成」的那把密钥 // 对 JWT 侧**完全不可见**:本函数会再生成一把存到另一个槽位, // 造成同一部署里两把密钥各自漂移(多实例验签失败、冷启动即换钥)。 - // 约定 JWT 与字段加密共用同一把密钥(见 db.ts:1276 的注释),因此这里 - // 显式回退读取加密密钥槽位,保证「生成了一份」就等于「两边都可用」。 + // 约定 JWT 签名与字段加密共用同一把密钥(见 db.ts 的「静态加密」注释块; + // 字段加密是否启用由 DB_CIPHER 决定,与密钥来源无关),因此这里 + // 显式回退读取该槽位,保证「生成了一份」就等于「两边都可用」。 try { const sharedSecret = useSecret( await readPersistedSecret(env, ENCRYPTION_SECRET_KV_KEY), diff --git a/src/backend/server/public.ts b/src/backend/server/public.ts index af215772..9016c823 100644 --- a/src/backend/server/public.ts +++ b/src/backend/server/public.ts @@ -12,6 +12,7 @@ import { getStoreConfigErrorDetail, isPersistentStorageAvailable, isServerlessRuntime, + readCipher, readDriver, readFormat, } from "../internal/model/store/backend" @@ -70,7 +71,7 @@ function bindBackendSuggestion(): string { * 只报告「配置了什么」「是否就绪」「哪里不对」,绝不回显密钥或 DSN 原文。 * * 返回: - * - config:DB_FORMAT / DB_DRIVER 的配置值与实际解析值 + * - config:DB_FORMAT / DB_DRIVER / DB_CIPHER 的配置值与实际解析值 * - storage:驱动可用性、健康状态、连接错误 * - jwt:签名/加密密钥是否就绪 * - ready:综合就绪判定(数据库 + 密钥都就绪) @@ -80,6 +81,8 @@ publicRouter.get("/env_check", async (c) => { const env = c.env as any const driverCfg = readDriver(env) const formatCfg = readFormat(env) + // 字段加密算法(none = 不加密,默认)。只报告配置,不回显任何密钥。 + const cipherCfg = readCipher(env) const serverless = isServerlessRuntime(env) // ── 存储状态(不抛错,内部已做容错)── @@ -288,6 +291,10 @@ publicRouter.get("/env_check", async (c) => { // 配置值(用户显式设置,或默认值) db_format: formatCfg, db_driver: driverCfg, + // 敏感字段落盘算法:none(默认,不加密)/ aes-256-gcm / + // aes-256-gcm-sha256 / aes-256-cbc-hmac。历史密文带 enc:vN: 前缀, + // 读取时自动识别,因此该项只影响**新写入**。 + db_cipher: cipherCfg, // 实际解析值(auto 探测后的结果);解析失败时后端内部是 "none", // 对界面没有意义且会显示成「do → none」,这里统一归一为 null。 resolved_driver: resolvedOrNull(storage?.driver), @@ -685,10 +692,15 @@ publicRouter.post("/init/setup", async (c) => { ) } - // 初始化阶段:确保加密密钥存在。 + // 初始化阶段:确保**共享密钥**存在(JWT 签名 + DB_CIPHER 启用时的字段加密)。 // // 只在 setup 中生成 —— 且仅当持久化键不存在时。一旦写入永不覆盖, // 否则既有加密数据将无法解密。其他任何阶段都只读不生成。 + // + // 注意:**与 DB_CIPHER 无关**。DB_CIPHER=none 仅表示「不加密数据库字段」, + // JWT 令牌签名仍然需要一把跨实例/跨冷启动一致的密钥:如果运维没有通过环境 + // 变量 JWT_SECRET 提供,就必须在这里生成并持久化,否则每个实例/每次冷启动 + // 都会各生成一把随机密钥,导致令牌随机失效。因此这里无条件调用。 await ensureEncryptionSecret(c.env) if (existing) { diff --git a/wrangler.jsonc b/wrangler.jsonc index a83d487b..9aeaf5ee 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -59,9 +59,23 @@ // 说明:mysql 仅在配置了连接信息时才参与 auto 探测,避免无谓的 TCP 建连。 "DB_DRIVER": "auto", + // 敏感字段落盘算法(默认 none:不加密;与 DB_DRIVER/DB_FORMAT 正交): + // none(默认) - 不加密,敏感字段明文落盘 + // aes-256-gcm - HKDF 派生 AES-256-GCM 密钥,每次读写只派生一次 + // (既有加密部署的 enc:v2: 形态,开销最低) + // aes-256-gcm-pbkdf2 - AES-256-GCM + PBKDF2-SHA256(10 万次) 逐字段派生 + // (历史 enc:v1: envelope,开销大) + // aes-256-cbc-hmac - AES-256-CBC + HMAC-SHA256(Encrypt-then-MAC) + // 说明:加密对象为 storages[].addition / 敏感 settings / users[].password / + // users[].otp_secret。密文带 enc:vN: 前缀,读取时按前缀自动解密, + // 因此把该项改回 none 不会导致既有数据不可读(下次保存自动转为明文)。 + // 注意:该项与 JWT 密钥无关 —— DB_CIPHER=none 时,未配置 JWT_SECRET 仍会 + // 自动生成并持久化共享密钥(JWT 签名需要)。 + "DB_CIPHER": "none", + // ── 安全鉴权(推荐以 Secret 类型配置,见 .dev.vars.example)────────── // - // JWT 签名密钥(>=16 字符,兼作字段加密与定时任务鉴权) + // JWT 签名密钥(>=16 字符,兼作可选的字段加密密钥与定时任务鉴权) // "JWT_SECRET": "", // // 跳过安装向导,自动以该密码初始化 admin From 80ff38e4324bc01b4f7fac34d3d13180dab3d255 Mon Sep 17 00:00:00 2001 From: PIKACHUIM Date: Sun, 20 Sep 2026 17:08:38 +0800 Subject: [PATCH 2/2] feat(db): add chacha20/des/3des ciphers and cut repeated crypto work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB_CIPHER 新增:chacha20-poly1305(enc:v4:,RFC 8439,纯 JS 实现)、 des-cbc-hmac / 3des-cbc-hmac(enc:v5:/enc:v6:,crypto-js + HMAC-SHA256)。 后两者仅作兼容用途:单 DES 有效密钥 56-bit 可被暴力破解、3DES 已被 NIST SP 800-131A 弃用,选用时打印一次性告警 - 新增 pkg/chacha20.ts:ChaCha20-Poly1305 纯 JS 实现(WebCrypto 全平台无此算法), 已通过 RFC 8439 §2.5.2/§2.8.2 官方向量并与 Node 原生实现交叉验证 - 新增 pkg/legacy-ciphers.ts:DES/3DES-CBC(WebCrypto 无 DES/3DES), 已与 OpenSSL des-ede3-cbc 交叉验证(单 DES 用等价的 EDE(K,K,K) 比对) - 性能①:密钥派生结果在进程内缓存,同一 isolate 只派生一次 (#69 已把「逐字段派生」降为「每次操作派生一次」,这里再降一级) - 性能②:sealDb 改为写时复制,不再对整库做 JSON 深拷贝 - 性能③:新增「未变化字段跳过重新加密」——明文、算法、密钥都没变时直接复用 上次的密文(三重校验保证换算法/关加密/换密钥/改值的行为完全不变), 因此改一个设置不再触发全库重新加密(对 PBKDF2/DES 一类昂贵算法尤为明显) - 测试:新增 pkg/cipher_algorithms.test.ts(RFC 向量 + 原生互操作), db_cipher.test.ts 扩展到 15 例(含缓存复用与写时复制不变量) - 已在仓库根目录验证边缘构建可正常打包新模块(node scripts/build-edge.mjs, 产物 +16.3KB;构建产物按 #69 的做法不随本 PR 提交) Co-Authored-By: CodeBuddy --- README.md | 42 ++- package.json | 2 +- src/backend/internal/model/db.ts | 225 ++++++++++-- src/backend/internal/model/db_cipher.test.ts | 132 ++++++- src/backend/internal/model/store/backend.ts | 15 +- src/backend/pkg/chacha20.ts | 254 +++++++++++++ src/backend/pkg/cipher_algorithms.test.ts | 158 +++++++++ src/backend/pkg/crypto.ts | 354 +++++++++++++++++-- src/backend/pkg/legacy-ciphers.ts | 91 +++++ wrangler.jsonc | 10 +- 10 files changed, 1196 insertions(+), 87 deletions(-) create mode 100644 src/backend/pkg/chacha20.ts create mode 100644 src/backend/pkg/cipher_algorithms.test.ts create mode 100644 src/backend/pkg/legacy-ciphers.ts diff --git a/README.md b/README.md index 434d739f..83fdca45 100644 --- a/README.md +++ b/README.md @@ -170,11 +170,34 @@ pnpm run deploy:worker **DB_CIPHER**(敏感字段落盘算法,**默认不加密**) - `none`(默认):不加密,敏感字段与普通 JSON 一样明文落盘 -- `aes-256-gcm`:HKDF-SHA256 派生一把 AES-256-GCM 密钥,每次读/写只派生一次 - (既有加密部署写入的 `enc:v2:` 形态,开销最低,开启加密时推荐使用) +- `aes-256-gcm`:HKDF-SHA256 派生一把 AES-256-GCM 密钥(`enc:v2:`) + —— 既有加密部署写入的形态,开销最低,开启加密时**推荐** - `aes-256-gcm-pbkdf2`:AES-256-GCM,密钥由 PBKDF2-SHA256(10 万次迭代)逐字段派生 - (历史 `enc:v1:` envelope,抗弱口令但开销大,仅建议用于兼容) -- `aes-256-cbc-hmac`:AES-256-CBC + HMAC-SHA256(Encrypt-then-MAC,非 GCM 族) + (历史 `enc:v1:` envelope,抗弱口令但每次读写都很慢,仅建议用于兼容) +- `aes-256-cbc-hmac`:AES-256-CBC + HMAC-SHA256(`enc:v3:`,Encrypt-then-MAC) +- `chacha20-poly1305`:ChaCha20-Poly1305(`enc:v4:`,RFC 8439,纯 JS 实现; + WebCrypto 全平台都没有 ChaCha20,故自带实现并通过 RFC 官方向量验证) +- `des-cbc-hmac` / `3des-cbc-hmac`:DES / 3DES-CBC + HMAC-SHA256(`enc:v5:` / `enc:v6:`) + —— **仅用于兼容/互操作**:单 DES 有效密钥只有 56-bit(可被暴力破解),3DES 已被 + NIST SP 800-131A 弃用(64-bit 分组 + Sweet32)。选用时后端会打印一次性告警, + 请勿用它们保护真实数据。 +- 别名(大小写无关):`gcm`/`hkdf`/`v2`、`pbkdf2`/`v1`、`cbc`/`v3`、`chacha20`/`v4`、 + `des`/`v5`、`3des`/`tripledes`/`v6`、`off`/`plain`;无法识别时告警并回退 `none` + +**几种算法的 CPU 特性(实测,120 字节字段,Node 22)** + +| 算法 | 单字段耗时 | 说明 | +| :-- | --: | :-- | +| `aes-256-gcm` | ~30 µs | WebCrypto 原生(调用开销为主) | +| `chacha20-poly1305` | ~18 µs | 纯 JS,小字段下反而更快 | +| `aes-256-cbc-hmac` | ~60 µs | 两次 WebCrypto 调用(加密 + HMAC) | +| `des-cbc-hmac` / `3des-cbc-hmac` | ~0.26 ms | 纯 JS(crypto-js) | +| `aes-256-gcm-pbkdf2` | ~27 ms | 每字段 10 万次 PBKDF2(历史包袱) | + +为此后端内置两项优化(对功能无影响):① 密钥派生结果在**进程内缓存**,同一 +isolate 只派生一次;② **未变化字段跳过重新加密** —— 明文、算法、密钥都没变时 +直接复用上次的密文,因此「改一个设置」不会触发全库重新加密(对 PBKDF2/DES +这类昂贵算法尤为明显)。 **推荐配置组合:** ```bash @@ -219,7 +242,7 @@ CF_API_KEY=your_api_token - **`none` 只表示「不加密数据库字段」,不影响其它任何行为**:JWT 令牌签名仍需一把 跨实例/跨冷启动一致的共享密钥,若未通过环境变量 `JWT_SECRET` 提供,安装向导仍会 自动生成并持久化 `openlist_encryption_secret`(与加密是否启用无关)。 -- 密文带版本前缀(`enc:v1:` / `enc:v2:` / `enc:v3:`),**读取时按前缀自动识别算法**, +- 密文带版本前缀(`enc:v1:` ~ `enc:v6:`),**读取时按前缀自动识别算法**, 与当前配置无关。因此: - 从加密切回 `none`(或升级后不再配置 `DB_CIPHER`):既有密文仍能正常解密, 并在**下一次配置保存**时自动转为明文(逐字段迁移,无需任何手动步骤); @@ -228,7 +251,9 @@ CF_API_KEY=your_api_token - 加密算法选择是**正交的一维**,不改变 `DB_DRIVER` / `DB_FORMAT` 的语义。 - 取值无法识别时回退 `none` 并在日志告警(不会静默启用某个算法); `/api/public/env_check` 的 `config.db_cipher` 会回显当前生效值。 -- 三种算法均基于 WebCrypto,在 Cloudflare Workers / EdgeOne Node 云函数 / Node.js 上行为一致。 +- AES 三种算法基于 WebCrypto(各平台原生);`chacha20-poly1305` 与 `des/3des` 因 + WebCrypto 不提供对应算法而使用纯 JS 实现 —— 全部在 Cloudflare Workers / + EdgeOne Node 云函数 / ESA / Node.js 上行为一致(不依赖 `node:crypto`)。 **向后兼容:** - `DB_DRIVER=json` 自动转换为 `DB_FORMAT=map` + 自动检测驱动 @@ -254,8 +279,9 @@ CF_API_KEY=your_api_token #### 安全配置 - `JWT_SECRET`:JWT 令牌签名密钥(必填),**同时用作可选的字段加密密钥**与定时任务鉴权 -- `DB_CIPHER`:敏感字段落盘算法(可选,默认 `none` 不加密):`none` / - `aes-256-gcm`(历史默认)/ `aes-256-gcm-sha256` / `aes-256-cbc-hmac` +- `DB_CIPHER`:敏感字段落盘算法(可选,默认 `none` 不加密):`none` / `aes-256-gcm` + (推荐)/ `aes-256-gcm-pbkdf2` / `aes-256-cbc-hmac` / `chacha20-poly1305` / + `des-cbc-hmac` / `3des-cbc-hmac`(后两者仅兼容用途,不安全) - `ADMIN_PASS`:初始管理员密码(可选,设置后跳过安装向导自动初始化 admin) #### 其他配置 diff --git a/package.json b/package.json index d3dfc610..8662a0cd 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "description": "Storage driver: `auto` (default), `blob`, `cfkv`, `kv`, `d1`, `do`, `mysql`. Leave as `auto` unless you need to force one. An explicitly configured driver is never replaced automatically: if it is unavailable, the app reports a configuration error (including which driver auto-detection would have picked) instead of writing to another backend. Invalid driver/format pairs are reported the same way." }, "DB_CIPHER": { - "description": "At-rest cipher for sensitive fields (drive credentials, 2FA secrets, password hashes): `none` (default, no encryption), `aes-256-gcm` (HKDF-derived AES-256-GCM key, derived once per read/write — the `enc:v2:` envelope written by existing encrypted deployments), `aes-256-gcm-pbkdf2` (legacy `enc:v1:` envelope, PBKDF2 per field), `aes-256-cbc-hmac` (AES-256-CBC + HMAC-SHA256, encrypt-then-MAC). Ciphertexts carry an `enc:vN:` prefix and are decrypted by prefix on read, so switching back to `none` (or changing algorithms) never makes existing data unreadable — it only changes how newly written data is stored (legacy ciphertext is migrated to plaintext on the next save). This setting does NOT affect the shared secret: with `none`, a secret is still generated and persisted during setup when JWT_SECRET is not provided, because JWT signing needs it." + "description": "At-rest cipher for sensitive fields (drive credentials, 2FA secrets, password hashes): `none` (default, no encryption), `aes-256-gcm` (HKDF-derived AES-256-GCM key — the `enc:v2:` envelope written by existing encrypted deployments, recommended), `aes-256-gcm-pbkdf2` (legacy `enc:v1:` envelope, PBKDF2 per field, slow), `aes-256-cbc-hmac` (`enc:v3:`), `chacha20-poly1305` (`enc:v4:`, RFC 8439, pure-JS implementation), `des-cbc-hmac` / `3des-cbc-hmac` (`enc:v5:`/`enc:v6:`, compatibility only — single DES is 56-bit and 3DES is deprecated, do not use for real data). Ciphertexts carry an `enc:vN:` prefix and are decrypted by prefix on read, so switching back to `none` (or changing algorithms) never makes existing data unreadable — it only changes how newly written data is stored (legacy ciphertext is migrated to plaintext on the next save). Key derivation is cached per isolate, and unchanged fields are not re-encrypted on save. This setting does NOT affect the shared secret: with `none`, a secret is still generated and persisted during setup when JWT_SECRET is not provided, because JWT signing needs it." } } }, diff --git a/src/backend/internal/model/db.ts b/src/backend/internal/model/db.ts index aacac66e..16946a4f 100644 --- a/src/backend/internal/model/db.ts +++ b/src/backend/internal/model/db.ts @@ -1,4 +1,5 @@ import { + __resetCipherKeyCacheForTest, cipherPrefix, createFieldCipher, detectCipherPrefix, @@ -1114,6 +1115,10 @@ export const __resetDbCacheForTest = () => { unsealKeyWarned = false sealKeyMissingWarned = false plaintextMigrationLogged = false + // 密钥派生缓存与「未变化字段跳过加密」缓存同样必须复位: + // 用例会切换 DB_CIPHER / 密钥,缓存串味会让断言依赖执行顺序。 + __resetCipherKeyCacheForTest() + sealedCache.clear() } /** 仅供测试:注入统计型存储后端。 */ @@ -1612,20 +1617,105 @@ export async function ensureEncryptionSecret( } } +/** + * 「未变化字段跳过重新加密」缓存(纯性能优化,不参与任何功能语义)。 + * + * ## 为什么需要 + * + * 内存中的库**始终是明文**,而每次 `saveDb`(任何一次配置改动、任何一次 admin + * 操作)都会把所有敏感字段重新加密一遍。字段没变时这次加密毫无意义: + * + * - 用 `aes-256-gcm` 时每字段约 30 µs(WebCrypto 调用开销); + * - 用 `aes-256-gcm-pbkdf2` 时每字段约 27 ms(PBKDF2 10 万次)—— 这才是真痛; + * - 用 `des/3des-cbc-hmac` 时每字段约 0.3 ms(纯 JS)。 + * + * load 时我们恰好知道「密文 ↔ 明文」的对应关系,把它记下来,save 时若明文未变、 + * 且算法与密钥都未变,就直接复用原密文,跳过整个密码学运算。 + * + * ## 为什么是安全的(无功能风险) + * + * 1. **三重校验**后才复用:`明文相同` + `算法/密钥指纹相同` + `密文算法 == 当前写入算法`。 + * 任一不满足都走正常加密路径 —— 因此「换算法」「关加密」「轮换密钥」「改值」 + * 的行为与不做该优化时**完全一致**(含自动迁移)。 + * 2. 复用只发生在**同一明文、同一密钥、同一算法**下,不会造成 nonce/IV 与 + * 不同明文复用(这是 AEAD 唯一需要避免的禁忌)。 + * 3. 缓存只在进程内、键为字段身份(`users:1:password`),容量有上限; + * 对同一字段写入不同值时条目会被覆盖,不会读到过期值。 + */ +interface SealedCacheEntry { + plain: string + sealed: string + /** 生成该密文时的 fieldCipher 指纹(算法 + 密钥) */ + fp: string + /** 生成该密文的算法(必须等于当前写入算法才允许复用) */ + cipher: string +} + +const SEALED_CACHE_LIMIT = 4096 +const sealedCache = new Map() + +function rememberSealed( + identity: string, + plain: string, + sealed: string, + fp: string, + cipher: string, +): void { + if (sealedCache.size >= SEALED_CACHE_LIMIT) { + const oldest = sealedCache.keys().next().value + if (oldest !== undefined) sealedCache.delete(oldest) + } + sealedCache.set(identity, { plain, sealed, fp, cipher }) +} + +function recallSealed( + identity: string, + plain: string, + fp: string, + cipher: string, +): string | null { + const hit = sealedCache.get(identity) + if (!hit) return null + if (hit.fp !== fp) return null // 算法或密钥变了 → 必须重新加密 + if (hit.cipher !== cipher) return null // 密文算法 ≠ 当前写入算法 → 需要迁移 + if (hit.plain !== plain) return null // 值变了 → 必须重新加密 + return hit.sealed +} + /** * 加密单个字段(`seal`)。 * * - 未启用加密(fieldCipher 为 null)或未取到密钥:原样返回明文。 * - 已有任意版本前缀:视为已加密(幂等),避免重复加密 —— 双重加密会在下次读取 * 时只解开一层、仍然呈现密文,表现为「密码看起来还是乱码」的诡异故障。 + * - 明文未变且算法/密钥未变:复用上次的密文,跳过密码学运算(见 sealedCache)。 */ async function sealValue( value: string, fieldCipher: FieldCipher | null, + identity: string, ): Promise { if (!value || !fieldCipher || fieldCipher.cipher === "none") return value if (isSealedCiphertext(value)) return value // idempotent - return cipherPrefix(fieldCipher.cipher) + (await fieldCipher.encrypt(value)) + + const reused = recallSealed( + identity, + value, + fieldCipher.fingerprint, + fieldCipher.cipher, + ) + if (reused) return reused + + const sealed = + cipherPrefix(fieldCipher.cipher) + (await fieldCipher.encrypt(value)) + rememberSealed( + identity, + value, + sealed, + fieldCipher.fingerprint, + fieldCipher.cipher, + ) + return sealed } /** @@ -1637,6 +1727,7 @@ async function sealValue( async function unsealValue( value: string, fieldCipher: FieldCipher | null, + identity: string, ): Promise { if (!value || !isSealedCiphertext(value)) return value if (!fieldCipher) { @@ -1652,8 +1743,21 @@ async function unsealValue( } return value } + const hit = detectCipherPrefix(value) try { - return await fieldCipher.decrypt(value) + const plain = await fieldCipher.decrypt(value) + // 只有「密文算法 == 当前写入算法」时才记账:否则一旦复用就等于阻止了 + // 「读到 v1 → 保存为 v2」这类自动迁移(那是必须发生的)。 + if (hit && hit.cipher === fieldCipher.cipher) { + rememberSealed( + identity, + plain, + value, + fieldCipher.fingerprint, + hit.cipher, + ) + } + return plain } catch (e) { console.warn( "[DB] Failed to decrypt a sealed secret (wrong JWT_SECRET?):", @@ -1718,42 +1822,93 @@ async function sealDb( data: any, fieldCipher: FieldCipher | null, ): Promise { - // 未启用加密:原样落盘(不做无意义的深拷贝) + // 未启用加密:原样落盘(不做无意义的复制) if (!fieldCipher || fieldCipher.cipher === "none" || !data) return data - const copy = JSON.parse(JSON.stringify(data)) - // 密钥派生由 fieldCipher 内部完成并缓存:一次 save 只派生一次(v2 的 HKDF key), - // 每个字段仍然各自使用独立随机 IV。 - // 1. 加密存储配置中的 addition 字段(网盘凭据) - for (const s of copy.storages || []) { + // ── 写时复制(copy-on-write)而不是整库 JSON 深拷贝 ── + // + // 历史实现每次落盘前都 `JSON.parse(JSON.stringify(data))`:把**整个配置** + // (含大量与敏感字段无关的数据)序列化再反序列化一遍,大配置下是纯开销。 + // 这里只在「某个字段确实被修改」时才复制它所属的数组/实体;同时用展开运算符 + // 保持键顺序,落盘结果与深拷贝版本逐字节一致。 + // + // 密钥派生由 fieldCipher 内部完成并缓存(见 crypto.ts 的 cachedDerive), + // 每个字段仍然各自使用独立随机 IV。 + let storagesOut: any[] | undefined + let settingsOut: any[] | undefined + let usersOut: any[] | undefined + + // 1. 存储配置中的 addition 字段(网盘凭据) + const srcStorages: any[] = Array.isArray(data.storages) ? data.storages : [] + for (let i = 0; i < srcStorages.length; i++) { + const s = srcStorages[i] if (!s || !s.addition) continue const str = typeof s.addition === "string" ? s.addition : JSON.stringify(s.addition) - if (str && str !== "{}") { - s.addition = await sealValue(str, fieldCipher) - } + if (!str || str === "{}") continue + const sealed = await sealValue( + str, + fieldCipher, + `storages:${s.id ?? i}:addition`, + ) + if (sealed === s.addition) continue + if (!storagesOut) storagesOut = srcStorages.slice() + storagesOut![i] = { ...s, addition: sealed } } - // 2. 加密敏感的系统设置 - for (const st of copy.settings || []) { - if (st && SENSITIVE_SETTING_KEYS.has(st.key) && st.value) { - st.value = await sealValue(String(st.value), fieldCipher) - } + // 2. 敏感的系统设置 + const srcSettings: any[] = Array.isArray(data.settings) ? data.settings : [] + for (let i = 0; i < srcSettings.length; i++) { + const st = srcSettings[i] + if (!st || !SENSITIVE_SETTING_KEYS.has(st.key) || !st.value) continue + const sealed = await sealValue( + String(st.value), + fieldCipher, + `settings:${st.key}:value`, + ) + if (sealed === st.value) continue + if (!settingsOut) settingsOut = srcSettings.slice() + settingsOut![i] = { ...st, value: sealed } } - // 3. 加密用户敏感信息 - for (const u of copy.users || []) { + // 3. 用户敏感信息(OTP 密钥 / 密码) + const srcUsers: any[] = Array.isArray(data.users) ? data.users : [] + for (let i = 0; i < srcUsers.length; i++) { + const u = srcUsers[i] + if (!u) continue + let next = u // OTP 密钥 - if (u && u.otp_secret) { - u.otp_secret = await sealValue(String(u.otp_secret), fieldCipher) + if (u.otp_secret) { + const sealed = await sealValue( + String(u.otp_secret), + fieldCipher, + `users:${u.id ?? i}:otp_secret`, + ) + if (sealed !== u.otp_secret) next = { ...next, otp_secret: sealed } } // 密码二次加密(defense-in-depth,即使已哈希也加密存储) - if (u && u.password) { - u.password = await sealValue(String(u.password), fieldCipher) + if (u.password) { + const sealed = await sealValue( + String(u.password), + fieldCipher, + `users:${u.id ?? i}:password`, + ) + if (sealed !== u.password) next = { ...next, password: sealed } + } + if (next !== u) { + if (!usersOut) usersOut = srcUsers.slice() + usersOut![i] = next } } - return copy + // 没有任何字段被修改:直接复用原对象(与深拷贝版本落盘结果一致) + if (!storagesOut && !settingsOut && !usersOut) return data + + const out: any = { ...data } + if (storagesOut) out.storages = storagesOut + if (settingsOut) out.settings = settingsOut + if (usersOut) out.users = usersOut + return out } /** @@ -1784,18 +1939,23 @@ async function unsealDb( const tasks: Array<() => Promise> = [] // 1. 解密存储配置(算法由密文前缀决定,与当前 DB_CIPHER 无关) - for (const s of data.storages || []) { + const srcStorages: any[] = Array.isArray(data.storages) ? data.storages : [] + for (let i = 0; i < srcStorages.length; i++) { + const s = srcStorages[i] if (s && typeof s.addition === "string" && isSealedCiphertext(s.addition)) { const target = s const sealedValue = target.addition + const identity = `storages:${s.id ?? i}:addition` tasks.push(async () => { - target.addition = await unsealValue(sealedValue, fieldCipher) + target.addition = await unsealValue(sealedValue, fieldCipher, identity) }) } } // 2. 解密系统设置 - for (const st of data.settings || []) { + const srcSettings: any[] = Array.isArray(data.settings) ? data.settings : [] + for (let i = 0; i < srcSettings.length; i++) { + const st = srcSettings[i] if ( st && SENSITIVE_SETTING_KEYS.has(st.key) && @@ -1804,29 +1964,34 @@ async function unsealDb( ) { const target = st const sealedValue = target.value + const identity = `settings:${st.key}:value` tasks.push(async () => { - target.value = await unsealValue(sealedValue, fieldCipher) + target.value = await unsealValue(sealedValue, fieldCipher, identity) }) } } // 3. 解密用户信息(OTP 密钥 / 密码) - for (const u of data.users || []) { + const srcUsers: any[] = Array.isArray(data.users) ? data.users : [] + for (let i = 0; i < srcUsers.length; i++) { + const u = srcUsers[i] if (!u) continue // OTP 密钥 if (typeof u.otp_secret === "string" && isSealedCiphertext(u.otp_secret)) { const target = u const sealedValue = target.otp_secret + const identity = `users:${u.id ?? i}:otp_secret` tasks.push(async () => { - target.otp_secret = await unsealValue(sealedValue, fieldCipher) + target.otp_secret = await unsealValue(sealedValue, fieldCipher, identity) }) } // 密码解密 if (typeof u.password === "string" && isSealedCiphertext(u.password)) { const target = u const sealedValue = target.password + const identity = `users:${u.id ?? i}:password` tasks.push(async () => { - target.password = await unsealValue(sealedValue, fieldCipher) + target.password = await unsealValue(sealedValue, fieldCipher, identity) }) } } diff --git a/src/backend/internal/model/db_cipher.test.ts b/src/backend/internal/model/db_cipher.test.ts index c675a018..fe29d46d 100644 --- a/src/backend/internal/model/db_cipher.test.ts +++ b/src/backend/internal/model/db_cipher.test.ts @@ -27,11 +27,19 @@ import { mapFormat } from "./store/format/map" const SECRET = "test-shared-secret-0123456789abcdef" -/** 三个真实算法(none 之外) */ +/** + * 全部真实算法(none 之外)。 + * + * 注意:`aes-256-gcm-pbkdf2` 每次派生要跑 10 万次 PBKDF2(约 27 ms/字段), + * 因此涉及它的用例会明显更慢 —— 这是历史 envelope 的固有代价。 + */ const SECRET_CIPHERS = [ "aes-256-gcm", "aes-256-gcm-pbkdf2", "aes-256-cbc-hmac", + "chacha20-poly1305", + "des-cbc-hmac", + "3des-cbc-hmac", ] as const function envFor(cipher?: string): any { @@ -96,6 +104,14 @@ test("readCipher:默认 none,别名可用,非法值回退 none", () => { assert.equal(readCipher({ DB_CIPHER: "pbkdf2" }), "aes-256-gcm-pbkdf2") assert.equal(readCipher({ DB_CIPHER: "v1" }), "aes-256-gcm-pbkdf2") assert.equal(readCipher({ DB_CIPHER: "v3" }), "aes-256-cbc-hmac") + assert.equal(readCipher({ DB_CIPHER: "chacha20" }), "chacha20-poly1305") + assert.equal(readCipher({ DB_CIPHER: "ChaCha20-Poly1305" }), "chacha20-poly1305") + assert.equal(readCipher({ DB_CIPHER: "v4" }), "chacha20-poly1305") + assert.equal(readCipher({ DB_CIPHER: "des" }), "des-cbc-hmac") + assert.equal(readCipher({ DB_CIPHER: "v5" }), "des-cbc-hmac") + assert.equal(readCipher({ DB_CIPHER: "3des" }), "3des-cbc-hmac") + assert.equal(readCipher({ DB_CIPHER: "tripledes" }), "3des-cbc-hmac") + assert.equal(readCipher({ DB_CIPHER: "v6" }), "3des-cbc-hmac") // 拼错不得静默启用某个算法 assert.equal(readCipher({ DB_CIPHER: "rot13" }), "none") @@ -107,17 +123,23 @@ test("readCipher:默认 none,别名可用,非法值回退 none", () => { } }) -test("密文前缀与算法一一对应(v1=PBKDF2 / v2=HKDF / v3=CBC-HMAC)", () => { +test("密文前缀与算法一一对应(v1..v6)", () => { assert.equal(cipherPrefix("none"), "") assert.equal(cipherPrefix("aes-256-gcm-pbkdf2"), "enc:v1:") assert.equal(cipherPrefix("aes-256-gcm"), "enc:v2:") assert.equal(cipherPrefix("aes-256-cbc-hmac"), "enc:v3:") + assert.equal(cipherPrefix("chacha20-poly1305"), "enc:v4:") + assert.equal(cipherPrefix("des-cbc-hmac"), "enc:v5:") + assert.equal(cipherPrefix("3des-cbc-hmac"), "enc:v6:") assert.equal(detectCipherPrefix("hash-abc"), null) assert.equal(detectCipherPrefix("enc:v9:abc"), null, "未知版本不得被识别") assert.equal(detectCipherPrefix("enc:v1:aa:bb:cc")?.cipher, "aes-256-gcm-pbkdf2") assert.equal(detectCipherPrefix("enc:v2:aa:bb")?.cipher, "aes-256-gcm") assert.equal(detectCipherPrefix("enc:v3:aa:bb:cc")?.cipher, "aes-256-cbc-hmac") + assert.equal(detectCipherPrefix("enc:v4:aa:bb:cc")?.cipher, "chacha20-poly1305") + assert.equal(detectCipherPrefix("enc:v5:aa:bb:cc")?.cipher, "des-cbc-hmac") + assert.equal(detectCipherPrefix("enc:v6:aa:bb:cc")?.cipher, "3des-cbc-hmac") assert.equal(isSealedCiphertext("enc:v2:aa:bb"), true) assert.equal(isSealedCiphertext("plain"), false) }) @@ -144,8 +166,9 @@ test("各算法加密→解密往返一致(含空串与多字节)", async () } }) -test("解密由前缀驱动:一种写入算法能解开全部历史版本的密文", async () => { - // 三种算法分别由不同的写入器产生,再用同一个(写 v3 的)解密器读取。 +test("解密由前缀驱动:任意写入算法都能解开全部版本的密文", async () => { + // v1/v2 手工产生(对应历史实现与 #69),v3~v6 分别用各自的写入器产生, + // 然后用**写 v3 的**解密器把它们全部解开(证明解密与当前配置无关)。 const v1 = "enc:v1:" + (await encrypt("v1-secret", SECRET)) const v2 = "enc:v2:" + @@ -153,13 +176,24 @@ test("解密由前缀驱动:一种写入算法能解开全部历史版本的 "v2-secret", await deriveConfigEncryptionKey(SECRET), )) - const v3fc = await createFieldCipher("aes-256-cbc-hmac", SECRET) - const v3 = "enc:v3:" + (await v3fc.encrypt("v3-secret")) + const producers: Array<[string, string]> = [ + ["aes-256-cbc-hmac", "v3-secret"], + ["chacha20-poly1305", "v4-secret"], + ["des-cbc-hmac", "v5-secret"], + ["3des-cbc-hmac", "v6-secret"], + ] + const sealedList: Array<[string, string]> = [] + for (const [cipher, plain] of producers) { + const fc = await createFieldCipher(cipher, SECRET) + sealedList.push([cipherPrefix(cipher) + (await fc.encrypt(plain)), plain]) + } const reader = await createFieldCipher("aes-256-cbc-hmac", SECRET) assert.equal(await reader.decrypt(v1), "v1-secret") assert.equal(await reader.decrypt(v2), "v2-secret") - assert.equal(await reader.decrypt(v3), "v3-secret") + for (const [sealed, plain] of sealedList) { + assert.equal(await reader.decrypt(sealed), plain) + } // 明文原样返回 assert.equal(await reader.decrypt("plain-value"), "plain-value") @@ -338,3 +372,87 @@ test("DB_CIPHER=none 不影响共享密钥的生成与持久化(JWT 仍需密 assert.ok((key as string).length >= 16) assert.equal(await db.isEncryptionReady(env), true, "生成后任意实例都能读到密钥") }) + +// ─── 性能相关不变量(对功能无影响,但必须长期成立)────────────────────────── + +test("saveDb 采用写时复制:不修改调用方传入的内存对象", async () => { + const db = await import("./db") + db.__resetDbCacheForTest() + const env = envFor("chacha20-poly1305") + const data = sampleDb() + + assert.equal(await db.saveDb(data, env, { force: true }), true) + + // 内存中的对象必须保持明文(历史实现是深拷贝,新实现是写时复制, + // 两者都**不得**把明文替换成密文 —— 否则调用方持有的引用会被污染)。 + assert.equal(rawUser(data).password, "hash-abc") + assert.equal(rawUser(data).otp_secret, "OTP-SECRET") + assert.equal( + data.storages[0].addition, + JSON.stringify({ token: "drive-token" }), + ) + assert.equal(rawSetting(data, "token").value, "tok-123") + assert.equal(rawSetting(data, "site_title").value, "OpenList") + + // 落盘内容则是密文 + const raw: any = await rawStored(env) + assert.equal( + detectCipherPrefix(rawUser(raw).password)?.cipher, + "chacha20-poly1305", + ) +}) + +test("未变化字段跳过重新加密:密文保持不变,且只重新加密改动过的字段", async () => { + const db = await import("./db") + db.__resetDbCacheForTest() + const env = envFor("aes-256-gcm") + + assert.equal(await db.saveDb(sampleDb(), env, { force: true }), true) + const first: any = await rawStored(env) + + // 内容完全相同再存一次 → 复用上次的密文(逐字节一致) + assert.equal(await db.saveDb(sampleDb(), env), true) + const second: any = await rawStored(env) + assert.equal( + rawUser(second).password, + rawUser(first).password, + "未变化的字段不应重新加密", + ) + assert.equal(rawUser(second).otp_secret, rawUser(first).otp_secret) + assert.equal(second.storages[0].addition, first.storages[0].addition) + assert.equal(rawSetting(second, "token").value, rawSetting(first, "token").value) + + // 只改一个字段 → 只有它重新加密,其余字段的密文原样保留 + const changed = sampleDb() + changed.users[0].password = "hash-xyz" + assert.equal(await db.saveDb(changed, env), true) + const third: any = await rawStored(env) + assert.notEqual(rawUser(third).password, rawUser(first).password) + assert.equal(rawUser(third).otp_secret, rawUser(first).otp_secret) + assert.equal(third.storages[0].addition, first.storages[0].addition) + + // 新值仍可正常解回 + db.__resetDbCacheForTest() + const loaded: any = await db.getDb(env) + assert.equal(rawUser(loaded).password, "hash-xyz") +}) + +test("load → save 复用:读回后原样保存不会重复加密(3DES 同样)", async () => { + const db = await import("./db") + db.__resetDbCacheForTest() + const env = envFor("3des-cbc-hmac") + + assert.equal(await db.saveDb(sampleDb(), env, { force: true }), true) + const first: any = await rawStored(env) + + // 强制一次真实加载(清缓存 → 读存储 → 解密,并在解密时记账) + db.__resetDbCacheForTest() + const loaded: any = await db.getDb(env) + assert.equal(rawUser(loaded).password, "hash-abc") + + // 未做任何修改就保存 → 复用加载时记下的密文 + assert.equal(await db.saveDb(loaded, env), true) + const second: any = await rawStored(env) + assert.equal(rawUser(second).password, rawUser(first).password) + assert.equal(second.storages[0].addition, first.storages[0].addition) +}) diff --git a/src/backend/internal/model/store/backend.ts b/src/backend/internal/model/store/backend.ts index fff8f8e8..41abe0d1 100644 --- a/src/backend/internal/model/store/backend.ts +++ b/src/backend/internal/model/store/backend.ts @@ -91,17 +91,22 @@ export function readFormat(env?: any): StorageFormat { * 落盘**,不改变存储位置与数据组织方式。 * * none(默认) - 不加密,敏感字段与普通 JSON 一样明文落盘 - * aes-256-gcm - HKDF-SHA256 派生一把 AES-256-GCM 密钥,每次 - * 读/写只派生一次(低成本,推荐开启加密时使用) - * aes-256-gcm-pbkdf2 - AES-256-GCM,密钥由 PBKDF2-SHA256(10 万次) 派生 - * (历史 envelope,逐字段派生、开销大) - * aes-256-cbc-hmac - AES-256-CBC + HMAC-SHA256(Encrypt-then-MAC) + * aes-256-gcm - HKDF-SHA256 派生一把 AES-256-GCM 密钥(enc:v2:) + * aes-256-gcm-pbkdf2 - AES-256-GCM,PBKDF2-SHA256(10 万次) 派生(enc:v1:,慢) + * aes-256-cbc-hmac - AES-256-CBC + HMAC-SHA256(enc:v3:,Encrypt-then-MAC) + * chacha20-poly1305 - ChaCha20-Poly1305(enc:v4:,RFC 8439,纯 JS) + * des-cbc-hmac - 单 DES-CBC + HMAC-SHA256(enc:v5:,**仅兼容,不安全**) + * 3des-cbc-hmac - 3DES-CBC + HMAC-SHA256(enc:v6:,**仅兼容,已弃用**) * * 为什么默认 none:加密会让「共享库给 Go 后端 / 直接用 SQL 查询」变得不可读, * 且读取时需逐字段解密(详见 #69 的 CPU 优化)。需要静态加密的部署显式配置即可; * 历史密文带 `enc:vN:` 前缀,读取时按前缀自动解密,因此从加密切回 none * **不会导致数据不可读**,只会在下次写入时转为明文(自动迁移)。 * + * DES/3DES 只作为「非 AES 族」的兼容选项存在:单 DES 的 56-bit 密钥可被暴力破解, + * 3DES 已被 NIST SP 800-131A 弃用,二者都不应保护真实数据(见 crypto.ts 的 + * WEAK_DB_CIPHERS 与一次性告警)。 + * * 取值无法识别时告警并回退 none(而不是静默启用某个算法)。注意:读取时 * 解密算法由密文前缀决定,与本函数取值无关 —— 拼错变量只会影响**新写入**。 */ diff --git a/src/backend/pkg/chacha20.ts b/src/backend/pkg/chacha20.ts new file mode 100644 index 00000000..bb985dc6 --- /dev/null +++ b/src/backend/pkg/chacha20.ts @@ -0,0 +1,254 @@ +/** + * ChaCha20-Poly1305(RFC 8439)纯 JS 实现。 + * + * 为什么需要自带实现: + * - WebCrypto(CF Workers / 浏览器 / Node 的 `crypto.subtle`)**不提供 ChaCha20**; + * - 本仓库的边缘构建(`scripts/node-shim.mjs`)会把 `node:crypto` 替换成抛错 shim, + * 因此也不能依赖 Node 的原生 `chacha20-poly1305`。 + * + * 实现要点: + * - ChaCha20:20 轮(10 次 double-round),32 位字运算;AEAD 中 nonce 为 12 字节, + * counter 从 0 开始(block 0 用于派生 Poly1305 一次性密钥,密文从 counter=1 开始)。 + * - Poly1305:使用 BigInt 做 130 位模运算(字段很短,几十~几百字节, + * BigInt 的实现简洁且足够快;密码学正确性优先于极限性能)。 + * - 认证标签按 RFC 8439 §2.8 计算:aad ‖ pad16 ‖ ciphertext ‖ pad16 ‖ len(aad) ‖ len(ct)。 + * - 校验标签使用**定长比较**,避免早退泄露信息。 + * + * 正确性验证:`cipher_algorithms.test.ts` 使用 RFC 8439 官方测试向量, + * 并与 Node 原生 `chacha20-poly1305` 交叉比对。 + */ + +const CHACHA_BLOCK = 64 +const TAG_LEN = 16 +/** RFC 8439 的模数:2^130 - 5 */ +const POLY_P = (1n << 130n) - 5n +/** r 的 clamp 掩码(RFC 8439 §2.5) */ +const POLY_R_MASK = 0x0ffffffc0ffffffc0ffffffc0fffffffn + +function rotl(v: number, c: number): number { + return ((v << c) | (v >>> (32 - c))) >>> 0 +} + +function quarterRound( + s: Uint32Array, + a: number, + b: number, + c: number, + d: number, +): void { + s[a] = (s[a] + s[b]) >>> 0 + s[d] = rotl(s[d] ^ s[a], 16) + s[c] = (s[c] + s[d]) >>> 0 + s[b] = rotl(s[b] ^ s[c], 12) + s[a] = (s[a] + s[b]) >>> 0 + s[d] = rotl(s[d] ^ s[a], 8) + s[c] = (s[c] + s[d]) >>> 0 + s[b] = rotl(s[b] ^ s[c], 7) +} + +function readU32LE(bytes: Uint8Array, offset: number): number { + return ( + (bytes[offset] | + (bytes[offset + 1] << 8) | + (bytes[offset + 2] << 16) | + (bytes[offset + 3] << 24)) >>> + 0 + ) +} + +/** 生成一个 64 字节 ChaCha20 密钥流块(写入 out) */ +function chacha20Block( + key: Uint32Array, + counter: number, + nonce: Uint32Array, + out: Uint8Array, +): void { + const st = new Uint32Array(16) + st[0] = 0x61707865 + st[1] = 0x3320646e + st[2] = 0x79622d32 + st[3] = 0x6b206574 + st[4] = key[0] + st[5] = key[1] + st[6] = key[2] + st[7] = key[3] + st[8] = key[4] + st[9] = key[5] + st[10] = key[6] + st[11] = key[7] + st[12] = counter >>> 0 + st[13] = nonce[0] + st[14] = nonce[1] + st[15] = nonce[2] + + const w = st.slice() + for (let i = 0; i < 10; i++) { + quarterRound(w, 0, 4, 8, 12) + quarterRound(w, 1, 5, 9, 13) + quarterRound(w, 2, 6, 10, 14) + quarterRound(w, 3, 7, 11, 15) + quarterRound(w, 0, 5, 10, 15) + quarterRound(w, 1, 6, 11, 12) + quarterRound(w, 2, 7, 8, 13) + quarterRound(w, 3, 4, 9, 14) + } + for (let i = 0; i < 16; i++) { + const v = (w[i] + st[i]) >>> 0 + out[i * 4] = v & 0xff + out[i * 4 + 1] = (v >>> 8) & 0xff + out[i * 4 + 2] = (v >>> 16) & 0xff + out[i * 4 + 3] = (v >>> 24) & 0xff + } +} + +function keyToWords(key: Uint8Array): Uint32Array { + if (key.length !== 32) throw new Error("ChaCha20 key must be 32 bytes") + const words = new Uint32Array(8) + for (let i = 0; i < 8; i++) words[i] = readU32LE(key, i * 4) + return words +} + +function nonceToWords(nonce: Uint8Array): Uint32Array { + if (nonce.length !== 12) throw new Error("ChaCha20 nonce must be 12 bytes") + return new Uint32Array([ + readU32LE(nonce, 0), + readU32LE(nonce, 4), + readU32LE(nonce, 8), + ]) +} + +/** + * ChaCha20 加/解密(XOR 密钥流,对称操作)。 + * + * @param counter 起始块计数(AEAD 用 1;原始流从 0 开始) + */ +export function chacha20Xor( + key: Uint8Array, + nonce: Uint8Array, + data: Uint8Array, + counter = 1, +): Uint8Array { + const keyWords = keyToWords(key) + const nonceWords = nonceToWords(nonce) + const out = new Uint8Array(data.length) + const block = new Uint8Array(CHACHA_BLOCK) + for (let off = 0; off < data.length; off += CHACHA_BLOCK) { + chacha20Block(keyWords, counter++, nonceWords, block) + const n = Math.min(CHACHA_BLOCK, data.length - off) + for (let i = 0; i < n; i++) out[off + i] = data[off + i] ^ block[i] + } + return out +} + +function leToBigInt(bytes: Uint8Array): bigint { + let acc = 0n + for (let i = bytes.length - 1; i >= 0; i--) acc = (acc << 8n) | BigInt(bytes[i]) + return acc +} + +function bigIntToLe(value: bigint, length: number): Uint8Array { + const out = new Uint8Array(length) + let v = value + for (let i = 0; i < length; i++) { + out[i] = Number(v & 0xffn) + v >>= 8n + } + return out +} + +/** Poly1305 一次性认证(msg 无需自行填充,本函数按 16 字节分块) */ +export function poly1305(msg: Uint8Array, oneTimeKey: Uint8Array): Uint8Array { + if (oneTimeKey.length !== 32) { + throw new Error("Poly1305 one-time key must be 32 bytes") + } + const r = leToBigInt(oneTimeKey.subarray(0, 16)) & POLY_R_MASK + const s = leToBigInt(oneTimeKey.subarray(16, 32)) + + let acc = 0n + for (let i = 0; i < msg.length; i += 16) { + const chunk = msg.subarray(i, Math.min(i + 16, msg.length)) + // 每个分块最高位补 1(RFC 8439 §2.5.1) + const n = leToBigInt(chunk) | (1n << BigInt(chunk.length * 8)) + acc = ((acc + n) * r) % POLY_P + } + acc = (acc + s) & ((1n << 128n) - 1n) + return bigIntToLe(acc, TAG_LEN) +} + +/** aad/ciphertext 的 16 字节对齐填充 */ +function pad16(bytes: Uint8Array): Uint8Array { + const rem = bytes.length % 16 + if (rem === 0) return new Uint8Array(0) + return new Uint8Array(16 - rem) +} + +function leU64(value: number): Uint8Array { + const out = new Uint8Array(8) + let v = BigInt(value) + for (let i = 0; i < 8; i++) { + out[i] = Number(v & 0xffn) + v >>= 8n + } + return out +} + +function concat(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((n, p) => n + p.length, 0) + const out = new Uint8Array(total) + let off = 0 + for (const p of parts) { + out.set(p, off) + off += p.length + } + return out +} + +/** RFC 8439 §2.8:构造 Poly1305 的认证输入(AAD 可为空) */ +function macData(ciphertext: Uint8Array, aad: Uint8Array): Uint8Array { + return concat([aad, pad16(aad), ciphertext, pad16(ciphertext), leU64(aad.length), leU64(ciphertext.length)]) +} + +/** 由 ChaCha20 block 0 的前 32 字节派生 Poly1305 一次性密钥 */ +function polyKey(key: Uint8Array, nonce: Uint8Array): Uint8Array { + const block0 = new Uint8Array(CHACHA_BLOCK) + chacha20Block(keyToWords(key), 0, nonceToWords(nonce), block0) + return block0.subarray(0, 32) +} + +/** AEAD 加密:返回密文与 16 字节认证标签 */ +export function chacha20Poly1305Seal( + key: Uint8Array, + nonce: Uint8Array, + plaintext: Uint8Array, + aad: Uint8Array = new Uint8Array(0), +): { ciphertext: Uint8Array; tag: Uint8Array } { + const ciphertext = chacha20Xor(key, nonce, plaintext, 1) + const tag = poly1305(macData(ciphertext, aad), polyKey(key, nonce)) + return { ciphertext, tag } +} + +/** AEAD 解密:标签不匹配时抛错(不返回任何明文) */ +export function chacha20Poly1305Open( + key: Uint8Array, + nonce: Uint8Array, + ciphertext: Uint8Array, + tag: Uint8Array, + aad: Uint8Array = new Uint8Array(0), +): Uint8Array { + if (tag.length !== TAG_LEN) throw new Error("Invalid Poly1305 tag length") + const expect = poly1305(macData(ciphertext, aad), polyKey(key, nonce)) + if (!timingSafeEqual(expect, tag)) { + throw new Error( + "ChaCha20-Poly1305 authentication failed (wrong key or tampered data)", + ) + } + return chacha20Xor(key, nonce, ciphertext, 1) +} + +/** 定长比较(不早退) */ +export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false + let diff = 0 + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i] + return diff === 0 +} diff --git a/src/backend/pkg/cipher_algorithms.test.ts b/src/backend/pkg/cipher_algorithms.test.ts new file mode 100644 index 00000000..f9e0d0ae --- /dev/null +++ b/src/backend/pkg/cipher_algorithms.test.ts @@ -0,0 +1,158 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { createCipheriv, createDecipheriv } from "node:crypto" +import { + chacha20Poly1305Open, + chacha20Poly1305Seal, + poly1305, +} from "./chacha20" +import { desCbcDecrypt, desCbcEncrypt } from "./legacy-ciphers" + +/** + * 新增算法的**独立正确性验证**。 + * + * 这两个算法都不在 WebCrypto 里(ChaCha20 全平台缺失、DES/3DES 已被规范移除), + * 因此必须用权威测试向量 + 与 Node/OpenSSL 原生实现交叉比对来锁定正确性: + * + * - ChaCha20-Poly1305:RFC 8439 §2.5.2(Poly1305)与 §2.8.2(AEAD)官方向量, + * 并与 Node 原生 `chacha20-poly1305` 交叉比对。 + * - DES / 3DES:与 OpenSSL 的 `des-ede3-cbc` 交叉比对。 + * 注意 OpenSSL 3 默认禁用 legacy provider,`des-cbc`(单 DES)不可用, + * 因此单 DES 用等价形式 EDE(K,K,K) 作为参照(数学上完全等价)。 + */ + +const hex = (u8: Uint8Array | Buffer) => Buffer.from(u8).toString("hex") +const fromHex = (s: string) => + new Uint8Array(Buffer.from(s.replace(/\s+/g, ""), "hex")) + +// ─── ChaCha20-Poly1305 ────────────────────────────────────────────────────── + +test("Poly1305 匹配 RFC 8439 §2.5.2 官方向量", () => { + const key = fromHex( + "85d6be7857556d337f4452fe42d506a80103808afb0db2fd4abff6af4149f51b", + ) + const msg = new TextEncoder().encode("Cryptographic Forum Research Group") + assert.equal(hex(poly1305(msg, key)), "a8061dc1305136c6c22b8baf0c0127a9") +}) + +test("ChaCha20-Poly1305 匹配 RFC 8439 §2.8.2 官方向量", () => { + const key = fromHex( + "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f", + ) + const nonce = fromHex("070000004041424344454647") + const aad = fromHex("50515253c0c1c2c3c4c5c6c7") + const plain = new TextEncoder().encode( + "Ladies and Gentlemen of the class of '99: If I could offer you only one " + + "tip for the future, sunscreen would be it.", + ) + + const { ciphertext, tag } = chacha20Poly1305Seal(key, nonce, plain, aad) + assert.equal( + hex(ciphertext), + "d31a8d34648e60db7b86afbc53ef7ec2a4aded51296e08fea9e2b5a736ee62d6" + + "3dbea45e8ca9671282fafb69da92728b1a71de0a9e060b2905d6a5b67ecd3b36" + + "92ddbd7f2d778b8c9803aee328091b58fab324e4fad675945585808b4831d7bc" + + "3ff4def08e4b7a9de576d26586cec64b6116", + ) + assert.equal(hex(tag), "1ae10b594f09e26a7e902ecbd0600691") + + // 往返 + const back = chacha20Poly1305Open(key, nonce, ciphertext, tag, aad) + assert.equal(new TextDecoder().decode(back), new TextDecoder().decode(plain)) +}) + +test("ChaCha20-Poly1305 与 Node 原生实现互操作,且拒绝被篡改的数据", () => { + const key = fromHex( + "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f", + ) + const nonce = fromHex("070000004041424344454647") + const plain = new TextEncoder().encode("openlist-config-secret-value") + + // 本实现 → Node 原生解密 + const { ciphertext, tag } = chacha20Poly1305Seal(key, nonce, plain) + const decipher = createDecipheriv("chacha20-poly1305", key, nonce, { + authTagLength: 16, + }) + decipher.setAuthTag(tag) + const nodePlain = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + assert.equal(nodePlain.toString("utf8"), "openlist-config-secret-value") + + // Node 原生 → 本实现解密 + const cipher = createCipheriv("chacha20-poly1305", key, nonce, { + authTagLength: 16, + }) + const nodeCt = Buffer.concat([cipher.update(plain), cipher.final()]) + assert.equal( + new TextDecoder().decode( + chacha20Poly1305Open( + key, + nonce, + new Uint8Array(nodeCt), + new Uint8Array(cipher.getAuthTag()), + ), + ), + "openlist-config-secret-value", + ) + + // 篡改密文 / 篡改标签 / 换 nonce 都必须失败 + const tampered = new Uint8Array(ciphertext) + tampered[0] ^= 0x01 + assert.throws(() => chacha20Poly1305Open(key, nonce, tampered, tag)) + const badTag = new Uint8Array(tag) + badTag[0] ^= 0x01 + assert.throws(() => chacha20Poly1305Open(key, nonce, ciphertext, badTag)) + const badNonce = new Uint8Array(nonce) + badNonce[0] ^= 0x01 + assert.throws(() => chacha20Poly1305Open(key, badNonce, ciphertext, tag)) +}) + +// ─── DES / 3DES ───────────────────────────────────────────────────────────── + +const IV8 = new TextEncoder().encode("12345678") +const PLAIN = new TextEncoder().encode( + "token-abcdefghijklmnopqrstuvwxyz-0123456789", +) + +function nodeDes3Encrypt(keyBytes: Uint8Array, data: Uint8Array) { + const c = createCipheriv("des-ede3-cbc", keyBytes, IV8) + return new Uint8Array(Buffer.concat([c.update(data), c.final()])) +} + +test("单 DES(8 字节密钥)与 OpenSSL EDE(K,K,K) 完全一致", () => { + const key8 = new TextEncoder().encode("12345678") + const mine = desCbcEncrypt(PLAIN, key8, IV8) + const triple = new Uint8Array([...key8, ...key8, ...key8]) + assert.equal(hex(mine), hex(nodeDes3Encrypt(triple, PLAIN))) + // 反向:OpenSSL 加密 → 本实现解密 + assert.equal( + new TextDecoder().decode(desCbcDecrypt(nodeDes3Encrypt(triple, PLAIN), key8, IV8)), + new TextDecoder().decode(PLAIN), + ) +}) + +test("3DES(24 字节密钥)与 OpenSSL des-ede3-cbc 完全一致", () => { + const key24 = new TextEncoder().encode("0123456789abcdef01234567") + const mine = desCbcEncrypt(PLAIN, key24, IV8) + assert.equal(hex(mine), hex(nodeDes3Encrypt(key24, PLAIN))) + // 反向:OpenSSL 加密 → 本实现解密 + assert.equal( + new TextDecoder().decode( + desCbcDecrypt(nodeDes3Encrypt(key24, PLAIN), key24, IV8), + ), + new TextDecoder().decode(PLAIN), + ) +}) + +test("DES/3DES 空串与多字节明文往返一致(PKCS#7 填充正确)", () => { + const key24 = new TextEncoder().encode("0123456789abcdef01234567") + for (const text of ["", "a", "中文 / emoji 😀", "x".repeat(300)]) { + const data = new TextEncoder().encode(text) + const iv = new Uint8Array(8).fill(7) + const sealed = desCbcEncrypt(data, key24, iv) + assert.equal(sealed.length % 8, 0, "密文必须是 8 字节分组的整数倍") + assert.equal( + new TextDecoder().decode(desCbcDecrypt(sealed, key24, iv)), + text, + ) + } +}) diff --git a/src/backend/pkg/crypto.ts b/src/backend/pkg/crypto.ts index 26810d7e..b7562714 100644 --- a/src/backend/pkg/crypto.ts +++ b/src/backend/pkg/crypto.ts @@ -3,7 +3,13 @@ * Uses Web Crypto API (crypto.subtle + crypto.getRandomValues) — * compatible with Cloudflare Workers and Node.js 18+. * All functions are async. + * + * 例外:`chacha20-poly1305` 与 `des/3des` 这两类**WebCrypto 不提供**的算法, + * 分别由 `./chacha20`(自带 RFC 8439 实现)与 `./legacy-ciphers`(crypto-js) + * 提供,详见文件末尾的 DB_CIPHER 说明。 */ +import { chacha20Poly1305Open, chacha20Poly1305Seal } from "./chacha20" +import { desCbcDecrypt, desCbcEncrypt } from "./legacy-ciphers" // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -361,28 +367,45 @@ export async function aesCbcEncryptBase64( // 设计要点: // // 1. **算法由环境变量选择,历史密文由前缀识别**。写入时按所选算法加版本前缀 -// (`enc:v1:` / `enc:v2:` / `enc:v3:`),读取时只看前缀、完全不依赖当前配置, +// (`enc:v1:` ~ `enc:v6:`),读取时只看前缀、完全不依赖当前配置, // 因此「换算法」「关掉加密」都不会让既有密文变成乱码 —— 旧密文会在本次读取时 // 按旧算法解开,并在下一次写入时按新算法(或明文)重新落盘。 // // 2. `none` 是默认值:不加密直接落盘。 // -// 3. 三种算法都基于同一把密钥(JWT_SECRET / 持久化的共享密钥): -// - v1 aes-256-gcm-pbkdf2 PBKDF2-SHA256(10 万次) 逐字段派生(历史 envelope) -// - v2 aes-256-gcm HKDF-SHA256 派生一把 AES key,每次读写只派生一次 -// (#69 引入的低 CPU 方案;既有部署默认写这种) -// - v3 aes-256-cbc-hmac AES-256-CBC + HMAC-SHA256(Encrypt-then-MAC,非 GCM 族) +// 3. 所有算法都基于同一把密钥(JWT_SECRET / 持久化的共享密钥): +// +// | 前缀 | DB_CIPHER | 实现 | +// |-----------|-------------------------|------------------------------------------| +// | enc:v1: | aes-256-gcm-pbkdf2 | PBKDF2-SHA256(10 万次) 逐字段派生(历史) | +// | enc:v2: | aes-256-gcm | HKDF-SHA256 派生一把 AES key(#69,推荐)| +// | enc:v3: | aes-256-cbc-hmac | AES-256-CBC + HMAC-SHA256(EtM) | +// | enc:v4: | chacha20-poly1305 | ChaCha20-Poly1305(RFC 8439,纯 JS) | +// | enc:v5: | des-cbc-hmac | 单 DES-CBC + HMAC-SHA256(仅兼容,不安全)| +// | enc:v6: | 3des-cbc-hmac | 3DES-CBC + HMAC-SHA256(仅兼容,已弃用) | +// +// 说明:WebCrypto(CF Workers / 浏览器 / Node)**没有** ChaCha20 与 DES/3DES, +// 且本仓库边缘构建会把 `node:crypto` 换成抛错 shim(`scripts/node-shim.mjs`), +// 因此 v4 为自带纯 JS 实现,v5/v6 复用已有依赖 crypto-js(详见 chacha20.ts / +// legacy-ciphers.ts 中的安全说明)。 // // 4. v3 采用「长度前缀」包裹明文:WebCrypto 的 AES-CBC 在部分运行时(Node 的 // OpenSSL 后端)会自动做/去 PKCS#7 padding,而 CF Workers/浏览器不会。 // 把真实长度写进密文头部(4 字节大端),解密后只取该长度即可,从而在任何 // 运行时都得到一致结果(多余 padding 与额外整块 padding 都会被忽略)。 +// +// 5. **密钥派生结果在进程内缓存**(见 cachedDerive):同一 (算法, 密钥) 只派生 +// 一次,后续读/写直接复用 —— #69 的「每次操作派生一次」在此进一步降为 +// 「每个 isolate 派生一次」。这是纯 memoization,不改变任何密文内容。 export type DbCipher = | "none" | "aes-256-gcm" | "aes-256-gcm-pbkdf2" | "aes-256-cbc-hmac" + | "chacha20-poly1305" + | "des-cbc-hmac" + | "3des-cbc-hmac" /** DB_CIPHER 的默认值:不加密(向后兼容「明文落盘」的既有部署) */ export const DEFAULT_DB_CIPHER: DbCipher = "none" @@ -393,12 +416,24 @@ export const DB_CIPHER_VALUES: DbCipher[] = [ "aes-256-gcm", "aes-256-gcm-pbkdf2", "aes-256-cbc-hmac", + "chacha20-poly1305", + "des-cbc-hmac", + "3des-cbc-hmac", ] +/** + * 不推荐用于真实数据的算法(仅兼容/测试用途)。 + * + * 选用它们时会打印**一次性告警**:DES 有效密钥只有 56-bit(可暴力破解)、 + * 3DES 已被 NIST SP 800-131A 弃用(64-bit 分组 + Sweet32)。实现完整性保护 + * (Encrypt-then-MAC)不代表密钥强度足够。 + */ +export const WEAK_DB_CIPHERS: DbCipher[] = ["des-cbc-hmac", "3des-cbc-hmac"] + /** * 别名映射(全部小写、去空白后匹配)。 * - * 允许别名是为了让 `DB_CIPHER=AES-256-GCM`、`gcm`、`v2` 这类习惯写法都能工作, + * 允许别名是为了让 `DB_CIPHER=AES-256-GCM`、`chacha20`、`v4` 这类习惯写法都能工作, * 避免用户因为大小写或短名不同而静默退回 `none`(那会让「以为开了加密」的部署 * 实际明文落盘)。 */ @@ -427,6 +462,23 @@ const DB_CIPHER_ALIASES: Record = { "aes-256-cbc": "aes-256-cbc-hmac", cbc: "aes-256-cbc-hmac", v3: "aes-256-cbc-hmac", + "chacha20-poly1305": "chacha20-poly1305", + chacha20: "chacha20-poly1305", + chacha: "chacha20-poly1305", + "chacha20-ietf-poly1305": "chacha20-poly1305", + "aead-chacha20": "chacha20-poly1305", + v4: "chacha20-poly1305", + "des-cbc-hmac": "des-cbc-hmac", + "des-cbc": "des-cbc-hmac", + des: "des-cbc-hmac", + v5: "des-cbc-hmac", + "3des-cbc-hmac": "3des-cbc-hmac", + "3des-cbc": "3des-cbc-hmac", + "3des": "3des-cbc-hmac", + des3: "3des-cbc-hmac", + tripledes: "3des-cbc-hmac", + "triple-des": "3des-cbc-hmac", + v6: "3des-cbc-hmac", } /** @@ -453,6 +505,9 @@ const CIPHER_VERSION: Record, number> = { "aes-256-gcm-pbkdf2": 1, "aes-256-gcm": 2, "aes-256-cbc-hmac": 3, + "chacha20-poly1305": 4, + "des-cbc-hmac": 5, + "3des-cbc-hmac": 6, } /** 密文版本号 → 算法 */ @@ -460,6 +515,9 @@ const VERSION_CIPHER: Record> = { 1: "aes-256-gcm-pbkdf2", 2: "aes-256-gcm", 3: "aes-256-cbc-hmac", + 4: "chacha20-poly1305", + 5: "des-cbc-hmac", + 6: "3des-cbc-hmac", } /** 密文前缀正则:`enc:v<数字>:` */ @@ -518,6 +576,95 @@ function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { return diff === 0 } +function utf8Bytes(text: string): Uint8Array { + return new TextEncoder().encode(text) +} + +// ── 密钥派生缓存(性能:每次读/写不再重复派生)──────────────────────────── +// +// #69 已经把「逐字段派生」降为「每次 save/load 派生一次」;这里再进一步: +// 同一 (算法, 密钥) 在**进程/isolate 内只派生一次**,后续所有读写直接复用。 +// +// 为什么安全:`cachedDerive` 是纯 memoization —— 输入相同则输出必然相同, +// 不改变任何密文/明文,也不改变密钥来源与优先级;缓存键包含密钥本身, +// 因此密钥轮换(或 env 变更)会自动 miss 并生成新条目。 +// +// 容量:只保留最近 KEY_CACHE_LIMIT 条(正常情况下 1~2 条),密钥轮换时 +// 旧的派生结果被淘汰,内存不会无限增长。 + +const KEY_CACHE_LIMIT = 8 +const keyDerivationCache = new Map() + +async function cachedDerive( + id: string, + secret: string, + factory: () => Promise, +): Promise { + const cacheId = `${id}\u0000${secret}` + if (keyDerivationCache.has(cacheId)) { + return keyDerivationCache.get(cacheId) as T + } + // 先 await 再入缓存:派生失败(如 WebCrypto 异常)不会被缓存, + // 避免一次瞬时故障被固化成「该密钥永久不可用」。 + const value = await factory() + if (keyDerivationCache.size >= KEY_CACHE_LIMIT) { + const oldest = keyDerivationCache.keys().next().value + if (oldest !== undefined) keyDerivationCache.delete(oldest) + } + keyDerivationCache.set(cacheId, value) + return value +} + +/** 仅供测试:清空密钥派生缓存(与 db.ts 的 __resetDbCacheForTest 联动)。 */ +export function __resetCipherKeyCacheForTest(): void { + keyDerivationCache.clear() + weakCipherWarned = false +} + +/** 单次告警:选用了弱算法(DES/3DES) */ +let weakCipherWarned = false + +/** SHA-256 摘要(字节) */ +async function sha256Bytes(input: string | Uint8Array): Promise { + const buf = await crypto.subtle.digest("SHA-256", toBytes(input)) + return new Uint8Array(buf) +} + +/** + * 由共享密钥派生 `length` 字节的密钥材料(SHA-256)。 + * + * 前提:共享密钥本身足够随机(`openssl rand -hex 32` 或 setup 生成的 64 位 hex)。 + * 因此这里不做 KDF 拉伸(PBKDF2 的 10 万次迭代代价太高,见 v1 的历史包袱); + * 若密钥是低熵口令,请使用 `aes-256-gcm-pbkdf2`。 + */ +async function deriveKeyBytes( + secret: string, + info: string, + length: number, +): Promise { + const digest = await sha256Bytes(`${info}|${secret}`) + return digest.slice(0, length) +} + +/** 导入 HMAC-SHA256 签名密钥 */ +async function importHmacKey(bytes: Uint8Array): Promise { + return crypto.subtle.importKey( + "raw", + bytes as any, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ) +} + +/** HMAC-SHA256 签名(返回 32 字节) */ +async function hmacSha256Sign( + key: CryptoKey, + data: Uint8Array, +): Promise { + return new Uint8Array(await crypto.subtle.sign("HMAC", key, data as any)) +} + /** 明文包裹:4 字节大端长度 + 原文(用于解密后精确切出原文,见上方说明 4) */ function wrapWithLength(bytes: Uint8Array): Uint8Array { const head = new Uint8Array(4) @@ -552,30 +699,28 @@ async function hmacSha256RawKey(secret: string, info: string): Promise { const iv = crypto.getRandomValues(new Uint8Array(16)) - const padded = pkcs7Pad(wrapWithLength(toBytes(data) as Uint8Array)) - const encKey = await deriveSha256AesCbcKey(secret, V3_ENC_INFO) + const padded = pkcs7Pad(wrapWithLength(utf8Bytes(data))) const cipherBuf = await crypto.subtle.encrypt( { name: "AES-CBC", iv }, - encKey, + keys.enc, padded as any, ) const cipherBytes = new Uint8Array(cipherBuf) - const macKey = await hmacSha256RawKey(secret, V3_MAC_INFO) - const mac = new Uint8Array( - await crypto.subtle.sign("HMAC", macKey, concatBytes(iv, cipherBytes) as any), - ) + const mac = await hmacSha256Sign(keys.mac, concatBytes(iv, cipherBytes)) return `${bytesToHex(iv)}:${bytesToHex(cipherBytes)}:${bytesToHex(mac)}` } -async function aesCbcHmacDecrypt( - body: string, - secret: string, -): Promise { +async function aesCbcHmacDecrypt(body: string, keys: V3Keys): Promise { const parts = body.split(":") if (parts.length !== 3) throw new Error("Invalid encrypted data format") const iv = fromHex(parts[0]) @@ -583,34 +728,107 @@ async function aesCbcHmacDecrypt( const mac = fromHex(parts[2]) // Encrypt-then-MAC:先验签再解密,避免把未经认证的数据送进解密器。 - const macKey = await hmacSha256RawKey(secret, V3_MAC_INFO) - const expect = new Uint8Array( - await crypto.subtle.sign("HMAC", macKey, concatBytes(iv, cipherBytes) as any), - ) + const expect = await hmacSha256Sign(keys.mac, concatBytes(iv, cipherBytes)) if (!timingSafeEqual(expect, mac)) { throw new Error("Encrypted data failed integrity check (wrong key?)") } - const encKey = await deriveSha256AesCbcKey(secret, V3_ENC_INFO) const plain = await crypto.subtle.decrypt( { name: "AES-CBC", iv: iv as any }, - encKey, + keys.enc, cipherBytes as any, ) return new TextDecoder().decode(unwrapWithLength(new Uint8Array(plain))) } +// ── v4:ChaCha20-Poly1305(RFC 8439,纯 JS)───────────────────────────────── +// +// WebCrypto 不提供 ChaCha20,故使用自带的 `chacha20.ts`。布局: +// `::` +// 其中 key = 32 字节派生材料。AEAD 自带完整性校验(Poly1305),无需额外 HMAC。 + +const V4_INFO = "openlist-db-cipher-v4-chacha20" + +async function chachaEncrypt(data: string, key: Uint8Array): Promise { + const nonce = crypto.getRandomValues(new Uint8Array(12)) + const { ciphertext, tag } = chacha20Poly1305Seal(key, nonce, utf8Bytes(data)) + return `${bytesToHex(nonce)}:${bytesToHex(ciphertext)}:${bytesToHex(tag)}` +} + +async function chachaDecrypt(body: string, key: Uint8Array): Promise { + const parts = body.split(":") + if (parts.length !== 3) throw new Error("Invalid encrypted data format") + const nonce = fromHex(parts[0]) + const ciphertext = fromHex(parts[1]) + const tag = fromHex(parts[2]) + return new TextDecoder().decode( + chacha20Poly1305Open(key, nonce, ciphertext, tag), + ) +} + +// ── v5/v6:DES / 3DES-CBC + HMAC-SHA256(Encrypt-then-MAC)────────────────── +// +// WebCrypto 不提供 DES/3DES,故使用 crypto-js(项目已有依赖,见 legacy-ciphers.ts)。 +// 布局:`::`,MAC 覆盖 iv‖cipher。 +// 注意 DES/3DES 仅作兼容用途,强度不足(见 legacy-ciphers.ts 的安全说明)。 + +const V5_INFO = "openlist-db-cipher-v5-des" +const V6_INFO = "openlist-db-cipher-v6-3des" +/** + * DES/3DES 的 MAC 密钥域(与加密密钥分离,且两种算法之间也分离 —— + * 避免「同一把密钥同时用于加密与认证」以及跨算法复用)。 + */ +const V5_MAC_INFO = "openlist-db-cipher-v5-des-mac" +const V6_MAC_INFO = "openlist-db-cipher-v6-3des-mac" + +async function desCbcHmacEncrypt( + data: string, + keyBytes: Uint8Array, + macKey: CryptoKey, +): Promise { + const iv = crypto.getRandomValues(new Uint8Array(8)) + const cipherBytes = desCbcEncrypt(utf8Bytes(data), keyBytes, iv) + const mac = await hmacSha256Sign(macKey, concatBytes(iv, cipherBytes)) + return `${bytesToHex(iv)}:${bytesToHex(cipherBytes)}:${bytesToHex(mac)}` +} + +async function desCbcHmacDecrypt( + body: string, + keyBytes: Uint8Array, + macKey: CryptoKey, +): Promise { + const parts = body.split(":") + if (parts.length !== 3) throw new Error("Invalid encrypted data format") + const iv = fromHex(parts[0]) + const cipherBytes = fromHex(parts[1]) + const mac = fromHex(parts[2]) + + const expect = await hmacSha256Sign(macKey, concatBytes(iv, cipherBytes)) + if (!timingSafeEqual(expect, mac)) { + throw new Error("Encrypted data failed integrity check (wrong key?)") + } + return new TextDecoder().decode(desCbcDecrypt(cipherBytes, keyBytes, iv)) +} + /** * 字段加解密器:把「写入算法」和「共享密钥」绑定成一个对象,供 db.ts 在 - * 一次 save / load 内复用(避免逐字段重复派生密钥)。 + * 一次 save / load 内复用(密钥派生结果进一步在进程内缓存,见 cachedDerive)。 * * 关键约定:`encrypt()` 用**写入算法**,`decrypt()` 用**密文前缀**识别算法。 - * 因此历史 v1 密文、#69 写入的 v2 密文、本实现新增的 v3 密文都能读; + * 因此历史 v1 密文、#69 写入的 v2 密文、以及本实现新增的 v3/v4/v5/v6 密文都能读; * 而「关掉加密 / 换算法」只影响新写入的内容。 */ export interface FieldCipher { /** 写入时使用的算法(`none` 不会构造本对象) */ cipher: DbCipher + /** + * 「算法 + 密钥」指纹(不含密钥本身,16 位 hex)。 + * + * 用途:db.ts 的「未变化字段跳过重新加密」缓存靠它判断缓存条目是否仍然有效 —— + * 只要算法或密钥变了,指纹就变,缓存必然失效,绝不会把旧密钥/旧算法的密文 + * 当成新配置下的结果写回去。 + */ + fingerprint: string /** 按写入算法加密,返回**不含前缀**的密文主体 */ encrypt(value: string): Promise /** 解密(入参是**含前缀**的完整密文),按前缀自动选择算法 */ @@ -621,12 +839,56 @@ export async function createFieldCipher( cipher: DbCipher, secret: string, ): Promise { - // v2 的密钥派生结果在本次读写中共享;纯 v1/v3 数据不会触发这次派生。 - let v2Key: Promise | null = null - const getV2Key = () => (v2Key ||= deriveConfigEncryptionKey(secret)) + // 弱算法(DES/3DES)只提示一次:它们是兼容手段,不是安全选择。 + if (cipher !== "none" && WEAK_DB_CIPHERS.includes(cipher) && !weakCipherWarned) { + weakCipherWarned = true + console.warn( + `[Crypto] DB_CIPHER=${cipher} is for compatibility only: single DES ` + + "(56-bit) is brute-forceable and 3DES is deprecated (NIST SP 800-131A). " + + "Use aes-256-gcm or chacha20-poly1305 for real data.", + ) + } + + const fingerprint = + cipher === "none" + ? "none" + : await cachedDerive(`fp:${cipher}`, secret, async () => { + const digest = await sha256Bytes( + `openlist-db-cipher-fp|${cipher}|${secret}`, + ) + return bytesToHex(digest).slice(0, 16) + }) + + // 各算法的密钥材料:**派生结果在进程内缓存**,同一次/后续读写都只派生一次。 + const getV2Key = () => + cachedDerive("v2", secret, () => deriveConfigEncryptionKey(secret)) + const getV3Keys = () => + cachedDerive( + "v3", + secret, + async (): Promise => ({ + enc: await deriveSha256AesCbcKey(secret, V3_ENC_INFO), + mac: await hmacSha256RawKey(secret, V3_MAC_INFO), + }), + ) + const getV4Key = () => + cachedDerive("v4", secret, () => deriveKeyBytes(secret, V4_INFO, 32)) + const getV5Key = () => + cachedDerive("v5", secret, () => deriveKeyBytes(secret, V5_INFO, 8)) + const getV6Key = () => + cachedDerive("v6", secret, () => deriveKeyBytes(secret, V6_INFO, 24)) + const getV5MacKey = () => + cachedDerive("v5-mac", secret, async () => + importHmacKey(await deriveKeyBytes(secret, V5_MAC_INFO, 32)), + ) + const getV6MacKey = () => + cachedDerive("v6-mac", secret, async () => + importHmacKey(await deriveKeyBytes(secret, V6_MAC_INFO, 32)), + ) return { cipher, + fingerprint, async encrypt(value: string): Promise { switch (cipher) { case "none": @@ -636,7 +898,21 @@ export async function createFieldCipher( case "aes-256-gcm-pbkdf2": return await encrypt(value, secret) case "aes-256-cbc-hmac": - return await aesCbcHmacEncrypt(value, secret) + return await aesCbcHmacEncrypt(value, await getV3Keys()) + case "chacha20-poly1305": + return await chachaEncrypt(value, await getV4Key()) + case "des-cbc-hmac": + return await desCbcHmacEncrypt( + value, + await getV5Key(), + await getV5MacKey(), + ) + case "3des-cbc-hmac": + return await desCbcHmacEncrypt( + value, + await getV6Key(), + await getV6MacKey(), + ) default: throw new Error(`Unsupported DB_CIPHER: ${String(cipher)}`) } @@ -651,7 +927,21 @@ export async function createFieldCipher( case "aes-256-gcm-pbkdf2": return await decrypt(body, secret) case "aes-256-cbc-hmac": - return await aesCbcHmacDecrypt(body, secret) + return await aesCbcHmacDecrypt(body, await getV3Keys()) + case "chacha20-poly1305": + return await chachaDecrypt(body, await getV4Key()) + case "des-cbc-hmac": + return await desCbcHmacDecrypt( + body, + await getV5Key(), + await getV5MacKey(), + ) + case "3des-cbc-hmac": + return await desCbcHmacDecrypt( + body, + await getV6Key(), + await getV6MacKey(), + ) default: throw new Error(`Unsupported ciphertext: ${String(hit.cipher)}`) } diff --git a/src/backend/pkg/legacy-ciphers.ts b/src/backend/pkg/legacy-ciphers.ts new file mode 100644 index 00000000..01cb2d8c --- /dev/null +++ b/src/backend/pkg/legacy-ciphers.ts @@ -0,0 +1,91 @@ +/** + * DES / 3DES-CBC(PKCS#7)纯 JS 实现。 + * + * ## 为什么用 crypto-js 而不是 WebCrypto + * + * WebCrypto(CF Workers / 浏览器 / Node 的 `crypto.subtle`)**都不提供 DES/3DES** + * (DES 早已从 WebCrypto 规范中移除);本仓库的边缘构建还会把 `node:crypto` + * 换成抛错 shim(见 `scripts/node-shim.mjs`)。而 `crypto-js` 已在本仓库依赖里 + * (多个网盘驱动在用,已被打进产物),其 `TripleDES` 支持 8 / 16 / 24 字节密钥: + * + * - 8 字节 → 单 DES(EDE(K,K,K) ≡ DES,已与 OpenSSL `des-ede3-cbc` 交叉验证一致) + * - 24 字节 → 3-key 3DES(168-bit,已与 OpenSSL 交叉验证一致) + * + * ## 安全性提醒(务必知悉) + * + * 两者都**只应作为兼容手段**,不应用于保护真实数据: + * - 单 DES 有效密钥仅 56-bit,可被专用硬件在小时级暴力破解; + * - 3DES 的 64-bit 分组在数据量大时受 Sweet32 攻击影响,NIST SP 800-131A + * 已不再认可其用于加密。 + * 因此本项目把 `des-cbc-hmac` / `3des-cbc-hmac` 实现为「CBC + 独立的 + * HMAC-SHA256(Encrypt-then-MAC)」以保证**完整性**,但**不提供**任何强度承诺; + * 需要真正的静态加密请使用 `aes-256-gcm` 或 `chacha20-poly1305`。 + * + * 该模块只做「字节进、字节出」,十六进制编解码与密钥派生由 `crypto.ts` 负责。 + */ +import CryptoJS from "crypto-js" + +/** Uint8Array → crypto-js WordArray(按字节,不依赖 lib-typedarrays) */ +function bytesToWordArray(bytes: Uint8Array): any { + const words: number[] = [] + for (let i = 0; i < bytes.length; i++) { + words[i >>> 2] |= bytes[i] << (24 - (i % 4) * 8) + } + return CryptoJS.lib.WordArray.create(words, bytes.length) +} + +/** crypto-js WordArray → Uint8Array */ +function wordArrayToBytes(wa: any): Uint8Array { + const sigBytes = Number(wa?.sigBytes ?? 0) + const words: number[] = wa?.words ?? [] + const out = new Uint8Array(sigBytes) + for (let i = 0; i < sigBytes; i++) { + out[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff + } + return out +} + +const DES_BLOCK = 8 + +/** + * DES / 3DES-CBC 加密(PKCS#7 填充)。 + * + * @param keyBytes 8 字节 → 单 DES;16 字节 → 2-key 3DES;24 字节 → 3-key 3DES + * @param iv 必须为 8 字节(DES 分组长度) + */ +export function desCbcEncrypt( + plain: Uint8Array, + keyBytes: Uint8Array, + iv: Uint8Array, +): Uint8Array { + if (iv.length !== DES_BLOCK) throw new Error("DES IV must be 8 bytes") + const out = CryptoJS.TripleDES.encrypt( + bytesToWordArray(plain), + bytesToWordArray(keyBytes), + { + iv: bytesToWordArray(iv), + mode: CryptoJS.mode.CBC, + padding: CryptoJS.pad.Pkcs7, + }, + ) + return wordArrayToBytes(out.ciphertext) +} + +/** DES / 3DES-CBC 解密(校验并去除 PKCS#7 填充) */ +export function desCbcDecrypt( + cipher: Uint8Array, + keyBytes: Uint8Array, + iv: Uint8Array, +): Uint8Array { + if (iv.length !== DES_BLOCK) throw new Error("DES IV must be 8 bytes") + const out = CryptoJS.TripleDES.decrypt( + { ciphertext: bytesToWordArray(cipher) } as any, + bytesToWordArray(keyBytes), + { + iv: bytesToWordArray(iv), + mode: CryptoJS.mode.CBC, + padding: CryptoJS.pad.Pkcs7, + }, + ) + return wordArrayToBytes(out) +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 9aeaf5ee..a7a08f3a 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -61,11 +61,13 @@ // 敏感字段落盘算法(默认 none:不加密;与 DB_DRIVER/DB_FORMAT 正交): // none(默认) - 不加密,敏感字段明文落盘 - // aes-256-gcm - HKDF 派生 AES-256-GCM 密钥,每次读写只派生一次 - // (既有加密部署的 enc:v2: 形态,开销最低) + // aes-256-gcm - HKDF 派生 AES-256-GCM 密钥(enc:v2:,推荐) // aes-256-gcm-pbkdf2 - AES-256-GCM + PBKDF2-SHA256(10 万次) 逐字段派生 - // (历史 enc:v1: envelope,开销大) - // aes-256-cbc-hmac - AES-256-CBC + HMAC-SHA256(Encrypt-then-MAC) + // (历史 enc:v1: envelope,每字段约 27ms,慢) + // aes-256-cbc-hmac - AES-256-CBC + HMAC-SHA256(enc:v3:) + // chacha20-poly1305 - ChaCha20-Poly1305(enc:v4:,RFC 8439,纯 JS) + // des-cbc-hmac - 单 DES + HMAC-SHA256(enc:v5:,仅兼容,不安全) + // 3des-cbc-hmac - 3DES + HMAC-SHA256(enc:v6:,仅兼容,已弃用) // 说明:加密对象为 storages[].addition / 敏感 settings / users[].password / // users[].otp_secret。密文带 enc:vN: 前缀,读取时按前缀自动解密, // 因此把该项改回 none 不会导致既有数据不可读(下次保存自动转为明文)。