浏览器连上一个声称是 orders.test 的服务,服务把自己的公钥和对某段数据的签名一起发来。浏览器用收到的公钥验证,返回真。现在可以提交订单吗?还不可以:攻击者完全可以自己生成公私钥,给自己编的一段消息签名,再附上自己的公钥。算法做对了,身份却是假的。10 篇说明 DH 可以和中间人分别建立共同秘密;12 篇说明签名可以被正确公钥验证。要把两种能力组合为“与预期服务安全通信”,先得知道应当信任哪个公钥,并确认它签的是这一次、这个角色、这段通信。

本篇用事先固定的合成公钥模拟“浏览器已有可信起点”,再演示恶意自签、错误服务名、换 key share 与重放。固定公钥只是教学前提,不是现实世界的信任来源;14 的证书链才真正回答 HTTPS 的服务名与公钥如何绑定。

相同签名算法,两种截然不同的输入

客户端看到“消息 + 签名 + 公钥”时,至少要决定三个独立输入:希望连接的服务名称,从可信配置、证书链或别的认证制度拿到的预期公钥或公钥约束,以及要验证的确切消息字节。如果把公钥信任和消息验证都留给发件者随意指定,验证会变成自我背书。

1
2
3
4
5
6
7
8
本地预期服务 orders.test ------+
已有可信公钥配置 / 已验证证书 ---+---> 选择用于验签的公钥
本次挑战 + 两端公有 share ------+---> 决定本次待验消息字节
|
网络上收到的签名 ----------------> 验证,并核对剩余状态

恶意自签:攻击者消息 + 攻击者签名 + 攻击者公钥 -> 算法可能返回真
但用事先确定的“orders.test 对应公钥”去验 -> 拒绝

图上特意把公钥来源画在网络消息之外。这不表示公钥不能通过网络发送;证书当然也会从网络送来。关键在于客户端必须用自己信任的根、名称约束与验证规则校验它,不能因为证书或自签公钥“刚好在这次消息里”就相信它。

区块链节点的入口完全不同:它会从交易规定的字段、地址或支出条件及共识规则找到有权公钥,非网站 CA 根;更不能拿区块链上的钱包签名代替 HTTPS 服务身份,也不能把 HTTPS 证书公钥当成钱包的支出权。之后 26–28 分别核对 Bitcoin、Ethereum 的具体授权条件。

把正确公钥和本次握手绑在一起

即使一把公钥已可信,验证者还得知道签名签的是哪个会话。仅让服务端签固定文本“我是 orders.test”,攻击者可以在另一次连接里原封不动转发这个旧签名。更合理的教学模型把本次挑战、预期服务名、客户端公有 share、服务端公有 share 按长度分帧后算摘要,再连一个标出服务器角色的域标签一起签。签名的输入包括本次双方看到的交换内容;改任一 share,已签消息的验证就不应通过。

1
2
3
4
frame(orders.test, challenge, clientShare, serverShare)
-> SHA-256 transcriptDigest
-> "lab:server-proof:v1" + 0x00 + transcriptDigest
-> Ed25519 签名 / 用可信服务公钥验签

这是一种只为教学设计的字节格式,并非 TLS CertificateVerify 格式、TLS 握手摘要或 WebAuthn challenge 编码。用 frame 是为了避免 02 篇的字符串拼接歧义;域标签区分“服务端证明”和“客户端证明”。哪一方持有秘密?合成服务端私钥只在临时实验进程里,验证器持有事先确定的服务端公钥;攻击者也能生成自己的密钥与签名,却不应能把攻击者公钥替换到验证器的可信配置中。

TLS 1.3 实际要分清三种不能互换的认证对象:CA 对证书内容的签发,服务器在 CertificateVerify 中对带角色上下文的握手摘要签名,以及 Finished 中用派生秘密计算的握手确认值。再下一阶段才有记录流量密钥与认证加密。本篇的自定义 Ed25519 模型没有 CA、没有 TLS 记录层,不能以“签了握手摘要”概括这些不同对象的完整协议实现。14–16 将对 RFC 9846 的真实字段逐段说明。

有效旧签名的重放要单独判断

模型签名消息含一个挑战。验证器第一次用正确公钥、正确服务名和 share 验签成功后,记录“这个挑战已被消费”。第二次拿完全相同的输入来验签,Ed25519 验证函数依然会返回真;但是模型的消费检查返回假。删去这个集合,旧的有效证据还能生效。新建一个验证器实例,集合为空,也可能再次通过。因此教学模型没有解决分布式服务多副本一致性、跨重启持久性或业务订单去重,更不表示 TLS 的普通 1-RTT/0-RTT 已在本章实测。

身份绑定、握手摘要、域分离、新鲜性分别回答不同问题:公钥代表谁、签名覆盖本次哪些消息、同样字节能否跨用途使用、旧的有效消息是否还应该生效。靠一种验证结果代替另外三种检查,会让攻击者从未保护的边界切入。

原语与教学模型各支持什么结论

examples/cryptography/13_binding.mjs 使用成熟 Node.js Ed25519 原语执行真正的签名和验签;合成可信公钥被先放进 LabVerifier,消费状态是内存 Set,字节格式与挑战组合为教学模型。运行:

1
2
node examples/cryptography/13_binding.mjs
node --test examples/cryptography/13_binding.test.mjs

2026-10-06 UTC,Node 22.23.2 上两条命令退出码均为 0,3 项测试通过。正确服务首次验证成功;改服务名、改 share、改角色域、换攻击者签名或重放旧挑战,分别按预期拒绝。反例:恶意者用自己的公钥验证自己生成的签名同样成功,但不能通过验证器事先固定的可信公钥。这是“正确公钥从哪里来”的问题,而非 Ed25519 的验签函数误报。临时私钥、实际挑战及任何派生秘密没有写入公开证据;实际 CA、TLS 端点或真实链交易均 NOT_RUN。

两道带答案的练习

画图题。 服务端发来自己选择的公钥与本次签名,浏览器根据刚收到的这把公钥验签。请补上“不改变签名算法但能防止恶意自签被当作 orders.test”的最小一类可信输入;再指出仅有它为何还不足以防旧会话消息重放。

可核对答案: 浏览器必须从自己可信的服务名/证书链/已固定公钥等规则获得公钥归属约束,并验证收到的公钥确实与预期身份绑定,而不能直接相信发件者自报。即使公钥正确,如果签的是可复用的旧消息且无本次 challenge、握手上下文或消费检查,旧签名仍能被转发;不同协议有不同的重放边界。

变更题。 删除 LabVerifier.check 的 usedChallenges.add(identifier) 后分别执行脚本和测试。哪项首先由假变真?为什么新建一个 LabVerifier 也能说明原始模型不具备跨重启重放保护?

可核对答案: 第二次使用相同有效签名的 check 从假变真,使 replay_rejected_by_consumption 和“只通过一次”测试失败。新建验证器的 Set 为空,未共享过去消费记录;签名依然有效。要对真实业务做一次性处理还须设计持久共享的原子消费机制,不能把教学 Set 当成生产协议。

资料与导航