死磕以太坊源碼分析之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 ConcatenationMAC(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)
- 驗證MAC
d = 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都驗證通過,則加密握手完成
如果首個數據幀的驗證失敗,則任意一方都可以斷開連接。
握手消息
發送端:
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-vsn
和 ack-vsn
中的所有不匹配。
實現必須忽略auth-body
和 ack-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-mac
和ingress-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-mac
和ingress-mac
,並且在ingress幀中比對header-mac
和 frame-mac
的值,就能對ingress幀中的MAC值進行校驗。這一步應當在解密header-ciphertext
和 frame-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-size
指msg-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”功能。初始握手后,連接的兩端都必須發送Hello或Disconnect消息。在接收到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://github.com/blockchainGuide/blockchainguide ☆ ☆ ☆ ☆ ☆
https://mindcarver.cn/ ☆ ☆ ☆ ☆ ☆