1. 离线PWA为什么要上加密:先聊聊安全底色
做了几年前端,踩过不少离线存储的坑,我越来越确信一件事:Service Worker + Cache Storage + IndexedDB 这套组合虽然好用,但默认情况下它一点都不安全。
很多人有个误区,觉得“PWA 必须跑在 HTTPS 下,数据应该挺安全的”。但 HTTPS 保护的是传输链路,一旦数据落地到用户设备上——不管是浏览器的 IndexedDB、Cache Storage,还是 localStorage——它就完全暴露在本地环境里。只要设备本身不可信(比如共享电脑、被植入恶意软件的机器),任何能打开 DevTools 的人、任何能读取磁盘文件的进程,都能把你的数据翻个底朝天。
这个场景在离线优先的应用里尤其致命。想象一下:你做了一个离线笔记应用、离线报销工具、离线问诊记录系统,用户在有网的时候会把敏感数据同步到本地,然后在没网的环境下继续使用。这些数据长期沉睡在设备上,等于在用户硬盘里埋了一堆明文地雷。
所以,正确的姿势是:在落盘之前先加密,在读取之后再解密。加密的活儿可以交给 SJCL(Stanford JavaScript Crypto Library)——这是斯坦福团队维护的一个纯 JavaScript 加密库,设计目标就是能在浏览器里高效跑密码学运算,而且它天然适配 PWA 的场景:体积可控、无原生依赖、支持树摇。
这篇文章不会讲一堆空洞的架构概念,而是从实际问题出发,拆解如何用 SJCL 给离线 PWA 的本地数据做一套端到端的加密方案。包括:为什么 IndexedDB 里的数据必须加密、SJCL 相比 Web Crypto API 的优势在哪、密钥怎么管理才不会让加密形同虚设、口令派生参数怎么配才安全、以及我自己实测踩过的几个大坑。
不管你是正在做离线应用、想把敏感数据保护起来,还是单纯对浏览器端加密方案感兴趣,这篇都能给你一套能直接落地的思路。
2. 方案选型:为什么是 SJCL,而不是“裸用” Web Crypto
2.1 Web Crypto API 的先天短板
现代浏览器其实自带了一套原生加密接口——Web Crypto API,能跑 AES、RSA、SHA 这些算法,性能也比纯 JS 实现好得多。那为什么我还要用 SJCL?
答案很简单:Web Crypto API 的异步模型和 PWA 离线缓存场景存在明显错配。
Web Crypto API 的几乎所有加密操作都是异步的,这意味着你得把加密逻辑写成一串 Promise 链或者 await 表达式。这本身不是大问题,但在离线 PWA 里,数据读写往往要穿透多层缓存逻辑(比如先查内存、再查 IndexedDB、再回源),每层都插入一个异步加密/解密操作,会让代码复杂度和排错成本翻倍。
更麻烦的是,Web Crypto API 的密钥导入导出虽然安全,但接口设计偏向底层。你要自己管理 CryptoKey 对象的存储、用途标记(usages)、算法参数(如 AES-GCM 的 iv 长度、tag 长度),这些细节一旦配错,要么加密失败,要么留下安全漏洞。
还有一点:Web Crypto API 依赖浏览器环境。如果你想在 Web Worker 里做加密,或者未来想把同一套加密逻辑复用到 Node.js 环境的构建脚本里,原生接口会出现环境和实现差异。SJCL 是纯 JavaScript,同一份代码在浏览器、Worker、Node 里都能跑,没有兼容性分裂。
2.2 SJCL 的几点独特优势
SJCL 最早由斯坦福的密码学团队维护,多年没有爆出过严重的安全漏洞,社区认可度是够的。而具体到离线 PWA 场景,它有四个让我觉得“真香”的地方:
内置 AES-GCM 模式,认证加密一步到位。GCM 模式在加密的同时生成认证标签,能防篡改。很多人不知道,如果你只用 CBC 模式(早期很多教程的标配),数据被篡改是发现不了的。SJCL 把 GCM 封装成开箱即用的 API,避免了“加密不等于防篡改”的认知陷阱。
口令密钥派生内置 PBKDF2。离线场景里,密钥通常来源于用户口令,而用户口令的熵通常不够。SJCL 内置 PBKDF2 实现,可以直接把弱口令拉伸成高强度的 AES 密钥,省去自己拼装算法工具的麻烦。
可插拔随机源。SJCL 在浏览器里会用
crypto.getRandomValues收集熵,确保每次加密的 IV 都足够随机。如果自己用Math.random()生成 IV,加密方案基本等于白搭——但 SJCL 帮你把这个坑填平了。体积友好。压缩后大约 30KB 左右,配合 PWA 的缓存策略,对首屏加载影响几乎可以忽略。相比引入一套 OpenPGP.js(几百 KB),SJCL 算是轻量中的轻量。
2.3 什么时候应该选 Web Crypto?
我不搞非黑即白。如果满足下面这些条件,你确实可以直接用 Web Crypto:
- 你的应用是纯在线场景,不需要复杂的离线数据同步逻辑;
- 加密操作频率不高,异步带来的复杂度可以接受;
- 你的代码只在现代浏览器里跑,不打算兼容任何旧内核;
- 团队里有人对密钥管理、算法参数足够熟练,能自己处理底层细节。
但如果目标是“离线优先 + 敏感数据 + 跨环境复用”,SJCL 的封装粒度确实更省心。
注意:SJCL 目前维护频率不算高频,使用前一定要锁定版本,不要盲目升级到破坏性 API 的新版本。我用的是 1.0.8,稳定性经过多年验证,没出过幺蛾子。
3. 加密方案的整体设计:密钥、数据和流程的三角关系
3.1 本地加密不是“把数据 XOR 一下”的事
构建离线加密方案,最容易犯的错误是把注意力全放在“选哪个加密算法”上,却忽略了全局设计。其实,一个加密系统的安全性,主要取决于密钥管理,而不是算法本身。
你就算用了 AES-256-GCM,如果密钥写死在代码里,那和用 ROT13 没本质区别——攻击者只要把 JS 文件拖进格式化工具,三分钟就能把密钥掏出来。所以,离线 PWA 的加密方案至少要拆成三层来考虑:
- 数据层:用什么算法加密实际数据(明文 JSON/Blob → 密文);
- 密钥层:数据加密密钥(Data Encryption Key, DEK)怎么生成、怎么保存;
- 身份层:用户口令怎么转化成能解锁密钥的东西。
这三层之间的关系,可以用一个很直观的比喻来理解:你把贵重物品(数据)锁进保险箱(DEK 加密),保险箱的钥匙(DEK)不能挂在保险箱外面,所以再用一个主密码(用户口令派生出的密钥)把它锁进另一个保险箱。只要主密码不泄露,整体就是安全的。
3.2 我推荐的方案:主密钥包裹(Key Wrapping)模式
我实践下来最稳的离线加密模式是:主密钥包裹 + 数据密钥随机生成。
具体拆解:
- 用户首次设置应用时,输入一个主密码口令(Passphrase);
- 用 SJCL 的 PBKDF2 把口令拉伸,生成一个 256 位的主密钥(Master Key);
- 随机生成一个 256 位的数据密钥(DEK),专门用来加密实际的业务数据;
- 用主密钥加密 DEK,把加密后的 DEK(即 wrapped key)存储到 IndexedDB 里;
- 业务数据一律用 DEK 加密后落盘,DEK 本身以密文形态保存,不落明文。
为什么要加一道“数据密钥”而不直接用主密钥加密业务数据?
因为业务数据量大、读写频繁。如果直接用主密钥加密所有数据,每次用户修改主密码,你就要重新加密全量数据——这个成本在离线大缓存场景下根本无法接受。而 key wrapping 模式下,修改口令只影响主密钥,只需要用新主密钥重新包裹一次 DEK 就行,全量数据完全不动。
这就是密码学里很经典的“分级密钥”思想:用高频变化的口令保护低频变化的 DEK,再用 DEK 保护数据。分层之后,每一层的更换成本都降到最低。
3.3 存储结构与明文元数据
在 IndexedDB 里,我推荐单独建一个 key-value 对象仓库(object store),专门用来存密钥包裹数据:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | string | 固定值,如'master_key_wrap' |
| wrapped_dek | string | 被主密钥加密后的 DEK(base64) |
| kdf_salt | string | PBKDF2 使用的盐值(base64) |
| kdf_iterations | number | PBKDF2 迭代次数 |
| cipher_name | string | 加密算法标识,如'aes-256-gcm' |
| version | number | 密钥方案版本号,便于未来迁移 |
注意细节:盐值(salt)必须随机生成,并且需要和密文分开存放。这里说的“分开”,不是放到两个数据库,而是说不能写死在代码里。因为盐值的作用就是防止预计算彩虹表攻击,如果所有用户共用同一个盐,口令破解难度会大幅下降。每个用户、每次口令变更,都应该生成独立的盐。
而业务数据的存储结构,建议统一封装成一个带 version 的 JSON:
{ "v": 1, "alg": "AES-GCM", "iv": "<base64 iv>", "ct": "<base64 ciphertext>" }这样无论未来算法升级还是参数调整,都能通过 version 和 alg 字段做兼容迁移。
4. 核心实现:SJCL 加解密与密钥管理实战
4.1 SJCL 的基本 API 形态
SJCL 的加解密接口非常简洁,核心是sjcl.encrypt和sjcl.decrypt。不过这两个函数是“高级封装”,它们内部会自动生成 IV、自动做密钥派生,适合快速上手。但如果你要做 key wrapping 这种分步操作,建议用sjcl.json子模块和底层密码原语。
先看最简单的用法(适合非敏感场景或原型验证):
import sjcl from 'sjcl'; // 加密 const plaintext = '这是一段需要保护的离线数据'; const password = '用户主密码'; // sjcl.encrypt 返回一个 JSON 字符串,包含 iv、salt、ct、迭代次数等 const ciphertext = sjcl.encrypt(password, plaintext); // 解密 const decrypted = sjcl.decrypt(password, ciphertext); console.log(decrypted); // 原文这个高级接口把盐、IV、迭代次数全部自动生成并塞进输出 JSON 里,对快速原型来说非常方便。
但进入生产级方案,我建议进一步拆开,使用sjcl.misc.pbkdf2+sjcl.cipher.aes+sjcl.mode.gcm组合来做。因为sjcl.encrypt默认使用口令直接作为密钥来源,而在 key wrapping 场景下,我们已经在用 PBKDF2 派生主密钥了,没必要让内部再派生一次。
4.2 分步实现:口令派生、密钥包裹、数据加密
下面是我在项目里沉淀下来的一套核心工具函数,直接可用。为保持简洁,这里用 ES Module 导入方式,构建工具用 Vite 或 Webpack 都行。
import sjcl from 'sjcl'; const KDF_ITERATIONS = 100000; // 后面解释为什么是这个数 const KEY_BITS = 256; // 工具:随机生成 base64 字符串 function randomBase64(bytes = 16) { const words = sjcl.random.randomWords(Math.ceil(bytes / 4)); return sjcl.codec.base64.fromBits(words); } // 1. 从用户口令派生主密钥 function deriveMasterKey(password, saltB64, iterations = KDF_ITERATIONS) { const saltBits = sjcl.codec.base64.toBits(saltB64); // 用 PBKDF2-HMAC-SHA256 拉伸口令,输出 256 位密钥 const derivedKey = sjcl.misc.pbkdf2(password, saltBits, iterations, KEY_BITS); return derivedKey; // BitArray } // 2. 生成数据密钥(DEK) function generateDEK() { return sjcl.random.randomWords(KEY_BITS / 32); // 8 个 32 位字 = 256 位 } // 3. 用主密钥包裹 DEK function wrapDEK(masterKeyBits, dekBits) { // 生成随机 IV(96 位,GCM 推荐值) const iv = sjcl.random.randomWords(3); // 96 bit const aes = new sjcl.cipher.aes(masterKeyBits); // GCM 模式加密,额外的认证数据留空 const ciphertextBits = sjcl.mode.gcm.encrypt(aes, dekBits, iv, [], 128); return { iv: sjcl.codec.base64.fromBits(iv), wrapped: sjcl.codec.base64.fromBits(ciphertextBits), }; } // 4. 用主密钥解包 DEK function unwrapDEK(masterKeyBits, wrappedB64, ivB64) { const wrappedBits = sjcl.codec.base64.toBits(wrappedB64); const ivBits = sjcl.codec.base64.toBits(ivB64); const aes = new sjcl.cipher.aes(masterKeyBits); const dekBits = sjcl.mode.gcm.decrypt(aes, wrappedBits, iv, [], 128); return dekBits; } // 5. 用 DEK 加密业务数据 function encryptData(dekBits, plaintext) { // 明文先转 UTF-8 位数组 const plaintextBits = sjcl.codec.utf8String.toBits(plaintext); const iv = sjcl.random.randomWords(3); const aes = new sjcl.cipher.aes(dekBits); const ciphertextBits = sjcl.mode.gcm.encrypt(aes, plaintextBits, iv, [], 128); return { iv: sjcl.codec.base64.fromBits(iv), ct: sjcl.codec.base64.fromBits(ciphertextBits), alg: 'AES-GCM', v: 1, }; } // 6. 用 DEK 解密业务数据 function decryptData(dekBits, encData) { const ivBits = sjcl.codec.base64.toBits(encData.iv); const ctBits = sjcl.codec.base64.toBits(encData.ct); const aes = new sjcl.cipher.aes(dekBits); const plaintextBits = sjcl.mode.gcm.decrypt(aes, ctBits, ivBits, [], 128); return sjcl.codec.utf8String.fromBits(plaintextBits); }代码不复杂,但有几个关键细节我必须展开讲,不然很容易出问题。
细节一:为什么单独拆出generateDEK而不是复用主密钥?
如果用户用的是低熵口令(比如 6 位数字密码),即使经过 PBKDF2 拉伸,主密钥的熵依然不够。此时如果直接用主密钥加密业务数据,攻击者可以用离线字典攻击,逐条尝试口令解出数据。而 DEK 是随机生成的 256 位密钥,熵极高,即使业务数据密文被拖走,攻击者也无法绕过主密钥直接破解 DEK——他能获得的只是被包裹的 DEK 密文,没有任何暴力破解的捷径。
细节二:GCM 模式的 tag 长度设多少?
代码里sjcl.mode.gcm.encrypt最后一个参数128代表认证标签(GCM tag)的长度。SJCL 支持 128 位 tag,这是 GCM 的标准安全配置,不推荐为了省空间改成 96 位或更短。因为 tag 承担着完整性校验的职责,太短会提高伪造概率。
细节三:IV 为什么是 96 位?
GCM 的 IV 标准推荐值就是 96 位(12 字节),这是 NIST 的推荐。小于这个值需要做特殊处理,大于这个值会降低内部计数器效率。SJCL 的randomWords(3)刚好生成 3 个 32 位字,即 96 位,正合适。
4.3 密钥变更流程:改密码不该全量重加密
用户改主密码时,如果走“全量重加密”的老路,体验会非常糟糕。想想一个离线知识库应用,用户存了几百篇笔记,一篇笔记算 10KB,也有几 MB 数据。全量重加密意味着户要等上好几秒甚至更久,期间还不能关闭页面。
key wrapping 方案把改密成本降到了极致:
- 用户输入旧密码和新密码;
- 用旧密码派生旧主密钥,解包出 DEK;
- 用新密码派生新主密钥,重新包裹 DEK;
- 只更新 IndexedDB 里的 wrapped_dek、kdf_salt、kdf_iterations 字段;
- 完事,业务数据一个字节都不用动。
整个过程只涉及两次 PBKDF2 派生和一次 GCM 加解密,处理时间在一百毫秒以内。用户几乎无感知。
4.4 会话密钥缓存:避免频繁派生
PBKDF2 迭代 10 万次,每次调用大约需要几十到几百毫秒(取决于设备性能)。如果在应用启动时、每次数据读写时都做一次派生,体验会很糟糕。
所以我在实践中会在内存/会话存储里缓存派生的主密钥和 DEK。具体做法:
- 用户在解锁页面输入密码后,派生主密钥、解包 DEK,然后存到一个模块级变量里;
- 只要当前标签页会话存活,后续加解密都直接用内存里的 DEK,不再走 PBKDF2;
- 用户主动锁定、切换账号、或标签页关闭时,清除内存密钥。
这个设计类似手机上的“解锁后保持会话”机制。安全性上有细微牺牲(内存中的密钥可被同进程的恶意代码窃取),但任何端到端加密方案都躲不开这个权衡——只要在浏览器里运行,进程内的恶意注入就无解。
5. 实操避坑:迭代次数、随机源与兼容性
5.1 PBKDF2 迭代次数怎么定:别拍脑袋
很多教程把迭代次数写成 10000,这在今天是远远不够的。PBKDF2 的迭代次数直接决定“离线字典攻击的成本”。假设攻击者拿到了一堆密文,他只需要做一件事:把每个常见口令跑一遍 PBKDF2,然后用得到的密钥去尝试解包 DEK,看是否能得到有效的 JSON 结构。迭代次数越高,每次尝试消耗的时间越长。
2017 年的 OWASP 建议是 120000 次,2023 年之后建议 600000 次以上。但我实测下来,600000 次 PBKDF2-HMAC-SHA256 在普通手机浏览器上可能要耗 1-2 秒,这个体验很差。
我的做法是动态校准:在用户首次设置密码时,先跑一个快速 benchmark——以 1000 次迭代试跑,测出单次迭代耗时,然后反推目标时间(500ms)对应的迭代次数,并把它写进存储里。
简化版逻辑:
async function calibrateIterations(targetMs = 500) { const sampleIterations = 1000; const salt = sjcl.random.randomWords(4); const start = performance.now(); sjcl.misc.pbkdf2('benchmark', salt, sampleIterations, 256); const elapsed = performance.now() - start; const estimated = Math.floor(sampleIterations * targetMs / elapsed); // 上下限保护 return Math.min(600000, Math.max(100000, estimated)); }这样在慢设备上自动降低迭代次数保证可用性,在快设备上自动提高迭代次数保证安全,取一个动态平衡。如果你不想做动态校准,固定 100000-300000 次之间,也是一个可以接受的范围,但一定别低于 100000。
5.2 SJCL 随机源的坑:不要在非安全上下文里用
SJCL 的随机数生成器在浏览器环境里会调用window.crypto.getRandomValues来播种。但在非安全上下文(HTTP 页面或 iframe)里,crypto对象可能不可用,SJCL 会发出警告并使用内置的弱随机源。
PWA 本身要求 HTTPS,所以正常部署不会踩到这个坑。但如果是本地开发(localhost 除外)、内网测试环境、或者某些 WebView 容器里,一定要提前检查:
if (!window.crypto || !window.crypto.getRandomValues) { // 直接给出错误提示,拒绝继续初始化 throw new Error('当前环境不支持安全随机数生成,无法初始化加密模块'); }为什么我把这个看得这么重?因为如果 SJCL 回退到弱随机源,它生成的所有 IV、盐、DEK 都可能被预测。攻击者一旦掌握 IV 的生成规律,GCM 模式的安全性会全面崩溃。加密系统里,随机性就是生命线,宁可让程序跑不起来,也不能让它悄悄用弱随机。
5.3 离线场景的缓存与加密顺序问题
PWA 有个典型场景:Service Worker 把资源缓存进 Cache Storage,运行时把数据写进 IndexedDB。如果你把这两件事混在一起处理,容易陷入“缓存的内容到底要不要加密”的困惑。
我个人的约定:
- 静态资源(JS/CSS/图片/HTML):不加密。它们是公开代码和样式,加密没有意义,还会拖慢加载、破坏流式缓存;
- 运行时业务数据(笔记、表单草稿、用户配置):必须加密后再写入 IndexedDB;
- 敏感的配置项(如 API Token、密钥材料):走加密 + IndexedDB,或者干脆存在内存里不落盘。
这个约定的核心原则是:能公开的公开,必须保护的绝不碰明文。静态资源走 Service Worker 预缓存即可,业务数据统一走 SJCL 加解密层。
5.4 版本迁移问题:密码方案要留后门
加密方案不是一成不变的。随着 SJCL 的安全参数推荐值提高,或者未来要迁移到 Web Crypto 的 AES-GCM,你需要让存储结构支持版本迁移。我在前面放的存储结构中已经预留了version和alg字段,实际中怎么用?
第一种迁移场景:升级 KDF 迭代次数。如果用户当前存储里的迭代次数是 50000,你希望提升到 300000。做法是在用户下次输入密码解锁时,检测到旧迭代次数低于新阈值,自动用旧参数解包 DEK,再用新参数重新包裹,并更新存储。用户全程无感。
第二种迁移场景:更换加密算法。比如从 SJCL 的 AES-GCM 换成 Web Crypto 的原生 AES-GCM。这就需要先解密数据,再用新算法重新加密。这个过程可以在后台逐条迁移,但注意要给每条记录标注 version,按版本选择解密路径。
我见过不少项目上线之后发现加密参数配得不合理,却因为没有设计版本字段而被迫全量重来。这真的是地基级的教训——哪怕是第一版代码,也要把 version 带上,成本几乎为零,收益是长期的。
6. 完整流程串联:从用户解锁到离线读写
讲了这么多拆解,用一个完整流程把它们串起来。假设你做一个离线记事本 PWA,核心流程如下。
6.1 首次初始化
- 用户输入一个主密码(至少 8 位,建议强制 12 位以上);
- 前端生成随机盐(16 字节),用动态校准的迭代次数跑 PBKDF2,得到主密钥;
- 随机生成 DEK,用主密钥包裹 DEK;
- 把盐、迭代次数、包裹后的 DEK、版本号写入 IndexedDB 的
key_store表; - 完成初始化,进入加解密工具类初始化流程。
6.2 解锁流程
- 从 IndexedDB 读取盐、迭代次数、包裹 DEK;
- 用户输入密码,跑 PBKDF2 得到主密钥;
- 用主密钥解包 DEK;
- 把 DEK 存入内存变量,所有后续加解密直接复用;
- 如果解包失败(GCM 认证失败),提示“密码错误”,而不是显示泛泛的“解密失败”——因为 GCM 的认证机制天然能判断密钥是否正确,这一点比旧的 CBC 模式体验好太多。
6.3 写入一条笔记
- 调用
encryptData(dekBits, JSON.stringify(note))得到密文对象; - 把密文对象存入 IndexedDB 的
notes表; - 同步逻辑(如果有)把密文同步到远端,远端只碰密文,不碰明文。
6.4 读取一条笔记
- 从 IndexedDB 取出密文对象;
- 调用
decryptData(dekBits, encData)得到明文 JSON; - 渲染到 UI;
- 用完的明文变量及时赋值为
null,减少内存驻留时间。
整个流程中,SJCL 负责三件核心事:PBKDF2 派生、AES-GCM 加解密、安全随机数生成。它不负责密钥的存取策略——那部分由你自己的分层设计决定。
7. 常见问题速查:我把踩过的坑都写在这
7.1 解密时报错 “ccm: tag doesn't match”
这个报错几乎每一个用过 SJCL 的人都会遇到。原因只有两种可能:
- 密钥不对。PBKDF2 派生用的盐、迭代次数、口令有一项不一致,导致主密钥错误,最终 GCM 认证标签校验失败;
- 密文被篡改。存储的 wrapped_dek 或业务密文在传输、写入过程中被改动。
排查方法:在密钥派生后,打印主密钥的 SHA-256 哈希并与首次初始化时保存的校验值比对(如果当时存过的话)。我没存的话,就直接解码存储里的数据,检查 base64 字符串是否前后一致,排除存储层的问题。
7.2 SJCL 在低端手机上解锁慢
低端手机的 PBKDF2 性能差距非常大。同一台安卓千元机,10 万次迭代可能耗时 800ms,而旗舰机只要 100ms。这直接影响用户对解锁界面的感知。
我的经验是两层优化:一是在初始化时做动态迭代次数校准,目标时间控制在 300-500ms;二是解锁流程加过渡动画,先把输入焦点给到用户,在等 PBKDF2 的过程中显示加载态。人机交互上,500ms 以内的等待基本无感。
7.3 密码变更后老数据解不开了
九成原因是变更流程里用错了盐。很多人改密码时只更新了密码字段,忘了盐也要一起更新。别忘了,PBKDF2 的输入是(口令,盐),两个变量任何一个变了,派生出的密钥就完全不同。如果新密钥包裹 DEK 成功,但读数据时依然用旧逻辑拿盐,就会解密失败。
正确做法在 4.3 已经写过:改密码务必同时换盐并更新存储。我习惯在代码里用同一个事务(transaction)更新这三个字段,保证原子性——万一中间步骤失败了,要么全更新,要么全不更新,绝不会出现盐和密钥版本错乱的中间态。
7.4 SJCL 压缩后体积怎么控制?
未压缩的 SJCL 大约 140KB,gzip 后约 30KB。在 PWA 里,你可以把它加入静态资源预缓存列表,利用 Service Worker 实现“首次访问加载一次,后续全部走缓存”的效果。
如果还想进一步瘦身,SJCL 支持按需引入子模块。例如只用 AES-GCM 的场景,可以精简掉 RSA、ECC、CBC 等模块,需要结合打包工具做 tree-shaking。具体做法是在构建配置中只import 'sjcl/core/sjcl'和import 'sjcl/core/aes'、import 'sjcl/core/gcm'、import 'sjcl/core/pbkdf2'、import 'sjcl/core/random'。注意 SJCL 的模块依赖关系比较琐碎,精简时务必在构建后跑一遍完整的加解密自测,确认没有遗漏子模块。
7.5 能不能把 DEK 直接存到 localStorage?
不能。localStorage 没有事务支持,而且任何脚本都能同步读取。如果你把 DEK 明文存进 localStorage,等于给所有 XSS 攻击留了一扇大门。DEK 要么存在于内存,要么以密文形态存放在 IndexedDB 里,绝不能明文存储。
8. 总结外的心得:这方案还能怎么演进
这套 SJCL 加密方案我已经在几个离线项目中跑了快一年,稳定性和安全性都没出过问题。最后分享几个后续演进方向,供你参考。
结合 Web Crypto 做混合架构。SJCL 负责密钥派生和密钥包裹,Web Crypto 负责高频数据加解密。SJCL 派生密钥后,把密钥导入成 CryptoKey 对象,后续批量加密走原生的 AES-GCM,性能和兼容性可以兼顾。SJCL 的跨环境优势也能继续发挥。
给“忘记密码”做数据恢复通道。离线加密和“找回密码”天然冲突——没有主密钥就永远解不开数据。但可以设计一个恢复密钥(Recovery Key),在首次初始化时生成一段 96 位的随机撤销码,用户抄下来存到安全的地方。撤销码本身不派生密钥,而是作为加密 DEK 的另一个“主密钥”。这样用户忘了口令时,输入撤销码也能解包 DEK。
对你的 Service Worker 缓存策略做分级。静态资源走 stale-while-revalidate,加密模块的 JS 文件建议走 cache-first,避免因为缓存失效导致用户打开应用时重新下载 30KB 的加密库——这在弱网环境下会影响解锁速度。我就是因为一开始没注意这个细节,被用户在高铁上吐槽过加载太慢。
离线加密这件事,方案本身不难,难的是把每一个细节都想到位。SJCL 给你提供了可靠的密码学原语,但密钥怎么管、盐怎么存、迭代次数怎么校、版本怎么迁,这些工程问题最终还是得靠你自己想清楚。希望这篇实战总结能帮你少走几步弯路。