死磕以太坊源码分析之rlpx协议
本文主要参考自eth官方文档:rlpx协议
符号
- X || Y:表示X和Y的串联
- X ^ Y: X和Y按位异或
- X[:N]:X的前N个字节
- [X, Y, Z, ...]:[X, Y, Z, …]的RLP递归编码
- keccak256(MESSAGE):以太坊使用的keccak256哈希算法
- ecies.encrypt(PUBKEY, MESSAGE, AUTHDATA):RLPx使用的非对称身份验证加密函数 AUTHDATA是身份认证的数据,并非密文的一部分 但是AUTHDATA会在生成消息tag前,写入HMAC-256哈希函数
- ecdh.agree(PRIVKEY, PUBKEY):是PRIVKEY和PUBKEY之间的椭圆曲线Diffie-Hellman协商函数
ECIES加密
ECIES (Elliptic Curve Integrated Encryption Scheme) 非对称加密用于RLPx握手。RLPx使用的加密系统:
- 椭圆曲线secp256k1基点G
- KDF(k, len):密钥推导函数 NIST SP 800-56 Concatenation
- MAC(k, m):HMAC函数,使用了SHA-256哈希
- AES(k, iv, m):AES-128对称加密函数,CTR模式
假设Alice想发送加密消息给Bob,并且希望Bob可以用他的静态私钥kB解密。Alice知道Bob的静态公钥KB。
Alice为了对消息m进行加密:
- 生成一个随机数r并生成对应的椭圆曲线公钥R = r * G
- 计算共享密码S = Px,其中(Px, Py) = r * KB
- 推导加密及认证所需的密钥kE || kM = KDF(S, 32)以及随机向量iv
- 使用AES加密 c = AES(kE, iv, m)
- 计算MAC校验 d = MAC(keccak256(kM), iv || c)
- 发送完整密文R || iv || c || d给Bob
Bob对密文R || iv || c || d进行解密:
- 推导共享密码S = Px, 其中(Px, Py) = r * KB = kB * R
- 推导加密认证用的密钥kE || kM = KDF(S, 32)
- 验证MACd = MAC(keccak256(kM), iv || c)
- 获得明文m = AES(kE, iv || c)
节点身份
所有的加密操作都基于secp256k1椭圆曲线。每个节点维护一个静态的secp256k1私钥。建议该私钥只能进行手动重置(例如删除文件或数据库条目)。
握手流程
RLPx连接基于TCP通信,并且每次通信都会生成随机的临时密钥用于加密和验证。生成临时密钥的过程被称作“握手” (handshake),握手在发起端(initiator, 发起TCP连接请求的节点)和接收端(recipient, 接受连接的节点)之间进行。
- 发起端向接收端发起TCP连接,发送auth消息
- 接收端接受连接,解密、验证auth消息(检查recovery of signature ==keccak256(ephemeral-pubk))
- 接收端通过remote-ephemeral-pubk和nonce生成auth-ack消息
- 接收端推导密钥,发送首个包含Hello消息的数据帧 (frame)
- 发起端接收到auth-ack消息,导出密钥
- 发起端发送首个加密后的数据帧,包含发起端Hello消息
- 接收端接收并验证首个加密后的数据帧
- 发起端接收并验证首个加密后的数据帧
- 如果两边的首个加密数据帧的MAC都验证通过,则加密握手完成
如果首个数据帧的验证失败,则任意一方都可以断开连接。
握手消息
发送端:
| 1 | auth = auth-size || enc-auth-body | 
接收端:
| 1 | ack = ack-size || enc-ack-body | 
实现必须忽略auth-vsn 和 ack-vsn中的所有不匹配。
实现必须忽略auth-body 和 ack-body中的所有额外列表元素。
握手消息互换后,密钥生成:
| 1 | static-shared-secret = ecdh.agree(privkey, remote-pubk) | 
帧结构
握手后所有的消息都按帧 (frame) 传输。一帧数据携带属于某一功能的一条加密消息。
分帧传输的主要目的是在单一连接上实现可靠的支持多路复用协议。其次,因数据包分帧,为消息认证码产生了适当的分界点,使得加密流变得简单了。通过握手生成的密钥对数据帧进行加密和验证。
帧头提供关于消息大小和消息源功能的信息。填充字节用于防止缓存区不足,使得帧组件按指定区块字节大小对齐。
| 1 | frame = header-ciphertext || header-mac || frame-ciphertext || frame-mac | 
MAC
RLPx中的消息认证 (Message authentication) 使用了两个keccak256状态,分别用于两个传输方向。egress-mac和ingress-mac分别代表发送和接收状态,每次发送或者接收密文,其状态都会更新。初始握手后,MAC状态初始化如下:
发送端:
| 1 | egress-mac = keccak256.init((mac-secret ^ recipient-nonce) || auth) | 
接收端:
| 1 | egress-mac = keccak256.init((mac-secret ^ initiator-nonce) || ack) | 
当发送一帧数据时,通过即将发送的数据更新egress-mac状态,然后计算相应的MAC值。通过将帧头与其对应MAC值的加密输出异或来进行更新。这样做是为了确保对明文MAC和密文执行统一操作。所有的MAC值都以明文发送。
| 1 | header-mac-seed = aes(mac-secret, keccak256.digest(egress-mac)[:16]) ^ header-ciphertext | 
计算 frame-mac
| 1 | egress-mac = keccak256.update(egress-mac, frame-ciphertext) | 
只要发送者和接受者按相同方式更新egress-mac和ingress-mac,并且在ingress帧中比对header-mac 和 frame-mac的值,就能对ingress帧中的MAC值进行校验。这一步应当在解密header-ciphertext 和 frame-ciphertext之前完成。
功能消息
初始握手后的所有消息均与“功能”相关。单个RLPx连接上就可以同时使用任何数量的功能。
功能由简短的ASCII名称和版本号标识。连接两端都支持的功能在隶属于“ p2p”功能的Hello消息中进行交换,p2p功能需要在所有连接中都可用。
消息编码
初始Hello消息编码如下:
| 1 | frame-data = msg-id || msg-data | 
其中,msg-id是标识消息的由RLP编码的整数,msg-data是包含消息数据的RLP列表。
Hello之后的所有消息均使用Snappy算法压缩。请注意,压缩消息的frame-size指msg-data压缩前的大小。消息的压缩编码为:
| 1 | frame-data = msg-id || snappyCompress(msg-data) | 
基于msg-id的复用
frame中虽然支持capability-id,但是在本RLPx版本中并没有将该字段用于不同功能之间的复用(当前版本仅使用msg-id来实现复用)。
每种功能都会根据需要分配尽可能多的msg-id空间。所有这些功能所需的msg-id空间都必须通过静态指定。在连接和接收Hello消息时,两端都具有共享功能(包括版本)的对等信息,并且能够就msg-id空间达成共识。
msg-id应当大于0x11(0x00-0x10保留用于“ p2p”功能)。
p2p功能
所有连接都具有“p2p”功能。初始握手后,连接的两端都必须发送Hello或Disconnect消息。在接收到Hello消息后,会话就进入激活状态,并且可以开始发送其他消息。由于前向兼容性,实现必须忽略协议版本中的所有差异。与处于较低版本的节点通信时,实现应尝试靠近该版本。
任何时候都可能会收到Disconnect消息。
Hello (0x00)
| 1 | [protocolVersion: P, clientId: B, capabilities, listenPort: P, nodeKey: B_64, ...] | 
握手完成后,双方发送的第一包数据。在收到Hello消息前,不能发送任何其他消息。实现必须忽略Hello消息中所有其他列表元素,因为可能会在未来版本中用到。
- protocolVersion当前p2p功能版本为第5版
- clientId表示客户端软件身份,人类可读字符串, 比如”Ethereum(++)/1.0.0“
- capabilities支持的子协议列表,名称及其版本:- [[cap1, capVersion1], [cap2, capVersion2], ...]
- listenPort节点的收听端口 (位于当前连接路径的接口),0表示没有收听
- nodeIdsecp256k1的公钥,对应节点私钥
Disconnect (0x01)
| 1 | [reason: P] | 
通知节点断开连接。收到该消息后,节点应当立即断开连接。如果是发送,正常的主机会给节点2秒钟读取时间,使其主动断开连接。
reason 一个可选整数,表示断开连接的原因:
| Reason | Meaning | 
|---|---|
| 0x00 | Disconnect requested | 
| 0x01 | TCP sub-system error | 
| 0x02 | Breach of protocol, e.g. a malformed message, bad RLP, … | 
| 0x03 | Useless peer | 
| 0x04 | Too many peers | 
| 0x05 | Already connected | 
| 0x06 | Incompatible P2P protocol version | 
| 0x07 | Null node identity received - this is automatically invalid | 
| 0x08 | Client quitting | 
| 0x09 | Unexpected identity in handshake | 
| 0x0a | Identity is the same as this node (i.e. connected to itself) | 
| 0x0b | Ping timeout | 
| 0x10 | Some other reason specific to a subprotocol | 
Ping (0x02)
| 1 | [] | 
要求节点立即进行Pong回复。
Pong (0x03)
| 1 | [] | 
回复节点的Ping包。
源码分析
主要功能
返回传输对象
返回一个transport对象,连接持续5秒
| 1 | // handshakeTimeout 5 | 
读取消息
返回Msg对象,调用读写器的ReadMsg,连接持续30秒
| 1 | func (t *rlpx) ReadMsg() (Msg, error) { | 
写入消息
调用读写器的WriteMsg写信息,连接持续20秒
| 1 | func (t *rlpx) WriteMsg(msg Msg) error { | 
协议版本握手
协议握手,输入输出均是protoHandshake对象,包含了版本号、名称、容量、端口号、ID和一个扩展属性,握手时会对这些信息进行验证
加密握手
握手时主动发起者叫initiator
接收方叫receiver
分别对应两种处理方式initiatorEncHandshake和receiverEncHandshake
两种处理方式成功以后都会得到一个secrets对象,保存了共享密钥信息,它会跟原有的net.Conn对象一起生成一个帧处理器:rlpxFrameRW
握手双方使用到的信息有:各自的公私钥地址对**(iPrv,iPub,rPrv,rPub)、各自生成的随机公私钥对(iRandPrv,iRandPub,rRandPrv,rRandPub)、各自生成的临时随机数(initNonce,respNonce).**
其中i开头的表示发起方**(initiator)信息,r开头的表示接收方(receiver)**信息.
| 1 | func (t *rlpx) doEncHandshake(prv *ecdsa.PrivateKey, dial *ecdsa.PublicKey) (*ecdsa.PublicKey, error) { | 
这里我们就讲解一下主动握手部分源码initiatorEncHandshake:
①:初始化握手对象
| 1 | h := &encHandshake{initiator: true, remote: ecies.ImportECDSAPublic(remote)} | 
②:生成验证信息
| 1 | authMsg, err := h.makeAuthMsg(prv) | 
| 1 | func (h *encHandshake) makeAuthMsg(prv *ecdsa.PrivateKey) (*authMsgV4, error) { | 
③:封包,将验证信息和握手进行rlp编码并拼接前缀信息
| 1 | authPacket, err := sealEIP8(authMsg, h) | 
④:通过conn发送消息
| 1 | conn.Write(authPacket) | 
⑤:处理接收的信息,得到响应包
readHandshakeMsg比较简单。 首先用一种格式尝试解码。如果不行就换另外一种。应该是一种兼容性的设置。 基本上就是使用自己的私钥进行解码然后调用rlp解码成结构体。结构体的描述就是下面的authRespV4,里面最重要的就是对端的随机公钥。 双方通过自己的私钥和对端的随机公钥可以得到一样的共享秘密。 而这个共享秘密是第三方拿不到的
| 1 | authRespMsg := new(authRespV4) | 
⑥:填充响应的respNonce(对方随机数,生成共享私钥用)和remoteRandomPub(对方的随机公钥)
| 1 | h.handleAuthResp(authRespMsg) | 
⑦:将请求包和响应包封装成共享秘密(secrets)
| 1 | h.secrets(authPacket, authRespPacket) | 
到此RLPX 相关的比较重要的内容就解读差不多了。
参考
https://github.com/blockchainGuide/blockchainguide ☆ ☆ ☆ ☆ ☆
https://mindcarver.cn/ ☆ ☆ ☆ ☆ ☆