1. 先把 crypto 模块的边界摸清楚,再动手写第一行加解密代码
第一次认真翻 crypto 模块的文档,是被一次代码审计打回来的。当时我用它给用户手机号做了加密存储,密钥写在配置文件里,createCipheriv选了aes-128-cbc,IV 图省事直接用了全零,自我感觉“加密了就行”。审计同学只回了一句话:这跟没加密的区别,只在于攻击者需要多花十分钟。
那次之后我才明白,加密和解密这件事,难点从来不在 API 怎么调,而在于你得先知道自己在解决哪一类问题:是要保密(别人看不懂)、要完整性(别人改不了)、要身份确认(确认是谁发的),还是要口令校验(确认登录的人知道密码)。crypto 模块把这些能力都塞在一个命名空间里,长得还都挺像,一不小心就会拿锤子去拧螺丝。
这篇内容适合两类人看:一类是刚接触 Node.js crypto 模块、想搞明白createHash、createCipheriv、sign这些 API 到底该在什么场合用的开发者;另一类是已经用了几年,但每次写加解密都得回去翻自己半年前的代码、复制粘贴一遍再改改的工程师。我会把踩过的坑、参数背后的算术、跨语言互通时的坑点都摊开讲,代码尽量给到可以直接抄走运行的完整版本。
1.1 三层能力:摘要、对称、非对称,先分清再动手
crypto 模块提供的功能看起来零散,其实可以归到三个层次上,每一个层次解决的问题完全不同。
摘要类是单向的,代表 API 是createHash和createHmac。它的输出无法反推回输入,用途是校验数据有没有被改过、两个大文件是不是同一份、口令是不是对得上。很多人第一次接触“MD5 加密”这个概念就是从这里开始的,但严格来说它不叫加密,叫哈希,因为没有解密这一步——本质上不存在“MD5 解密”,网上那些所谓的解谜站点干的其实是暴力枚举彩虹表比对,输入只要稍微长一点就无能为力。
对称加密类是双向的,代表 API 是createCipheriv和createDecipheriv。加密和解密用同一把密钥,速度快,适合加密大块数据,比如用户的身份证号、一段聊天记录、一个上传的文件。它的问题是密钥分发:你要把密钥安全地送到解密方手里。
非对称类的代表是generateKeyPairSync、publicEncrypt、privateDecrypt、sign、verify。公钥加密私钥解密,或者私钥签名公钥验签,解决了密钥分发问题,但性能比对称加密差好几个数量级,而且能加密的明文长度有硬上限。
实际工程里几乎不会只用其中一层。最常见的组合是:用非对称加密把一把临时生成的对称密钥传给对方,然后双方用这把对称密钥加密真正的业务数据。这个模式叫信封加密,后面第 5 节会展开讲怎么落地。
1.2 crypto 是 OpenSSL 的一层绑定,不是它自己实现的算法
这一点很关键,因为它决定了你遇到的大部分报错该往哪个方向查。
Node.js 的 crypto 模块本身并不包含 AES、RSA、SHA 这些算法的实现代码,它是通过内部绑定调用 OpenSSL 这个 C 语言密码学库。你在 JavaScript 里写的crypto.createCipheriv('aes-256-gcm', ...),最终会落到 OpenSSL 的EVP_EncryptInit_ex之类的函数上。
这个事实带来三个直接后果。
第一,算法名字符串的合法性由 OpenSSL 决定。你写'aes-256-gcm'能跑,写'aes_256_gcm'就报Digest method not supported或者Unknown cipher。想查当前环境支持哪些算法,直接跑crypto.getCiphers()和crypto.getHashes()打印出来看,比翻文档快。
第二,Node 版本升级会带来 OpenSSL 版本跃迁。Node 17 之后底层换到了 OpenSSL 3,一批老算法被挪进了 legacy provider,默认不可用。这就是为什么很多老项目升级 Node 之后会突然冒出error:0308010C:digital envelope routines::unsupported——代码一行没改,底层把des-ecb、md4这类算法默认关掉了。要么改算法,要么在启动参数里挂上 legacy provider,前者是正解。
第三,某些行为在不同平台上有细微差异。同一段代码在 Linux 上跑得好好的,换到某些系统上算法的默认参数可能不一样。如果你要做跨平台部署,参数尽量显式传全,别依赖默认值。
1.3 搜报错之前,先确认自己在哪个生态里
crypto这个名字在各语言生态里被反复使用,导致网上搜“crypto 报错”出来的结果经常驴唇不对马嘴。我自己就干过一次蠢事:排查了半天 Node 的加密逻辑,最后发现报错来自另一个服务,是 Java 侧的java.lang.NoClassDefFoundError: org/apache/hadoop/crypto——那是 Hadoop 依赖打包时的类缺失问题,跟 Node 一点关系都没有。
类似的还有:Python 里pycryptodome提供了Crypto包,导入时的大小写和 Node 完全不同;有些移动端和嵌入式场景会把加密相关的组件也命名成 crypto;浏览器端有window.crypto(Web Crypto API),它的 API 风格是 Promise 加subtle命名空间,跟 Node 的 crypto 模块长得完全不一样,虽然 Node 后来也把globalThis.crypto补上了,但两套 API 混用会让人非常困惑。
所以排查加解密问题的第一步永远是:确认报错来自哪个运行时、哪个库、哪个版本。把这个确认清楚,能省掉一半的无效搜索时间。
1.4 有一条线不能越:只处理你有权限处理的数据
加解密技术本身是中性的,但用途有边界。我给自己定的规矩很简单:只对自己拥有或已获授权处理的数据做加解密,包括自己系统的数据、自己生成的文件、自己项目的配置。不参与绕过他人技术保护措施的行为,不帮助他人解开本不该他持有的加密内容。
这条线不是道德说教,而是实打实的风险控制。绕过别人的保护机制往往同时触碰多个法律条款,为了一点技术好奇心去趟这个浑水,性价比极低。后面讲的所有内容,都建立在这个前提上。
2. AES 对称加密:为什么我把默认答案换成了 AES-256-GCM
如果今天你在项目里只需要记一条关于对称加密的结论,那就是:新写的代码里,默认用aes-256-gcm。这不是因为它新,而是因为它在设计上同时给到了保密性和完整性,而老一代的aes-128-cbc只能给保密性——攻击者可以在不知道密钥的情况下篡改你的密文,解密后你还浑然不觉。
2.1 从 ECB 踩到 CBC,再换到 GCM:一段分组模式的进化史
先说说为什么不能用 ECB。ECB 模式的做法是把明文按 16 字节切成块,每块独立加密。问题在于,相同的明文块会产生完全相同的密文块,于是整段密文的统计特征被完整保留下来。经典的演示是用 ECB 加密一张纯色背景的图片,加密后图片轮廓依然清晰可辨。用在结构化数据上同理:如果你的字段里有很多重复值(比如性别、状态码),攻击者光看密文分布就能猜出不少信息。
CBC 模式解决了这个问题:每一块明文先和前一块的密文做异或再加密,相同的明文块在不同位置会产生不同密文。为此它需要一个初始向量 IV 来启动这个链条。CBC 的坑在于它本身不提供完整性保护,而且如果 IV 在多次加密中重复使用,攻击者可以通过对比密文差异推导出明文关系(这就是 BEAST 那类攻击的基本思路)。
GCM 是 AEAD(带关联数据的认证加密)模式,它一次输出两个东西:密文和一个 16 字节的认证标签 authTag。解密时除了密钥和 IV 要对,authTag 也必须对得上,任何一个字节的篡改都会导致解密直接抛错。这意味着你不需要再额外算一个 HMAC 来保证完整性。
这里有个必须记住的坑:GCM 的 IV 绝对不能重复使用。同一个密钥下,如果两段不同的明文用了相同的 IV,攻击者能通过认证标签的数学关系反推出密钥相关材料,这是灾难级的。GCM 的 IV 规范推荐长度是 12 字节(96 位),用crypto.randomBytes(12)随机生成,碰撞概率在合理的调用量下可以忽略。
各模式的对比大致是这样:
| 模式 | 保密性 | 完整性 | IV 要求 | 建议 |
|---|---|---|---|---|
| ECB | 弱(模式泄露) | 无 | 无 | 新代码不要用 |
| CBC | 是 | 无 | 16 字节随机,不可复用 | 兼容老系统时才用 |
| CTR | 是 | 无 | 计数器不可复用 | 需要流式且自带完整性校验时慎用 |
| GCM | 是 | 是(authTag) | 12 字节随机,绝不可复用 | 默认选择 |
2.2 Key、IV、AuthTag、AAD:四个角色各自的职责
很多人第一次写 GCM 会觉得参数太多记不住,其实把每个东西的职责想清楚就顺了。
Key是从口令派生出或由密钥管理系统下发的秘密。AES-256 要求正好 32 字节,不是“32 个字符”。如果你直接把一个字符串当 key 传进去,Node 会按 UTF-8 编码成字节,只要字符串的字节长度不对就会报Invalid key length。中文口令尤其容易踩这个坑:一个汉字 UTF-8 占 3 字节,10 个汉字就是 30 字节,离 32 差两个字节,而你从字符数上根本看不出来。
IV初始向量,是每次加密都要重新随机生成的值,它不需要保密,可以直接和密文拼在一起传输。它的作用是让同一把密钥在多次加密中产生不同的密文。GCM 用 12 字节。
AuthTag认证标签,加密完成后通过cipher.getAuthTag()取出,默认 16 字节。它必须和密文一起保存或传输,解密前通过decipher.setAuthTag(tag)塞回去。忘记取 authTag 或者传输时丢了它,是新手最常见的错误,症状是解密时抛Unsupported state or unable to authenticate data。
AAD(Additional Authenticated Data)附加认证数据,可选参数。它的特点是:参与完整性校验,但不被加密。典型用途是放协议版本号、租户 ID、数据主键这类需要公开但必须防篡改的元信息。如果攻击者把版本号从 v1 改成 v2 来诱导你走老逻辑,AAD 会让他没法得逞。
2.3 一份可以直接抄走的 AES-256-GCM 实现
下面这段是我现在项目里用的版本,把版本号放进 AAD,把 IV、Tag、密文按固定顺序打包成一个 base64 字符串,好处是存储层只需要一个字段就能装下全部信息。
const crypto = require('crypto'); // 从口令派生 32 字节密钥。salt 必须是每个口令独立随机生成的,不能固定 function deriveKey(password, salt, keylen = 32) { return crypto.scryptSync(password, Buffer.from(salt, 'hex'), keylen, { N: 1 << 15, // 32768,CPU/内存开销参数 r: 8, // 块大小 p: 1, // 并行度 maxmem: 64 * 1024 * 1024, }); } // 打包格式:[版本 2B][IV 12B][Tag 16B][密文 ...] function seal(plaintext, key) { const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); const aad = Buffer.from('v1', 'utf8'); cipher.setAAD(aad); const body = Buffer.concat([ cipher.update(plaintext, 'utf8'), cipher.final(), ]); const tag = cipher.getAuthTag(); return Buffer.concat([aad, iv, tag, body]).toString('base64'); } function open(packed, key) { const buf = Buffer.from(packed, 'base64'); if (buf.length < 30) throw new Error('密文长度异常'); const aad = buf.subarray(0, 2); const iv = buf.subarray(2, 14); const tag = buf.subarray(14, 30); const body = buf.subarray(30); const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); decipher.setAAD(aad); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8'); } module.exports = { deriveKey, seal, open };用法和验证:
const { deriveKey, seal, open } = require('./aesgcm'); const salt = crypto.randomBytes(16); const key = deriveKey('my-strong-passphrase', salt.toString('hex')); const token = seal('13800138000', key); console.log(token); console.log(open(token, key)); // 13800138000 // 篡改一个字符,解密必然失败 const tampered = token.slice(0, -2) + 'AA'; try { open(tampered, key); } catch (e) { console.log('认证失败:', e.message); }这段代码里有几个设计选择值得说明。为什么版本号放在最前面而不是放到密文尾部?因为解析时需要先读版本号决定用哪套解包逻辑,放前面可以边读边判断,不用把整个 Buffer 切一遍。为什么 AAD 用固定的'v1'而不是拼接更多字段?因为 AAD 越长,每次校验的开销越大,而它保护的信息本身是公开的,长度控制在够用就好。
2.4 报错信息对照表:把症状和根因对上号
加解密的报错信息大多比较抽象,我把这些年遇到过的整理成一张表,出问题时可以按症状反查。
| 报错关键词 | 真实原因 | 排查动作 |
|---|---|---|
Invalid key length | 密钥字节数与算法不匹配,aes-256 需 32 字节 | 打印Buffer.from(key).length,别按字符数判断 |
Invalid IV length | IV 长度与加密时不一致,GCM 通常应为 12 字节 | 确认解密时取出的 IV 偏移正确 |
Unsupported state or unable to authenticate data | authTag 不匹配、密钥错误、AAD 不一致、密文被截断 | 逐项比对 tag 是否为完整 16 字节、AAD 是否一致 |
bad decrypt | CBC 模式下密钥错误或填充被破坏 | 确认密钥与填充方式,检查密文是否被截断 |
Cannot read properties of undefined (reading 'setAuthTag') | 解密端没有从密文里解析出 tag | 检查打包格式与切片偏移 |
digital envelope routines::unsupported | 使用了被 OpenSSL 3 移除的旧算法 | 换用aes-256-gcm,或升级相关依赖 |
ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE | 该用私钥的地方传了公钥,或格式不匹配 | 检查密钥类型与 PEM 头 |
一个实测的小技巧:遇到认证失败时,先把密钥、IV、tag 的十六进制值都打印出来,跟加密端的日志逐一对比。绝大多数情况下根本不是算法问题,而是某一处切片偏移错了 2 个字节,或者 AAD 在加密端是'v1'、解密端写成了'V1'。
3. 哈希这条线:MD5 只能当校验码,口令必须用慢哈希
哈希函数在 crypto 模块里存在感很强,因为它用起来最无脑:crypto.createHash('md5').update(data).digest('hex'),一行完事。也正因为无脑,它被滥用的程度最高。我在线上代码里见过用 MD5 存用户密码、用 MD5 做接口签名、用 MD5 生成订单号的,这三件事各有各的问题。
3.1 hash 和 hmac 不是一回事,用错了等于没校验
createHash是纯哈希,任何人拿到数据和算法都能算出相同结果。它适合做两件事:校验数据完整性(比如下载文件后比对哈希值)、给数据生成一个短的指纹(比如给 URL 参数生成缓存键)。
createHmac是带密钥的哈希,也叫消息认证码。它在计算过程中混入了一把密钥,没有密钥的人算不出正确结果。它适合做接口签名、Webhook 验签、内部服务之间的请求认证。
两者的区别在安全上是决定性的。如果你用纯哈希做接口签名,攻击者拿到你的签名算法后,可以直接构造任意请求并算出合法签名,签名机制形同虚设。这类事故我在实际项目里遇到过不止一次,代码大概长这样:
// 反面教材:用纯哈希当签名 const sign = crypto.createHash('md5').update(secret + payload + secret).digest('hex');看起来像是“把密钥掺进去了”,但 MD5 的迭代结构决定了这种拼接方式在特定长度下存在长度扩展攻击的风险,而且 MD5 本身早就不抗碰撞了。正确的写法是:
const crypto = require('crypto'); function hmacSign(payload, key) { return crypto.createHmac('sha256', key).update(payload, 'utf8').digest('hex'); } function hmacVerify(payload, key, signature) { const expected = hmacSign(payload, key); const a = Buffer.from(expected, 'utf8'); const b = Buffer.from(signature, 'utf8'); if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b); }注意最后那个timingSafeEqual。普通的===字符串比较会在第一个不同字符处提前返回,攻击者可以通过测量响应时间的微小差异,一个字节一个字节地把签名猜出来。这个攻击在局域网环境里是可行的,timingSafeEqual就是为了消除这种时间侧信道而存在的。用它之前必须先判断长度相等,否则函数本身会抛异常——这是它的一个使用前提,文档里写了但很容易被忽略。
3.2 存口令为什么必须用 scrypt 或 pbkdf2,参数怎么定
把 MD5 换成 SHA-256 来存密码,是不是就安全了?不是。原因在于,MD5 和 SHA-256 都是为“快”而设计的,一块普通的显卡每秒能算几十亿次 SHA-256。如果你的数据库泄露了,攻击者用一张常见口令表跑一遍,几小时内就能把大部分口令还原出来。
真正的解法是使用专门为存口令设计的慢哈希:scrypt和pbkdf2。它们的核心思想是通过大量计算和内存访问,让单次计算的开销变大。攻击者要跑 10 亿次组合,从几小时变成几十年,成本上就不划算了。
Node 的crypto.scryptSync有三个关键参数:
| 参数 | 含义 | 我的常用取值 | 影响 |
|---|---|---|---|
| N | CPU/内存成本,必须是 2 的幂 | 32768(即 1<<15) | 内存用量约 128 × N × r 字节 |
| r | 块大小 | 8 | 同时影响内存与计算量 |
| p | 并行度 | 1 | 主要影响 CPU 时间 |
| maxmem | 允许的最大内存 | 64MB | 默认 32MB,容易不够 |
这里有个很容易踩的坑:当 N 取 32768、r 取 8 时,理论内存需求是 128 × 32768 × 8 = 33.5MB,正好超过 Node 默认的 32MB 上限,会直接抛出memory limit exceeded。解决办法就是把maxmem显式调大,比如设成 64MB。我见过有人为了绕开这个限制把 N 调小到 16384,其实是把安全强度降下来了,正确做法是提高 maxmem。
如果要用 PBKDF2,推荐参数是 HMAC-SHA256 加至少 60 万次迭代。迭代次数不是越大越好,而是要结合你的服务器承受能力来定。我的经验测法是:在目标机器上写个循环,测出单次校验耗时,控制在 100 到 200 毫秒之间。低于 50 毫秒说明强度可能不够,高于 500 毫秒说明登录接口会成为吞吐瓶颈,尤其在并发登录时会有明显体感。
还有个必须注意的点:scryptSync是同步的,会阻塞事件循环。如果我前面说的单次 100 毫秒成立,那么在一个单线程的 Node 服务里,一次登录校验就会让其他所有请求排队 100 毫秒。生产环境的登录接口一定要用异步版本crypto.scrypt(回调或 Promise 包装),把计算放到 libuv 的线程池里,别用 Sync 版本。
存库时的结构建议是:scrypt$N$r$p$salt$hash,把参数也一起存下来。这样将来你想调整参数强度时,老数据仍然能按原来的参数验证通过,用户下次登录成功后再用新参数重新生成一遍,实现平滑升级。
3.3 大文件校验:流式哈希与内存占用的取舍
如果要对一个 2GB 的日志文件算哈希,用fs.readFileSync读进来再update,你的进程内存会瞬间飙到 2GB 以上,大概率被系统干掉。正确做法是流式处理:
const fs = require('fs'); const crypto = require('crypto'); function sha256File(path) { return new Promise((resolve, reject) => { const hash = crypto.createHash('sha256'); fs.createReadStream(path, { highWaterMark: 1024 * 1024 }) .on('data', (chunk) => hash.update(chunk)) .on('end', () => resolve(hash.digest('hex'))) .on('error', reject); }); }这里我把highWaterMark设成了 1MB,比默认的 64KB 大。原因是哈希计算是纯 CPU 操作,非常快,真正的瓶颈在磁盘 IO 的读写切换次数上。块太小会导致频繁的系统调用,块太大则每次分配的内存多、GC 压力上升。1MB 到 4MB 这个区间在实际测试里表现比较均衡,具体值可以看你机器的页大小和磁盘类型微调。
顺带说一个实际场景:给上传的文件做去重时,先算哈希再比对是最简单的办法,但如果两个文件只差一个字节,哈希完全不同,去重就失效了。这时候需要的是内容定义分块或者局部敏感哈希,那就超出 crypto 模块的范围了,得换专门的库。
4. 非对称加密与签名:RSA 有个绕不过去的长度天花板
对称加密好用,但密钥怎么送到对方手里是个死结。非对称加密就是为这个场景设计的:公钥可以随便公开,私钥自己留着,任何人用你的公钥加密的数据,只有你的私钥能解开。
4.1 RSA 加密的 190 字节上限,以及为什么不能硬塞
第一次用crypto.publicEncrypt加密一段用户信息,很多人会直接抛data too large for key size。这不是 bug,是 RSA 的数学结构决定的。
RSA 加密的本质是一次模幂运算,明文必须先转换成一个小于模数 N 的整数。2048 位密钥意味着模数是 2048 位,也就是 256 字节。但你不能把 256 字节全用上,因为填充方案也要占位置。用 OAEP 加 SHA-256 填充时,可用空间的计算方式是:
最大明文长度 = 模长字节数 - 2 × 哈希长度 - 2 = 256 - 2 × 32 - 2 = 190 字节190 字节,大概是 63 个汉字。这个数字记住很有用,它能直接告诉你“RSA 加密用户资料”这个思路行不通。
那实际怎么做?标准方案还是信封加密:随机生成一把 AES 密钥,用它加密业务数据,再用 RSA 公钥加密这把 AES 密钥。接收方先用自己的私钥解出 AES 密钥,再用它解密数据。整个流程里 RSA 只处理 32 字节的密钥,远远没到 190 字节的上限。
const crypto = require('crypto'); // 生成一对 2048 位 RSA 密钥,推荐 PKCS#8 和 SPKI 格式 const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); // 信封加密 const dek = crypto.randomBytes(32); const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', dek, iv); const body = Buffer.concat([cipher.update('需要保护的业务数据', 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); const wrappedKey = crypto.publicEncrypt( { key: publicKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, dek ); // 解密侧 const unwrapped = crypto.privateDecrypt( { key: privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, wrappedKey ); const decipher = crypto.createDecipheriv('aes-256-gcm', unwrapped, iv); decipher.setAuthTag(tag); console.log(Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8'));密钥长度怎么选?2024 年的建议是至少 2048 位,3072 位更稳妥。1024 位已经明确不安全,不要再用。4096 位不是不行,但签名和验签的耗时会明显上升,在高频调用的场景里不划算。
填充方式上,加密用 OAEP,签名用 PSS。老的RSA_PKCS1_PADDING存在一些已知的攻击面,新代码不要用。Node 里对应的常量是RSA_PKCS1_OAEP_PADDING和RSA_PKCS1_PSS_PADDING。
4.2 别再说“用私钥加密”:签名用的是一套独立的 API
“私钥加密、公钥解密”这个说法在早期教材里很常见,但它在密码学上是不严谨的,而且会导致错误的技术选型。原因有两点:一是私钥加密可以理解为对任意数据都成立,理论上存在伪造风险;二是很多实现里私钥加密的填充方式和公钥加密不同,跨语言调用时对不上。
正确的做法是用专门的签名接口:
const data = Buffer.from('order=20240101&amount=100', 'utf8'); const signature = crypto.sign('sha256', data, { key: privateKey, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST, }); const ok = crypto.verify('sha256', data, { key: publicKey, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST, }, signature); console.log(ok); // true签名的语义很清晰:证明这份数据是由持有私钥的人发出的,并且中途没有被篡改。它和加密是两件独立的事,可以同时使用:先对数据签名证明来源,再对称加密保护内容。
如果项目里不需要考虑和老的 RSA 系统兼容,我更推荐直接用 Ed25519:
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519'); const sig = crypto.sign(null, Buffer.from('payload'), privateKey); const valid = crypto.verify(null, Buffer.from('payload'), publicKey, sig);Ed25519 的密钥更短、签名更快、实现上更难出错,sign的第一个参数传null是因为算法本身已经确定了哈希方式。现在新做系统,只要上下游都支持,我基本都选它。
4.3 密钥格式:PEM、DER、PKCS#1、PKCS#8 到底怎么换算
跨系统对接时,密钥格式不一致是仅次于参数不一致的第二大坑。常见的有这么几种:
| 格式标识 | PEM 头 | 内容 |
|---|---|---|
| PKCS#1 私钥 | BEGIN RSA PRIVATE KEY | 只包含 RSA 参数,不支持其他算法 |
| PKCS#8 私钥 | BEGIN PRIVATE KEY | 通用的私钥容器,可装 RSA、EC、Ed25519 |
| PKCS#1 公钥 | BEGIN RSA PUBLIC KEY | 老格式,不少 Java 工具默认导出这个 |
| SPKI 公钥 | BEGIN PUBLIC KEY | 通用公钥格式,Node 的spki就是它 |
Node 的generateKeyPairSync里,私钥编码类型选pkcs8、公钥选spki,这是兼容性最好的组合。但如果对方给你的是 PKCS#1 的私钥,你直接喂给crypto.createPrivateKey,可能会报error:0909006C:PEM routines:get_name:no start line之类的错。转换命令很简单:
# PKCS#1 私钥转 PKCS#8 openssl pkcs8 -topk8 -nocrypt -in rsa_pkcs1.pem -out rsa_pkcs8.pem # PKCS#8 私钥转 PKCS#1(加 -traditional) openssl rsa -in rsa_pkcs8.pem -traditional -out rsa_pkcs1.pem # 从证书里导出 SPKI 公钥 openssl x509 -in cert.pem -pubkey -noout > pub_spki.pem如果密钥是 JWK 格式(很多身份认证服务的标准格式),Node 可以直接导入,不需要转换:
const keyObject = crypto.createPrivateKey({ key: jwkObject, format: 'jwk' });这个能力从 Node 15 开始提供,之前只能自己手写 Base64URL 解码来拼 DER 结构,非常麻烦。如果你的项目还在用老版本,升级到这个版本以上能省不少事。
5. 从能跑到敢上线:加解密落地时真正会出问题的几件事
代码写通了只是第一步。我经历过几次事故,没有一次是算法用错,全是工程细节出了问题。
5.1 密钥管理:配置文件里放密钥是最贵的一课
前面所有例子里我都在用变量传密钥,这是刻意的。把密钥写进代码仓库是最常见也最危险的错误。代码仓库的访问权限通常比生产环境宽松得多,一次误操作把仓库设成公开,密钥就彻底暴露了。就算仓库是私有的,历史提交记录里也永远留着那串密钥,删掉文件是没用的,必须做提交历史重写。
我现在遵循的一套做法是这样的。
第一层,密钥不落代码。开发环境从环境变量读,生产环境从密钥管理服务读。启动时做一次初始化,把密钥加载到内存里,进程别的地方只从内存拿。
第二层,做密钥轮换能力。这就是我在第 2 节那段代码里把版本号放进 AAD 的原因。将来要换密钥,只需要解密时先读版本号,从密钥表里挑对应版本的那一把,新数据用新版加密,老数据在下次写入时自然迁移。没有版本号的话,换密钥就意味着一次全量数据迁移,还得停机。
第三层,把加解密封装在一个模块里,业务代码永远碰不到createCipheriv。这个约定的价值在于,将来要换算法、换密钥来源、加监控埋点,都只需要改一个文件。我见过最糟糕的情况是加密逻辑散落在十几个文件里,有的用 CBC 有的用 GCM,有的密钥从环境变量读有的硬编码,最后没人敢动。
如果你用的是云上的密钥管理服务,还能拿到一个额外好处:加解密操作本身会留下审计日志,谁在什么时候调用了哪把密钥都有记录。对于合规要求比较严的行业,这个日志的价值不比加密本身低。
5.2 跨语言互通:加密能跑通不代表对方能解开
这是最让人抓狂的一类问题:Node 这边加密成功,Java 那边解密报错,两边的开发各执一词,都觉得自己没问题。
根据我的排查经验,跨语言对不上的原因八成集中在这几个点上。
第一个是 IV 和 Tag 在报文里的位置和顺序。Node 习惯把 tag 放在密文后面(很多示例代码就是这么写的),Java 的Cipher默认把 tag 附在密文末尾,Python 的 pycryptodome 则需要你单独取出。三边一旦约定不一致,就会解密失败。统一约定一个明确的打包格式,写成文档发给对方,比来回猜测高效得多。
第二个是 RSA OAEP 的 MGF1 哈希。Node 里oaepHash: 'sha256'会把 OAEP 摘要和 MGF1 摘要都设成 SHA-256。而 Java 里写RSA/ECB/OAEPWithSHA-256AndMGF1Padding,某些提供者的 MGF1 默认仍然用 SHA-1,结果就是一边加密另一边解不开。Java 侧需要用OAEPParameterSpec显式把 MGF1 也指定为 SHA-256 才能对齐。这个坑我踩过一次,排查了半天,最后是靠对比两边的报错栈才定位到。
第三个是 CBC 的填充命名。Node 的aes-256-cbc默认使用 PKCS#7 填充,Java 里叫PKCS5Padding,Python 里叫pad。名字不同,实际行为是一样的,但文档表述不一致经常误导人以为需要额外处理。
第四个是 HMAC 的输入形式。Node 的update接受 Buffer,Java 的Mac接受byte[]。如果上游把待签名的内容做了一次 hex 编码再传过去,而下游是对原始字节做签名,结果自然对不上。签名前先约定清楚:签的是原始字节还是编码后的字符串。
把这几条做成一张对照表,团队里谁接入新语言都能直接查:
| 能力 | Node 写法 | 其他语言对应 | 高频不一致点 |
|---|---|---|---|
| AES-256-GCM | aes-256-gcm | JavaAES/GCM/NoPadding、PythonAES.MODE_GCM | tag 拼接位置、IV 长度 |
| AES-256-CBC | aes-256-cbc | JavaAES/CBC/PKCS5Padding | 填充名称、IV 长度 16 字节 |
| RSA-OAEP | oaepHash: 'sha256' | JavaOAEPParameterSpec | MGF1 默认哈希不同 |
| RSA 签名 | RSA_PKCS1_PSS_PADDING | JavaRSASSA-PSS | saltLength 取值 |
| PBKDF2 | pbkdf2Sync | Pythonhashlib.pbkdf2_hmac | 迭代次数、salt 编码 |
| HMAC | createHmac | JavaMac.getInstance | 输入是原始字节还是编码字符串 |
5.3 大文件与并发:什么时候该换成流式接口
处理几个 G 的文件时,createCipheriv加一次性update会把整个文件读进内存,这个前面已经说过了,要用流:
const fs = require('fs'); const crypto = require('crypto'); const { pipeline } = require('stream/promises'); async function encryptFile(src, dest, key) { const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); await pipeline( fs.createReadStream(src), cipher, fs.createWriteStream(dest) ); return { iv: iv.toString('hex'), tag: cipher.getAuthTag().toString('hex') }; }但这里有个 GCM 特有的问题必须提醒:流式解密时,authTag 只有在整条流读完才能验证。也就是说,如果你边解密边把明文写出去,那么在验证失败之前,可能已经写了几百 MB 的不可信数据。对于安全要求高的场景,稳妥做法是把密文完整解密到临时文件并验证通过后再改名,或者改用分块加密:每块独立生成 IV 和 tag,逐块验证。代价是报文体积会略大,但流式场景下更安全。
另一个常被忽略的是并发下的性能表现。加密本身是 CPU 密集型操作,Node 的主线程只有一条,大量同步加密会拖垮整个服务的响应时间。我的做法是把批量加解密任务丢到worker_threads里跑,主线程只负责调度和 IO。至于createCipheriv本身没有异步版本,这是因为它单次调用的开销相对可控,真正的耗时来自数据量本身,用工作线程分担才是对症下药。
5.4 上线前我会过一遍的自检清单
最后把自己每次上线前会确认的几条列出来,都是吃过亏之后加上的。
- 密钥是不是从配置中心或环境变量来的,仓库里搜不到任何硬编码的密钥字符串。
- 每个加密操作生成的 IV 是不是
crypto.randomBytes,有没有哪个分支偷懒用了固定值。 - authTag 是不是被完整地保存或传输了,长度是不是 16 字节。
- 解密失败时的异常是不是被正确捕获了,有没有直接把异常堆栈返回给前端。
- 存口令用的哈希参数是不是记录在数据里了,能不能支持将来调参。
- 有没有对解密出来的数据做类型和长度校验,防止解密成功但内容异常的情况。
- 跨语言接口有没有一份双方确认过的格式文档,包含字段顺序、编码方式、填充方案。
我个人的体会是,加解密这件事的难点分布得很不平均:真正花在算法原理上的时间不到两成,剩下八成都在密钥怎么管、格式怎么约定、异常怎么处理、性能怎么扛这些看起来不那么"密码学"的地方。把这几件事想明白了,crypto 模块其实就那么几个 API,剩下的都是工程功夫。