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 就推广到所有签名算法。

实际项目还要明确:

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 还必须处理:

因此业务代码应使用成熟 JWT 库,而不是复制上面的教学代码自行实现协议。

当前项目已有完整教程:

5.4 jsonwebtoken 中最重要的验证动作

日常业务应调用 verify(),不能用 decode() 代替:

import jwt from "jsonwebtoken";

const payload = jwt.verify(token, accessTokenSecret, {
  algorithms: ["HS256"],
  issuer: "nloop-api",
  audience: "nloop-client",
});

这里同时验证签名、允许的算法以及配置的标准声明。返回值仍来自外部输入,之后还要检查 subroletokenType 等业务字段是否存在且类型正确。

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 生产使用清单

先定义安全目标

算法与参数

随机值

密钥管理

Node.js 运行时

业务边界

5.7 最后的实用判断

遇到新需求时,不要先问“应该调用哪个 crypto API”,先完成下面这句话:

我要保护的是 ______,攻击者可能做到 ______,系统仍然需要 ______。

例如:

我要保护的是数据库中的第三方 API Token,
攻击者可能只拿到数据库备份,
系统仍然需要在调用第三方服务时恢复原文。

这时可以考虑由应用持有、与数据库分离管理的 AES-GCM Key。

再例如:

我要保护的是用户密码,
攻击者可能拿到用户表并离线猜测,
系统只需要判断登录输入是否匹配,不需要恢复原文。

这时应选择带随机盐和成本参数的密码哈希,而不是加密。

真正的密码学实践,不是记住最多的算法名字,而是先把问题分类正确,再把密钥、参数、数据格式和失败处理一起设计完整。