一笔合成订单通过 HTTPS 发到服务端,订单正文 order=demo-001;quantity=1,客户端和服务端另约定用 HMAC 验证这笔 API 请求。第一次成功后,攻击者原封不动重发先前捕获的请求:请求体没被改,HMAC 依然正确。攻击者不必知道秘密,也可能让业务再次处理这笔订单。这不是 HMAC 被破解,而是校验输入里缺了一道“此请求是否已经生效”的判断。

签名哪些字节,谁持有密钥

HMAC 的计算方和验证方都必须有同一共享秘密;服务端也能生成同样的 MAC。因此它和钱包私钥签名交易不一样:交易可由其他参与者用公钥验证,而 HMAC 的服务端一方不能凭自己也能制造的 MAC 向第三方证明“只有客户端能生成”。TLS 又是第三件事:浏览器确认连的是 orders.test 并加密连接,不自动把 HTTP 订单绑定到应用层的权限或一次性消费状态。

为了让 HMAC 验证表达明确含义,发送者与验证者必须对同一串字节达成一致。例如只签 body、不签 HTTP 路径,原本针对 /orders 的正文在另一个端点也可能变成有意义的输入。简单用 method + path + body 拼接又容易产生歧义:("ab", "c") 与 ("a", "bc") 在无边界拼接后都是 abc。当前实验采用一套特定服务的长度前缀格式:

1
2
3
4
5
6
7
8
9
10
11
本次请求的认证串:
"order-api-v1\0"
+ len(method) || method
+ len(raw_path) || raw_path
+ len(content_type) || content_type
+ len(timestamp) || timestamp
+ len(nonce) || nonce
+ len(body) || body

请求 HMAC = HMAC-SHA256(32 字节会话外共享秘密, 上述字节)
接收方 = 重新构造、恒时比较、验时间、原子占用 nonce、按业务规则处理

每个 len 是四字节大端长度。版本前缀隔开不同用途,避免同一共享秘密被无意拿去验证另一套格式的字段。raw_path 是此本地服务器看到的 ASCII 请求目标路径;代理若将百分号编码、斜杠或头字段重新解释/合并,应用和签名者必须明确谁构造与消费最终的路径。不可只在入口验证字节 A,然后让下游按另一个规范化方式执行操作 B。本例限制了必需头字段只能出现一次;没有宣称解决所有真实反向代理和 HTTP 消息歧义。

RFC 9421 定义的是可用于 HTTP Message Signatures 的组件选择、规范化与签名基串,签名可采用不同算法;RFC 9530 的 Content-Digest 是另外一种内容摘要字段,若用它配合 HTTP 签名,仍需明确将它纳入受保护组件。实验的 order-api-v1 是独立的教学协议,不声称与 RFC 9421 客户端互通;仅验一次自己现算的正文 SHA-256 更不能证明来源。

时间窗口不是消费状态

时间戳可以限制重放被接受的时间范围。服务端按自己的时间检查差值不能超过 60 秒,客户端如果时钟错误可能被拒绝;攻击者在这 60 秒里转发原始有效请求,HMAC 和时间戳都能通过。随机 nonce 是一次请求的标识,只有和服务端保存的“已经消费”状态一起用,才有发现重复的条件。对一段确认处理成功的请求,服务端必须保证查看和占用 nonce 的动作符合实际并发/业务事务要求。

1
2
3
4
5
6
TLS 握手:客户端确认本次请求送给 orders.test
HTTP 请求:method、raw_path、headers、body 等进入 HMAC 输入
服务端:HMAC ? --否--> 401
--是--> 时间在窗口内 ? --否--> 401
--是--> nonce 首次消费 ? --否--> 409
--是--> 执行业务规则 → 响应

图中最后一箭头不意味着订单交付;还没有付款、库存、交易确认或外部履约。HTTPServer 单进程串行处理下的内存 set 足以展示本次运行的重复检测;重启、多个实例或多个地区可能各持不同集合。生产系统要让共享状态按业务语义原子占用,处理重试、崩溃及失败恢复。不能简单把“签了一个 nonce”当成“集群仅处理一次”。

一次可失败的订单实验

examples/cryptography/23_order_api.py 启动 127.0.0.1 随机端口上的 TLS 1.3 订单服务,仅将一次性测试 CA 传给此次客户端。双方在进程内使用临时生成的 HMAC 秘密,签名和私钥不打印、不进仓库。第一条请求获 200;完全重放同一有效请求返回 409;不重签而改数量或路径返回 401;用正确秘密签了过期的请求返回 401;甚至重新签一条新正文,只要复用已消费 nonce,仍返回 409。

1
2
python3 examples/cryptography/23_order_api.py
python3 -m unittest discover -s examples/cryptography -p 'test_23*.py' -v

2026-10-06 UTC,两条命令退出码为 0、1 项测试通过;脚本输出的是各分支是否满足断言的布尔值。这个实验在回环上的确建了真实 TLS 连接并计算真实 HMAC,但只覆盖一个服务、一个进程、单线程处理的内存状态;没有实现 RFC 9421、跨代理/跨区域状态、订单支付和外部交付。在钱包通过 HTTPS RPC 提交测试交易时,TLS 只保护这段 RPC 通信,而钱包交易私钥签的交易字节要再由节点按链规则校验;RPC 的 API HMAC 不能替代链上交易签名。

两道带答案的练习

推导题: 画出客户端、TLS 端点、应用订单服务的边界。攻击者只会复制一条已经成功的完整请求,不知道 HMAC key,也不能解开 TLS。假设他合法地控制自己的客户端,曾得到这条已签请求;在有效时间窗口内去掉应用端的 nonce 消费检查,哪个断言将不再成立?TLS 为什么不能自动兜底?

可核对答案: 应用层第二次处理同样的 HMAC、时间和 nonce 时失去 identical_replay_rejected 断言;客户端主动在另一条合法 TLS 连接上发送已签请求,即使每条 TLS 记录都有认证标签,服务端应用仍可能再次执行。若攻击者既不是客户端又无法访问明文请求,能否获得可重发的请求是额外的威胁模型前提,不得声称 TLS 被破解。

实验变更题: 删除 23_order_api.py 的 abs(now - request_time) > 60 判定,或者删除 nonce in accepted_nonces 判定再运行测试。分别指出哪个已签的失败对照会转为成功;只改 HMAC 的算法名能解决哪一个问题?

可核对答案: 删除时间检查时正确签名的过期请求不再被拒,expired_signed_request_rejected 失败;删除 nonce 消费检查,原请求复制和合法重签后重用旧 nonce 均可能被接受,相应重复断言失败。单独把 HMAC-SHA256 改成另一种安全 MAC 不会让接收方记住已消费请求或理解路由权限。

资料与导航