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() 会避免这种分布偏差。

六位验证码本身只有一百万种可能,因此即使生成方式安全,也必须配合:

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