死磕以太坊原始碼分析之rlpx協議
阿新 • • 發佈:2020-11-24
> 死磕以太坊原始碼分析之rlpx協議
>
本文主要參考自eth官方文件:[rlpx協議](https://github.com/blockchainGuide)
## 符號
- `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-pubk` 和 `nonce`生成`auth-ack`訊息
4. 接收端推導金鑰,傳送首個包含[Hello](https://github.com/ethereum/devp2p/blob/master/rlpx.md#hello-0x00)訊息的資料幀 (frame)
5. 發起端接收到`auth-ack`訊息,匯出金鑰
6. 發起端傳送首個加密後的資料幀,包含發起端[Hello](https://github.com/ethereum/devp2p/blob/master/rlpx.md#hello-0x00)訊息
7. 接收端接收並驗證首個加密後的資料幀
8. 發起端接收並驗證首個加密後的資料幀
9. 如果兩邊的首個加密資料幀的MAC都驗證通過,則加密握手完成
如果首個數據幀的驗證失敗,則任意一方都可以斷開連線。
### 握手訊息
**傳送端:**
```go
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
```
**接收端:**
```go
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`中的所有額外列表元素。
握手訊息互換後,金鑰生成:
```go
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) 傳輸。一幀資料攜帶屬於某一功能的一條加密訊息。
分幀傳輸的主要目的是在單一連線上實現可靠的支援多路複用協議。其次,因資料包分幀,為訊息認證碼產生了適當的分界點,使得加密流變得簡單了。通過握手生成的金鑰對資料幀進行加密和驗證。
幀頭提供關於訊息大小和訊息源功能的資訊。填充位元組用於防止快取區不足,使得幀元件按指定區塊位元組大小對齊。
```go
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狀態初始化如下:
**傳送端:**
```go
egress-mac = keccak256.init((mac-secret ^ recipient-nonce) || auth)
ingress-mac = keccak256.init((mac-secret ^ initiator-nonce) || ack)
```
**接收端:**
```go
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](https://github.com/ethereum/devp2p/blob/master/rlpx.md#hello-0x00)訊息中進行交換,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](https://github.com/ethereum/devp2p/blob/master/rlpx.md#hello-0x00)訊息時,兩端都具有共享功能(包括版本)的對等資訊,並且能夠就msg-id空間達成共識。
msg-id應當大於0x11(0x00-0x10保留用於“ p2p”功能)。
-----
## p2p功能
所有連線都具有“p2p”功能。初始握手後,連線的兩端都必須傳送[Hello](https://github.com/ethereum/devp2p/blob/master/rlpx.md#hello-0x00)或[Disconnect](https://github.com/ethereum/devp2p/blob/master/rlpx.md#disconnect-0x01)訊息。在接收到Hello訊息後,會話就進入啟用狀態,並且可以開始傳送其他訊息。由於前向相容性,實現必須忽略協議版本中的所有差異。與處於較低版本的節點通訊時,實現應嘗試靠近該版本。
任何時候都可能會收到[Disconnect](https://github.com/ethereum/devp2p/blob/master/rlpx.md#disconnect-0x01)訊息。
### 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](https://github.com/ethereum/devp2p/blob/master/rlpx.md#pong-0x03)回覆。
### Pong (0x03)
```
[]
```
回覆節點的[Ping](https://github.com/ethereum/devp2p/blob/master/rlpx.md#ping-0x02)包。
-----
## 原始碼分析
### 主要功能
#### 返回傳輸物件
> 返回一個transport物件,連線持續5秒
```go
// handshakeTimeout 5
func newRLPX(fd net.Conn) transport {
....
}
```
#### 讀取訊息
> 返回Msg物件,呼叫讀寫器的ReadMsg,連線持續30秒
```go
func (t *rlpx) ReadMsg() (Msg, error) {
..
t.fd.SetReadDeadline(time.Now().Add(frameReadTimeout))
}
```
#### 寫入訊息
> 呼叫讀寫器的WriteMsg寫資訊,連線持續20秒
```go
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)**資訊.
```go
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`:
①:初始化握手物件
```GO
h := &encHandshake{initiator: true, remote: ecies.ImportECDSAPublic(remote)}
```
②:生成驗證資訊
```go
authMsg, err := h.makeAuthMsg(prv)
```
```GO
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編碼並拼接字首資訊
```go
authPacket, err := sealEIP8(authMsg, h)
```
④:通過conn傳送訊息
```go
conn.Write(authPacket)
```
⑤:處理接收的資訊,得到響應包
> `readHandshakeMsg`比較簡單。 首先用一種格式嘗試解碼。如果不行就換另外一種。應該是一種相容性的設定。 基本上就是使用自己的私鑰進行解碼然後呼叫rlp解碼成結構體。
>
> 結構體的描述就是下面的authRespV4,裡面最重要的就是對端的隨機公鑰。 雙方通過自己的私鑰和對端的隨機公鑰可以得到一樣的共享祕密。 而這個共享祕密是第三方拿不到的
```GO
authRespMsg := new(authRespV4)
authRespPacket, err := readHandshakeMsg(authRespMsg, encAuthRespLen, prv, conn)
```
⑥:填充響應的respNonce(對方隨機數,生成共享私鑰用)和remoteRandomPub(對方的隨機公鑰)
```GO
h.handleAuthResp(authRespMsg)
```
⑦:將請求包和響應包封裝成共享祕密(secrets)
```go
h.secrets(authPacket, authRespPacket)
```
到此RLPX 相關的比較重要的內容就解讀差不多了。
------
## 參考
> https://github.com/blockchainGuide/blockchainguide ☆ ☆ ☆ ☆ ☆
>
> https://mindcarver.cn/ ☆ ☆ ☆ ☆ ☆
>
> https://github.com/ethereum/devp2p/blob/master/r