☰
Hash、MAC、HMAC 别再搞混了:接口签名与密码存储的选型指南
2026/9/25 6:28:25 网站建设 项目流程

密码学里最容易被混为一谈的三个概念,大概就是 Hash、MAC 和 HMAC 了。很多做了几年开发的工程师,在被问到"MD5 和 HMAC-SHA256 有什么区别"时,还是会卡壳。更常见的场景是:接口签名用了 MD5,觉得加了密钥就安全了,结果被人用长度扩展攻击打得措手不及。这三个东西名字长得像,用途也有重叠,但底层假设和安全边界完全不同。搞混它们的代价,轻则接口被人伪造请求,重则整个鉴权体系形同虚设。

这篇内容面向的是需要做接口签名、数据完整性校验、密码存储的开发者,也适合正在学密码学基础、准备面试或者打 CTF 的朋友。我会从"它们各自解决什么问题"入手,把三者的设计动机、内部结构、安全边界讲清楚,再落到实际代码里怎么选、怎么用、哪里容易踩坑。读完你应该能明确回答:什么时候用裸 Hash,什么时候必须上 HMAC,以及为什么 MAC 不等于 HMAC。

1. 从三个真实场景看这三个概念到底在解决什么

1.1 场景一:下载文件后校验完整性,为什么只需要 Hash

你从某个镜像站下载了一个系统镜像,站点旁边贴了一串 SHA256 值。你下载完在终端跑一句sha256sum image.iso,对比一下结果一致,就认为文件没被篡改。这个场景里用的就是裸 Hash。

Hash 函数的核心能力是把任意长度的输入压缩成固定长度的输出,且这个过程不可逆、抗碰撞。它不需要密钥,任何人拿到同样的输入都能算出同样的输出。所以它天然适合做"完整性校验"——只要原始摘要值是通过可信渠道拿到的(比如官网 HTTPS 页面),你本地算出来的值对得上,就说明文件在传输过程中没被改动。

但这里有个关键前提:摘要值本身必须来自可信渠道。如果攻击者能同时篡改文件和页面上的摘要值,那这个校验就毫无意义。这正是裸 Hash 的边界——它只能防"传输意外损坏",防不了"主动篡改"。

1.2 场景二:接口签名防伪造,为什么裸 Hash 会出事

假设你设计了一个 API 签名方案:把所有参数按字典序拼起来,末尾加上一个 secret,然后算 MD5,把结果作为 sign 传给服务端。服务端用同样的方式算一遍对比。看起来加了密钥,应该安全了吧?

问题在于,MD5 和 SHA1、SHA256 这类 Merkle–Damgård 结构的 Hash 都存在长度扩展攻击(Length Extension Attack)。攻击者即使不知道你的 secret,只要知道MD5(secret + data)的结果和secret的长度,就能在不知道 secret 的情况下,构造出MD5(secret + data + padding + evil_data)的合法签名。因为这类 Hash 在计算时是把数据分块迭代的,前一块的输出直接作为下一块的初始状态,攻击者可以"接着算"。

这就是为什么"Hash + 密钥"这种土办法不能当 MAC 用。你需要的是一个从设计上就抵抗这类攻击的结构,于是 HMAC 登场了。

1.3 场景三:消息认证码 MAC,它到底认证了什么

MAC(Message Authentication Code)是一个更抽象的概念:用密钥对消息生成一个认证标签,验证方用同一密钥验证标签是否合法。它同时保证了完整性(消息没被改)和真实性(消息确实来自持有密钥的一方)。

MAC 是一个"目标",不是一个具体算法。HMAC 是实现 MAC 的一种具体构造方式,CMAC、GMAC、Poly1305 也都是 MAC 的具体实现。所以严格来说,"MAC 和 HMAC 的区别"这个问法本身有点问题——HMAC 是 MAC 的一个子集。真正该问的是"HMAC 相比其他 MAC 构造有什么特点"。

把这三个概念的关系理一下:

概念是否需要密钥核心目标典型算法
Hash否完整性(防意外损坏)MD5、SHA1、SHA256、SM3
MAC是完整性 + 真实性HMAC、CMAC、GMAC、Poly1305
HMAC是完整性 + 真实性HMAC-SHA256、HMAC-SM3

理解了这张表,后面所有的细节都是围绕"为什么需要密钥""密钥怎么用才安全"展开的。

2. Hash 函数的内部结构决定了它的安全边界

2.1 Merkle–Damgård 结构:为什么会有长度扩展攻击

MD5、SHA1、SHA256 都采用 Merkle–Damgård 迭代结构。它的工作方式是这样的:先把消息填充到块大小的整数倍(填充规则通常是补一个 1、若干 0、再附上原始长度),然后分块送入压缩函数,每一块的输出作为下一块的输入状态,最后一块的输出就是最终摘要。

这个设计有个副作用:最终摘要实际上就是压缩函数在某个中间状态上的输出。如果攻击者知道H(secret + data)的值,他就知道了压缩函数处理完secret + data + padding之后的状态。他可以把这个状态当作初始值,继续喂入自己想追加的数据,算出新的合法摘要。整个过程不需要知道 secret 的内容。

我用一段伪代码说明攻击者的思路:

# 正常流程 digest = SHA256(secret + data) # 攻击者已知 digest 和 len(secret),想伪造 secret + data + evil 的签名 # 1. 把 digest 当作 SHA256 的初始状态(需要能控制 IV) # 2. 从 padding 之后的位置继续喂入 evil # 3. 得到 forged_digest = SHA256_state(digest, evil) # 4. 发送 data + padding + evil 和 forged_digest,服务端验证会通过

实际攻击中,攻击者需要能控制 Hash 函数的初始向量(IV),这在标准库调用里通常做不到,但攻击者可以自己实现一份 Hash 逻辑,把 digest 塞进去当 IV。所以这个攻击在理论上是完全可行的,历史上也真实发生过(比如 Flickr 的 API 签名被绕过)。

2.2 SHA3 和 SM3:不同结构带来的不同特性

SHA3 用的是海绵结构(Sponge Construction),和 Merkle–Damgård 完全不同。它维护一个大的状态,通过"吸收"(absorb)输入、"挤出"(squeeze)输出。这种结构天然抵抗长度扩展攻击,因为攻击者无法从输出反推内部状态。

SM3 是国密杂凑算法,输出 256 位,结构上接近 SHA256,同样属于 Merkle–Damgård 家族,所以理论上也存在长度扩展攻击的风险。这也是为什么国密体系里做消息认证要用 HMAC-SM3,而不是直接 SM3(secret + data)。

这里给一个实用的判断原则:

只要你的场景里"密钥"和"消息"是拼接后一起 Hash 的,就要警惕长度扩展攻击。正确做法是用 HMAC,或者用 SHA3 这类抗长度扩展的结构。

2.3 密码存储为什么不能用裸 Hash

很多人把用户密码做一次 MD5 存进数据库,觉得"反正是不可逆的"。但现代 GPU 每秒能算几十亿次 MD5,配合彩虹表,弱密码几乎瞬间被还原。即使加盐(salt),如果盐是固定的、迭代次数是 1,依然扛不住暴力破解。

密码存储的正确做法是用慢哈希:PBKDF2、bcrypt、scrypt、Argon2。它们的核心思路是故意把计算变慢(通过大量迭代或内存占用),让暴力破解的成本高到不可接受。这跟 Hash 用于完整性校验的场景完全不同——那里追求的是快,这里追求的是慢。

用途推荐算法关键参数
文件完整性校验SHA256、SHA3、SM3无
密码存储Argon2id、bcrypt、scrypt迭代次数、内存、并行度
消息认证HMAC-SHA256、HMAC-SM3密钥长度 ≥ 256 位

3. HMAC 的构造:为什么它比"Hash 加盐"靠谱

3.1 HMAC 的两层嵌套结构拆解

HMAC 的标准定义是:

HMAC(K, m) = H((K' ⊕ opad) || H((K' ⊕ ipad) || m))

其中 K' 是把密钥 K 填充或 Hash 到块大小后的结果,ipad 是 0x36 重复块大小次,opad 是 0x5C 重复块大小次。

拆开看就是两层:

  1. 内层:H((K' ⊕ ipad) || m),把密钥和一个固定常量异或后,拼在消息前面做一次 Hash。
  2. 外层:把内层的结果,拼在K' ⊕ opad后面再做一次 Hash。

这个设计精妙的地方在于:外层 Hash 把内层的输出"包"了起来。攻击者即使能对内层做长度扩展,也无法影响外层的计算,因为外层的输入是内层的完整输出,攻击者不知道内层的密钥相关状态。两层嵌套彻底堵死了长度扩展攻击的路。

3.2 密钥长度和填充规则的实际影响

HMAC 对密钥长度有明确处理规则:

  • 如果密钥长度等于块大小(SHA256 是 64 字节),直接用。
  • 如果密钥长度大于块大小,先对密钥做一次 Hash,把结果当密钥。
  • 如果密钥长度小于块大小,右侧补 0 到块大小。

这个规则意味着,HMAC 的密钥长度超过块大小并不会带来额外安全性。比如你用 128 字节的密钥做 HMAC-SHA256,它会被 Hash 成 32 字节再用。所以密钥长度选 32 字节(256 位)就够了,再长是浪费。

实际工程里我见过有人用超长密钥"图个安心",其实没必要。真正影响安全性的是密钥的随机性,而不是长度。用os.urandom(32)或secrets.token_bytes(32)生成的随机密钥,比一个 200 字节的弱口令强得多。

3.3 一个完整的 HMAC 签名与验证实现

下面用 Python 写一个接口签名的完整例子,包含防重放的时间戳和随机数:

import hmac import hashlib import time import secrets SECRET_KEY = secrets.token_bytes(32) # 实际部署时从安全配置读取 def sign_request(params: dict) -> dict: # 1. 加入时间戳和随机数,防重放 params['timestamp'] = str(int(time.time())) params['nonce'] = secrets.token_hex(16) # 2. 按字典序拼接 sorted_items = sorted(params.items()) message = '&'.join(f'{k}={v}' for k, v in sorted_items) # 3. HMAC-SHA256 计算签名 signature = hmac.new( SECRET_KEY, message.encode('utf-8'), hashlib.sha256 ).hexdigest() params['sign'] = signature return params def verify_request(params: dict, max_age: int = 300) -> bool: received_sign = params.pop('sign', None) if not received_sign: return False # 1. 校验时间戳,防止旧请求重放 try: ts = int(params.get('timestamp', 0)) except ValueError: return False if abs(time.time() - ts) > max_age: return False # 2. 重新计算签名 sorted_items = sorted(params.items()) message = '&'.join(f'{k}={v}' for k, v in sorted_items) expected = hmac.new( SECRET_KEY, message.encode('utf-8'), hashlib.sha256 ).hexdigest() # 3. 常量时间比较,防时序攻击 return hmac.compare_digest(received_sign, expected)

这段代码里有三个关键点值得展开:

第一,用hmac.compare_digest而不是==。普通的字符串比较是短路比较,遇到第一个不同的字符就返回,攻击者可以通过测量响应时间逐字节猜出正确签名。compare_digest是常量时间比较,无论内容如何,耗时都一样。

第二,时间戳窗口不能太大。我一般设 5 分钟,太短会导致客户端时钟偏差误判,太长会给重放攻击留窗口。配合 nonce 做服务端去重(比如存 Redis 设 5 分钟过期),可以进一步收紧。

第三,参数拼接规则必须严格统一。空值、数组、嵌套对象怎么处理,客户端和服务端必须完全一致,否则会出现"客户端算的签名服务端验不过"的经典问题。我踩过最坑的一次是 URL 编码:客户端对参数做了 URL encode 再拼接,服务端用原始值拼接,结果中文参数全部验签失败。

4. 选型实战:什么场景该用哪个

4.1 一张决策表帮你快速定位

场景推荐方案理由
文件下载校验SHA256 / SM3无需密钥,快,够用
接口签名HMAC-SHA256抗长度扩展,密钥可控
密码存储Argon2id / bcrypt慢哈希,抗暴力破解
数据库字段防篡改HMAC-SHA256需要密钥,防内部篡改
JWT 签名HMAC-SHA256 或 RS256HS256 对称,RS256 非对称
消息队列消息认证HMAC-SHA256高性能,标准化
硬件资源受限CMAC-AES复用 AES 硬件加速

这张表不是绝对的,但覆盖了 90% 的日常场景。选型时先问自己两个问题:需不需要密钥?需不需要防主动篡改?两个都是"是",就上 HMAC;只有第二个是"否",裸 Hash 就够。

4.2 JWT 里 HS256 和 RS256 的取舍

JWT 的签名算法选择是个高频争议点。HS256 就是 HMAC-SHA256,对称密钥,签发和验证用同一个密钥。RS256 是 RSA 非对称签名,私钥签发、公钥验证。

什么时候用 HS256?单体应用或内部服务之间,签发方和验证方是同一套系统,共享密钥没有额外风险,HS256 性能更好、实现更简单。

什么时候用 RS256?签发方和验证方是不同信任域。比如你做了一个开放平台,第三方需要验证你签发的 token,但你不想把密钥给他们(给了他们就能伪造 token)。这时候用 RS256,公钥可以随便分发,私钥只有你自己持有。

我见过一个典型的坑:某团队用 HS256,然后把密钥硬编码在客户端 SDK 里。任何人反编译 SDK 就能拿到密钥,进而伪造任意用户的 token。这种场景必须用 RS256。

4.3 国密场景下 SM3 和 HMAC-SM3 的配合

在需要符合国密规范的场景里,杂凑用 SM3,消息认证用 HMAC-SM3。SM3 输出 256 位,块大小 64 字节,和 SHA256 参数一致,所以 HMAC 的构造方式完全一样,只是把底层 Hash 换成 SM3。

# 伪代码示意,实际需用支持 SM3 的库如 gmssl from gmssl import sm3 def hmac_sm3(key: bytes, message: bytes) -> bytes: block_size = 64 if len(key) > block_size: key = sm3.sm3_hash(key) key = key.ljust(block_size, b'\x00') ipad = bytes(b ^ 0x36 for b in key) opad = bytes(b ^ 0x5C for b in key) inner = sm3.sm3_hash(ipad + message) outer = sm3.sm3_hash(opad + inner) return outer

注意 SM3 本身是 Merkle–Damgård 结构,所以绝对不能用SM3(secret + data)当 MAC,必须用 HMAC-SM3。这一点在国密改造项目里经常被忽略,我见过直接把 SM3 拼接密钥当签名用的实现,安全性等同于裸 Hash 加盐。

5. 那些年我踩过的坑和排查思路

5.1 签名验不过?先查这五个地方

接口签名验不过是最常见的联调问题,我总结了一个排查顺序,基本能覆盖 95% 的情况:

  1. 参数拼接顺序:确认双方都是按字典序(或约定的顺序)拼接,大小写敏感。
  2. 空值和特殊字符:空字符串、null、数组、中文、特殊符号的处理规则是否一致。
  3. 编码方式:UTF-8 还是 GBK,URL encode 在哪一步做。
  4. 密钥是否一致:有没有多余的空格、换行,是不是从配置文件读的时候带了引号。
  5. 时间戳和 nonce:是不是时间窗口过期了,nonce 是不是被服务端判重了。

我遇到过一次特别隐蔽的:客户端用 Python 的dict拼接,服务端用 Java 的TreeMap,两边对数字类型参数的处理不同——Python 把1和1.0当不同值,Java 统一成1。结果某个参数传浮点数时签名就对不上。后来统一规定所有参数先转字符串再拼接才解决。

5.2 长度扩展攻击的真实复现

为了让你直观感受这个攻击,我用 Python 演示一下对MD5(secret + data)的攻击思路(仅用于理解原理):

# 假设攻击者已知: # - 原始消息 data = "amount=100" # - 签名 sig = MD5(secret + data) # - secret 长度 = 8(通过其他途径推测) # 攻击者想伪造 data + padding + "&amount=9999" 的签名 # 利用 hashpumpy 这类工具可以自动完成 import hashpumpy original_sig = "已知的MD5值" original_data = "amount=100" append_data = "&amount=9999" secret_len = 8 new_sig, new_data = hashpumpy.hashpump( original_sig, original_data, append_data, secret_len ) # new_data 就是 data + padding + append_data # new_sig 就是服务端会认可的合法签名

防御方法只有一个:用 HMAC。或者用 SHA3 这类抗长度扩展的结构。没有别的捷径。

5.3 时序攻击:一个容易被忽略的侧信道

前面提到用compare_digest,这里展开说一下为什么。假设验证逻辑是:

if received_sign == expected_sign: return True

Python 的字符串比较是逐字节的,遇到不同就返回 False。攻击者发送大量请求,测量响应时间,就能推断出前几个字节是否正确。虽然网络抖动会干扰,但通过大量采样统计,依然能还原出完整签名。

防御很简单:用常量时间比较函数。Python 用hmac.compare_digest,Java 用MessageDigest.isEqual,Go 用subtle.ConstantTimeCompare。这个细节在安全审计里经常被点名,值得养成习惯。

5.4 密钥管理:比算法更容易出事的地方

算法选对了,密钥管理翻车,一样白搭。我见过的问题包括:

  • 密钥硬编码在代码里,提交到了 Git 仓库。
  • 密钥在多个环境(开发、测试、生产)复用。
  • 密钥轮换时没有过渡期,导致旧客户端全部验签失败。
  • 密钥存在配置文件里,权限没控制好,被低权限进程读到。

我的做法是:密钥从环境变量或密钥管理服务读取,不同环境不同密钥,轮换时支持双密钥并行验证(新签名用新密钥,验证时新旧都试),给客户端留出升级窗口。这些看起来是运维细节,但实际决定了整个签名体系能不能长期稳定运行。

6. 把三个概念串起来:一张图看清它们的层次关系

6.1 从"目标"到"实现"的层次

如果非要用一句话概括三者的关系:Hash 是基础工具,MAC 是安全目标,HMAC 是实现 MAC 的一种标准方法。

  • Hash 提供的是"压缩 + 不可逆",本身不涉及密钥,所以只能做完整性校验。
  • MAC 引入了密钥,把"完整性"升级成"完整性 + 真实性",但 MAC 是概念,不是算法。
  • HMAC 用两层嵌套的 Hash 构造,把 Hash 变成了一个安全的 MAC,是工程上最常用的 MAC 实现。

理解了这层关系,你就不会再有"MAC 和 HMAC 有什么区别"这种困惑了——它们不在一个层次上。

6.2 对称加密体系里的位置

在对称加密体系里,加密(如 AES)解决的是机密性,MAC 解决的是完整性和真实性。两者经常配合使用,比如 AES-GCM 就是"加密 + 认证"一体的模式,内部用 GHASH 做认证。而 HMAC 更多用在"只认证不加密"的场景,比如接口签名、JWT。

这里有个经典的安全原则:先加密再 MAC,还是先 MAC 再加密?这个问题的标准答案是"先加密再 MAC"(Encrypt-then-MAC),因为这样 MAC 覆盖了密文,攻击者无法通过篡改密文来影响解密结果。如果反过来,可能引入 padding oracle 之类的攻击。不过现代推荐直接用 AEAD 模式(如 AES-GCM、ChaCha20-Poly1305),把加密和认证打包解决,避免自己组合出错。

6.3 给不同基础读者的学习路径

如果你是刚接触密码学的开发者,建议按这个顺序深入:

  1. 先把 Hash 的性质搞清楚:抗碰撞、抗原像、雪崩效应。
  2. 理解长度扩展攻击的原理,这是理解 HMAC 为什么存在的关键。
  3. 动手实现一遍 HMAC,不要只用库函数,自己按公式写一遍。
  4. 研究 AEAD 模式,理解现代密码学"加密和认证不可分割"的设计哲学。
  5. 最后看密钥管理、侧信道防护这些工程细节。

如果你是为了面试或 CTF,重点放在长度扩展攻击、HMAC 构造、时序攻击这几个高频考点上。CTF 里经常出现"给了 MD5(secret + data) 让你伪造签名"的题目,识别出长度扩展攻击就能秒解。

最后分享一个我自己的习惯:每次设计签名方案时,先在纸上把"攻击者能拿到什么、能控制什么、想达到什么目的"列清楚,再对照 HMAC 的安全假设检查一遍。这个习惯帮我避开了好几次潜在的设计缺陷。密码学这东西,算法本身往往是安全的,出问题的永远是使用方式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询