需求很简单:客户端要把一把对称密钥安全地交给服务端的某个离线保管方, 服务端自己不能解。用非对称加密,客户端拿公钥加密,私钥从不进服务器。
然后发现现成的路全堵死了:crypto-js 只做对称加密和摘要,没有 RSA;
小程序没有 crypto.subtle;jsencrypt 这类库依赖 window 和浏览器 API,
在小程序里跑不起来,而且只支持 PKCS#1 v1.5 填充——那个填充方式早就该淘汰了。
最后只能手写。这篇记录实现路径、和 Node 的互通验证、以及一个绕不开的性能问题。
PKCS#1 v1.5 的填充结构里有一段随机字节,看起来也是随机的,问题出在它的可预测性: 填充格式高度固定,攻击者手里有一份密文和一个「解密是否合法」的反馈,就能逐字节还原明文 (Bleichenbacher 攻击)。1998 年提出,二十多年里反复在各种 TLS 实现上复现。
OAEP 的结构更复杂,随机性分布在整个数据块上,且对「解密失败」的响应必须是统一的。
Node 的 RSA_PKCS1_OAEP_PADDING 和浏览器的 RSA-OAEP 都是它。
设模长 k 字节(4096 位即 512 字节),哈希长度 hLen = 32(SHA-256),
待加密消息 M,标签 L 为空串:
lHash = SHA256("") // 32 字节常量
DB = lHash || PS || 0x01 || M // PS 是若干 0x00,总长凑到 k-hLen-1
seed = random(hLen) // 32 字节随机
dbMask = MGF1(seed, k - hLen - 1)
maskedDB = DB XOR dbMask
seedMask = MGF1(maskedDB, hLen)
maskedSeed = seed XOR seedMask
EM = 0x00 || maskedSeed || maskedDB // 共 k 字节
MGF1 是一个基于哈希的掩码生成函数,实现只有几行:
function mgf1(seed, len) {
const out = []
let counter = 0
let produced = 0
while (produced < len) {
const c = [(counter >>> 24) & 0xff, (counter >>> 16) & 0xff,
(counter >>> 8) & 0xff, counter & 0xff]
out.push(...sha256(concat(seed, c)))
produced += 32
counter += 1
}
return out.slice(0, len)
}
然后是模幂:c = m^e mod n。这一步是整个实现里唯一有性能代价的地方。
在没有 BigInt 的年代,JS 里做大数模幂要自己实现大整数乘法(分块 base-2^26 之类),几百行代码还不一定对。 现在小程序基础库已经支持 BigInt,直接写:
function modPow(base, exp, mod) {
let result = 1n
let b = base % mod
let e = exp
while (e > 0n) {
if (e & 1n) result = (result * b) % mod
b = (b * b) % mod
e >>= 1n
}
return result
}
快速幂,O(log e) 次模乘。4096 位模数、65537 的公开指数(只有两个 1 位),
实际只需要 16 次模乘,中端安卓机上大约 20–60 毫秒。这个开销可以接受。
公钥通常是这种格式的 PEM:
-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA...
-----END PUBLIC KEY-----
中间那段 base64 解出来是 DER 编码的 ASN.1 结构,层级是:
SubjectPublicKeyInfo ::= SEQUENCE {
algorithm SEQUENCE { OID rsaEncryption, NULL }
subjectPublicKey BIT STRING { -- 里面又是一层
SEQUENCE { modulus INTEGER, publicExponent INTEGER }
}
}
不想引入 ASN.1 解析库(又是几 KB 到几十 KB 的代码包),可以做一件更省事的事: 构建时就把它拆好。用 Node 在本地把 PEM 解析成两个十六进制字符串,直接写进小程序的常量文件:
// 构建期一次性执行,产物是小程序里的一个普通对象
const { createPublicKey } = require('crypto')
const jwk = createPublicKey(pem).export({ format: 'jwk' })
// jwk.n = 模数的 base64url,jwk.e = 指数的 base64url
小程序运行时只做 base64url 解码和 BigInt('0x' + hex),零解析开销,也不用担心格式兼容。
公钥本来就是公开的,硬编码没有任何安全代价。
手写密码学最容易犯的错是「自己加密、自己解密,两边都错但能对上」。 OAEP 这种填充方案尤其如此——填充字段错一个位置,自己实现自己解仍然成功,换成标准实现立刻失败。
所以验证必须跨实现。Node 侧:
const crypto = require('crypto')
const plain = Buffer.from('要加密的 32 字节主密钥内容', 'utf8')
const cipher = crypto.publicEncrypt(
{ key: publicPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
plain
)
console.log(cipher.toString('base64')) // 交给小程序端解不了,反过来用
我的做法是写成一个对称的测试脚本:小程序端用同一把公钥加密一段固定明文,把密文吐给 Node, Node 用私钥解,比对原文是否逐字节一致。这个测试通过之前,不要相信任何「自己实现自己验证」的绿灯。
几个对不上的常见原因,按发生概率排:
L 必须是空串,不是 null,也不是省略——lHash 是 SHA256(""),不是 32 个 0。PS 的长度是 k - 2*hLen - 2 - mLen,少算一个字节整个 DB 就错位。0x00 保证它小于模数——
少了这个字节,数值可能大于 n,模运算会静默截断。OAEP 的容量上限是 k - 2*hLen - 2:
32 字节的主密钥远远够用。如果哪天要加密的数据超过这个长度,正确做法不是分段 RSA 加密 (那会引入一系列新的填充问题),而是用 RSA 加密一个对称密钥,再用对称密钥加密数据—— 这正是这篇和上一篇交接的地方。
选了 4096。理由不是「更安全更好」,而是权衡:
如果这个加密发生在每一次上传里,那就该选 2048——延迟会累积成用户能感觉到的卡顿。
这个实现里只有加密,没有解密。这不是没写完,而是结构上的保证: 客户端代码里不存在私钥,也就没有解密的可能。
一个库如果同时提供 encrypt 和 decrypt,那么任何人拿到这份代码,
都会很自然地去找私钥放在哪;而当私钥根本不在这个仓库里时,代码本身就是「这里没有解密能力」的证明。
安全设计里有一条常被忽略的原则:能删掉的能力比能限制的能力更可靠。 权限校验写错一行就失效,而一段没有被实现的解密函数,不会因为任何 bug 而开始工作。
手写密码学通常不是好建议。这里的判断依据是:算法是公开的、填充规范有 RFC 明确步骤、 模幂只有十行、并且有一个跨实现的一致性测试兜底。缺任何一条,我都会选择换方案而不是自己写。
实际写下来,最耗时的不是算法本身,而是第三节之前的那个决定: 要接纳「客户端没有现成密码学能力」这个事实,而不是花两天时间尝试让某个浏览器库在小程序里跑起来。