5. 签名、JWT 与生产实践
5.1 HMAC 与数字签名的区别
两者都能验证消息完整性和来源,但信任模型不同。
HMAC:双方共享同一个 Secret
服务 A:message + secret -> HMAC
服务 B:message + secret -> 重新计算并比较
服务 B 既能验证,也能生成新的合法 HMAC。适合单体服务、Webhook 或双方都可信的内部系统。
非对称数字签名:私钥签名,公钥验证
签发服务:message + private key -> signature
其他服务:message + signature + public key -> true / false
拿到公钥的服务只能验证,不能伪造签名。它适合多个服务需要验证、但不应该都拥有签发权的架构。
签名和 HMAC 都不会隐藏消息内容。如果还需要保密,必须使用加密或安全传输协议。
5.2 sign() 与 verify() 的接口形状
下面只展示 Node.js 接口如何对应“私钥签名、公钥验证”的原理。密钥生成、格式、轮换和证书体系是另外的完整主题。
import {
generateKeyPairSync,
sign,
verify,
} from "node:crypto";
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
const message = Buffer.from("order=123&amount=99.00", "utf8");
const signature = sign(null, message, privateKey);
console.log(verify(null, message, publicKey, signature)); // true
console.log(
verify(
null,
Buffer.from("order=123&amount=9999.00", "utf8"),
publicKey,
signature
)
); // false
Ed25519 的 Node.js 接口在这里使用 null 作为摘要算法参数,因为该签名算法自身定义了所需的内部处理。其他算法的参数规则不同,不要看到 null 就推广到所有签名算法。
实际项目还要明确:
- 签名的究竟是哪一串规范化字节。
- 公钥如何可信地分发。
- 私钥如何安全保存。
- Key ID 与轮换策略。
- 是否要包含时间戳、随机数来防止重放。
5.3 JWT HS256 如何建立在 HMAC 上
常见的签名 JWT 有三段:
Base64URL(Header).Base64URL(Payload).Base64URL(Signature)
HS256 的核心关系是:
signingInput = encodedHeader + "." + encodedPayload
signature = HMAC-SHA256(signingInput, secret)
因此 JWT 的 Header 和 Payload 只是编码,拿到 Token 的人都可以读取。HMAC 保护的是它们没有被偷偷修改,并证明 Token 来自 Secret 持有者。
下面的代码只演示“HS256 签名部分”的底层关系:
import { createHmac, timingSafeEqual } from "node:crypto";
function hs256Signature(signingInput: string, secret: string): Buffer {
return createHmac("sha256", secret)
.update(signingInput, "ascii")
.digest();
}
function verifyHs256Signature(
signingInput: string,
encodedSignature: string,
secret: string
): boolean {
const expected = hs256Signature(signingInput, secret);
const received = Buffer.from(encodedSignature, "base64url");
return (
expected.length === received.length &&
timingSafeEqual(expected, received)
);
}
这还不是完整、安全的 JWT 验证器。真正验证 JWT 还必须处理:
- 严格解析三段格式。
- 限制允许的算法,防止算法混淆。
- 检查
exp、nbf。 - 检查
iss、aud。 - 验证 Payload 的业务字段与类型。
- 处理 Key ID 和密钥轮换。
因此业务代码应使用成熟 JWT 库,而不是复制上面的教学代码自行实现协议。
当前项目已有完整教程:
5.4 jsonwebtoken 中最重要的验证动作
日常业务应调用 verify(),不能用 decode() 代替:
import jwt from "jsonwebtoken";
const payload = jwt.verify(token, accessTokenSecret, {
algorithms: ["HS256"],
issuer: "nloop-api",
audience: "nloop-client",
});
这里同时验证签名、允许的算法以及配置的标准声明。返回值仍来自外部输入,之后还要检查 sub、role、tokenType 等业务字段是否存在且类型正确。
jwt.decode(token);
只负责读取内容,任何人都可以构造一份可被 decode() 解析的 Payload。它不能用于登录状态和权限判断。
5.5 crypto API 速查
| API | 输入 | 输出 | 高频用途 | 主要注意点 |
|---|---|---|---|---|
randomBytes(size) |
字节数 | 随机 Buffer |
Token、Key、Salt、IV | 不要用 Math.random() 替代 |
randomInt(min, max) |
范围 | 随机整数 | 验证码、抽样 | 最大值不包含;验证码仍需限流 |
randomUUID() |
无 | UUID 字符串 | 请求 ID、资源 ID | 唯一标识不等于完整认证设计 |
createHash(algorithm) |
数据流 | 摘要 | 文件指纹、随机 Token 摘要 | SHA-256 不直接保存密码 |
createHmac(algorithm, key) |
消息、Secret | MAC | Webhook、HS256 原理 | 消息规范与 Secret 都要正确 |
scrypt() |
密码、盐、成本 | 派生字节 | 密码哈希、密码派生密钥 | 优先异步;需限流和参数管理 |
timingSafeEqual() |
等长 Buffer | boolean |
比较 MAC、哈希 | 调用前检查长度 |
createCipheriv() |
Key、IV、明文 | 密文与 Tag | 敏感字段加密 | GCM 每次使用新 IV |
createDecipheriv() |
Key、IV、Tag、密文 | 明文或错误 | 解密并认证 | 必须验证 Tag,统一处理失败 |
sign() / verify() |
私钥/公钥、消息 | 签名/布尔值 | 非对称签名 | 算法、密钥格式和轮换要配套 |
5.6 生产使用清单
先定义安全目标
- 是防止被读取,还是防止被修改?
- 原文未来是否必须恢复?
- 谁负责生成,谁负责验证?
- 数据库泄露、应用服务器泄露分别要防什么?
算法与参数
- 新设计优先使用成熟的高层协议和现代算法组合。
- 密码使用 Argon2id 或
scrypt,不要直接使用 SHA-256。 - 对称加密优先使用 AES-GCM 等认证加密。
- 不自己设计加密协议,不使用自创算法。
- 持久化结果包含格式版本和必要参数。
随机值
- 安全值来自
node:crypto,不是Math.random()。 - Salt 每个密码独立随机生成。
- AES-GCM 在同一个 Key 下绝不重复 IV。
- Token 除了随机性,还要有过期、撤销与限流机制。
密钥管理
- Key、HMAC Secret、私钥不进入源码、Git 和日志。
- 启动时检查密钥是否存在、解码后长度是否正确。
- 密钥和密文分离管理。
- 设计 Key ID、轮换、旧数据迁移和紧急吊销流程。
- 权限遵循最小化:只验证的服务不要拿签名私钥。
Node.js 运行时
- 服务端耗时密码派生使用异步
scrypt(),避免阻塞事件循环。 - 密码验证、加密任务仍然消耗有限 CPU 和线程池资源,需要并发限制。
- 解密失败、验签失败视为不可信输入,不泄露详细内部错误。
- 升级 Node.js 和依赖时检查安全公告,并执行回归测试。
业务边界
- 全程使用 HTTPS。
- 输入先验证类型和合理长度,避免大输入消耗过多资源。
- 敏感操作检查最新账号状态与权限。
- 日志不记录密码、Secret、私钥和完整 Token。
- 密码接口限流;短验证码限制尝试次数并及时失效。
5.7 最后的实用判断
遇到新需求时,不要先问“应该调用哪个 crypto API”,先完成下面这句话:
我要保护的是 ______,攻击者可能做到 ______,系统仍然需要 ______。
例如:
我要保护的是数据库中的第三方 API Token,
攻击者可能只拿到数据库备份,
系统仍然需要在调用第三方服务时恢复原文。
这时可以考虑由应用持有、与数据库分离管理的 AES-GCM Key。
再例如:
我要保护的是用户密码,
攻击者可能拿到用户表并离线猜测,
系统只需要判断登录输入是否匹配,不需要恢复原文。
这时应选择带随机盐和成本参数的密码哈希,而不是加密。
真正的密码学实践,不是记住最多的算法名字,而是先把问题分类正确,再把密钥、参数、数据格式和失败处理一起设计完整。