死磕以太坊原始碼分析之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/ ☆ ☆ ☆ ☆ ☆