跨链桥防重放:消息身份不能跟着签名格式变化
同一笔跨链提款,换一种签名编码再提交,应该被当成第二笔业务吗?显然不应该。但如果接收端用签名字节的哈希充当“已消费”索引,业务身份就会被密码学包装牵着走。即使签名库正确处理了编码,消息的域、目标和唯一编号仍需要单独设计。
EIP-712 提供结构化消息与域隔离的规则,同时明确它本身不包含重放保护。因此“采用了 typed data”不是跨链桥防双花的完成条件。规范
消息键应覆盖业务意图
一个提款消息通常需要绑定源链、目标链、源端合约、目标接收器、业务 nonce、资产、金额和接收人。具体字段由协议决定,但不能只绑定其中一部分,再让调用者自由替换其他字段。唯一性还必须说明作用域:同一个源发送者的 nonce,不能和另一发送者混为一谈。
签名是否由可信主体产生,消息是否对应已最终确认的源链事件,以及消息是否被消费,是三个独立问题。多签、轻客户端或 ZK 证明各自解决不同的认证问题,不该都被一个verify()布尔值掩盖。
本地演示:改变包装不改变消费记录
下面使用规范化 JSON 和 SHA-256 演示“业务字段决定消息身份”。这是应用层身份示例,不是 Solidity ABI 编码、EIP-712 哈希、Keccak 或真实签名验证器。不要把输出拿去当链上兼容签名摘要。
import hashlib import json FIELDS = {"src", "dst", "sender", "receiver", "nonce", "asset", "amount", "to"} used = set() def message_id(message): if set(message) != FIELDS: raise ValueError("missing or unexpected business fields") raw = json.dumps(message, sort_keys=True, separators=(",", ":")).encode() return hashlib.sha256(raw).hexdigest() def consume_after_authentication(message): key = message_id(message) if key in used: return "REPLAY" used.add(key) return "PROCESSED" m = dict(src=1, dst=2, sender="bridge-A", receiver="bridge-B", nonce=7, asset="token-X", amount=50, to="alice") assert consume_after_authentication(m) == "PROCESSED" # Different signature wrappers would still pass the same authenticated message. assert consume_after_authentication(dict(m)) == "REPLAY" assert message_id(dict(m, dst=3)) != message_id(m) assert message_id(dict(m, sender="bridge-C")) != message_id(m) print("4 identity cases checked; authentication is not implemented")保存为replay.py并运行python3 replay.py。函数名里的after_authentication是调用前提,不是认证实现。真实接收器需要在认证成功后、外部调用前按正确的重入防护和事务语义消费消息;进程内的set既没有持久化,也没有并发原子性。
映射升级也会改变消息的含义
源资产到目标资产的映射如果可被管理者随意改写,旧消息可能在新配置下解释成另一种资产。协议应说明消息绑定映射版本还是读取当前配置,升级如何影响排队中的提款,取消与退款由谁处理。
时间锁能为危险变更提供观察窗口,但不能证明新映射正确。OpenZeppelin 文档也将提案者和执行者分开,强调延迟给用户留下审阅与退出机会。访问控制与延迟
本文没有复现任何头部桥的真实漏洞,也没有调用模型或验证器。代码验证的是四个消息身份用例;可信根、密码学签名、源链最终性和跨链执行仍未集成。一个可靠的防重放设计,应能明确回答“这是谁的哪一条业务消息”,而不只回答“这串签名以前见过没有”。