订单页面上看见 quantity=1,不等于签名程序看到相同的消息。浏览器可以发送 JSON、表单字段或二进制协议;同一个 JSON 对象还可以有不同的空格和键顺序。如果一个端点对它收到的原始字节做摘要,另一个端点先解析再重新输出 JSON,即使两边都在谈“同一笔订单”,校验也可能失败。更糟的是,两个不同字段组合能拼出完全相同的字节:此时再强的哈希或签名算法也无法代替消息边界的约定。

第 00、01 篇讲“要保护什么”。这篇回答一个更靠前的问题:确切保护了哪些字节?输入没有说清,关于身份、用途和完整性的结论都容易越界。

从可读字符走到被验证的输入

假设订单文本里有中文“订单”。程序要先约定字符编码,例如 UTF-8,再确定字段顺序、数字表示、空白处理与字段长度,最后才可把结果交给摘要或签名操作。

1
2
3
4
5
6
7
8
9
业务对象 {"sku":"book","quantity":1}
| 指定字段、顺序、字符编码、长度和表示法
v
确定的一串字节 --------> SHA-256 --------> 32 字节摘要
| ^
+-- 签名/加密等操作 | 比对者须另有可信预期值

UTF-8 字节 <---> 十六进制显示 / Base64 显示
可逆编码,不是加密

"订单".encode("utf-8").hex() 的输出是 e8aea2e58d95:一个可供读者检查的具体字节结果。十六进制和 Base64 改变的是表示方法,并没有隐藏原文;拿到 Base64 的人可以直接解码。这与 07 篇要使用的认证加密不同,后者有秘密密钥和校验失败条件。

实验里的订单 JSON {"quantity":1,"sku":"book"} 和 {"sku": "book", "quantity": 1} 解析为相等的 Python 对象,原始字节却不同。它们的 SHA-256 分别是 2d25aa19273f0207fb907d8f93bcf8da97eb8a6a4dfdc0e79b50ca10697a990f 和 de39bcace818b7ff09a4210ac435c8a576884d7fa505848c1d26a56455861aa9。一端签第一串,另一端把它重排成第二串再验,失败首先应查验签输入,不要先宣布密钥已被攻破。

序列化是把结构化字段变成约定的字节;分帧是让接收者确定字段从哪里开始、到哪里结束;规范表示是使允许的同一对象有统一的可验证字节表示。这里只引入这三个操作词;具体链的交易编码、TLS 结构和 HTTP 签名覆盖范围在对应章节各自核对。Python 的 json.dumps(sort_keys=True) 固定了本系列教学订单的部分表示,但并不是 RFC 8785 的 JSON Canonicalization Scheme 实现;尤其别把浮点数、Unicode、重复键等所有可能输入都交给这几行代码,随后声称得到通用签名格式。

两个字段之间究竟有多宽

考虑 merchant 和 order 两个字节字段,省事做法是直接拼起来再计算摘要或签名输入。

1
2
3
4
5
6
第一笔:  b"ab" + b"c"  -> b"abc"
第二笔: b"a" + b"bc" -> b"abc"

约定每个字段前有 2 字节大端长度:
第一笔: 00 02 | 61 62 | 00 01 | 63
第二笔: 00 01 | 61 | 00 02 | 62 63

不分帧时,两个“业务对象”的输入本来就完全相同,算出相同摘要不是发现了哈希碰撞;即使换成数字签名,它也没办法从 b"abc" 判断签署者想签哪一组字段。这是输入约定错误,不是算法失效。定义长度、字段类型和顺序后,两串字节不同,验证者也必须按同一个约定解析;否则仍可能对同一签名产生两种解释。教学脚本用两字节长度,因此字段超过 65535 字节时主动报错,避免无声截断。生产协议通常有更完整的字段类型、上下文、版本和长度约束,不能直接复制这个玩具格式。

更隐蔽的情形是两边处理顺序不同:浏览器可能先把请求体里的 UTF-8 字节发给代理,代理先解析再重排 JSON,后端随后根据新字节验证老签名。若签名覆盖的就是原始正文,这个“等义重排”也会失败;若某一方签的只是部分字段,则未覆盖字段甚至可被中间人改掉而签名仍有效。协议必须说清签名输入是否包含请求方法、主机名、路径、主体、时间和域信息,而不是一句“对请求签名”就算完成设计。HTTP 消息签名另在 23 篇细讲。

一次可失败实验

examples/cryptography/02_encoding.py 只依赖 Python 3 标准库:输出直接拼接与显式长度格式的十六进制、JSON 对象和字节是否相同、两种表示的摘要以及可反解的 Base64。仓库根目录运行:

1
2
python3 examples/cryptography/02_encoding.py
python3 -m unittest discover -s examples/cryptography -p 'test_02*.py' -v

2026-10-06 UTC,在 Python 3.12.3 上两条命令退出码均为 0,5 个断言通过。直接拼接的输入都是 616263;加长度后分别为 00026162000163 与 00016100026263。脚本输出的 Base64 为 eyJxdWFudGl0eSI6MSwic2t1IjoiYm9vayJ9,解开就是公开 JSON,没有秘密参与。

失败对照在脚本测试里已经写明:删除长度,原本应有区别的两组输入重新相等,test_two_byte_lengths_separate_two_fields 会失败;让字段超长却不报错,test_length_boundary_is_checked 会失败。这个实验是手算/教学模型加标准库摘要,不是 Bitcoin 或 Ethereum 交易签名测试,也没验任何真实网络端点。

把它放回两张图:HTTPS 的握手会把各阶段约定的消息表示纳入特定的握手摘要,双方不能各自“看起来一样”就算一致;钱包签的通常是特定类型交易的规定预像或其摘要,交易展示页显示的字段不等于直接送进签名算法的字节。后者需要链、交易类型和用途绑定;如果还要求保证提交一次有效授权只能被消费一次,另查 nonce、未花费输出或应用状态。编码可确定输入,不能提供保密性;确定输入也不能单独提供来源认证。

两道带答案的练习

推导题。 将 merchant=b"shop"、order=b"7" 放进实验定义的两字节大端长度格式,手写十六进制。为什么 merchant=b"sho"、order=b"p7" 在直接拼接时无法区分,而在分帧后可以?

可核对答案: 长度分别为 4 和 1,第一组得到 0004 73686f70 0001 37;第二组长度为 3 和 2,得到 0003 73686f 0002 7037。直接拼接均为 73686f7037。两组分帧输入不同才允许验证者恢复正确字段;不能从未分帧的相同摘要倒推原字段。

变更题。 把 02_encoding.py 里的 framed 改成只给第一个字段写长度,第二个字段保持原样,再跑五个测试。现有两字段实验可能仍会区分,这足以证明这个新格式在所有协议中安全无歧义吗?

可核对答案: 不足以。固定只有两个字段、并且消息长度完全可信时,可以由第一段长度和整体长度推出余下字段;但如果还允许尾随字段、嵌套结构或多种消息类型,就要额外明确整体长度和字段范围。现有测试只覆盖指定的输入与两字段模型;请增加“第三字段/可选字段”反例后再声称支持新的结构。不应把“测试未失败”当作通用解析器的证明。

资料与导航