Python HMAC 验签的字节边界:JSON 内容没变,重新序列化后为什么仍然失败
接收一个带签名的回调,框架已经把JSON解析成字典。业务代码把字典再转回JSON,用共享密钥计算HMAC,字段和值明明一样,验签却失败。关键不在字典内容,而在发送方到底认证了哪一串字节:空格、换行和字段排列都可能改变认证输入。
本文在Linux、CPython 3.12.14实跑。保存为demo.py,执行python3 demo.py即可。示例密钥是公开的教学字符串,正文也是固定内存数据;没有真实凭据、外部请求或服务端操作。算法明确指定SHA-256,签名表示约定为六十四个小写十六进制字符。
AI生成的概念示意图:信封里相同图形的排列变化对应不同封印,表示原文字节与认证结果相连;不是加密流程截图。
完整程序与本次实跑
完整可运行程序
import hmac
import json
import re
key = b"tutorial-key-not-a-secret"
raw = b'{"job": 7, "state": "ready"}'
signature = hmac.new(key, raw, "sha256").hexdigest()
def verify(body, supplied):
if not isinstance(supplied, str) or re.fullmatch(r"[0-9a-f]{64}", supplied) is None:
return False
expected = hmac.new(key, body, "sha256").hexdigest()
return hmac.compare_digest(expected, supplied)
obj = json.loads(raw)
rebuilt = json.dumps(obj, separators=(",", ":")).encode("utf-8")
assert json.loads(rebuilt) == obj
assert raw != rebuilt
assert verify(raw, signature)
assert not verify(rebuilt, signature)
assert not verify(raw.replace(b"7", b"8"), signature)
assert not verify(raw, "xyz")
print("same JSON value:", json.loads(raw) == json.loads(rebuilt))
print("same bytes:", raw == rebuilt)
print("original accepted:", verify(raw, signature))
print("rebuilt accepted:", verify(rebuilt, signature))
print("modified accepted:", verify(raw.replace(b"7", b"8"), signature))
print("bad signature accepted:", verify(raw, "xyz"))
streamed = hmac.new(key, digestmod="sha256")
streamed.update(raw[:10])
streamed.update(raw[10:])
assert hmac.compare_digest(streamed.hexdigest(), signature)
print("same bytes in chunks:", streamed.hexdigest() == signature)本次实际输出(以下为结果,不是程序)
same JSON value: True same bytes: False original accepted: True rebuilt accepted: False modified accepted: False bad signature accepted: False same bytes in chunks: True
先承认语义相同和字节相同可以分开
第一行same JSON value为True,说明解析后的对象相等。第二行same bytes为False,因为重新序列化时去掉了逗号和冒号后的空格。JSON解析器可以把不同文本读成同一个值,HMAC却不会先理解JSON,它按收到的字节与密钥计算认证码。
程序因此得到original accepted为True而rebuilt accepted为False。这里没有要求哪一种JSON排版更正确;只要求验证端使用与签名端完全一致的输入。如果实际协议签的是原始请求体,就应在框架解析或中间件改写之前保存那份原始字节。
验证函数同时检查表示和内容
verify先确认签名是字符串,再用fullmatch限制长度与字符集合,随后计算期望值并调用compare_digest。这样,格式不符合本例约定的xyz会直接返回False,而不是与错误类型混比。若服务采用Base64或带算法前缀的签名头,应按该服务的协议解析,不能原样照搬这个十六进制入口。
compare_digest用于比较认证值,避免普通相等比较因内容不同而提前结束的做法;它不替代协议解析,也不保证整个请求处理过程具有相同耗时。输入类型、长度差异及格式校验本身仍可能被观察到。本文只把它用于同一格式的固定长度摘要比较。
改变一个值会破坏原有认证
modified accepted为False,因为正文里的任务编号从七变成八,而提供的签名仍属于原文。调用方应把验证失败当作这份消息未通过认证,不应为了“帮助通过”而尝试多种空白处理、重新排序或多种编码组合。那会使真正接受的协议变得难以描述与排查。
验签成功以后再把正文交给JSON解析和业务校验,仍要检查必填字段、允许的状态以及任务编号是否属于当前账户。HMAC证明的是持有密钥的一方对这些字节计算了相应认证码,不会自行判断里面的业务动作合理与否。
分块输入可以保持同一份消息
最后一行same bytes in chunks为True。两次update按原有顺序拼出了同样的字节流,所以结果与一次性传入raw相同。分块边界本身不是消息内容,不会自动插入换行或分隔符;顺序改变或遗漏一块才会得到另一份输入。
接入有大小上限的流式请求时,可以利用这一点边读边计算,但仍须明确是否保存正文供后续解析。不要一边对原文做HMAC,一边让业务处理另一份已经规范化的数据,却没有检查两者的对应关系。示例固定长度很小,不讨论无界流的内存管理。
认证以外还要另订协议
HMAC不负责隐藏正文,也不自动防止同一条合法消息被重复提交。若服务要求时间戳、事件编号或防重放窗口,应依官方协议将相应数据纳入认证输入并执行独立检查。这里没有凭空添加某个线上服务的字段规则。
生产密钥应由受控的密钥配置提供,不能使用本文公开字符串。排查失败时优先核对认证输入的长度、协议版本与读取位置,避免把正文、密钥或完整签名写进普通日志。先证明双方取到了同一串字节,再检查业务层,通常能缩小问题范围。
参考资料
官方资料核验于2026年10月2日;上述输出来自本次固定输入实跑,退出码为0。


