在Web开发这件事上,谈到JavaScript密码算法与网络安全,我听到最多的质疑就是:“前端加密有什么意义?数据反正会到后端,后端验不就行了?”这个想法不能算错,但它忽略了一大类真实存在的场景。比如:你的应用需要校验第三方回调的数据有没有被中途篡改;需要在不暴露明文的情况下把敏感字段传给数据分析平台;甚至需要直接在浏览器端做文件的分块加密上传,让服务器只保存密文。这些需求,靠纯粹的传输层加密解决不了,必须真正把JavaScript密码算法用起来。
这篇内容不是教科书式的概念罗列,而是我在几个真实项目里蹚过坑之后的总结,包含浏览器原生Web Crypto API的完整实操、密钥管理的取舍、以及一系列调试经验。适合正在做Web安全改造、加密存储、数据防篡改需求的开发者,也适合那些拿到需求却不知道从哪儿下手的同学。我会尽量把每一步为什么这么做讲清楚,你可以直接拿去复用。
1. 密码学基础:为什么前端加密让我又爱又恨
1.1 对称加密与非对称加密的核心逻辑
先把最基础的东西说透。对称加密和非对称加密,名字听着唬人,本质区别就一句话:加密和解密用的密钥是不是同一个。
对称加密,典型代表是AES,加密和解密用同一把钥匙。就好比你家的门锁,用钥匙锁上门,也用同一把钥匙打开。它的优点是速度快、算力开销小,适合加密大量数据。缺点也很致命:钥匙怎么送到对方手里?如果两个人通过网络通信,总得先把这把密钥传过去,传输过程中一旦被截获,后面所有密文都等于裸奔。
非对称加密,典型代表是RSA和椭圆曲线(ECC),它用一对钥匙:公钥和私钥。公钥可以公开,谁都能拿;私钥自己保管,绝不外传。这个设计更像街边的公共信箱——任何人都能把信投进信箱(用公钥加密),但只有握着信箱钥匙的人能打开(用私钥解密)。非对称加密解决了密钥分发问题,但性能比对称加密慢几个数量级,不适合直接加密大段数据。
所以在真实项目里,两者从来不是二选一,而是搭配使用:用非对称加密协商出一把临时对称密钥,再用这把对称密钥加密业务数据。这也就是TLS握手的核心思路之一。前端场景里,如果你不需要跟别人协商密钥,更多时候直接使用对称加密就够了,密钥从用户口令派生,根本不落到服务器上。
1.2 浏览器端的威胁模型:加密到底保护了什么
很多开发者对前端加密的最大误解,是拿它当HTTPS的替代品,这是大忌。HTTPS解决的是传输链路被窃听和篡改的问题,它保证的是“数据从浏览器到服务器之间没人看得懂”;而JavaScript密码算法解决的是“数据在业务层落库、传递、校验”时的安全问题。两者是不同层级的防护,不是二选一的关系。
举个我实际见过的例子:某个系统的网关会记录请求日志用于排障,但日志里包含用户身份证号等敏感字段。即使整个链路是HTTPS,日志一旦落到存储系统,就变成了明文数据库,安全审计直接不过关。后来改成前端先用AES加密敏感字段,后端只把密文交给日志系统,日志查询时再解密。这就是应用层加密的真实价值。
再比如一个云笔记应用,用户写的内容同步到服务器。如果服务器本身可以解密数据,那运维人员、被拖库时攻击者都能看到全部笔记。这时候就需要端到端加密:前端加密后上传,服务器只负责存储密文,解密永远发生在用户自己的浏览器里。这类需求,单纯指望HTTPS和服务器端防护是不够的。
我把前端加密的真实作用总结成三件事:第一,数据完整性校验,确保密文或明文在传输、存储过程中没有被篡改;第二,敏感字段的端到端加密,让中间环节看不到明文;第三,防重放和防伪造,比如用HMAC验证回调请求确实来自受信任方。想清楚这三件事,你就不会被“前端加密是否安全”这类问题困住。
1.3 哈希、消息认证码与加密:三者千万别混用
在讲代码之前,必须把哈希、消息认证码和加密这三个概念掰开。我见过太多项目把SHA256叫“加密”,实际上SHA256算出来的摘要根本解不回去。哈希函数是单项通道,它把你的一段数据映射成固定长度的指纹,数据稍微改一个bit,指纹就面目全非。哈希的作用是校验完整性,不是保密。
消息认证码(MAC)和哈希的最大区别是它需要一把密钥。哈希谁都能算,MAC只有持有密钥的人才能算得出来。所以在第三方回调验签、API防篡改这些场景里,用HMAC而不是普通哈希,攻击者没有密钥,伪造不了正确的校验值。
加密则是可逆的,它能保护机密性,让明文变成看不懂的密文,接收方用密钥还原。哈希、MAC、加密,三者的定位分别是完整性、认证性、机密性。真正项目里往往是组合拳:先用哈希或MAC保证数据没被改过,再用加密让数据不被别人看到。脑子里有了这个框架,后面看代码就不会晕。
有一个很直观的类比:哈希像验毒试纸,只能告诉你这包药材有没有被动过;MAC像是在试纸上再加一层私人暗号,只有你和对方知道暗号规则;加密则是把药材整个锁进保险箱,锁是密码锁,没有密码连看都看不到。三件事互相独立,但经常一起用。
2. JavaScript加密实战:浏览器原生API的正确打开方式
2.1 Web Crypto API:为什么原生实现值得信赖
先亮明态度:新项目做JavaScript密码算法,优先用浏览器原生的Web Crypto API,而不是各种第三方加密库。理由很简单:原生API是浏览器内核实现的,运行时有硬件加速,密钥材料在底层做管理,JavaScript环境本身接触不到完整的密钥细节,从源头上减少被XSS脚本窃取密钥的风险。第三方纯JS库虽然方便,但很多实现存在历史包袱。
Web Crypto API的核心对象是crypto.subtle,提供摘要、加解密、签名、密钥派生等能力。注意subtle这个名字,官方文档说得很直白:这是一套强大的低级API,越强大越容易用错,所以把“subtle”写进了命名里。
先来一个最基础的哈希操作,对字符串算SHA256摘要:
async function sha256(input) { const data = new TextEncoder().encode(input); const digestBuffer = await crypto.subtle.digest('SHA-256', data); const hashArray = Array.from(new Uint8Array(digestBuffer)); return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); }这段代码有两个新手常犯的坑。第一,digest方法不直接接受字符串,必须先把字符串编码成Uint8Array,我用的是TextEncoder。第二,crypto.subtle.digest返回的是ArrayBuffer,不能直接打印,需要转成Uint8Array再逐字节转十六进制字符串。很多人天真的console.log(digest),打出来永远是[object ArrayBuffer],还以为是自己写错了。
哈希用的算法名是'SHA-256',这里也可以用'SHA-1'和'SHA-384',但SHA-1已经不安全,除非做兼容老系统的完整性校验,否则一律不要用。新项目无脑选SHA-256。
2.2 AES-GCM:对称加密实战封装
浏览器端做对称加密,我的首选永远是AES-GCM。原因有三个:一是GCM模式同时提供加密和完整性认证,既保证了机密性,又防止密文被篡改;二是Web Crypto API原生支持,不需要额外引入库;三是一个模式解决两件事,代码结构更简单。
AES-GCM有两个关键参数:密钥和IV(初始化向量)。IV不需要保密,但绝对不能重复。同一把密钥下如果IV重复,攻击者可以通过密文的异或关系还原出明文信息。我用crypto.getRandomValues生成12字节的随机IV,这是一个比较标准的做法,12字节是GCM模式推荐的长度,太长或太短都会带来额外的兼容性问题。
下面是一组可以直接复用的封装,加密函数返回{ iv, ciphertext }:
async function aesGcmEncrypt(secretKey, plaintext, additionalData) { const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: iv, tagLength: 128, additionalData: additionalData }, secretKey, new TextEncoder().encode(plaintext) ); return { iv: iv, ciphertext: new Uint8Array(encrypted) }; } async function aesGcmDecrypt(secretKey, iv, ciphertext, additionalData) { const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: iv, tagLength: 128, additionalData: additionalData }, secretKey, ciphertext ); return new TextDecoder().decode(decrypted); }这中间有个很容易被忽略的细节:additionalData(AAD)。它是什么?它是一段不加密但要参与认证的数据,典型用法是绑定上下文。比如你加密一条消息,可以把消息的ID、发送人、版本号放进AAD,这样如果攻击者把一条合法密文换个ID插到别处,解密时AAD校验不过,立刻失败。等于给密文加了一层“防移花接木”的保险。
crypto.subtle.encrypt返回的密文里已经包含了GCM的认证标签,不需要你再单独拼接tag,解密时也是自动校验。如果密文被篡改,decrypt会直接抛OperationError异常。这一点对排查问题很重要,下面第4章还会展开讲。
2.3 PBKDF2与口令派生:密码存储的正确姿势
接下来是另一个高频场景:用户密码。很多人拿到需求“密码加密存数据库”,直接对密码算一个SHA256就存进去,这是巨大的错误。SHA256速度太快,攻击者拿到数据库后,用GPU暴力跑几亿次哈希毫无压力,弱口令一跑一个准。
正确的做法是什么?要么用专门的慢哈希算法,比如bcrypt、scrypt、Argon2;要么用密钥派生函数PBKDF2,把密码加上随机盐,经过成千上万次迭代,派生出定长密钥或哈希值。慢哈希的本质是让每次猜测的成本变得很高,从而提高暴力破解的代价。
在浏览器端,Web Crypto API支持PBKDF2。下面是一个加盐派生的完整示例:
async function deriveKeyFromPassword(password, salt, iterations = 310000) { const baseKey = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits'] ); const derivedBits = await crypto.subtle.deriveBits( { name: 'PBKDF2', salt: salt, iterations: iterations, hash: 'SHA-256' }, baseKey, 256 ); return new Uint8Array(derivedBits); } // 调用示例 const salt = crypto.getRandomValues(new Uint8Array(16)); const derivedKey = await deriveKeyFromPassword('用户密码', salt);盐的作用是防预计算攻击和彩虹表。即使两个用户设置相同密码,只要盐不同,最终派生结果也不同。盐必须是随机生成、长度足够(16字节以上),并且可以明文存储,不需要保密。迭代次数决定了慢哈希的成本,安全社区目前的共识是PBKDF2-HMAC-SHA256至少30万次迭代,硬件环境允许的话做到60万次也不过分。在低端移动设备上,迭代次数太高的体验会明显变慢,需要根据自己的用户设备画像平衡。
这里我必须强调一个边界:浏览器端运行PBKDF2主要是为了“客户端派生密钥”,比如做端到端加密时,从用户口令生成本地密钥,口令永远不离开设备。而真正的密码库校验,依然强烈建议放在服务器端用专门的密码哈希方案处理。如果只是在浏览器里算个PBKDF2然后当密码哈希存到数据库,那就失去了服务端慢哈希的专业算法优势,安全强度反而打折。
3. 真实项目落地:一个加密同步方案的完整拆解
3.1 场景设定与整体架构
理论讲再多,不落地等于零。我拿一个做过多次的方案来拆解:云笔记的加密同步。需求很简单,笔记内容要加密后上传到服务器,服务器只存密文,即使数据库被拖走,攻击者拿到的也是一堆无法解读的字节。
这里采用的架构是“密钥层级”思想。直接用用户口令加密所有笔记并不可行,因为任意一条笔记更新时都要重新派生密钥,而且口令一旦修改,所有数据都要重加密。更合理的方案是引入两层密钥:
- 数据加密密钥(DEK):为每一条笔记随机生成,真正用于AES-GCM加密笔记内容。
- 密钥加密密钥(KEK):由用户口令加盐通过PBKDF2派生,用于加密DEK。
整体流程是:用户输入口令,浏览器本地派生出KEK;每新建一条笔记,生成随机DEK加密笔记内容;DEK本身再用KEK加密后,和笔记密文一起上传服务器。用户查看笔记时,先用口令派生KEK,解包出DEK,再用DEK解密笔记。服务器全程只接触密文和包裹后的密钥,没有能力还原明文。
这个架构带来一个直接收益:用户修改口令时,只需要用新KEK重新包裹所有DEK,不需要把几十万条笔记重新加密一遍。操作成本从“重写全库”降为“重写密钥表”,量级完全不同。
3.2 关键实现与代码走读
密钥包装可以借助Web Crypto API的wrapKey和unwrapKey,原生支持AES-GCM作为包装算法。下面是核心代码逻辑。
生成一条新笔记的加密过程:
// 第一步:生成随机DEK,AES-GCM 256位 const dataKey = await crypto.subtle.generateKey( { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ); // 第二步:用DEK加密笔记内容 const iv = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: iv }, dataKey, new TextEncoder().encode(plaintext) ); // 第三步:用KEK包裹DEK const wrappedKey = await crypto.subtle.wrapKey( 'raw', dataKey, kek, { name: 'AES-GCM', iv: iv } );读取笔记时的解包过程:
// 第一步:从服务器拿到包裹后的密钥 // 第二步:用KEK解包DEK const unwrappedKey = await crypto.subtle.unwrapKey( 'raw', wrappedKeyBuffer, kek, { name: 'AES-GCM', iv: iv }, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ); // 第三步:用DEK解密笔记 const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: iv }, unwrappedKey, ciphertext );注意到一个问题没有:包裹DEK时用了和加密笔记相同的iv。这里要特别小心,如果完全复用同一个DEK和同一个IV加密两份数据,是灾难性的。但在密钥包裹场景中,包裹DEK用的是KEK,加密笔记用的是DEK,两把密钥不同,复用IV并不会造成安全风险。不过为了保险起见,我建议还是分别为wrapKey和encrypt生成独立的IV,避免后续有人重构代码时混淆密钥上下文。
实现这个方案时,DEK是否标记为extractable也是关键决策。包裹密钥之后,本来可以把DEK设置为false,即不可导出,这样JavaScript环境根本无法取出DEK的原始字节,只能用它加解密。但在实际存储场景中,一旦KEK丢失或用户忘记口令,不可导出的DEK意味着数据彻底无法恢复。考虑到用户找回口令的需求,我通常会保留extractable: true,但伴随更严格的口令策略和提醒机制,让用户知道忘记口令等于放弃数据。
3.3 密钥管理与轮换的教训
说到密钥管理,我在真实项目里踩过不少坑,这里集中说几个。
第一,不要为了方便把密钥存进localStorage。localStorage对同源下的所有脚本开放,一个XSS漏洞就能把密钥偷走。哪怕你把密钥先做一层编码再存,也只是掩耳盗铃,攻击者只需要照猫画虎解码一次。密钥的最好归宿是“不落盘”,每次使用前由口令重新派生。
第二,IndexedDB比localStorage更适合存密钥,主要原因不是安全性,而是它支持结构化克隆和更大的数据容量,可以存CryptoKey对象,并且存取是异步的。但要注意,IndexedDB对XSS同样没有免疫力,它只是工程上更合理,不代表密钥就安全了。
第三,密钥轮换。很多项目上线时做了加密,却完全没有轮换机制。一旦怀疑密钥泄露,或者安全合规要求定期更换,你总不能让人改一次密钥就重加密所有历史数据。所以设计数据模型时就要预留密钥版本字段。
我现在的做法是在每条密文记录里额外存一个keyVersion字段。轮换时生成新的KEK,批量解包-重新包裹DEK,更新版本号。解密时根据记录的版本号选择对应的KEK,历史数据平滑过渡。整个过程用户无感知,数据量也不需要全量重加密,这是密钥层级架构另一个很大的优势。
第四,永远不要在前端日志里打印密钥或口令。调试时开发者也难免手滑,把console.log(wrappedKey)留在线上代码里。我在代码规范里直接加了要求:涉及密钥对象的日志一律使用console.log('[key] omitted'),避免思维方式上就养出随手打的习惯。
4. 常见问题与排查技巧实录
4.1 安全上下文与兼容性:为什么我的crypto.subtle是undefined
Web Crypto API有一个硬性条件:必须在安全上下文中可用。所谓安全上下文,简单说就是HTTPS页面或localhost环境。如果你直接在某个HTTP内网地址打开页面,window.crypto.subtle大概率是undefined,所有加密代码都会报错。
排查这个问题最快的方式是看一眼浏览器控制台错误。如果是“Cannot read properties of undefined (reading 'digest')”,先确认是不是在HTTP环境。开发阶段建议用localhost进行联调,因为浏览器把localhost视为安全上下文,不需要配HTTPS证书。生产环境则必须上HTTPS,这本身也是Web安全的基本要求。
Node.js环境下情况略有不同。Node.js 20开始,全局的crypto对象默认包含subtle,可以直接使用;更早版本需要从require('crypto').webcrypto取。前后端共用同一套Web Crypto API代码是可行的,但要注意两端默认支持细节存在差异,比如某些算法名、哈希算法清单可能不一致。
兼容性方面还有一个必须提的坑:老版本浏览器(IE时代遗产,比如某些企业浏览器内核)根本不支持crypto.subtle。如果产品明确需要支持这类环境,要么引入降级方案,要么干脆在功能设计上做分流,不要试图用同一个代码路径硬兼容,否则你会被一串又一串的环境判断折磨到崩溃。
4.2 二进制数据的编码转换:ArrayBuffer、Uint8Array与Base64
做密码学开发,二进制数据的编码转换几乎是每天都要处理的事。crypto.subtle的输入输出全是ArrayBuffer和Uint8Array,但网络传输、数据库存储、URL传参都要求字符串或Base64。很多新手在这里翻车,我见到最多的报错是“byte length mismatch”或“invalid key length”,仔细一查全是编码没转对。
下面这组工具函数我几乎每个项目都会粘一份:
function bytesToHex(bytes) { return Array.from(new Uint8Array(bytes)) .map(b => b.toString(16).padStart(2, '0')) .join(''); } function base64ToBytes(base64) { const binaryString = atob(base64); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes; } function bytesToBase64(bytes) { let binary = ''; const uint8 = new Uint8Array(bytes); for (let i = 0; i < uint8.length; i++) { binary += String.fromCharCode(uint8[i]); } return btoa(binary); }这里有一个容易懵的点:Uint8Array和ArrayBuffer是两种不同的东西。Uint8Array本质是一个“视图”,它里面有一个buffer属性指向底层的ArrayBuffer。你如果传给crypto.subtle一个从ArrayBuffer上切出来的Uint8Array,通常没问题,因为Web Crypto API接受BufferSource,也就是它俩都行。但当你需要精确控制长度时,务必用byteLength而不是length,因为Uint8Array的长度可能只是底层ArrayBuffer的一部分。
另一个和服务器通信时的典型问题:后端返回Base64字符串,前端直接JSON.parse解出字符串后,忘了转回字节数组就传给crypto.subtle.importKey,结果密钥导入报错。处理JSON传输时,密钥和密文一律用Base64字符串承载,拿到前端先转字节,再参与运算。
4.3 AES-GCM解密失败?逐层排查清单
AES-GCM解密失败抛OperationError,这是最典型的加密报错。我把自己总结的排查思路列成清单,按下面的顺序查,大多数问题五分钟内能定位。
第一,检查IV是否和解密时一致。很多人只保存了密文,忘记保存IV。AES-GCM解密必须使用加密时的同一个IV,否则GCM认证必然失败。所以存储时一定要把iv和ciphertext放在一起保存,甚至可以把IV直接拼在密文前面,然后统一转为Base64。
| 现象 | 最可能原因 | 处理建议 |
|---|---|---|
| 解密报OperationError | 密文被篡改、IV不对、AAD不一致 | 逐项比对密文、IV、AAD |
| 密钥导入失败 | 密钥字节长度不对或编码错误 | 确认Base64转字节无残留、长度符合算法要求 |
| 页面打开即报错 | crypto.subtle不可用 | 检查HTTPS、localhost、浏览器版本 |
| 服务器与前端互通失败 | 编码规则不一致,比如前端hex、后端base64 | 统一编码格式,建立转换工具函数 |
| 数据偶尔解密失败 | 并行加密时IV生成冲突或随机源退化 | 确认用crypto.getRandomValues,避免自实现随机数 |
第二,确认IV字节数是否为12字节。GCM标准推荐12字节,太长在某些实现里要额外做IV哈希处理,容易出现兼容性问题。我见过有人用了16字节IV,Node.js 和浏览器某一边能解、另一边不行,查到最后是IV长度处理规则不一致。
第三,检查additionalData。如果你在加密时传了AAD,解密时也必须传一模一样的AAD,顺序和字节都不能错。这里最坑的是AAD在传输过程中如果被序列化成了不一样的编码,比如字符串编码从UTF-8变成UTF-16,解密也会失败。
第四,确认导出导入密钥时没有改变字节顺序。密钥的raw导出直接对应字节序列,不应该有任何处理。有些代码为了显示好看,把密钥转成hex字符串再次解码,中间一旦出现大小写处理误差,密钥就废了。
4.4 大数据量加密的性能取舍
前面讲的所有示例都是对小数据量直接加密,但真正做文件同步、图片加密这类需求时,直接把整个文件塞进crypto.subtle.encrypt会让内存瞬间爆炸。浏览器里加密一个几百MB的文件,你几乎能听到风扇狂转的声音。
标准做法是分块加密,把文件切成固定大小的分块,比如1MB一块,依次加密后传给服务器。但GCM模式有分块安全边界,分块太小会扩大密文膨胀率,分块太大则内存占用高。我实测下来,1MB到4MB是一个比较舒服的区间,密文膨胀率约为密文长度的0.8%(GCM各分块都要加一个认证标签,通常16字节),在可接受范围内。
分块加密时,每个分块必须使用独立的IV,并且不能以任何可预测的方式递增。最稳妥的是每块都用crypto.getRandomValues生成随机IV,同时把分块序号、IV、密文长度作为存储元数据保存。注意分块序号的意义:它防止攻击者删除或调换分块顺序,结合每个分块的GCM认证标签,可以有效保证数据的完整性和顺序。
const CHUNK_SIZE = 1024 * 1024; // 1MB async function encryptFile(file, key) { const chunks = []; for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) { const slice = file.slice(offset, offset + CHUNK_SIZE); const buf = await slice.arrayBuffer(); const iv = crypto.getRandomValues(new Uint8Array(12)); const enc = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: iv }, key, buf ); chunks.push({ index: chunks.length, iv: bytesToBase64(iv), data: bytesToBase64(new Uint8Array(enc)) }); } return chunks; }在这个例子里,我先对每个分块独立加密,再把分块元数据传给服务器。如果不做Web Worker,主线程中循环加密1MB分块会让页面出现明显的卡顿。更合理的方式是把加密任务扔进Web Worker,把主线程腾出来给用户交互。我在项目中通常会创建一个专门的加密Worker,接收ArrayBuffer,返回加密结果,主线程只维护任务状态和进度条。整体体验能从“点击保存后页面冻结三秒”变成“后台默默加密、界面丝滑不减”。
最后分享一个调用习惯:多次调用crypto.subtle的异步方法时,不要在循环里无脑await串行执行,逐个排队会拖慢总时长。在任务粒度小、相互独立的前提下,可以考虑用Promise.all并发控制,但要注意并发量,一次性发几十个加密任务同样能把CPU踩满。我的经验是维护一个并发池,同时运行4到6个加密任务,既充分利用多核资源,又不至于让页面失去响应。加密性能优化本质上是个资源调度的活,在浏览器这种单线程表象下,需要多试几种策略找到适合自己场景的平衡点。
踩过几次坑之后,我现在做前端加密方案已经形成了一套固定动作:先确认威胁模型,想清楚要防谁;再选算法,能走Web Crypto API就不引第三方库;然后设计密钥层级,预计未来的轮换需求;最后把编码转换工具函数写好放在公共模块里。这个流程让我从“写出一段能跑的加密代码”进化到“做出一个几年后依然经得起推敲的加密设计”,中间省下来的排查时间非常可观。
如果你正好在做一个新的加密功能,我建议先从第2章的AES-GCM封装入手,把它内置到项目工具库里,再逐步叠加PBKDF2派生、密钥包裹和分块加密。不要一开始就上大而全的加密框架,浏览器密码学最危险的地方恰恰是那些看起来很省事的封装,它们隐藏了太多细节,而安全这件事,最怕的就是细节失守。