我的编程技术分享 返回文章列表

在微信小程序里手写 RSA-OAEP 加密

微信小程序RSA密码学 2026-10-30

需求很简单:客户端要把一把对称密钥安全地交给服务端的某个离线保管方, 服务端自己不能解。用非对称加密,客户端拿公钥加密,私钥从不进服务器。

然后发现现成的路全堵死了:crypto-js 只做对称加密和摘要,没有 RSA; 小程序没有 crypto.subtle;jsencrypt 这类库依赖 window 和浏览器 API, 在小程序里跑不起来,而且只支持 PKCS#1 v1.5 填充——那个填充方式早就该淘汰了。

最后只能手写。这篇记录实现路径、和 Node 的互通验证、以及一个绕不开的性能问题。

一、为什么必须是 OAEP,不能用 PKCS#1 v1.5

PKCS#1 v1.5 的填充结构里有一段随机字节,看起来也是随机的,问题出在它的可预测性: 填充格式高度固定,攻击者手里有一份密文和一个「解密是否合法」的反馈,就能逐字节还原明文 (Bleichenbacher 攻击)。1998 年提出,二十多年里反复在各种 TLS 实现上复现。

OAEP 的结构更复杂,随机性分布在整个数据块上,且对「解密失败」的响应必须是统一的。 Node 的 RSA_PKCS1_OAEP_PADDING 和浏览器的 RSA-OAEP 都是它。

二、OAEP-SHA256 的具体步骤

设模长 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 的出现让这件事变简单了

在没有 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 里取出模数和指数

公钥通常是这种格式的 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),零解析开销,也不用担心格式兼容。 公钥本来就是公开的,硬编码没有任何安全代价。

五、和 Node 对齐:唯一可靠的验证方式

手写密码学最容易犯的错是「自己加密、自己解密,两边都错但能对上」。 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 用私钥解,比对原文是否逐字节一致。这个测试通过之前,不要相信任何「自己实现自己验证」的绿灯。

几个对不上的常见原因,按发生概率排:

  1. 标签用错。L 必须是空串,不是 null,也不是省略——lHash 是 SHA256(""),不是 32 个 0。
  2. MGF1 的计数器字节序。必须是 4 字节大端,从 0 开始。
  3. 填充长度算错。PS 的长度是 k - 2*hLen - 2 - mLen,少算一个字节整个 DB 就错位。
  4. 把 EM 当成整数直接模幂。EM 是 512 字节,前面必须有一个 0x00 保证它小于模数—— 少了这个字节,数值可能大于 n,模运算会静默截断。

六、一次能加密多少

OAEP 的容量上限是 k - 2*hLen - 2:

32 字节的主密钥远远够用。如果哪天要加密的数据超过这个长度,正确做法不是分段 RSA 加密 (那会引入一系列新的填充问题),而是用 RSA 加密一个对称密钥,再用对称密钥加密数据—— 这正是这篇和上一篇交接的地方。

七、密钥长度选 2048 还是 4096

选了 4096。理由不是「更安全更好」,而是权衡:

如果这个加密发生在每一次上传里,那就该选 2048——延迟会累积成用户能感觉到的卡顿。

八、只写加密方向,是故意的

这个实现里只有加密,没有解密。这不是没写完,而是结构上的保证: 客户端代码里不存在私钥,也就没有解密的可能。

一个库如果同时提供 encrypt 和 decrypt,那么任何人拿到这份代码, 都会很自然地去找私钥放在哪;而当私钥根本不在这个仓库里时,代码本身就是「这里没有解密能力」的证明。

安全设计里有一条常被忽略的原则:能删掉的能力比能限制的能力更可靠。 权限校验写错一行就失效,而一段没有被实现的解密函数,不会因为任何 bug 而开始工作。

九、小结

手写密码学通常不是好建议。这里的判断依据是:算法是公开的、填充规范有 RFC 明确步骤、 模幂只有十行、并且有一个跨实现的一致性测试兜底。缺任何一条,我都会选择换方案而不是自己写。

实际写下来,最耗时的不是算法本身,而是第三节之前的那个决定: 要接纳「客户端没有现成密码学能力」这个事实,而不是花两天时间尝试让某个浏览器库在小程序里跑起来。