线路协议
Zycord 节点之间彼此说些什么,以及那些让一个对等协议不至于变成放大器的规则。这是一份通往规范文件的导读,不是它的替代品。
规范是 spec/:参数文件和向量 语料。本页若与代码树不一致,以代码树为准 — 一个实现只要能复现创世 id 并通过那些向量,它就是合规的,本页写什么都不改变这一点。下面是一份关于各项要求都在哪里的地图。
传输#
先 TCP,再 TLS 1.3。对等身份是一把承载在自签名 X.509 证书中的 Ed25519 密钥,由双方各自出示。证书链不做验证 — 这里没有证书颁发机构,也没有什么可供其背书 — 所以验证要求恰好一份证书, 要求其公钥必须是 Ed25519,并把该密钥提取出来作为对方的身份。
由此获得的性质是与某把密钥的通道绑定,而不是授权:TLS 保证完成握手的那一方持有所出示身份对应的私钥,并保证数据流在传输途中无法被读取或修改。 它对那个对等方是否诚实只字未提。协议中没有任何东西是基于身份来授予权限的。
证书的有效期是固定的常量,而不是围绕当前时间的一个窗口,这是刻意的: 相对窗口会让证书是否被接受取决于时钟是否一致,而一个沿着时钟偏差分裂的网络, 是一个因与共识无关的原因而分裂的网络。
实现不得把任何其他层的密钥重用为对等 身份。节点在每次启动时都生成一把全新的,并且从不把它写到磁盘上。
分帧#
offset size field
0 4 length uint32, little-endian - payload length, excluding this header
4 1 kind uint8 - see below
5 n payload
length必须在任何分配之前与MaxMessageBytes= 8 MiB 相核对。一个按所声称的长度进行分配的接收方,无论后面还做了什么,都存在一个远程内存耗尽缺陷。kind必须落在[1, 9]之内。零以及任何高于已知最大种类的值都是协议违规,而不是某个未知扩展:在协议版本 1 中没有向前兼容的逃生舱口,因为在握手时核对一个版本字段就已让它变得不必要。
消息种类#
| 取值 | 名称 | 方向 | 载荷 |
|---|---|---|---|
| 1 | hello | 双向,首个 | 握手 |
| 2 | certificate | gossip | ssz(Certificate) |
| 3 | block-announce | gossip | 区块头加上各凭证的 id |
| 4 | get-block | 请求 | 32 字节的区块 id ‖ u32 分块索引 |
| 5 | block | 响应 | ssz(Block) 的一个分块 |
| 6 | get-headers | 请求 | 定位符 |
| 7 | headers | 响应 | 一段连续的区块头 |
| 8 | get-peers | 请求 | 空 |
| 9 | peers | 响应 | 地址列表 |
共识对象没有任何网络专用的编码,正是这一点让 “我收到的东西的 id”成为一句可核查的陈述。
每一条入站消息都由其发送方付费#
这是规范文件中最值得完整读一遍的部分,因为对等协议通常正是在这里泄漏的。有两条规则管辖它。
成本排序规则#
工作按成本递增的顺序进行,而一条消息在其后那个昂贵步骤之前就被计费。 一个先验证、后计分的接收方等于造了一个放大器:发起来便宜的东西,正是核查起来昂贵的东西。
每一种结果都要计价#
不存在没有名字的 Free。每一种消息种类,与它可能产生的每一种
结果相交叉,都出现在一张带有分值的表里。一种没被计费就从表中漏过去的结果,正是这条规则存在所要防止的缺陷
— 而在实践中,它已经通过比“某一行没定价”更狭窄的路径被触达过:某个实现把一个格式错误的帧
从它唯一的计分器旁引开,结果只要还有一个请求未完成,那个帧就是免费的,而任何表里都没有多出一行。
提供服务同样是计量的。区块字节是唯一一种比索取它的请求大上几个数量级的响应, 所以除了请求计数之外,它们还自带一份字节预算。
连接管理,以及日蚀攻击防御#
这一节承载的实测失败案例比其他任何一节都多,而下面每一条要求之所以存在, 都是因为一个缺少它的实现在一次测量中被日蚀攻击了。
- 出站目标的选择必须带有地址多样性,好让一个托管地址段无法填满一个节点的出站槽位。
- 出站目标还必须按 gossip 来源加以限额。光有地址多样性是不够的,原因是算术上的,而不是判断上的:地址组是一个攻击者拥有的地址所具有的属性,而一个对等方声称的地址不过是它编出来的字节,而任意四个字节都是一个合法的 IPv4 主机。一个一个地址都没有的攻击者,只需一帧的代价,就能为每个字符串铸出一个全新的多样性组。它编不出来的,是这个声称所抵达时用的那条连接。
- 一条入站连接所抵达的那个套接字不得成为出站目标。那是对方的来源地址 — 一个由它的操作系统挑选的临时端口,而不是它监听的东西 — 所以花在拨打它上的槽位,是花在一个不可能应答的地址上的槽位。
- 这两条限额都必须针对一个节点所持有的连接来计数,而不是针对某一次选择调用。否则,一个把已连接的对等方排除在外的拨号循环,会在每一轮都把两份预算重新填满,于是这个限额只是让攻击者每份额度延迟一轮,而不是把它限制住。实测:一个报信方在四轮之内,先后占据了 8 个出站槽位中的 2 个、4 个、6 个、8 个。
- 对等节点存储必须被持久化,而且必须有上界。一个每次重启后都从空白开始的节点,等于在每次重启时都给攻击者一次全新的机会;而一个带着攻击者那份名单启动的节点,等于把同一次机会永久地交给了它。
- 一个有上界的存储不得因为已满而拒绝一个格式正确的地址;它要为之腾位。一个从未联系过的诚实地址,永远不会比一个从未联系过的编造地址更好,所以率先把存储填满的攻击者,就把它对之后提供的一切都锁死了 — 包括运营者自己的引导列表。
- 腾位时选中谁,比腾位这件事本身更要紧。“从最大的那个群体里挑受害者”读起来像是洪水把自己冲走,而它只有在洪水就是存储里最大的那个东西时才成立。在一个从一个热心对等方引导启动的节点上,最大的群体就是那个对等方的地址簿。在一个按此排序的实现上实测:来自单一来源的 200 个编造地址挤掉了 200 个诚实地址,而攻击者分文未付。
- 在无从区分的条目之间,最后的平局裁决不得是任何由散播消息的对等方所选择的东西 — 包括那个地址 — 而这一点适用于选择,正如它适用于腾位。在一个选择器最终落到地址字符串上的实现上实测:8 个诚实地址对 8 个编造地址,返回的是8 个全是编造的,而且十轮下来诚实的出站连接为零。
哪些东西是刻意缺席的#
| 缺席之物 | 为什么 |
|---|---|
| NAT 穿透 | 代价已被明说,而重开条件是测出来的,不是假定的:公共 testnet 上可被拨入的节点占比,是重开这项决定的首要条件。 |
| 压缩 | 在一条由攻击者控制的流上放一个压缩器,是一个攻击面,而换来的带宽节省,还没有人测量出对它的需求。 |
| 消息级签名 | 传输层认证的是通道;共识对象自带它们自己的签名。第三层将会去认证转发方,而这不是任何决定所依赖的东西。 |
| 请求 id | 请求与响应按连接和顺序配对,所以同步在它自己的连接上运行 — 这样就少了一个状态机。若某个对等方不可被拨入,同步可以在一条既有的 gossip 连接上运行,届时必须按内容来匹配响应。 |
| 一套向前兼容的扩展机制 | 协议版本 1 在握手时核对自己的版本,不匹配就断开。扩展将以协议版本 2 的形式到来。 |
一个凭证 id 没有覆盖什么#
值得在这里重述一遍,因为它决定了一条转发规则。一份凭证的 id 承诺的是它所 授权的东西,而绝不是它仅仅展示的东西,所以签名位于该 id 的原像之外。 这使得证据成为任何位于传输途中的人都能替换的东西:拿一份 凭证,把它的签名换成垃圾,然后传播出去 — id 相同,而实例字节此刻已然无效。
有两条规则堵上了它。区块要对它所携带的证据作出承诺,途径是一个建立在 实例字节哈希之上、而非建立在 id 之上的根。以及转发处理的是实例字节,不是 id: 一个收到某份实例字节、而其证据验证失败的节点,会丢弃那份实例字节, 而不牵连那个 id — 该 id 不会被标记,不会被缓存为无效,而稍后一份能通过验证的 实例字节会照常转发。一份被残害过的副本,让残害者付出发送它的带宽,而让那份凭证分文不损。
从零开始实现它#
一个能通过
黄金向量的独立实现是对等实现,不是分叉 — 这正是
以这种方式来制定规范的全部意义。请从
spec/README.md 开始了解共识对象,然后用
zcd vectors 核查你的成果。