1.为什么Node.js开发者需要认真理解crypto模块
很多后端项目在早期会把加密逻辑交给第三方库处理,例如bcrypt、jsonwebtoken、crypto-js等。这些库本身没有问题,但Node.js从设计之初就把crypto作为核心模块内置进来,底层由OpenSSL支撑,不需要额外安装依赖,也不会因为包管理器的供应链问题引入额外风险。对于密码哈希、消息认证、对称加密、随机令牌生成这类基础操作,直接使用crypto往往更可控。
我在项目里遇到过一种情况:团队为了生成一个随机订单号,用了Math.random()拼接时间戳。结果在压测环境下出现了重复值。后来换成crypto.randomBytes()之后问题消失。这个经历说明,安全相关的随机性不能依赖普通伪随机函数,必须使用密码学安全的随机源。
crypto模块能做的事情可以归为几类:
-
哈希摘要:把任意长度数据映射为固定长度值,常用于密码存储、文件校验。
-
HMAC:带密钥的哈希,用于验证消息完整性和来源真实性。
-
对称加密:同一把密钥完成加密和解密,例如AES-256-CBC、AES-256-GCM。
-
非对称加密与密钥对:生成公钥/私钥,用于签名、验签、密钥交换。
-
密钥派生:从用户密码派生出高强度密钥,例如pbkdf2、scrypt。
-
安全随机数:生成token、salt、IV、UUID等。
下面按实际使用频率和工程重要性展开。
2.导入方式与模块结构
crypto是内置模块,Common JS环境下直接require:
const crypto = require('crypto');
console.log(Object.keys(crypto).slice(0, 8));
ESM环境下可以这样写:
import crypto from 'node:crypto';
建议在import或require时加上node:前缀,这样能明确告诉阅读者这是Node.js内置模块,而不是npm包。Node.js14以后都支持这种写法,现在框架中越来越常见。
模块内部方法很多,常用方法可以按用途分类:
| 方法 | 用途 |
|---|---|
| createHash() | 创建哈希对象 |
| update() | 向哈希、HMAC或加密对象追加数据 |
| digest() | 输出最终哈希结果 |
| createHmac() | 创建带密钥的HMAC对象 |
| createCipheriv() | 创建对称加密对象 |
| createDecipheriv() | 创建对称解密对象 |
| randomBytes() | 生成密码学安全随机字节 |
| randomUUID() | 生成UUIDv4 |
| pbkdf2()/pbkdf2Sync() | 从密码派生密钥 |
| scrypt()/scryptSync() | 使用scrypt算法派生密钥 |
| generateKeyPair() | 生成公钥/私钥对 |
| createSign()/createVerify() | 数字签名与验签 |
| timingSafeEqual() | 恒定时间比较,防时序攻击 |
3.哈希:createHash与digest
哈希是单向函数,输入相同则输出相同,但无法从输出反推输入。常见算法有MD5、SHA-1、SHA-256、SHA-512。MD5和SHA-1已经不适合安全场景,SHA-256是目前通用选择。
示例:
const crypto = require('node:crypto');
const password = 'hello@321';
const hash = crypto
.createHash('sha256')
.update(password)
.digest('hex');
console.log(hash);
// a324b3c988d36893cca0f2d4dc79d38c7e47b8b7be945b9e3bebe7e3a60b9bb6
这里有几个细节值得注意。
第一,update()可以多次调用,适合流式处理大文件:
const hash = crypto.createHash('sha256');
hash.update('part1');
hash.update('part2');
console.log(hash.digest('hex'));
第二,digest()一旦调用,哈希对象就不能再继续update。如果需要重新计算,必须重新createHash。
第三,直接把用户密码做一次SHA-256存储并不安全。因为相同密码会产生相同哈希,攻击者可以用彩虹表反查。正确做法是使用带salt的密钥派生函数,例如pbkdf2或scrypt。这一点很多初学者容易忽略。
4.HMAC:验证消息完整性与来源
HMAC是Hash-basedMessageAuthenticationCode,它把哈希函数和一把共享密钥结合起来。只有持有同一密钥的双方才能生成相同HMAC。常用于webhook签名校验、API请求签名、JWT签名等场景。
const crypto = require('node:crypto');
const secretKey = 'my-secret-key';
const message = 'Hello,Node.js!';
const hmac = crypto
.createHmac('sha256', secretKey)
.update(message)
.digest('hex');
console.log(hmac);
// 455817d6da1a0340478c87882f1a886fb7543a8f47cf67eab4c95e2a7f6bee14
在项目里,我通常会把HMAC用在两个地方:一是接收第三方回调时验证签名,二是内部服务之间传递关键参数时做防篡改标记。secretKey不能硬编码在代码里,应该通过环境变量或密钥管理服务注入。
另外一个容易踩坑的点是:比较HMAC时不要用===。普通字符串比较会在第一个不同字符处提前返回,攻击者可以通过响应时间差异逐步猜测正确值。应该使用crypto.timingSafeEqual():
const a = Buffer.from(hmacA, 'hex');
const b = Buffer.from(hmacB, 'hex');
if (a.length === b.length && crypto.timingSafeEqual(a, b)) {
console.log('签名有效');
}
5.对称加密与解密:AES-256-CBC
对称加密使用同一把密钥加密和解密。AES是主流算法,CBC是其中一种分组模式。下面是一个完整示例:
const crypto = require('node:crypto');
const key = crypto.randomBytes(32); // AES-256 需要 32 字节密钥
const iv = crypto.randomBytes(16); // CBC 需要 16 字节初始向量
const message = 'Hello Node.js!';
// 加密
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(message, 'utf8', 'hex');
encrypted += cipher.final('hex');
// 解密
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
console.log('Encrypted:', encrypted);
console.log('Decrypted:', decrypted);
输出示例:
Encrypted: 982b8f0336ada7f37062c336e5328abd
Decrypted: Hello Node.js!
这段代码能跑通,但生产环境还需要考虑更多。
第一,IV的处理。CBC模式下,IV不需要保密,但必须每次加密都不同。通常把IV和密文拼接在一起存储或传输,解密时再拆开。如果IV固定,相同明文会产生相同密文,泄露模式信息。
第二,完整性校验。CBC本身不提供完整性保护。推荐使用AES-256-GCM,它同时提供加密和认证:
const key = crypto.randomBytes(32);
const iv = crypto.randomBytes(12); // GCM 常用 12 字节 IV
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update('敏感数据', 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag().toString('hex');
// 解密时验证 authTag
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
GCM模式下如果authTag不匹配,decipher.final()会抛出异常。这比CBC更安全,适合传输敏感数据。
第三,密钥管理。示例里用randomBytes生成密钥,实际项目中密钥应该来自配置中心、KMS或环境变量,不能写死在代码里。
6.密钥派生:pbkdf2与scrypt
用户密码不能直接当密钥用,因为密码熵低。pbkdf2和scrypt通过加盐和多次迭代,把弱密码变成强度更高的密钥。
pbkdf2异步版本:
const crypto = require('node:crypto');
const password = 'user-password';
const salt = crypto.randomBytes(16);
crypto.pbkdf2(password, salt, 100000, 64, 'sha512', (err, derivedKey) => {
if (err) throw err;
console.log(derivedKey.toString('hex'));
});
scrypt对内存要求更高,抵抗GPU破解能力更强:
const crypto = require('node:crypto');
const salt = crypto.randomBytes(16);
crypto.scrypt('user-password', salt, 64, (err, derivedKey) => {
if (err) throw err;
console.log(derivedKey.toString('hex'));
});
选择建议:新项目优先考虑scrypt或Argon2(Argon2不在Node.js内置crypto中,需要额外库)。如果必须用内置模块,scrypt比pbkdf2更抗硬件加速攻击。pbkdf2的优势是兼容性好,几乎所有环境都支持。
存储时需要把salt、迭代次数、算法标识一起保存,否则无法验证后续登录。常见格式类似:
scrypt$N=16384,r=8,p=1$base64salt$base64hash
7.随机数与UUID
crypto.randomBytes()生成密码学安全随机字节,适合做token、salt、IV、密钥。
const token = crypto.randomBytes(32).toString('hex');
console.log(token);
crypto.randomUUID()直接生成UUIDv4:
const id = crypto.randomUUID();
console.log(id);
// 例如:'f4a1c2e0-9b3a-4c7d-8e2f-1a2b3c4d5e6f'
不要用Math.random()生成安全相关值。Math.random()不是密码学安全,攻击者可以根据输出推测内部状态。
8.数字签名与密钥对
非对称加密场景下,crypto可以生成密钥对、签名和验签。
const crypto = require('node:crypto');
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
});
const sign = crypto.createSign('sha256');
sign.update('需要签名的数据');
sign.end();
const signature = sign.sign(privateKey, 'hex');
const verify = crypto.createVerify('sha256');
verify.update('需要签名的数据');
verify.end();
console.log(verify.verify(publicKey, signature, 'hex')); // true
RSA密钥长度建议至少2048位。新项目也可以考虑Ed25519,它更短、更快:
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
签名和验签在JWT、代码签名、API鉴权中很常见。使用crypto直接做签名,可以避免引入额外依赖,但需要自己处理密钥格式和序列化。
9.常见错误与类型安全建议
错误一:digest后继续update。
const hash = crypto.createHash('sha256');
hash.update('a');
hash.digest('hex');
hash.update('b'); // 抛出错误
错误二:解密时IV或key不匹配。会抛出ERR_OSSL_EVP_BAD_DECRYPT或类似错误。建议用try/catch包裹解密逻辑,并记录必要上下文,但不要记录密钥和明文。
错误三:密钥长度不符合算法要求。AES-256需要32字节密钥,AES-128需要16字节。使用错误长度会直接报错。
错误四:把编码搞混。update的输入编码和digest的输出编码要明确。处理二进制数据时建议统一用Buffer。
TypeScript项目里,crypto的类型定义由@types/node提供。建议把密钥、IV、salt都显式标注为Buffer,避免字符串和Buffer混用。
import crypto from 'node:crypto';
const key: Buffer = crypto.randomBytes(32);
const iv: Buffer = crypto.randomBytes(12);
10.现在框架中的惯用写法
Express或Fastify项目中,crypto通常封装成工具模块,例如utils/crypto.ts,对外暴露hashPassword、verifyPassword、encrypt、decrypt、signToken等函数。这样业务代码不需要直接操作底层API,也方便统一更换算法。
NestJS中可以通过自定义Provider注入CryptoService,把密钥管理交给ConfigService。Next.js的RouteHandler或ServerAction中同样可以直接使用node:crypto,但要注意运行环境必须是Node.jsruntime,而不是EdgeRuntime。EdgeRuntime对crypto的支持有限,部分API不可用。
11.本节课程知识要点
-
crypto是Node.js内置模块,基于OpenSSL,无需额外安装。
-
哈希用于完整性校验和密码存储,但密码必须加盐并使用pbkdf2或scrypt。
-
HMAC用于带密钥的消息认证,比较时使用timingSafeEqual。
-
对称加密优先选择AES-256-GCM,CBC需要额外完整性保护。
-
密钥、IV、salt使用randomBytes生成,不要用Math.random。
-
密钥管理比算法选择更重要,生产环境不要硬编码密钥。
-
解密、验签等操作要处理异常,避免泄露敏感信息。
12.练习与思考
-
使用crypto.createHash计算一个文件的SHA-256,并尝试用流式读取大文件。
-
实现一个基于HMAC的API请求签名方案,包含时间戳和随机数。
-
使用AES-256-GCM加密一段JSON数据,把IV、authTag和密文一起序列化存储,再实现解密函数。
-
比较pbkdf2和scrypt在相同硬件下的耗时,思考迭代次数和内存参数如何选择。
-
用timingSafeEqual改写一个已有的token比较逻辑,说明为什么普通比较不安全。
Node.jscrypto模块看起来API不多,但每个方法背后都涉及密码学取舍。哈希、HMAC、对称加密、密钥派生、随机数、签名,这些能力组合起来可以覆盖大多数后端安全需求。真正决定安全性的往往不是算法本身,而是密钥管理、IV使用方式、错误处理和比较逻辑这些工程细节。把crypto用对,比换一个更“高级”的库更有价值。