☰
离线PWA数据加密实战:SJCL密钥管理与AES-GCM落地
2026/10/17 18:16:44 网站建设 项目流程

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)模式

我实践下来最稳的离线加密模式是:主密钥包裹 + 数据密钥随机生成。

具体拆解:

  1. 用户首次设置应用时,输入一个主密码口令(Passphrase);
  2. 用 SJCL 的 PBKDF2 把口令拉伸,生成一个 256 位的主密钥(Master Key);
  3. 随机生成一个 256 位的数据密钥(DEK),专门用来加密实际的业务数据;
  4. 用主密钥加密 DEK,把加密后的 DEK(即 wrapped key)存储到 IndexedDB 里;
  5. 业务数据一律用 DEK 加密后落盘,DEK 本身以密文形态保存,不落明文。

为什么要加一道“数据密钥”而不直接用主密钥加密业务数据?

因为业务数据量大、读写频繁。如果直接用主密钥加密所有数据,每次用户修改主密码,你就要重新加密全量数据——这个成本在离线大缓存场景下根本无法接受。而 key wrapping 模式下,修改口令只影响主密钥,只需要用新主密钥重新包裹一次 DEK 就行,全量数据完全不动。

这就是密码学里很经典的“分级密钥”思想:用高频变化的口令保护低频变化的 DEK,再用 DEK 保护数据。分层之后,每一层的更换成本都降到最低。

3.3 存储结构与明文元数据

在 IndexedDB 里,我推荐单独建一个 key-value 对象仓库(object store),专门用来存密钥包裹数据:

字段类型说明
idstring固定值,如'master_key_wrap'
wrapped_dekstring被主密钥加密后的 DEK(base64)
kdf_saltstringPBKDF2 使用的盐值(base64)
kdf_iterationsnumberPBKDF2 迭代次数
cipher_namestring加密算法标识,如'aes-256-gcm'
versionnumber密钥方案版本号,便于未来迁移

注意细节:盐值(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 方案把改密成本降到了极致:

  1. 用户输入旧密码和新密码;
  2. 用旧密码派生旧主密钥,解包出 DEK;
  3. 用新密码派生新主密钥,重新包裹 DEK;
  4. 只更新 IndexedDB 里的 wrapped_dek、kdf_salt、kdf_iterations 字段;
  5. 完事,业务数据一个字节都不用动。

整个过程只涉及两次 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 首次初始化

  1. 用户输入一个主密码(至少 8 位,建议强制 12 位以上);
  2. 前端生成随机盐(16 字节),用动态校准的迭代次数跑 PBKDF2,得到主密钥;
  3. 随机生成 DEK,用主密钥包裹 DEK;
  4. 把盐、迭代次数、包裹后的 DEK、版本号写入 IndexedDB 的key_store表;
  5. 完成初始化,进入加解密工具类初始化流程。

6.2 解锁流程

  1. 从 IndexedDB 读取盐、迭代次数、包裹 DEK;
  2. 用户输入密码,跑 PBKDF2 得到主密钥;
  3. 用主密钥解包 DEK;
  4. 把 DEK 存入内存变量,所有后续加解密直接复用;
  5. 如果解包失败(GCM 认证失败),提示“密码错误”,而不是显示泛泛的“解密失败”——因为 GCM 的认证机制天然能判断密钥是否正确,这一点比旧的 CBC 模式体验好太多。

6.3 写入一条笔记

  1. 调用encryptData(dekBits, JSON.stringify(note))得到密文对象;
  2. 把密文对象存入 IndexedDB 的notes表;
  3. 同步逻辑(如果有)把密文同步到远端,远端只碰密文,不碰明文。

6.4 读取一条笔记

  1. 从 IndexedDB 取出密文对象;
  2. 调用decryptData(dekBits, encData)得到明文 JSON;
  3. 渲染到 UI;
  4. 用完的明文变量及时赋值为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 给你提供了可靠的密码学原语,但密钥怎么管、盐怎么存、迭代次数怎么校、版本怎么迁,这些工程问题最终还是得靠你自己想清楚。希望这篇实战总结能帮你少走几步弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询