宝塔拨测博客拨测技术博客
宝塔拨测

HMAC 和「哈希(密钥+明文)」有什么区别?拼接边界、长度扩展与两层哈希

签名对不上,最常见的不是密钥错了,而是两边算的不是同一个东西:一边在用「密钥 + 明文」拼起来算一次哈希,另一边要求的是 HMAC。两种结果都是定长的十六进制串,长度一样、样子一样,但性质差得很远。

先把工具跑一遍看清楚它在算什么。HMAC 生成工具 只需要两样输入:明文和密钥。填完选算法和输出编码,结果实时刷新,页面上没有「计算」按钮。页面注明所有计算在浏览器本地完成。

明文 hello、密钥 secret、HmacSHA256,输出为 88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b

「密钥 + 明文」一起哈希,缺的是边界

最直觉的做法是把密钥当盐一样拼在明文前面,算一次哈希。问题是,哈希函数只看到拼好的最终字节串,它不知道哪几个字节是密钥。

实测同一台机器上的一组:

密钥 明文 拼起来的字节 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 节给的定义不复杂,一共七步:

  1. 把密钥 K 末尾补 0,补到块长 B 字节(MD5、SHA-1、SHA-256 的 B 都是 64 字节);
  2. 补齐后的 K 与 ipad(0x36 重复 B 次)逐位异或;
  3. 后面接上要认证的明文;
  4. 对这一串做一次哈希;
  5. 补齐后的 K 与 opad(0x5C 重复 B 次)逐位异或;
  6. 后面接上第 4 步的结果;
  7. 再哈希一次,输出。

写成公式就是 H(K XOR opad, H(K XOR ipad, text))。两个关键点:

  • 密钥先被摊成定长,所以密钥多长都被吸收进固定结构,不存在「边界可以被挪」的空间;
  • 哈希做了两遍,外面那遍算的是定长的内层摘要,不是原始消息,续算无处下手。

另外两条来自 RFC 2104 第 2 节和第 3 节:密钥长度超过 B 时会先被哈希一次再当密钥用;密钥短于输出长度 L 则「强烈不建议」,因为会直接拉低强度。SHA-256 的 L 是 32 字节。

工具实测:这三种输入变化会让签名全变

把一个字符改掉、把密钥换掉,32 个字节的结果会整体变掉,不会只变一两个字符。这是摘要算法的正常表现,也是排查签名问题的关键——只要结果不对,先怀疑输入真的不一样。

同一个明文 hello,密钥从 secret 换成 secret2,结果变成 f0ddfade…

实测同一明文 hello、算法 HmacSHA256、输出 Hex:

场景 输入 结果
基准 密钥 secret 88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b
只换密钥 密钥 secret2 f0ddfadefbd28cee92fdc3770cee58cca603579dfe56bad83a6b363494810d5b
明文结尾多一个换行 明文 hello 加换行 171b5670f7b4037fb90bef773b022130e48100fdd40ea023730097da9a68f4ff
密钥尾部多一个空格 密钥 secret 加空格 d4049a94dfd8537589e223c431347738106488f75b6d27617fac6abe2559c6c0
中文明文 明文 你好 584bc9e5e121864b4348b573fbbaf488bf938112376f7eb6bbad22ae31b53474

中文这一行是 UTF-8 下两个汉字 6 个字节参与计算的结果;换成 GBK 编码是 4 个字节,字节不同,结果必然不同。这五行里的每个值都在本机用 openssl dgst -sha256 -hmac 复算过,与工具输出一致。

还有个容易被当成 bug 的行为:密钥留空时不出结果。明文填着、密钥空着,结果框保持占位提示「HMAC 计算结果将显示在这里」,复制按钮变灰,页面不给任何报错——它只是不算。HMAC 的定义要求有密钥,没有密钥就没有签名可谈。

输出编码不是另一种算法

同一份摘要换表示方式,长度会变,但那不是又算了一遍。

同一个 HMAC-SHA256 结果切到 Base64 输出,得到 iKqz7ejT…fAs=

以 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 概览与签名认证》。

按官方格式拼出的五行签名串,用示例密钥算出的签名是 368c83c5…

上图那条的明文是五行:

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 个换行符,多一个、少一个、末尾再补一个,算出来就是另一个签名,而报错永远是笼统的「签名错误」。

签名对不上,按这五步查

  1. 待签名串有没有多余空白:明文末尾多一个换行就从 88aab3ed… 变成 171b5670…;从网页或日志复制最容易带进来。
  2. 密钥本身有没有带空格或引号:密钥写成 secret 加一个空格,结果是 d4049a94…,看起来同样是 64 个字符,肉眼分不出。
  3. 拼接格式是否一致:字段顺序、分隔符、body 是否被压缩或重新序列化、时间戳是秒还是毫秒。同一份数据换个拼法就是另一串字节。
  4. 输出表示是否一致:Hex 还是 Base64、大小写、Base64url 是否去掉了等号。这一层错了,摘要本身是对的,传出去也是错的。
  5. 算法是否同一个: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,并在上线前用同一组固定输入互相对一次。

HMACAPI签名HmacSHA256密钥长度扩展宝塔拨测