2. 随机数、普通哈希与 HMAC
这一章介绍日常最常见的三个基础能力:生成不可预测的值、计算数据指纹、验证带共享密钥的消息。
2.1 randomBytes():生成 Token、密钥和盐
生成找回密码 Token:
import { randomBytes } from "node:crypto";
const resetToken = randomBytes(32).toString("base64url");
console.log(resetToken);
这里的 32 表示 32 字节,也就是 256 bit 随机数据,不是最终字符串长度。Base64URL 编码后字符串通常更长。
典型用途:
const sessionToken = randomBytes(32).toString("base64url");
const aes256Key = randomBytes(32).toString("base64");
const passwordSalt = randomBytes(16);
“随机字节长度”和“编码后字符长度”是两个概念。安全强度看随机字节中的熵,不应通过字符串看起来有多长来判断。
数据库不要保存可直接使用的 Token
找回密码 Token、Refresh Token 这类高熵随机值可以把 SHA-256 摘要存进数据库:
import { createHash, randomBytes } from "node:crypto";
function hashToken(token: string): string {
return createHash("sha256").update(token, "utf8").digest("hex");
}
const rawToken = randomBytes(32).toString("base64url");
const tokenDigest = hashToken(rawToken);
console.log("发给用户:", rawToken);
console.log("存进数据库:", tokenDigest);
用户提交 Token 后,对输入执行相同 SHA-256,再查询摘要。如果数据库泄露,攻击者拿到的摘要不能直接当作 Token 使用。
这里能使用快速 SHA-256,是因为 Token 本身由 32 个随机字节产生,几乎无法枚举。人类密码的可猜范围小,不能照搬这个方案。
2.2 randomInt():生成无偏的范围整数
import { randomInt } from "node:crypto";
const dice = randomInt(1, 7); // 可能是 1~6,最大值 7 不包含
const sixDigitCode = randomInt(0, 1_000_000)
.toString()
.padStart(6, "0");
console.log({ dice, sixDigitCode });
不要使用“随机字节对范围取余”这种简化写法:
// 不推荐:某些范围会产生取模偏差
const value = randomBytes(1)[0]! % 10;
randomInt() 会避免这种分布偏差。
六位验证码本身只有一百万种可能,因此即使生成方式安全,也必须配合:
- 很短的有效期。
- 尝试次数限制。
- 按账号、IP 或设备限流。
- 验证成功后立即失效。
2.3 randomUUID():生成业务唯一标识
import { randomUUID } from "node:crypto";
const requestId = randomUUID();
const orderId = randomUUID();
console.log({ requestId, orderId });
UUID 适合作为请求 ID、资源 ID 或幂等键。它的重点是唯一标识,不应该直接把“UUID”理解为万能身份凭证。安全 Token 是否合适还取决于协议、有效期、存储和撤销方式。
2.4 createHash():计算数据指纹
哈希对象采用流式设计:
createHash() -> update() 一次或多次 -> digest()
import { createHash } from "node:crypto";
const digest = createHash("sha256")
.update("hello", "utf8")
.update(" world", "utf8")
.digest("hex");
console.log(digest);
digest() 表示完成计算并取出结果。调用后不能继续 update() 同一个哈希对象。
文件完整性检查
大文件不应一次性全部读进内存,可以利用 Stream:
import { createReadStream } from "node:fs";
import { createHash } from "node:crypto";
function sha256File(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = createHash("sha256");
const input = createReadStream(filePath);
input.on("data", (chunk: Buffer) => {
hash.update(chunk);
});
input.on("error", reject);
input.on("end", () => resolve(hash.digest("hex")));
});
}
async function main(): Promise<void> {
console.log(await sha256File("package.json"));
}
main().catch(console.error);
哈希只能说明两个输入是否一致。若攻击者可以同时替换文件和网页上公布的摘要,普通哈希就无法证明来源,这时需要 HMAC 或数字签名。
2.5 createHmac():带 Secret 的完整性验证
HMAC 可以理解为“只有知道 Secret 才能计算出的消息指纹”:
import { createHmac, timingSafeEqual } from "node:crypto";
function createMac(message: string, secret: string): Buffer {
return createHmac("sha256", secret)
.update(message, "utf8")
.digest();
}
function verifyMac(
message: string,
receivedMacBase64Url: string,
secret: string
): boolean {
const expected = createMac(message, secret);
const received = Buffer.from(receivedMacBase64Url, "base64url");
if (received.length !== expected.length) {
return false;
}
return timingSafeEqual(received, expected);
}
const secret = "请改成从安全配置中读取的高熵随机值";
const body = JSON.stringify({ orderId: "order-123", paid: true });
const mac = createMac(body, secret).toString("base64url");
console.log(mac);
console.log(verifyMac(body, mac, secret)); // true
console.log(verifyMac(body + "!", mac, secret)); // false
这类模式常见于 Webhook:发送方对原始请求体字节计算 HMAC,接收方用同一个 Secret 重新计算并比较。
注意:对 JSON 解析后再 JSON.stringify(),字段顺序或空白可能已经改变。验证 Webhook 时应保留平台要求的原始请求体,并严格遵守对方规定的拼接格式、时间戳和算法。
2.6 为什么使用 timingSafeEqual()
普通字符串比较遇到不同字符时可能提前返回,执行时间可能泄露“前面有多少内容相同”。timingSafeEqual() 用更稳定的比较方式降低时序侧信道风险。
它要求两个 Buffer 长度相同,所以必须先检查长度:
if (received.length !== expected.length) {
return false;
}
return timingSafeEqual(received, expected);
它只保护比较这一步。输入解析、长度判断和整个协议仍需正确设计,不能因为调用了一个接口就认为不存在所有时序问题。
2.7 本章选择口诀
不可预测的值 randomBytes / randomInt
业务唯一标识 randomUUID
公开的数据指纹 createHash
带共享秘密的指纹 createHmac
敏感摘要比较 timingSafeEqual