跳到主要内容
ecdsa.com

HMAC vs 数字签名:共享密钥还是密钥对

两者都能证明消息没有被改动。但只有签名能向自己无法生成签名的人证明消息出自谁手。
对比对象HMAC数字签名

HMAC 还是数字签名,是任何消息认证方案中最重要的设计决策;它关乎信任,而不是速度。HMAC 标签用同一个密钥计算和验证,因此能验证的人也都能伪造。签名用私钥生成、用公钥验证,因此验证方无法生成新的有效消息。

概览

HMAC(RFC 2104、FIPS 198-1)数字签名
密钥一个共享密钥私钥签名,公钥验证
谁能生成有效的标签或签名任何持有该密钥的人,包括每一个验证方只有私钥持有者
不可否认性(可向第三方证明)否是,前提是密钥与签名者之间存在可信的绑定
密钥分发密钥必须以保密方式送达每一个验证方公钥可以公开发布(JWKS、证书、DNS)
证明的大小HMAC-SHA256 为 32 字节64 字节(Ed25519、ECDSA P-256),256~512 字节(RSA)
速度(Apple M4 Max 单核)256 字节消息每秒约 7,000,000 个标签(OpenSSL 3.6.5)每秒约 60,000 个 Ed25519 签名或 87,000 个 P-256 签名
典型用途Webhook、会话 Cookie、AWS SigV4、TOTP、HS256 JWT、TLS 1.2 记录 MACTLS 证书与握手、OIDC ID 令牌、软件包签名、Git、通行密钥(passkey)
后量子配合 SHA-256 是安全的(Grover 算法只能把求原像的工作量减半)经典方案会被 Shor 算法攻破;需要 ML-DSA 或类似方案

HMAC 吞吐量用 openssl speed -hmac sha256 测得(256 字节输入时约 1.83 GB/s);签名速率用 openssl speed 测得。

区别在于信任模型

HMAC 是一种带密钥的哈希:H((K ⊕ opad) ‖ H((K ⊕ ipad) ‖ m))。它的安全目标是:没有密钥的人无法生成有效的标签。如果恰好只有两方共享密钥,并且彼此信任对方不会伪造,这就足够了,因为此时有效的标签就意味着“这是对方发来的”。一旦有三方或更多参与方,这一点就不再成立:任何验证方都能向其他各方冒充发送方;发生争议时,发送方也可以声称标签是接收方生成的。

数字签名把这两种能力分开了。公开验证密钥并不能帮助任何人签名,因此一个颁发者可以服务数千个验证方;只要密钥已经通过证书、密钥目录或账号记录与签名者绑定,签名就可以作为来源证据出示给审计人员或法院。正是由于这种分离,供许多服务使用的身份令牌(OpenID Connect ID 令牌、由资源服务器验证的访问令牌)都采用签名;也正因如此,验证方配置泄露在使用签名时无害,在使用 HMAC 时却是灾难性的。

实践要点

密钥管理:HMAC 密钥必须随机生成(HMAC-SHA256 至少 256 位;RFC 7518 §3.2 对 HS256 有此要求),以保密方式传输,由每一个验证方保存,并且要么在所有地方同时轮换,要么设置宽限期,让两个密钥同时有效。签名密钥则把私钥部分保存在一个地方,最好是 HSM 或 KMS,并通过已发布密钥集中的密钥标识符进行轮换。

性能与大小:HMAC 只是两次哈希计算,每个核心每秒可完成数百万次,标签为 32 字节。签名每次需要几十微秒,大小为 64 字节或更多。对大多数 Web 流量来说,这两点都无关紧要;但对逐包认证或消息量非常大的内部通信来说就很重要,这些场景通常使用 HMAC 或 AEAD。

常见陷阱:HMAC 必须以常量时间比较,不得用 hash(key ‖ message) 代替(这种构造会被 SHA-256 的长度扩展攻击攻破),而且必须对实际收到的字节进行认证(原始请求体,而不是重新序列化的 JSON 对象)。签名必须用验证方固定的算法来验证,绝不能用消息自己选择的算法。

下面的脚本用 Node.js 26.10 运行,以两种方式对同一个 Webhook 请求体进行认证。

Node.js:对同一个 Webhook 请求体生成 HMAC-SHA256 标签和 Ed25519 签名
import { createHmac, generateKeyPairSync, randomBytes, sign, timingSafeEqual, verify } from "node:crypto";

const body = Buffer.from('{"event":"invoice.paid","id":"evt_123"}');

// HMAC: sender and receiver share one secret (in production it comes from a secret store).
const secret = randomBytes(32);
const tag = createHmac("sha256", secret).update(body).digest();
const expected = createHmac("sha256", secret).update(body).digest();
console.log("HMAC-SHA256 tag:", tag.length, "bytes, valid:", timingSafeEqual(tag, expected));

// Signature: sender keeps the private key, receivers hold only the public key.
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
const signature = sign(null, body, privateKey);
console.log("Ed25519 signature:", signature.length, "bytes, valid:", verify(null, body, publicKey, signature));
输出
HMAC-SHA256 tag: 32 bytes, valid: true
Ed25519 signature: 64 bytes, valid: true

HMAC 密钥必须与每一个验证方共享;而 Ed25519 私钥始终留在发送方,分发出去的只有 32 字节的公钥。

实践中的 Webhook 与 API

大多数 Webhook 提供商使用 HMAC,因为每个订阅方都有自己的密钥:GitHub 发送 X-Hub-Signature-256: sha256=,后面跟着请求体的十六进制 HMAC-SHA256;Stripe 则对时间戳加请求体签名,并放在 Stripe-Signature 请求头中,让接收方能够拒绝重放的请求。由于每个订阅方的密钥都是唯一的,多方问题不会出现。如果一个发送方面向许多不应共享密钥的接收方,或者接收方希望无需预置密钥就能验证,就会用到基于 HTTP 消息签名(RFC 9421)或 JWS 的签名 Webhook。AWS Signature Version 4 是基于 HMAC 的请求签名;OAuth DPoP 和 Web Bot Auth 使用签名,因为验证方不得有能力冒充客户端。

如何选择

  • 由同一个服务签发和验证(会话 Cookie、CSRF 令牌、内部 JWT):HMAC-SHA256。
  • 一个发送方、一个接收方,密钥通过带外方式共享(按客户分配的 Webhook):HMAC,并在被签名的数据中包含时间戳。
  • 一个颁发者、许多验证方(身份令牌、公开 API、验证服务器数据的移动应用):签名,密钥发布在 JWKS 中。
  • 需要向第三方提供证据(合同、审计日志、软件发布版本):签名加可信时间戳。
  • 客户端必须无法相互冒充(DPoP、机器人身份验证、设备证明):签名,每个客户端使用自己的密钥。
  • 可信链路上的消息速率非常高:HMAC 或 AEAD。

迁移说明

从 HMAC 迁移到签名,通常是因为出现了第二个验证方,或者密钥泄露了。

  1. 引入算法标识符:如果你的格式中还没有(JOSE alg、HTTP 签名的 alg 参数、版本前缀),就添加一个,让验证方无需盲目信任消息,也能知道它使用的是哪种方案。
  2. 发布公钥并附上密钥标识符(JWKS、HTTP 签名密钥目录),给验证方留出获取公钥的时间。
  3. 过渡期间双重签名:同时发送 HMAC 请求头和签名,并让验证方优先使用签名。
  4. 在只需要验证的验证方上删除共享密钥;安全收益正来自这一步,所以不要“以防万一”而保留它。
  5. 切勿让同一个密钥同时用于两种方案。把 RSA 公钥当作 HMAC 密钥重复使用,正是曾经攻破多个 JWT 库的算法混淆;参见 HS256 与 RS256 对比。

参考资料

全部对比