@@ -1,58 +0,0 @@ 浅谈以太坊源码分析之rlpx协议 | 凤凰涅槃进阶之路

浅谈以太坊源码分析之rlpx协议

Abel sun2022年12月25日
约 3360 字大约 11 分钟

浅谈以太坊源码分析之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进行加密:

  1. 生成一个随机数r并生成对应的椭圆曲线公钥R = r * G
  2. 计算共享密码S = Px,其中 (Px, Py) = r * KB
  3. 推导加密及认证所需的密钥kE || kM = KDF(S, 32)以及随机向量iv
  4. 使用AES加密 c = AES(kE, iv, m)
  5. 计算MAC校验 d = MAC(keccak256(kM), iv || c)
  6. 发送完整密文R || iv || c || d给Bob

Bob对密文R || iv || c || d进行解密:

  1. 推导共享密码S = Px, 其中(Px, Py) = r * KB = kB * R
  2. 推导加密认证用的密钥kE || kM = KDF(S, 32)
  3. 验证MACd = MAC(keccak256(kM), iv || c)
  4. 获得明文m = AES(kE, iv || c)

节点身份

所有的加密操作都基于secp256k1椭圆曲线。每个节点维护一个静态的secp256k1私钥。建议该私钥只能进行手动重置(例如删除文件或数据库条目)。


握手流程

RLPx连接基于TCP通信,并且每次通信都会生成随机的临时密钥用于加密和验证。生成临时密钥的过程被称作“握手” (handshake),握手在发起端(initiator, 发起TCP连接请求的节点)和接收端(recipient, 接受连接的节点)之间进行。

  1. 发起端向接收端发起TCP连接,发送auth消息
  2. 接收端接受连接,解密、验证auth消息(检查recovery of signature == keccak256(ephemeral-pubk)
  3. 接收端通过remote-ephemeral-pubknonce生成auth-ack消息
  4. 接收端推导密钥,发送首个包含Helloopen in new window消息的数据帧 (frame)
  5. 发起端接收到auth-ack消息,导出密钥
  6. 发起端发送首个加密后的数据帧,包含发起端Helloopen in new window消息
  7. 接收端接收并验证首个加密后的数据帧
  8. 发起端接收并验证首个加密后的数据帧
  9. 如果两边的首个加密数据帧的MAC都验证通过,则加密握手完成

如果首个数据帧的验证失败,则任意一方都可以断开连接。

握手消息

发送端:

auth = auth-size || enc-auth-body
auth-size = size of enc-auth-body, encoded as a big-endian 16-bit integer
auth-vsn = 4
auth-body = [sig, initiator-pubk, initiator-nonce, auth-vsn, ...]
enc-auth-body = ecies.encrypt(recipient-pubk, auth-body || auth-padding, auth-size)
auth-padding = arbitrary data

接收端:

ack = ack-size || enc-ack-body
ack-size = size of enc-ack-body, encoded as a big-endian 16-bit integer
ack-vsn = 4
ack-body = [recipient-ephemeral-pubk, recipient-nonce, ack-vsn, ...]
enc-ack-body = ecies.encrypt(initiator-pubk, ack-body || ack-padding, ack-size)
ack-padding = arbitrary data

实现必须忽略auth-vsnack-vsn中的所有不匹配。

实现必须忽略auth-bodyack-body中的所有额外列表元素。

握手消息互换后,密钥生成:

static-shared-secret = ecdh.agree(privkey, remote-pubk)
ephemeral-key = ecdh.agree(ephemeral-privkey, remote-ephemeral-pubk)
shared-secret = keccak256(ephemeral-key || keccak256(nonce || initiator-nonce))
aes-secret = keccak256(ephemeral-key || shared-secret)
mac-secret = keccak256(ephemeral-key || aes-secret)

帧结构

握手后所有的消息都按帧 (frame) 传输。一帧数据携带属于某一功能的一条加密消息。

分帧传输的主要目的是在单一连接上实现可靠的支持多路复用协议。其次,因数据包分帧,为消息认证码产生了适当的分界点,使得加密流变得简单了。通过握手生成的密钥对数据帧进行加密和验证。

帧头提供关于消息大小和消息源功能的信息。填充字节用于防止缓存区不足,使得帧组件按指定区块字节大小对齐。

frame = header-ciphertext || header-mac || frame-ciphertext || frame-mac
header-ciphertext = aes(aes-secret, header)
header = frame-size || header-data || header-padding
header-data = [capability-id, context-id]
capability-id = integer, always zero
context-id = integer, always zero
header-padding = zero-fill header to 16-byte boundary
frame-ciphertext = aes(aes-secret, frame-data || frame-padding)
frame-padding = zero-fill frame-data to 16-byte boundary

MAC

RLPx中的消息认证 (Message authentication) 使用了两个keccak256状态,分别用于两个传输方向。egress-macingress-mac分别代表发送和接收状态,每次发送或者接收密文,其状态都会更新。初始握手后,MAC状态初始化如下:

发送端:

egress-mac = keccak256.init((mac-secret ^ recipient-nonce) || auth)
ingress-mac = keccak256.init((mac-secret ^ initiator-nonce) || ack)

接收端:

egress-mac = keccak256.init((mac-secret ^ initiator-nonce) || ack)
ingress-mac = keccak256.init((mac-secret ^ recipient-nonce) || auth)

当发送一帧数据时,通过即将发送的数据更新egress-mac状态,然后计算相应的MAC值。通过将帧头与其对应MAC值的加密输出异或来进行更新。这样做是为了确保对明文MAC和密文执行统一操作。所有的MAC值都以明文发送。

header-mac-seed = aes(mac-secret, keccak256.digest(egress-mac)[:16]) ^ header-ciphertext
egress-mac = keccak256.update(egress-mac, header-mac-seed)
header-mac = keccak256.digest(egress-mac)[:16]

计算 frame-mac

egress-mac = keccak256.update(egress-mac, frame-ciphertext)
frame-mac-seed = aes(mac-secret, keccak256.digest(egress-mac)[:16]) ^ keccak256.digest(egress-mac)[:16]
egress-mac = keccak256.update(egress-mac, frame-mac-seed)
frame-mac = keccak256.digest(egress-mac)[:16]

只要发送者和接受者按相同方式更新egress-macingress-mac,并且在ingress帧中比对header-macframe-mac的值,就能对ingress帧中的MAC值进行校验。这一步应当在解密header-ciphertextframe-ciphertext之前完成。


功能消息

初始握手后的所有消息均与“功能”相关。单个RLPx连接上就可以同时使用任何数量的功能。

功能由简短的ASCII名称和版本号标识。连接两端都支持的功能在隶属于“ p2p”功能的Helloopen in new window消息中进行交换,p2p功能需要在所有连接中都可用。

消息编码

初始Hello消息编码如下:

frame-data = msg-id || msg-data
frame-size = length of frame-data, encoded as a 24bit big-endian integer

其中,msg-id是标识消息的由RLP编码的整数,msg-data是包含消息数据的RLP列表。

Hello之后的所有消息均使用Snappy算法压缩。请注意,压缩消息的frame-sizemsg-data压缩前的大小。消息的压缩编码为:

frame-data = msg-id || snappyCompress(msg-data)
frame-size = length of (msg-id || msg-data) encoded as a 24bit big-endian integer

基于msg-id的复用

frame中虽然支持capability-id,但是在本RLPx版本中并没有将该字段用于不同功能之间的复用(当前版本仅使用msg-id来实现复用)。

每种功能都会根据需要分配尽可能多的msg-id空间。所有这些功能所需的msg-id空间都必须通过静态指定。在连接和接收Helloopen in new window消息时,两端都具有共享功能(包括版本)的对等信息,并且能够就msg-id空间达成共识。

msg-id应当大于0x11(0x00-0x10保留用于“ p2p”功能)。


p2p功能

所有连接都具有“p2p”功能。初始握手后,连接的两端都必须发送Helloopen in new windowDisconnectopen in new window消息。在接收到Hello消息后,会话就进入激活状态,并且可以开始发送其他消息。由于前向兼容性,实现必须忽略协议版本中的所有差异。与处于较低版本的节点通信时,实现应尝试靠近该版本。

任何时候都可能会收到Disconnectopen in new window消息。

Hello (0x00)

[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)

[reason: P]

通知节点断开连接。收到该消息后,节点应当立即断开连接。如果是发送,正常的主机会给节点2秒钟读取时间,使其主动断开连接。

reason 一个可选整数,表示断开连接的原因:

ReasonMeaning
0x00Disconnect requested
0x01TCP sub-system error
0x02Breach of protocol, e.g. a malformed message, bad RLP, ...
0x03Useless peer
0x04Too many peers
0x05Already connected
0x06Incompatible P2P protocol version
0x07Null node identity received - this is automatically invalid
0x08Client quitting
0x09Unexpected identity in handshake
0x0aIdentity is the same as this node (i.e. connected to itself)
0x0bPing timeout
0x10Some other reason specific to a subprotocol

Ping (0x02)

[]

要求节点立即进行Pongopen in new window回复。

Pong (0x03)

[]

回复节点的Pingopen in new window包。


源码分析

主要功能

返回传输对象

返回一个transport对象,连接持续5秒

// handshakeTimeout 5
func newRLPX(fd net.Conn) transport {
....
}

读取消息

返回Msg对象,调用读写器的ReadMsg,连接持续30秒

func (t *rlpx) ReadMsg() (Msg, error) {
  ..
 t.fd.SetReadDeadline(time.Now().Add(frameReadTimeout))
}

写入消息

调用读写器的WriteMsg写信息,连接持续20秒

func (t *rlpx) WriteMsg(msg Msg) error {
  ...
 t.fd.SetWriteDeadline(time.Now().Add(frameWriteTimeout))
}

协议版本握手

协议握手,输入输出均是protoHandshake对象,包含了版本号、名称、容量、端口号、ID和一个扩展属性,握手时会对这些信息进行验证

加密握手

握手时主动发起者叫initiator

接收方叫receiver

分别对应两种处理方式initiatorEncHandshake和receiverEncHandshake

两种处理方式成功以后都会得到一个secrets对象,保存了共享密钥信息,它会跟原有的net.Conn对象一起生成一个帧处理器:rlpxFrameRW

握手双方使用到的信息有:各自的公私钥地址对**(iPrv,iPub,rPrv,rPub)、各自生成的随机公私钥对(iRandPrv,iRandPub,rRandPrv,rRandPub)、各自生成的临时随机数(initNonce,respNonce).** 其中i开头的表示发起方**(initiator)信息,r开头的表示接收方(receiver)**信息.

func (t *rlpx) doEncHandshake(prv *ecdsa.PrivateKey, dial *ecdsa.PublicKey) (*ecdsa.PublicKey, error) {
 var (
  sec secrets
  err error
 )
 if dial == nil {
  sec, err = receiverEncHandshake(t.fd, prv) // 接收者
 } else {
  sec, err = initiatorEncHandshake(t.fd, prv, dial) //主动发起者
 }
...
 t.rw = newRLPXFrameRW(t.fd, sec)
 t.wmu.Unlock()
 return sec.Remote.ExportECDSA(), nil
}

这里我们就讲解一下主动握手部分源码initiatorEncHandshake

①:初始化握手对象

h := &encHandshake{initiator: true, remote: ecies.ImportECDSAPublic(remote)}

②:生成验证信息

authMsg, err := h.makeAuthMsg(prv) 
func (h *encHandshake) makeAuthMsg(prv *ecdsa.PrivateKey) (*authMsgV4, error) {
 // 生成己方随机数initNonce
 h.initNonce = make([]byte, shaLen)
 _, err := rand.Read(h.initNonce)
...
 }
// 生成随机的一组公私钥对
 h.randomPrivKey, err = ecies.GenerateKey(rand.Reader, crypto.S256(), nil)
...
 }
 // 生成静态共享秘密token(用己方私钥和对方公钥进行有限域乘法)
 token, err := h.staticSharedSecret(prv)
 ...
 }
//  和己方随机数异或后用随机生成的私钥签名
 signed := xor(token, h.initNonce)
 signature, err := crypto.Sign(signed, h.randomPrivKey.ExportECDSA())
...
 }
...
 return msg, nil
}

③:封包,将验证信息和握手进行rlp编码并拼接前缀信息

authPacket, err := sealEIP8(authMsg, h)

④:通过conn发送消息

conn.Write(authPacket)

⑤:处理接收的信息,得到响应包

readHandshakeMsg比较简单。 首先用一种格式尝试解码。如果不行就换另外一种。应该是一种兼容性的设置。 基本上就是使用自己的私钥进行解码然后调用rlp解码成结构体。

结构体的描述就是下面的authRespV4,里面最重要的就是对端的随机公钥。 双方通过自己的私钥和对端的随机公钥可以得到一样的共享秘密。 而这个共享秘密是第三方拿不到的

 authRespMsg := new(authRespV4)
 authRespPacket, err := readHandshakeMsg(authRespMsg, encAuthRespLen, prv, conn)

⑥:填充响应的respNonce(对方随机数,生成共享私钥用)和remoteRandomPub(对方的随机公钥)

 h.handleAuthResp(authRespMsg)

⑦:将请求包和响应包封装成共享秘密(secrets)

h.secrets(authPacket, authRespPacket)

到此RLPX 相关的比较重要的内容就解读差不多了。


参考

< /blockchainguide> ☆ ☆ ☆ ☆ ☆

https://mindcarver.cn/open in new window ☆ ☆ ☆ ☆ ☆

https://github.com/ethereum/devp2p/blob/master/rlpx.mdopen in new window

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.9.1