拓冰建站拓冰建站
首页 / 资讯中心 / 正文

HMAC-SHA消息认证原理、在线工具与接口签名避坑指南

做接口对接这些年HMAC-SHA 是我见过出场率最高的消息认证方案之一。不管是开放平台的 API 签名、Webhook 回调校验还是内部服务之间传递报文防止被篡改十套方案里有六套最终都会落到 HMAC-SHA 上。很多人一开始只把它当成一个「算签名的函数」等真正联调对不上号的时候才发现自己对它的理解其实很模糊。这篇文章我会先用大白话把 HMAC-SHA 消息认证的原理讲清楚再分享一个我一直在用的在线生成工具接着给出完整的实操步骤和验证方法最后把我在实际对接中踩过的坑和排查经验整理成一份速查清单。不管你是前端、后端还是刚入行的测试同学看完都应该能直接上手。1. 先搞清楚 HMAC-SHA 到底在解决什么痛点1.1 消息认证为什么不能只靠普通哈希先说一个最常见的场景。你的系统要给合作方的系统推送一条业务数据比如「订单 10086 金额 199 元请确认」。这条数据从你的服务器发出经过公网到达合作方服务器。中间经过路由器、运营商机房、对方网关任何一个环节都可能被第三方截获、修改。合作方收到这条消息后凭什么相信它真的是你发的内容有没有被人动过手脚有人第一反应是用普通哈希比如 MD5 或 SHA-256把消息算一遍把摘要附在消息后面对方再算一遍比对不一样就说明被改了。这个思路在「数据没有被恶意攻击者盯上」的前提下是成立的但在真实对接场景里漏洞非常大。攻击者完全可以在篡改消息的同时把摘要也重新算一遍一起替换掉。因为普通哈希算法是公开的谁都能算它只能证明数据在传输过程中有没有「意外损坏」证明不了「是谁发的、有没有被故意篡改」。我平时喜欢用一个类比来解释普通哈希相当于信封上的邮戳谁都能盖盖上只能说明这封信经过邮局说明不了内容有没有被人拆开再封回去。而 HMAC 相当于在文件封口处盖一个只有你和接收方才知道的私章别人不知道章长什么样自然盖不出来。这个「只有双方知道的私章」就是密钥。HMAC 把密钥和消息揉在一起做哈希没有密钥的人就算看到完整的消息内容和摘要也无法伪造出一个合法的摘要。继续往前推一步。HMAC 的全称是 hash-based message authentication code基于哈希的消息认证码它是 MACMessage Authentication Code的一种。它要解决两个问题一是完整性确认消息没被改过二是认证性确认消息来自持有同一把密钥的对方。这两个目标正好对应上面说的两个痛点。后面很多系统做接口签名本质上利用的也是这两个特性。1.2 HMAC 的构造原理两次哈希揉进一个秘密网上关于 HMAC 的公式很多RFC 2104 里定义得很清楚HMAC(K, M) H((K ⊕ opad) ∥ H((K ⊕ ipad) ∥ M))第一次看这条公式的人十有八九是懵的。我换个方式拆一下。整个计算过程分成两层每一层都是做一次普通哈希第一层叫内层哈希。先把密钥 K 处理成跟哈希分组长度对齐的 K如果密钥比分组长度短就在后面补 0如果比分组长度长就先对这个密钥做一次哈希得到的结果作为 K。然后让 K 和一组固定字节 ipad 做异或XORipad 是 0x36 这个字节重复分组长度那么多次。异或之后的结果再拼接上原始消息 M做一次哈希得到一个中间值。第二层叫外层哈希。把 K 和另一组固定字节 opad 做异或opad 是 0x5c 这个字节重复分组长度那么多次。异或的结果再拼上第一层得到的中间值再算一次哈希。最终输出的这个值就是 HMAC 的结果。拆开看本质就是「把密钥加工成两把不同的子密钥」一把用于内层一把用于外层中间夹着消息做了一次哈希。为什么要绕这两圈有两个关键原因。第一个原因是防御长度扩展攻击length extension attack。如果采用最直观的 H(secret ∥ message) 这种拼接方式在 MD5、SHA-1 这类 Merkle–Damgård 结构的哈希算法下攻击者在不知道密钥的情况下也能根据已有的 H(secret ∥ message) 推导出 H(secret ∥ message ∥ extra) 的结果从而在合法签名后面追加内容进行伪造。HMAC 把密钥放在两层嵌套计算里外部只暴露第二层哈希的最终结果从结构上就堵死了这条路。第二个原因是实现上的容错。即使底层某个哈希算法被发现有弱点HMAC 的双层结构也能在很大程度上避免弱点被直接利用。当然这不是说可以放心用 MD5 做 HMAC绝对不推荐但 HMAC 的结构确实比简单拼接健壮得多。这也是它能写进 RFC、被 TLS、JWT、各种云平台签名协议广泛采用的根本原因。1.3 SHA-1、SHA-256、SHA-384、SHA-512 到底怎么选HMAC-SHA 这个名字里的 SHA指的是底层哈希算法常见的有 SHA-1、SHA-256、SHA-384、SHA-512 四种。它们是同一个家族输出长度不同内部的分组长度也不同直接决定了摘要的长短和计算速度。算法摘要长度分组长度hex 输出长度当前建议SHA-1160 bit / 20 字节64 字节40 字符不推荐碰撞攻击已公开SHA-256256 bit / 32 字节64 字节64 字符默认推荐SHA-384384 bit / 48 字节128 字节96 字符可选SHA-512512 bit / 64 字节128 字节128 字符可选SHA-1 在哈希碰撞方面已经被学术界证明存在实际攻击。早在 2017 年Google 就公开了两个内容不同但 SHA-1 摘要完全一样的 PDF 文件。虽然 HMAC 的双层结构对直接碰撞攻击有一定防御力但完全没有必要拿一个已经被广泛认为过时的算法去对接新系统。除非你要对接一个十年前定下来的老协议、对方只能支持 SHA-1否则我一般建议直接用 SHA-256。SHA-256 在性能和安全性之间是最均衡的选择。绝大多数在线工具、SDK、云平台的签名示例都默认支持它踩坑概率最小。SHA-384、SHA-512 的摘要更长安全强度理论上更高在 64 位 CPU 上由于寄存器宽度更宽计算速度甚至可能比 SHA-256 还快。但实际对接中它们并不比 SHA-256 有压倒性优势而且摘要更长在日志、URL 参数里也更占地方。我的原则很简单没有特殊要求就用 SHA-256如果对接方的安全规范明确写了用什么算法就跟着对方约定走不要自己擅自换。2. 核心参数拆解key、message、算法一个都不能错2.1 key 和 message 的边界在哪里搞懂 HMAC 的第一个坎是分清 key 和 message。key 是认证双方事先约定好的共享密钥它是签名能够成立的根基相当于你家里大门的钥匙message 是真正要传输的业务数据相当于你寄出去的包裹内容。HMAC 计算时把 key 和 message 混在一起做哈希最后出来的摘要本身不包含 key 的原始信息从计算上无法反推出 key。所以消息可以在公网上明文传输但签名只有同时知道 key 的人才能算得出来。实际业务里最常见的错误是有人把 key 和 message 的位置搞反。比如把「密钥」直接当作待签名的消息内容把业务数据当作 key。这样算出来的 HMAC 值当然也是某个合法输出但你和对方两边的算法一对比永远是两个不同的值。因为签名对接要求双方在同一个参数位置上使用同一条数据你俩一个把密钥放前面一个把密钥放后面结果自然对不上。排查这种问题最快的方式是让双方各自把四个要素打印出来逐一比对算法、key 的原文、message 的原文、编码形式不要只盯着最终签名看。还有一个边界问题容易被忽略密钥的管理。密钥不能每次临时生成更不能在日志里明文打印。我见过一个典型事故调试阶段把 key 打到了日志里测试环境日志又被同步到日志平台权限控制不严基本等于把密钥公开了。密钥应该放在配置中心或环境变量里不同环境用不同 key并且定期轮换。另外密钥一定要用随机源生成比如 openssl rand -hex 32不要手打一段「my-secret-key-123」这种可读字符串。原因下面详细说。2.2 密钥长度到底多长才够RFC 2104 对 HMAC 的密钥长度没有硬性限制从 1 字节到任意长度都能参与运算因为超长密钥在进入计算之前会先被哈希处理。但密码学界的共识是密钥长度低于哈希输出长度时理论强度会有一点点损耗超过哈希输出长度时安全强度基本不再增加因为最终 HMAC 的强度受限于底层哈希的输出长度。在实际工程里我一般这样定SHA-256 对应的密钥不要少于 32 字节也就是 256 bit直接用 openssl rand -hex 32 生成得到 64 位十六进制字符串作为密钥存储和分发。如果你用的是人可读的短口令比如 8 位字母那 HMAC 的安全性就被口令强度拖垮了。攻击者可以对着口令字典暴力枚举猜中之后就能算出所有合法签名。换句话说算法再强密钥弱一样白搭。这里补充一个很多人不知道的细节密钥在参与 HMAC 计算时并不是直接使用原始文本的。先看长度长度超过底层算法分组长度SHA-256 是 64 字节时先对密钥做一次哈希长度不足分组长度时在后面补 0 补齐。这个细节在对接时一般不需要你自己实现因为标准库都处理好了。但当你用在线工具和代码互相对结果时必须确保两边输入的密钥是「完全相同的字节」尤其注意不要因为复制粘贴多了一个空格或者换行导致密钥内容变了。2.3 编码与输出格式hex 还是 base64HMAC 计算出来的最终结果是一段原始字节。为了在文本协议、URL、日志里传输通常要把这段字节编码成 hex 或者 base64 字符串来展示。hex 就是每个字节用两位十六进制表示SHA-256 的摘要正好 32 字节所以 hex 输出固定是 64 个字符base64 更紧凑32 字节编码后是 44 个字符末尾通常会带 。选择哪种格式本身没有对错重要的是对接双方约定一致。很多对接失败的案例都是因为服务端用小写 hex、客户端转成了大写 hex或者一端用 base64、一端用 hex两边都觉得自己没错结果验签永远失败。我建议在接口文档里把这一行写死「signature 字段为 HMAC-SHA256 计算结果的十六进制小写字符串」。有了明确约定工具和代码照着实现就不会在格式上出岔子。另外要注意在线工具通常同时提供 hex 和 base64 两个输出框有些还提供 raw bytes 的展示。我第一次用的时候就犯过迷糊拿 base64 的结果和代码里 hexdigest 的输出比对怎么都比不上。提醒所有刚开始接触的人先用小写 hex这是最直观、最好排查的格式等两边通了再考虑你们的协议是否需要 base64。3. 在线生成 HMAC-SHA 的实操全流程3.1 一个顺手的在线工具应该具备的素质先说我平时怎么找工具。直接在浏览器里搜「HMAC generator」或者「HMAC-SHA256 online」能搜出一堆。但我用了一圈之后筛工具的标准就三条少一条都不用。第一纯前端计算。也就是说密钥和消息是在你的浏览器本地完成运算的不会把密钥上传到别人的服务器。怎么判断最简单的办法是输入密钥后拔掉网线或开飞行模式看它还能不能正常出结果。如果能说明是本地 JS 计算的可以放心用如果断网后页面报错或没反应说明它大概率依赖后端接口那你的密钥等于交给了第三方风险很大。第二输入项必须完整覆盖算法、密钥、消息三要素并且支持十六进制密钥输入。有些工具只接受「文本密钥」当你手里的密钥本身就是一串 hex 字符串时它会把你这串字符当原始文本去算结果就和你代码里用 bytes.fromhex 解出来的原始字节算出的结果完全不同。这一步是很多签名对不上号的祸根。第三能切换 hex / base64 输出最好带一键复制。平时联调时一次要算好几组数据复制顺手能省很多事。我实际用下来真正好用的工具往往界面非常简单没有任何花哨功能一屏以内就能操作完。反而是那些号称支持几十种算法的重型页面加载一堆脚本我反倒不敢把密钥交给它。3.2 手把手用在线工具生成一个标准测试向量我用 RFC 4231 里公布的标准测试向量来演示。之所以推荐它是因为这是一组公开、权威的数据你可以拿它去检验任何工具、任何代码是否正常工作比拿「自己随便编的字符串」靠谱得多。第一组测试向量长这样密钥20 个字节的 0x0b写成十六进制就是 0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b消息字符串 Hi There注意中间有个空格算法SHA-256期望结果b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7操作步骤打开你的在线工具把密钥输入框切到「hex」模式粘贴上面这串 0b 开头的十六进制。消息输入框填 Hi There不能带多余空格或换行。算法下拉选 SHA-256。输出格式选 hex。点击计算或生成把得到的字符串和上面的期望结果比对。如果你得到的不是 b034 开头那串先把输入清理一遍再试。密钥 0b 后面有没有多打一个字符消息 Hi There 中间的空格是不是被全角空格替换了输出是不是选成了 base64这些我都帮同事排查过是最高频的三个原因。3.3 用代码验证在线工具的结果工具再好用对接最终得落到代码里。我分别用 Python 和 Node.js 写一版验证代码和上面的标准向量对照两边应该得到完全一样的结果。Python 版本import hmac import hashlib key bytes.fromhex(0b * 20) message bHi There hmac_value hmac.new(key, message, hashlib.sha256).hexdigest() print(hmac_value) # 输出: b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7Node.js 版本const crypto require(crypto); const key Buffer.from(0b.repeat(20), hex); const hmacValue crypto.createHmac(sha256, key) .update(Hi There) .digest(hex); console.log(hmacValue); // 输出: b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7两组代码的输出和 RFC 4231 一致也和你刚才在线工具算出来的结果一致这样就形成了一个「标准向量—在线工具—代码」三方互验的闭环。以后不管是在新语言里实现 HMAC还是怀疑某个工具算错了都拿这组向量先验一遍。如果结果验证通过说明工具可信、代码可信、你的操作方法没问题。接着再用你自己的真实密钥和消息跑一遍就可以放心去对接了。4. 实际对接中的常见坑与排查实录4.1 隐形字符空格、换行、BOM 一个都别放过签名对不上时我第一个怀疑的对象永远是消息数据本身。在线工具里从 Excel、微信、邮件复制过来的字符串经常带一些看不到的字符比如开头的 BOM字节序标记、结尾的换行、被自动替换的全角空格。这些东西在屏幕上看不出来但参与哈希计算时一个字节都不少结果必然不同。排查办法很简单在在线工具粘贴完消息后把光标放到文本首尾用方向键慢慢走一遍看看光标移动是否异常或者先在文本编辑器里打开「显示空白字符」功能确认没有多余内容。代码侧也一样建议不要把事情搞复杂多个字段拼接时谁先谁后、中间用什么分隔符必须在文档里写清楚双方严格一致。我见过同一套对接服务端按 orderId amount 拼接客户端按 amount orderId 拼接两边各自都能算出签名但永远对不上。4.2 密钥格式不一致文本密钥还是 hex 密钥这个坑值得反复强调。同样一个密钥比如 abc123当「文本密钥」处理参与计算的是 ASCII 字节 61 62 63 31 32 33也就是这六个字符本身。当「hex 密钥」处理相当于先把这串十六进制字符解码成原始字节再参与计算。如果在线工具里有「密钥格式」选项而你选了其中一种代码那边却默认把密钥当 UTF-8 文本处理两边结果必然对不上。解决办法是在接口文档里直接写清楚密钥的表示法比如「secret 为十六进制字符串参与计算前先转换为原始字节」然后在工具和代码里都按这个约定来。千万不要用「我看着那两个字符串一样啊」来代替明确约定。4.3 输出大小写、URL 编码带来的二次差异hex 输出是小写还是大写base64 输出放在 URL 里要不要做百分号编码这些也是很容易被忽略的约定。很多在线工具默认输出小写 hex但有些老系统的规范要求签名结果大写比对前必须先统一。我的建议是文档里写清楚然后在双方的代码里都加上小写转换比如 Python 的 .lower()、JavaScript 的 .toLowerCase()从源头避免大小写分歧。还有一关联问题当签名结果出现在 URL 查询参数里时base64 里的 、/、 三个字符会被 URL 编码成 %2B、%2F、%3D。如果对接方在服务端解码顺序不对结果也会对不上。所以能在 query string 里用 hex 就尽量用 hex可以省掉这一层麻烦。4.4 防重放时间戳、nonce 和签名的配合HMAC 能证明消息没被篡改、来自持有密钥的一方但它防不了「抓包重放」。攻击者把你们之前发过的合法请求原封不动再发一次接收方算出来的签名是合法的如果不做额外校验这个请求就会被当成一次新的有效请求处理。轻则重复下单重则造成资金风险。常规做法是在消息里带上请求时间戳 timestamp 和一个随机数 nonce并且让这两个字段参与 HMAC 计算。接收方先检查时间戳是否在允许的时间窗口内比如前后 5 分钟再检查这个 nonce 是否已经用过了可以用 Redis 的 SETNX 命令去重都通过才继续验签。我建议把这个逻辑当作签名方案的一部分一起设计而不是事后补救。等线上出了问题再改签名协议客户端和服务端要同时改成本高得多。4.5 签名比对要用恒定时间比较最后一个坑属于安全层面。先看一段代码如果把服务端自己算出的签名和客户端传来的签名用 直接比较看起来没问题实际存在一个叫时序攻击timing attack的理论风险。字符串比较在遇到第一个不同的字符时就会提前返回攻击者通过反复测量响应时间可以逐字节猜出合法签名的内容。标准库已经封装好了安全比较函数Python 是 hmac.compare_digestNode.js 是 crypto.timingSafeEqual。建议直接用它代码量几乎为零expected hmac.new(key, message, hashlib.sha256).digest() received bytes.fromhex(client_signature) if not hmac.compare_digest(expected, received): raise ValueError(签名校验失败)const expected crypto.createHmac(sha256, key).update(message).digest(); const received Buffer.from(clientSignature, hex); if (!crypto.timingSafeEqual(expected, received)) { throw new Error(签名校验失败); }这个细节在小系统上不一定有人专门攻击你但养成习惯没有坏处而且实现成本几乎为零没必要省。4.6 常见问题速查表现象可能原因排查与解决办法在线工具与代码结果不一致密钥格式选择不同或输入有隐形字符先用 RFC 4231 标准向量互验两边签名始终对不上key 和 message 位置放反或拼接顺序不一致双方打印算法、key、message、编码逐一比对中文消息验签失败编码不是 UTF-8统一用 UTF-8不要用 GBK 或系统默认编码线上偶发验签失败时间戳超时窗口或 nonce 重复检查服务器时钟同步确认时间窗口与去重逻辑签名结果里有 / 不好传使用了 base64URL 场景改用 hex或先做百分号编码5. 从工具到工程HMAC-SHA 的场景化落地5.1 API 读写接口的签名流程最常见的落地场景是开放平台 API 签名。服务端给客户端发一个 secretKey客户端在请求里带上 accessKey、timestamp、nonce以及用 secretKey 对「规范化请求串」计算出的 HMAC-SHA256 签名服务端验签通过才放行。这里说的规范化请求串通常要约定清楚哪些参数参与签名、参数怎么排序、拼接格式是什么。一种常见做法是把字段名按字典序排序用 keyvalue 连接再用 拼接最后把 timestamp 和 nonce 也塞进去。这个串就是 HMAC 的 message。任何一方理解不一致签名就对不上。我见过很多联调时间都耗在「规范字符串到底怎么拼」上所以文档里一定要写清楚最好直接附一个示例请求和对应的签名值让对方照着对。这里再说一下 HMAC 和 RSA 数字签名的区别。HMAC 是对称方案双方共享一把密钥计算快、实现简单适合服务端到服务端这种密钥可控的场景。RSA 签名是非对称方案私钥签名、公钥验签不需要共享密钥但计算慢、需要管理证书。对大多数内部 API 鉴权和回调校验来说HMAC-SHA256 是性价比最高的选择。5.2 Webhook 回调校验另一个高频场景是 Webhook 回调。第三方系统主动往你的服务器推事件比如支付成功、用户状态变更。回调 URL 是公开的任何人知道地址都能往这个接口 POST 数据。如果不做校验攻击者伪造回调可以轻松造成业务混乱。标准做法是第三方在回调请求头里带上 X-Signature 字段值是用你们共享密钥对「请求体原始字节」计算出的 HMAC-SHA256 签名你的服务拿到请求体原文自己算一遍再用恒定时间比较函数比对。这里有个容易踩的细节有些同学先解析 JSON 再拼接字段去验签如果 JSON 里的字段顺序和对方生成签名时不一致结果必挂。正确姿势是先用原始 body 字节验签验签通过后再去解析 JSON。5.3 前端也可以直接生成 HMAC并不是只有后端才能算 HMAC。Web 端的 Web Crypto API 原生支持 HMAC不依赖任何第三方库。比如在纯前端给请求加签时可以这样写async function generateHmac(secret, message) { const enc new TextEncoder(); const key await crypto.subtle.importKey( raw, enc.encode(secret), { name: HMAC, hash: SHA-256 }, false, [sign] ); const sig await crypto.subtle.sign(HMAC, key, enc.encode(message)); return Array.from(new Uint8Array(sig)) .map(b b.toString(16).padStart(2, 0)) .join(); }需要注意Web Crypto 里的密钥对象被设计为不可导出这是出于安全考虑但你也因此拿不到密钥的原始内容去做日志打印。签名结果转 hex 需要自己处理上面这段就是完整的转换逻辑。用这种方式加签前记得先用 RFC 4231 的标准向量验证一次确认输出和在线工具、后端代码一致再接入业务逻辑。5.4 规避常见设计误区最后说三个我见过比较多的设计误区。第一个是把密钥直接下发给客户端 App。移动端、浏览器端的密钥约等于透明攻击者反编译安装包或者抓一遍请求就能拿到。这类场景应该考虑更完整的认证方案HMAC-SHA 更适合服务端之间或者你能确保密钥不会落到不可信环境的双方。第二个是用弱密钥配合强算法。密钥强度必须匹配算法强度。32 字节随机密钥配 SHA-256 是合理组合8 位字母口令配 SHA-512 并不会更安全因为攻击者破解难度取决于整个链条里最薄弱的一环而不是最强的那一环。第三个是签名协议不做版本管理。签名算法迟早要升级比如从 SHA-1 迁到 SHA-256。建议在一开始就约定签名头里带 version 字段服务端同时支持新旧两种算法灰度切换而不是某一天突然全量改掉把线上请求全部打挂。我见过不止一次因为协议升级没做兼容导致一上线就事故的情况。做签名方案时往前多想一步后面能省很多事。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门