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

mindcarver發表於2020-11-24

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

相關文章