概述
读者可以前往我的博客获得更好的阅读体验。
关于以太坊的P2P
网络问题,目前的资料较为零散,本文尝试结合具体的go-ethereum
源代码,尽可能为读者完整介绍以太坊所使用的ÐΞVp2p
(devp2p
)网络架构和运转流程。
devp2p
各协议栈之间的关系可以参考下图:
其中,Node Discovery Protocol v5
运行在UDP
上,其余均运行在TCP
协议上。
本文会以节点第一次进入以太坊P2P网络的流程为主线,介绍Node Discovery Protocol v5
中各个模块的构成和作用。
前置知识
本文将介绍的很多组件均依赖于RLP
,所以在此处我们对其首先进行介绍。
RLP 编码
在以太坊中,我们使用的最基本的编码格式就是RLP
编码(递归长度前缀解码),这是一种特殊的序列化形式。RLP
可以对以下内容进行编码:
- 字符串(
String
),在编码过程中会被转换为纯粹的二进制数据字节 - 列表(
list
)
为了方便读者理解RLP
编码的具体功能和实现,我们将采用先介绍解码,再介绍编码的形式。
对于RLP
的解码,流程如下:
- 根据输入的第一个字节(即前缀)判断解码的数据类型、实际数据的长度和偏移量
- 根据数据类型和偏移量进行解码
- 根据第一步返回的实际数据长度寻找下一个前缀并重复解码流程
上述流程也说明了此编码协议的名称的由来。读者可以通过此工具为RLP online辅助学习。
接下来我们介绍二进制字节解码的规则:
- 前缀在
[0x00, 0x7f]
范围内,我们认为其自身就是数据,且解码类型为单个二进制字节 - 前缀为
0x80
,解码数据为空值(如uint(0)
、""
等) - 前缀为
[0x81, 0xb7]
,解码数据为字符串且前缀后跟长度等于第一个字节减去 0x80 的二进制字节 - 前缀为
[0xb8, 0xbf]
,解码数据为二进制字节,读取前缀后将前缀与0xb7
相减获得第二部分的长度,然后读取第二部分的长度即二进制字节
接下来,我们给出具体的实例:
0x8b68656c6c6f20776f726c64
对于上述内容进行解码,首先我们读取第一字节0x8b
,发现此字节符合条件3,我们协议计算字符串长度为0x8b - 0x80
,即 11 bytes
,这意味着字符串的具体长度为11 bytes
。我们依照此长度读取11 bytes
,就获得了解码后的数据0x68656c6c6f20776f726c64
。
如果你可以确认此数据的编码格式就可以进行下一步编码,在此处我们给出的数据使用了
UTF-8
编码,转换后为hello world
。
另一个例子如下:
0xb90400(61)*1024
(61)*1024
的意思为61
连续重复1024次。依照上述流程,我们首先读取前缀0xb9
,发现其大于0xb8
,相减后获得0xb9 - 0xb7
,即2 bytes
。我们读取后2 bytes
,即0400
(1024),这是最终的二进制字节长度,我们再读取1024 bytes
,获得(61) * 1024
。
同理,此处我们也使用了
utf-8
编码,转换后为A
综合使用上述规则,我们可以编码长度低于65536 bytes
的二进制字节。
接下来,我们介绍列表的解码规则:
- 前缀为
0xc0
,解码数据为空列表 - 前缀为
[0xc1, 0xf7]
,解码数据为列表,且长度为前缀与0xc0
的差 - 前缀为
[0xf8, 0xff]
, 解码数据为列表,读取前缀后将前缀与0xf7
相减获得第二部分的长度,然后读取第二部分的长度即列表长度
此规则与二进制字节的解码规则类似。
一个简单的例子:
0xcc 8568656c6c6f 85776f726c64
上述示例加空格只为了更好划分结构
我们首先读取前缀0xcc
,发现此数据结构为列表且长度为0xcc - 0xc0
,即12
。获得此消息后我们对后文数据进行读取,后文数据为两个字符串,读者可以根据上文给出的方法自行解码。将最后的数据使用utf-8
解码后获得["hello","world"]
如果读者对代码实现感兴趣,我非常建议阅读go-ethereum
的源代码。
ECDH
ECDH
是一种基于椭圆函数曲线的Diffie–Hellman
密钥协商算法。通过此算法,两个节点可以在不安全的信道中交换生成对称密码学的密钥。为方便对算法实现进行介绍,我们假设节点A与节点B需要进行ECDH
生成对称密码学密钥,同时节点A和节点B都可以简单获得对方的公钥。
交换流程如下:
- 节点查询对方的公钥,并与自己的私钥相乘
- 获得的乘法结果即为对称密码学密钥
证明如下:
节点A的公钥为 Q A = d A ⋅ G Q_A = d_A\cdot G QA=dA⋅G ,其中 d A d_A dA 为节点A的私钥
节点B的公钥为 Q B = d B ⋅ G Q_B = d_B\cdot G QB=dB⋅G ,其中 d B d_B dB 为节点A的私钥
依照上述给出的流程,结果如下:
节点A生成的密钥为 d A ⋅ Q B = d A ⋅ d B ⋅ G d_A\cdot Q_B = d_A\cdot d_B\cdot G dA⋅QB=dA⋅dB⋅G
节点B生成的密钥为 d B ⋅ Q A = d B ⋅ d A ⋅ G d_B\cdot Q_A = d_B\cdot d_A\cdot G dB⋅QA=dB⋅dA⋅G
显然,生成的密钥是相同的,完成了密钥的交换。
HKDF
HKDF
是一种基于HMAC
的密钥推导算法,由RFC5869进行了相关定义。其功能为给定随机密钥生成材料(如上文获得的ECDH
对称密钥),经过HKDF
函数可以获得给定长度的具有高密码学强度的密钥。简单来说,可以将此算法看作密钥长度转换算法。
在了解HKDF
前,我们首先需要知道HMAC
的运作原理,如下图:
我们在此处不详细介绍具体流程。读者若感兴趣,建议阅读《图解密码技术》 第三版的 194 页部分,非常详细介绍了相关的计算流程。上图即来自此书。
在了解了HMAC
流程后,我们就可以介绍HKDF
的相关流程。我们以上文生成的密钥作为基础材料,记为secret
,并使用 SHA256 作为哈希算法,目标是获得长度为 256 位的标准密钥。
第一步,将secret
作为信息,使用给定或随机生成的salt
作为密钥进行HMAC
计算获得MAC
值
第二步,我们需要获得 256 位的密钥,计算密钥长度与哈希算法输出值之间的比值并向上取整,此数值记为n
。由于此处我们选择的哈希算法与密钥长度正好相同,所以计算结果为n = 1
。接下来,我们进行以下计算:
T(0) = empty string (zero length)
T(1) = HMAC-Hash(PRK, T(0) | info | 0x01)
T(2) = HMAC-Hash(PRK, T(1) | info | 0x02)
T(3) = HMAC-Hash(PRK, T(2) | info | 0x03)
...
T(n) = HMAC-Hash(PRK, T(n-1) | info | hex(n))
将上述结果进行拼接T = T(1) | T(2) | T(3) | ... | T(N)
,最后选择T
的前 256 位作为密钥。
在Go
语言中,我们可以使用以下golang.org/x/crypto
中的官方实现:
func New(hash func() hash.Hash, secret, salt, info []byte) io.Reader
具体文档可参考这里
Node Discovery Protocol v5
作为一个全新的节点,加入以太坊网络最重要的一步就是尽可能与其他节点建立链接,完善自己的节点列表。下图表示了发现以太坊网络节点的流程:
我们会逐一解释其中每一个过程使用的具体协议和协议定义。
初始节点获得
当我们启动一个全新节点时,我们会与go-ethereum
规定的启动节点进行通信,这些节点的地址被硬编码在go-ethereum
源代码中,读者可以前往此处查看。我们可以看到其中有几种典型的编码格式,如下:
-
形如
enode://d860a01f9722d78051619d1e2351aba3f43f943f6f00718d1b9baa4101932a1f5011f16bb2b1bb35db20d6fe28fa0bf09636d26a87d31de9ec6203eeedb1f666@18.138.108.67:30303
,其中d860a01f9722d78051619d1e2351aba3f43f943f6f00718d1b9baa4101932a1f5011f16bb2b1bb35db20d6fe28fa0bf09636d26a87d31de9ec6203eeedb1f666
为节点运营者的未压缩公钥,其余数据为IP地址和端口号 -
形如
enr:-KG4QOtcP9X1FbIMOe17QNMKqDxCpm14jcX5tiOE4_TyMrFqbmhPZHK_ZPG2Gxb1GE2xdtodOfx9-cgvNtxnRyHEmC0ghGV0aDKQ9aX9QgAAAAD__________4JpZIJ2NIJpcIQDE8KdiXNlY3AyNTZrMaEDhpehBDbZjM_L9ek699Y7vhUJ-eAdMyQW_Fil522Y0fODdGNwgiMog3VkcIIjKA
,此形式使用了ENR
编码格式,由EIP778进行了相关定义。 -
形如
enrtree://AKA3AM6LPBYEUDMVNU3BSVQJ5AD45Y7YPOHJLEF6W26QOE4VTUDPE
,此形式使用了enrtree
的编码格式,由EIP1459进行定义,主要用于通过DNS查询对等节点
上述不同的编码格式中enode
格式最简单且具有自解释性,我们不会在后文进行讨论。后文主要讨论enr
和enrtree
形式。读者可以通过enode
中的公钥在EtherNode查询到所有节点的信息。
ENR
ENR
相较于较好理解的enode
格式由以下优势:
- 可拓展性高,由于
ENR
使用了以太坊中通用的RLP
编码,我们可以在其中加入其他数据 - 安全性好,
ENR
要求用户用户在数据内加入签名,避免了伪造节点信息的情况
在编码中,我们需要依次编码以下数据:
signature
签名,对于记录信息的签名seq
序列号,每当记录重新修改发布后一个增加此值- 其他键值数据
此处的其他键值数据理论上可以随意填写,填写时应保证键在前、值在后,同时尽可以保证字符使用ASCII
编码。但EIP1459
对于以下字段进行了预定义:
键 | 值 |
---|---|
id (0x6964) |
当前节点身份方案的名称,如v4 |
secp256k1 (0x736563703235366b31) |
压缩后的公钥( 33 bytes) |
ip (0x6970) |
节点的 IP 地址 |
tcp (0x746370) |
节点的 TCP 端口号 |
udp (0x756470) |
节点的 UDP 端口号 |
ip6 (0x697036) |
节点的 IPv6 地址( 16 bytes ) |
tcp6 (0x74637036) |
节点 IPv6 地址使用的 TCP 端口号 |
udp6 (0x75647036) |
节点 IPv6 地址使用的 UDP 端口号 |
其中键后的括号表示其名称的
ASCII
编码结果
接下来,我们给出以下参数:
- 私钥 0xb71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291
- seq 01
- id “v4”
- ip 127.0.0.1
- udp port 30303
我们使用上述参数构建此节点的enr
。
首先我们需要获得公钥,通过