← Net 模块 TLS/SSL 模块 →

Node.js crypto模块教程:哈希、HMAC、AES加解密与密钥派生指南

著
原创 2026-10-12 Node.js 已有人查阅

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.练习与思考

  1. 使用crypto.createHash计算一个文件的SHA-256,并尝试用流式读取大文件。

  2. 实现一个基于HMAC的API请求签名方案,包含时间戳和随机数。

  3. 使用AES-256-GCM加密一段JSON数据,把IV、authTag和密文一起序列化存储,再实现解密函数。

  4. 比较pbkdf2和scrypt在相同硬件下的耗时,思考迭代次数和内存参数如何选择。

  5. 用timingSafeEqual改写一个已有的token比较逻辑,说明为什么普通比较不安全。

Node.jscrypto模块看起来API不多,但每个方法背后都涉及密码学取舍。哈希、HMAC、对称加密、密钥派生、随机数、签名,这些能力组合起来可以覆盖大多数后端安全需求。真正决定安全性的往往不是算法本身,而是密钥管理、IV使用方式、错误处理和比较逻辑这些工程细节。把crypto用对,比换一个更“高级”的库更有价值。

← Node.js Net模块教程:TCP 服务端与客户端编程全解 Node.js TLS模块教程:tls.createServer、tls.connect与证书验证指南 →
分享笔记 (共有 篇笔记)
验证码:
微信公众号