HMAC 和「哈希(密钥+明文)」有什么区别?拼接边界、长度扩展与两层哈希
签名对不上,最常见的不是密钥错了,而是两边算的不是同一个东西:一边在用「密钥 + 明文」拼起来算一次哈希,另一边要求的是 HMAC。两种结果都是定长的十六进制串,长度一样、样子一样,但性质差得很远。
先把工具跑一遍看清楚它在算什么。HMAC 生成工具 只需要两样输入:明文和密钥。填完选算法和输出编码,结果实时刷新,页面上没有「计算」按钮。页面注明所有计算在浏览器本地完成。

「密钥 + 明文」一起哈希,缺的是边界
最直觉的做法是把密钥当盐一样拼在明文前面,算一次哈希。问题是,哈希函数只看到拼好的最终字节串,它不知道哪几个字节是密钥。
实测同一台机器上的一组:
| 密钥 | 明文 | 拼起来的字节 | MD5(一次哈希) | HMAC-SHA256 |
|---|---|---|---|---|
| secret | hello | secrethello | e1c9e47be92b358510029b76aea4c5e9 | 88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b |
| secre | thello | secrethello | 同上 | 8a32eaa15ee0543bf28f83af845ed3b6e33ffab67bc0f09ad97b862cd00d5250 |
同样是 secrethello 这 10 个字节,一次哈希给出同一个值;HMAC 却给出两个完全不同的值。差别不在算法快慢,而在 HMAC 把「密钥有多长」这件事算进了结构里:密钥先被补齐或压缩成固定块长,再参与运算。
这意味着什么:用「哈希(密钥+明文)」做校验,一旦某个环节的密钥长度可以被换掉(比如密钥从配置里换成一个更短的,或者签名格式本身就是把 密钥||数据 切分出来的),同一段字节串就能同时对应两组不同的 (密钥, 数据)。校验方按自己的切法算,攻击者按另一种切法构造,两边看到的是同一个哈希值。
长度扩展:拿到摘要就能往后接,不用知道密钥
拼接写法还有个更直接的麻烦。MD5、SHA-1、SHA-256 都属于迭代式哈希(Merkle–Damgård 结构):数据被切成块,逐块压缩,最终输出的摘要就是处理完所有块后的内部状态。
结果就是:知道你算出 H(密钥||数据) 这个摘要、也知道 密钥||数据 有多长的人,可以把这个摘要直接当成初始状态,继续往后面压新的块,算出 H(密钥||数据||追加内容) 的有效摘要——整个过程不需要知道密钥。
后果很具体:如果某个下载链接的签名是 H(密钥||参数),而这个摘要就明文写在 URL 里,那攻击者能在这个参数后面拼接任意内容,得到的摘要照样能通过校验。这也是「哈希(密钥+明文)」做 URL 签名、下载鉴权被认为不可靠的原因。
HMAC 换成两层哈希就把这条路堵住了,见下一节的公式。攻击者能看到的是最外层的摘要,他在外层能接着算出来的,只是把这个外层结构往后多接一块,而接出来的值不对应任何一条更长的消息的 HMAC——要伪造 HMAC(K, 新消息),得先算对新的内层摘要,而算内层摘要必须有密钥。
HMAC 是怎么算的:两层哈希,密钥被摊成定长
RFC 2104 第 2 节给的定义不复杂,一共七步:
- 把密钥 K 末尾补 0,补到块长 B 字节(MD5、SHA-1、SHA-256 的 B 都是 64 字节);
- 补齐后的 K 与 ipad(0x36 重复 B 次)逐位异或;
- 后面接上要认证的明文;
- 对这一串做一次哈希;
- 补齐后的 K 与 opad(0x5C 重复 B 次)逐位异或;
- 后面接上第 4 步的结果;
- 再哈希一次,输出。
写成公式就是 H(K XOR opad, H(K XOR ipad, text))。两个关键点:
- 密钥先被摊成定长,所以密钥多长都被吸收进固定结构,不存在「边界可以被挪」的空间;
- 哈希做了两遍,外面那遍算的是定长的内层摘要,不是原始消息,续算无处下手。
另外两条来自 RFC 2104 第 2 节和第 3 节:密钥长度超过 B 时会先被哈希一次再当密钥用;密钥短于输出长度 L 则「强烈不建议」,因为会直接拉低强度。SHA-256 的 L 是 32 字节。
工具实测:这三种输入变化会让签名全变
把一个字符改掉、把密钥换掉,32 个字节的结果会整体变掉,不会只变一两个字符。这是摘要算法的正常表现,也是排查签名问题的关键——只要结果不对,先怀疑输入真的不一样。

实测同一明文 hello、算法 HmacSHA256、输出 Hex:
| 场景 | 输入 | 结果 |
|---|---|---|
| 基准 | 密钥 secret | 88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b |
| 只换密钥 | 密钥 secret2 | f0ddfadefbd28cee92fdc3770cee58cca603579dfe56bad83a6b363494810d5b |
| 明文结尾多一个换行 | 明文 hello 加换行 | 171b5670f7b4037fb90bef773b022130e48100fdd40ea023730097da9a68f4ff |
| 密钥尾部多一个空格 | 密钥 secret 加空格 | d4049a94dfd8537589e223c431347738106488f75b6d27617fac6abe2559c6c0 |
| 中文明文 | 明文 你好 | 584bc9e5e121864b4348b573fbbaf488bf938112376f7eb6bbad22ae31b53474 |
中文这一行是 UTF-8 下两个汉字 6 个字节参与计算的结果;换成 GBK 编码是 4 个字节,字节不同,结果必然不同。这五行里的每个值都在本机用 openssl dgst -sha256 -hmac 复算过,与工具输出一致。
还有个容易被当成 bug 的行为:密钥留空时不出结果。明文填着、密钥空着,结果框保持占位提示「HMAC 计算结果将显示在这里」,复制按钮变灰,页面不给任何报错——它只是不算。HMAC 的定义要求有密钥,没有密钥就没有签名可谈。
输出编码不是另一种算法
同一份摘要换表示方式,长度会变,但那不是又算了一遍。

以 hello + secret 的 HmacSHA256 为例:
| 输出编码 | 结果 | 长度 |
|---|---|---|
| Hexadecimal (base 16) | 88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b | 64 字符 |
| Base64 (base 64) | iKqz7ejTrflNJquQ07r9SiCDBww7zOnAFO4EpEOEfAs= | 44 字符 |
| Base64url (url safe) | iKqz7ejTrflNJquQ07r9SiCDBww7zOnAFO4EpEOEfAs | 43 字符 |
| Binary (base 2) | 256 位 0/1 串 | 256 字符 |
这四行是同一份 32 字节数据的四种写法:Hex 一个字节写两个字符,Base64 三个字节压成四个字符,Base64url 把加号、斜杠换成减号和下划线并去掉末尾等号,Binary 直接展开成二进制。对接接口时这一层最容易出错——服务端要 Base64、你给 Hex,签名怎么算都对不上,因为传过去的是两串不同的字符。
8 种算法输出多长,HmacSHA3 要单独说
工具下拉里有 8 种算法,实测输出长度如下(明文 hello、密钥 secret、Hex):
| 算法(页面标签) | 字节数 | Hex 字符数 |
|---|---|---|
| HmacMD5 | 16 | 32 |
| HmacRIPEMD160 | 20 | 40 |
| HmacSHA1 | 20 | 40 |
| HmacSHA224 | 28 | 56 |
| HmacSHA256 | 32 | 64 |
| HmacSHA384 | 48 | 96 |
| HmacSHA3 | 64 | 128 |
| HmacSHA512 | 64 | 128 |
前七种与 openssl 3.5.7 逐个比对一致,HmacSHA3 除外:同一组输入,工具给出 c580e89aab50d9068254fed8461c691a918009dfbd4376af0241eb652276a244271039b38a9d9d2cdfcac15f5f2056478cb0f094374adedcc54f9d45788e4f4d,openssl 的 HmacSHA3-512 给出 bc07c2dfc0295b420662bda474eb8db11b0389822e13da56cf9991f467f2f6c713c481aa8663900ecaee310bf2f226eaa5c2d1345dfebee990658bd529a9c504,两者都是 64 字节,值不同。
所以对接时不要只看算法名写了 SHA3:要么双方用同一套实现、先用一条固定输入的测试向量对齐,要么直接用 HmacSHA256 或 HmacSHA512,这两个到处都有标准对照。另外 HmacMD5、HmacSHA1、HmacRIPEMD160 这三种摘要只有 16–20 字节,强度已经不适合给对外接口做签名。
真实场景:宝塔域名 API 的签名串长什么样
HMAC 在宝塔自己的接口里就有用到。域名 API 的签名是固定的三步:先把五个字段用换行拼成一串,再用 Secret Key 做 HMAC-SHA256,最后把十六进制结果放进 X-Signature 请求头。官方文档给的形式是 account_id、timestamp、method、path、body 五行依次换行拼接,详见《宝塔域名 API 概览与签名认证》。

上图那条的明文是五行:
acct_abc123def456
1794281902
POST
/api/v1/dns/record/create
{"domain_id":123,"record":"www","type":"A","value":"192.168.1.1"}
密钥用文档里的示例值,算法 HmacSHA256,算出的签名是 368c83c53c959172c221c1c9a998f9b52ec3536cddc9e34c8c0ed3bc8efcf97d。这一串在本机用 openssl 和 Node 的 crypto 各算了一遍,值相同——说明工具的结果可以直接拿去和服务端对账。
这也解释了为什么这类签名对换行极其敏感:五行之间是 4 个换行符,多一个、少一个、末尾再补一个,算出来就是另一个签名,而报错永远是笼统的「签名错误」。
签名对不上,按这五步查
- 待签名串有没有多余空白:明文末尾多一个换行就从 88aab3ed… 变成 171b5670…;从网页或日志复制最容易带进来。
- 密钥本身有没有带空格或引号:密钥写成 secret 加一个空格,结果是 d4049a94…,看起来同样是 64 个字符,肉眼分不出。
- 拼接格式是否一致:字段顺序、分隔符、body 是否被压缩或重新序列化、时间戳是秒还是毫秒。同一份数据换个拼法就是另一串字节。
- 输出表示是否一致:Hex 还是 Base64、大小写、Base64url 是否去掉了等号。这一层错了,摘要本身是对的,传出去也是错的。
- 算法是否同一个:HmacSHA256 与 HmacSHA1 结果长度都不一样,一眼能看出来;长度一样但值不同,就要怀疑像 HmacSHA3 那样的实现差异。
排查顺序建议从「输入」往「算法」走:前三步都能用工具当场复现,改一个字符立刻看到结果,不用猜。要按哈希值反查某段文本,用 MD5 / 哈希加密工具;要把签名结果在 Hex 与 Base64 之间换,用 Base64 编解码工具。
常见问题
HMAC 是加密吗?能不能反推出明文?
不是加密,是带密钥的摘要(MAC,消息认证码)。它和哈希一样是单向的:给定结果算不出明文,也没有「用密钥解开」这回事。它解决的是「这段数据有没有被改过、是不是知道我密钥的人发的」,不是「内容保密」。内容要保密得用 AES 这类加密算法,HMAC 只做完整性校验和身份确认。
HMAC 和「哈希(密钥+明文)」到底差在哪?
两点:一是边界,二是可续算性。哈希函数只看最终字节串,不记录「哪几个字节是密钥」,所以 md5(密钥 secre + 明文 thello) 和 md5(密钥 secret + 明文 hello) 是同一个值 e1c9e47b…,而 HMAC 在这两种拆分下分别是 8a32eaa1… 和 88aab3ed…,完全不同。二是 MD5、SHA-1、SHA-256 这类迭代哈希的输出就是内部状态,知道摘要的人可以在末尾追加数据继续算;HMAC 的外层哈希吃的是定长内层摘要,续算需要密钥。
HMAC-SHA256 的密钥要多长?
RFC 2104 第 3 节建议至少 L 字节(L 是哈希输出长度,SHA-256 是 32 字节);密钥长过块长 B 会先被哈希一次再参与计算,再长也不会显著提升强度(MD5、SHA-1、SHA-256 的块长 B 都是 64 字节,SHA-384 与 SHA-512 是 128 字节)。密钥要随机生成、定期更换:密钥泄露后换算法救不回来,只能换密钥。
工具算出来的签名和接口对不上,先查什么?
按顺序查四件事:待签名串里有没有多余的换行或空格(实测明文结尾多一个换行,结果就从 88aab3ed… 变成 171b5670…);密钥有没有带首尾空格(密钥写成 secret 加一个空格,结果是 d4049a94…);双方拼接格式是否一致(分行、字段顺序、body 是否压缩);输出表示是否一致(Hex 大小写、Base64 与 Base64url 的等号)。四项都对上还不对,就是算法选错了。
密钥填在网页里安全吗?
这个工具的说明写的是「所有计算在浏览器本地完成」。实测计算期间页面只发了两个请求:一条使用统计(内容只有 {"type":"hmac_generate","event_id":…},不含明文和密钥)和一个前端性能监控上报。真实业务里的密钥不建议随便贴到网上工具,敏感密钥在本机用 openssl 或语言自带的 hmac 库算更稳妥。
为什么工具里的 HmacSHA3 和别人算的不一样?
实测差异确实存在:同一组输入(明文 hello、密钥 secret),工具给出 c580e89a…(64 字节),openssl 3.5.7 的 HmacSHA3-512 给出 bc07c2df…,两者不同。SHA3 这一代有几套实现(标准 FIPS 202 的 SHA3 与早期 Keccak),对接时不能只看算法名。跨系统签名优先用 HmacSHA256 或 HmacSHA512,并在上线前用同一组固定输入互相对一次。