死磕以太坊源碼分析之rlpx協議


死磕以太坊源碼分析之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. 接收端推導密鑰,發送首個包含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

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”功能的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表示沒有收聽
  • nodeIdsecp256k1的公鑰,對應節點私鑰

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://github.com/blockchainGuide/blockchainguide ☆ ☆ ☆ ☆ ☆

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

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM