第一次在小程序里写加密,我下意识地去找 crypto.subtle,然后发现它不存在。
浏览器里那套 WebCrypto 是 W3C 规范,小程序运行在自研的 JS 引擎里,只实现了 ECMAScript 的一个子集,
加上微信自己的 wx.* 接口。没有 SubtleCrypto,没有原生 AES,
window 和 document 也都没有。
所以能在小程序里用的加密,只剩下一条路:把纯 JS 实现的密码学库打进代码包。这篇记录这条链路的搭法, 以及两个会直接让应用崩掉、但文档里不会写的细节。
加密方案合不合理,取决于防的是谁。这里的目标很具体:
这个模型决定了三件事:密钥不能上云、加密要在客户端做完、密文可以放心地交给托管方。 也直接决定了后面「为什么不做 HMAC」的取舍。
最直接的做法是「密码 → PBKDF2 → 直接加密文件」。问题在于改密码:所有已上传的文件都得重新加密一遍, 几百个文件、几个 GB,手机上做不完。
所以拆成两级:
用户密码 --PBKDF2-SHA256(salt, 20000 次)--> KEK(32 字节)
KEK --AES-256-CBC(随机 IV)--> 包裹后的 MK,存云端
MK(32 字节随机数)--AES-256-CBC--> 每一个文件块
主密钥 MK 是一个 32 字节的随机数,永远以「被 KEK 包裹」的形式存在。改密码时只需要用新 KEK 重新包裹一次 MK, 已上传的密文一个字节都不用动。
// 包裹结果 = IV(16B) || AES-CBC(KEK, MK),整体 base64 后存云端
async function wrapKey(kek, mk) {
const packed = await encryptBlock(kek, mk)
const out = new Uint8Array(packed.iv.length + packed.data.length)
out.set(packed.iv, 0)
out.set(packed.data, packed.iv.length)
return base64Encode(out)
}
这里有个容易忽略的点:KEK 永远不落盘。它只在用户输入密码到解锁完成的那几秒存在于内存里, 用完就丢。下次进来重新派生一次。这个决定让「改密码」变成一次 200 毫秒的操作, 代价是每次冷启动都要重新算 PBKDF2。
AES-GCM 更现代,自带完整性校验,看起来是更好的选择。但它要求把整个明文读进内存—— 一个 1GB 的视频,小程序会先 OOM 崩掉,连异常都抓不到。
所以文件按固定大小切块,每块独立随机 IV,独立加密:
async function encryptBlock(key, plain) {
const iv = await randomBytes(16) // 每块都重新取
const enc = CryptoJS.AES.encrypt(toWordArray(plain), toWordArray(key), {
iv: toWordArray(iv),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
})
return { iv, data: toBytes(enc.ciphertext) }
}
每块用独立 IV 是硬要求。CBC 模式下如果两块用同一个 IV 和同一个密钥,相同的前缀会产出相同的密文块, 攻击者不需要解密就能看出两个文件开头一样——这是真实存在过的漏洞类型。IV 不需要保密,但必须随机且不重复。
分块的另一个好处是断点续传变简单了:块是独立单位,某一块失败重传那一段就行,不用从头来。
这个 bug 我调了一晚上。现象是:密码输错,本该提示「密码不正确」,实际是整个页面白屏,控制台一行报错都没有。
原因在 crypto-js 解密后的返回结构。WordArray 有两个字段:words
和 sigBytes。前者是 32 位整数数组,后者才是真实字节长度。
密码正确时,PKCS7 去填充会按填充字节数正确缩减 sigBytes。密码错误时,
解密出来的最后一个字节是个随机值(1–255),crypto-js 把它当作填充长度扣掉,
sigBytes 就变成了负数。接着:
new Uint8Array(-13) // RangeError: Invalid typed array length
异常在 await 链条的深处抛出,被外层的 try/catch 吞掉,页面状态停在一个半初始化阶段,
于是白屏。修复只有一行:
function toBytes(wordArray) {
const words = wordArray.words
const sigBytes = Math.max(0, wordArray.sigBytes || 0) // 关键
const out = new Uint8Array(sigBytes)
for (let i = 0; i < sigBytes; i += 1) {
out[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff
}
return out
}
夹到 0 之后,错误密码解出的是空数组,上层据此判断「解包失败」,正常走到提示密码错误的分支。
这个写法在 crypto-js 的很多教程里都没有,因为绝大多数示例只测密码正确的那条路径。
顺带一提:解密失败不应该抛异常来表示。密码学函数的失败是一条正常业务分支, 用异常表达会让调用方不得不在每个层级写 try/catch,很容易在某处漏掉。
小程序提供了 wx.getRandomValues,但它和浏览器的同步版本不一样——结果在
success 回调里,没有同步返回值:
wx.getRandomValues({
length: 16,
success: res => { /* res.randomValues 在这里 */ },
fail: err => {}
})
于是很容易写出这种代码:
// 错误写法:randomValues 永远是 undefined
const iv = wx.getRandomValues({ length: 16 }).randomValues
iv 是 undefined,传给 crypto-js 后它会自己生成一个 IV
并继续跑完整个加密流程。功能看起来完全正常,但你的 IV 已经不是你以为的那一个了。
如果这段代码后来又降级到 Math.random 填坑,IV 强度就彻底垮掉。
正确写法是把它包成 Promise,并且在降级时留下日志:
async function randomBytes(n) {
if (typeof wx !== 'undefined' && wx.getRandomValues) {
try {
const res = await new Promise((resolve, reject) => {
wx.getRandomValues({ length: n, success: resolve, fail: reject })
})
const raw = res && res.randomValues
if (raw) return new Uint8Array(raw)
} catch (e) { /* 落到降级分支 */ }
}
console.warn('[crypto] 安全随机源不可用,已降级到 Math.random')
const out = new Uint8Array(n)
for (let i = 0; i < n; i += 1) out[i] = Math.floor(Math.random() * 256)
return out
}
降级本身是必要的(老版本基础库没有这个接口),但必须可观测。如果哪天线上出现大量这条警告, 说明有一批用户的加密强度是打折的,这是需要知道的事,而不是悄悄吞掉。
AES-CBC 只保证机密性,不保证完整性。理论上攻击者可以翻转密文里的比特,让解密结果变成乱码; 更糟的情况是填充预言攻击(padding oracle)——如果程序对「填充错误」和「内容错误」给出不同反馈, 攻击者可以逐字节解出明文。
这个方案没有加 HMAC。原因是威胁模型里不包含「存储方主动篡改」:
这是一个取舍,不是疏忽。如果哪天场景里出现了「存储方是不可信但会主动作恶」的一方, 正确做法是换成 AES-GCM 或者 Encrypt-then-MAC,而不是在现有结构上打补丁。
代码里定的是 20000 次。这个数字不是抄的,是按两端约束夹出来的:
结论是:迭代次数只能缓解弱密码,救不了弱密码。所以产品上做了另一件事——把「解锁密码」和 「加密密码」分成两个:解锁密码可以短,加密密码强制要求更长。PBKDF2 保护的是后者。
说清楚这套方案做不到什么,比说它能做什么更有用:
最后一条是我写这类功能时踩过的最大的坑:技术上做了什么,文案就只能说什么。 代码里加了一条恢复通道,产品页面上「连我们都看不到」这句话就变成了虚假陈述。
整条链路的核心其实只有三句话:密码派生出 KEK,KEK 只包裹 MK,MK 分块加密文件。 剩下的都是这两个坑和一堆取舍。