5

死磕以太坊源码分析之rlpx协议

 3 years ago
source link: https://learnblockchain.cn/article/1979
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

死磕以太坊源码分析之 rlpx 协议
文章资料:https://github.com/blockchainGuide/blockchainguide

本文主要参考自 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. 验证 Mac d = 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. 接收端推导密钥,发送首个包含 Hello 消息的数据帧 (frame)
  5. 发起端接收到 auth-ack 消息,导出密钥
  6. 发起端发送首个加密后的数据帧,包含发起端 Hello 消息
  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

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”功能的 Hello 消息中进行交换,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 空间都必须通过静态指定。在连接和接收 Hello 消息时,两端都具有共享功能(包括版本)的对等信息,并且能够就 msg-id 空间达成共识。

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


p2p 功能

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

任何时候都可能会收到 Disconnect 消息。

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 表示没有收听
  • nodeId secp256k1 的公钥,对应节点私钥

Disconnect (0x01)

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

[]

要求节点立即进行 Pong 回复。

Pong (0x03)

[]

回复节点的 Ping 包。


返回传输对象

返回一个 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 相关的比较重要的内容就解读差不多了。


公众号: 区块链技术栈

https://mindcarver.cn/ ☆ ☆ ☆ ☆ ☆

https://github.com/ethereum/devp2p/blob/master/rlpx.md


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK