以太坊網絡架構解析

做者:0x7F@知道創宇404區塊鏈安全研究團隊html

0x00 前言

區塊鏈的火熱程度一直以直線上升,其中以區塊鏈 2.0 —— 以太坊爲表明,不斷的爲傳統行業帶來革新,同時也推進區塊鏈技術發展。node

區塊鏈是一種分佈式數據存儲、點對點傳輸、共識機制、加密算法等計算機技術的新型應用模式,這是一個典型的去中心化應用,創建在 p2p 網絡之上;本文以學習和分析以太坊運做原理爲目的,將以太坊網絡架構做爲一個切入點,逐步深刻分析,最終對以太坊網絡架構有個大體的瞭解。git

經過學習以太坊網絡架構,能夠更容易的對網絡部分的源碼進行審計,便於後續的協議分析,來發現未知的安全隱患;除此以外,目前基於 p2p 網絡的成熟的應用很是少,藉助分析以太坊網絡架構的機會,能夠學習一套成熟的 p2p 網絡運行架構。算法

本文側重於數據鏈路的創建和交互,不涉及網絡模塊中的節點發現、區塊同步、廣播等功能模塊。docker

0x01 目錄

  1. Geth 啓動
  2. 網絡架構
  3. 共享密鑰
  4. RLPXFrameRW 幀
  5. RLP 編碼
  6. LES 協議
  7. 總結

其中第 三、四、5 三個小節是第 2 節「網絡架構」的子內容,做爲詳細的補充。json

0x02 Geth 啓動

在介紹以太坊網絡架構以前,首先簡單分析下 Geth 的總體啓動流程,便於後續的理解和分析。數組

以太坊源碼目錄安全

  1.  
    tree -d -L 1
  2.  
    .
  3.  
    ├── accounts 帳號相關
  4.  
    ├── bmt 實現二叉merkle樹
  5.  
    ├── build 編譯生成的程序
  6.  
    ├── cmd geth程序主體
  7.  
    ├── common 工具函數庫
  8.  
    ├── consensus 共識算法
  9.  
    ├── console 交互式命令
  10.  
    ├── containers docker 支持相關
  11.  
    ├── contracts 合約相關
  12.  
    ├── core 以太坊核心部分
  13.  
    ├── crypto 加密函數庫
  14.  
    ├── dashboard 統計
  15.  
    ├── eth 以太坊協議
  16.  
    ├── ethclient 以太坊RPC客戶端
  17.  
    ├── ethdb 底層存儲
  18.  
    ├── ethstats 統計報告
  19.  
    ├── event 事件處理
  20.  
    ├── internal RPC調用
  21.  
    ├── les 輕量級子協議
  22.  
    ├── light 輕客戶端部分功能
  23.  
    ├── log 日誌模塊
  24.  
    ├── metrics 服務監控相關
  25.  
    ├── miner 挖礦相關
  26.  
    ├── mobile geth的移動端API
  27.  
    ├── node 接口節點
  28.  
    ├── p2p p2p網絡協議
  29.  
    ├── params 一些預設參數值
  30.  
    ├── rlp RLP系列化格式
  31.  
    ├── rpc RPC接口
  32.  
    ├── signer 簽名相關
  33.  
    ├── swarm 分佈式存儲
  34.  
    ├── tests 以太坊JSON測試
  35.  
    ├── trie Merkle Patricia實現
  36.  
    ├── vendor 一些擴展庫
  37.  
    └── whisper 分佈式消息
  38.  
     
  39.  
    35 directories

初始化工做服務器

Geth 的 main() 函數很是的簡潔,經過 app.Run() 來啓動程序:網絡

  1.  
    [./cmd/geth/main. go]
  2.  
    func main() {
  3.  
    if err := app.Run(os.Args); err != nil {
  4.  
    fmt.Fprintln(os.Stderr, err)
  5.  
    os.Exit( 1)
  6.  
    }
  7.  
    }

其簡潔是得力於 Geth 使用了 gopkg.in/urfave/cli.v1 擴展包,該擴展包用於管理程序的啓動,以及命令行解析,其中 app 是該擴展包的一個實例。

在 Go 語言中,在有 init() 函數的狀況下,會默認先調用 init() 函數,而後再調用 main() 函數;Geth 幾乎在 ./cmd/geth/main.go#init() 中完成了全部的初始化操做:設置程序的子命令集,設置程序入口函數等,下面看下 init() 函數片斷:

  1.  
    [./cmd/geth/main. go]
  2.  
    func init() {
  3.  
    // Initialize the CLI app and start Geth
  4.  
    app.Action = geth
  5.  
    app.HideVersion = true // we have a command to print the version
  6.  
    app.Copyright = "Copyright 2013-2018 The go-ethereum Authors"
  7.  
    app.Commands = []cli.Command{
  8.  
    // See chaincmd.go:
  9.  
    initCommand,
  10.  
    importCommand,
  11.  
    exportCommand,
  12.  
    importPreimagesCommand,
  13.  
    ...
  14.  
    }
  15.  
    ...
  16.  
    }
在以上代碼中,預設了 app實例的值,其中 app.Action = geth做爲 app.Run()調用的默認函數,而 app.Commands保存了子命令實例,經過匹配命令行參數能夠調用不一樣的函數(而不調用 app.Action),使用 Geth 不一樣的功能,如:開啓帶控制檯的 Geth、使用 Geth 創造創世塊等。

節點啓動流程

不管是經過 geth() 函數仍是其餘的命令行參數啓動節點,節點的啓動流程大體都是相同的,這裏以 geth() 爲例:

  1.  
    [./cmd/geth/main.go]
  2.  
    func geth(ctx *cli.Context) error {
  3.  
    node := makeFullNode(ctx)
  4.  
    startNode(ctx, node)
  5.  
    node. Wait()
  6.  
    return nil
  7.  
    }
其中makeFullNode()函數將返回一個節點實例,而後經過startNode()啓動。在 Geth 中,每個功能模塊都被視爲一個服務,每個服務的正常運行驅動着 Geth 的各項功能;makeFullNode()經過解析命令行參數,註冊指定的服務。如下是 makeFullNode()代碼片斷:
  1.  
    [./cmd/geth/config. go]
  2.  
    func makeFullNode(ctx *cli.Context) *node.Node {
  3.  
    stack, cfg := makeConfigNode(ctx)
  4.  
     
  5.  
    utils.RegisterEthService(stack, &cfg.Eth)
  6.  
     
  7.  
    if ctx.GlobalBool(utils.DashboardEnabledFlag.Name) {
  8.  
    utils.RegisterDashboardService(stack, &cfg.Dashboard, gitCommit)
  9.  
    }
  10.  
     
  11.  
    ...
  12.  
     
  13.  
    // Add the Ethereum Stats daemon if requested.
  14.  
    if cfg.Ethstats.URL != "" {
  15.  
    utils.RegisterEthStatsService(stack, cfg.Ethstats.URL)
  16.  
    }
  17.  
    return stack
  18.  
    }

而後經過 startNode() 啓動各項服務並運行節點。如下是 Geth 啓動流程圖:

每一個服務正常運行,相互協做,構成了 Geth:

0x03 網絡架構

經過 main() 函數的調用,最終啓動了 p2p 網絡,這一小節對網絡架構作詳細的分析。

三層架構
以太坊是去中心化的數字貨幣系統,自然適用 p2p 通訊架構,而且在其上還支持了多種協議。在以太坊中,p2p 做爲通訊鏈路,用於負載上層協議的傳輸,能夠將其分爲三層結構:

  1. 最上層是以太坊中各個協議的具體實現,如 eth 協議、les 協議。
  2. 第二層是以太坊中的 p2p 通訊鏈路層,主要負責啓動監聽、處理新加入鏈接或維護鏈接,爲上層協議提供了信道。
  3. 最下面的一層,是由 Go 語言所提供的網絡 IO 層,也就是對 TCP/IP 中的網絡層及如下的封裝。

p2p 通訊鏈路層
從最下層開始逐步分析,第三層是由 Go 語言所封裝的網絡 IO 層,這裏就跳過了,直接分析 p2p 通訊鏈路層。p2p 通訊鏈路層主要作了三項工做:

  1. 由上層協議的數據交付給 p2p 層後,首先經過 RLP 編碼。
  2. RLP 編碼後的數據將由共享密鑰進行加密,保證通訊過程當中數據的安全。
  3. 最後,將數據流轉換爲 RLPXFrameRW 幀,便於數據的加密傳輸和解析。
    (以上三點由下文作分析)

p2p 源碼分析
p2p 一樣做爲 Geth 中的一項服務,經過「0x03 Geth 啓動」中 startNode() 啓動,p2p 經過其 Start() 函數啓動。如下是 Start() 函數代碼片斷:

  1.  
    [./p2p/server. go]
  2.  
    func (srv *Server) Start() (err error) {
  3.  
    ...
  4.  
    if !srv.NoDiscovery {
  5.  
    ...
  6.  
    }
  7.  
    if srv.DiscoveryV5 {
  8.  
    ...
  9.  
    }
  10.  
    ...
  11.  
    // listen/dial
  12.  
    if srv.ListenAddr != "" {
  13.  
    if err := srv.startListening(); err != nil {
  14.  
    return err
  15.  
    }
  16.  
    }
  17.  
    ...
  18.  
    go srv.run(dialer)
  19.  
    ...
  20.  
    }

上述代碼中,設置了 p2p 服務的基礎參數,並根據用戶參數開啓節點發現(節點發現不在本文的討論範圍內),隨後開啓 p2p 服務監聽,最後開啓單獨的協程用於處理報文。如下分爲服務監聽和報文處理兩個模塊來分析。

服務監聽

經過 startListening() 的調用進入到服務監聽的流程中,隨後在該函數中調用 listenLoop 用一個無限循環處理接受鏈接,隨後經過 SetupConn() 函數爲正常的鏈接創建 p2p 通訊鏈路。在 SetupConn() 中調用 setupConn() 來作具體工做,如下是 setupConn() 的代碼片斷:

  1.  
    [./p2p/server.go]
  2.  
    func (srv *Server) setupConn(c *conn, flags connFlag, dialDest *discover.Node) error {
  3.  
    ...
  4.  
    if c.id, err = c.doEncHandshake(srv.PrivateKey, dialDest); err != nil {
  5.  
    srv.log. Trace("Failed RLPx handshake", "addr", c.fd.RemoteAddr(), "conn", c.flags, "err", err)
  6.  
    return err
  7.  
    }
  8.  
    ...
  9.  
    phs, err := c.doProtoHandshake(srv.ourHandshake)
  10.  
    ...
  11.  
    }

setupConn() 函數中主要由 doEncHandshake() 函數與客戶端交換密鑰,並生成臨時共享密鑰,用於本次通訊加密,並建立一個幀處理器 RLPXFrameRW;再調用 doProtoHandshake() 函數爲本次通訊協商遵循的規則和事務,包含版本號、名稱、容量、端口號等信息。在成功創建通訊鏈路,完成協議握手後,處理流程轉移到報文處理模塊。

下面是服務監聽函數調用流程:

報文處理

p2p.Start() 經過調用 run() 函數處理報文,run() 函數用無限循環等待事務,好比上文中,新鏈接完成握手包後,將由該函數來負責。run() 函數中支持多個命令的處理,包含的命令有服務退出清理、發送握手包、添加新節點、刪除節點等。如下是 run() 函數結構:

  1.  
    [./p2p/server. go]
  2.  
    func (srv *Server) run(dialstate dialer) {
  3.  
    ...
  4.  
    for {
  5.  
    select {
  6.  
    case <-srv.quit: ...
  7.  
    case n := <-srv.addstatic: ...
  8.  
    case n := <-srv.removestatic: ...
  9.  
    case op := <-srv.peerOp: ...
  10.  
    case t := <-taskdone: ...
  11.  
    case c := <-srv.posthandshake: ...
  12.  
    case c := <-srv.addpeer: ...
  13.  
    case pd := <-srv.delpeer: ...
  14.  
    }
  15.  
    }
  16.  
    }

爲了理清整個網絡架構,本文直接討論 addpeer 分支:當一個新節點添加服務器節點時,將進入到該分支下,根據以前的握手信息,爲上層協議生成實例,而後調用 runPeer(),最終經過 p.run() 進入報文的處理流程中。

繼續分析 p.run() 函數,其開啓了讀取數據和 ping 兩個協程,用於處理接收報文和維持鏈接,隨後經過調用 startProtocols() 函數,調用指定協議的 Run() 函數,進入具體協議的處理流程。

下面是報文處理函數調用流程

p2p 通訊鏈路交互流程

這裏總體看下 p2p 通訊鏈路的處理流程,以及對數據包的封裝。

0x04 共享密鑰

在 p2p 通訊鏈路的創建過程當中,第一步就是協商共享密鑰,該小節說明下密鑰的生成過程。

迪菲-赫爾曼密鑰交換
p2p 網絡中使用到的是「迪菲-赫爾曼密鑰交換」技術[1]。迪菲-赫爾曼密鑰交換(英語:Diffie–Hellman key exchange,縮寫爲D-H) 是一種安全協議。它可讓雙方在徹底沒有對方任何預先信息的條件下經過不安全信道建立起一個密鑰。

簡單來講,連接的兩方生成隨機的私鑰,經過隨機的私鑰獲得公鑰。而後雙方交換各自的公鑰,這樣雙方均可以經過本身隨機的私鑰和對方的公鑰來生成一個一樣的共享密鑰(shared-secret)。後續的通信使用這個共享密鑰做爲對稱加密算法的密鑰。其中對於 A、B公私鑰對知足這樣的數學等式:ECDH(A私鑰, B公鑰) == ECDH(B私鑰, A公鑰)

共享密鑰生成
在 p2p 網絡中由 doEncHandshake() 方法完成密鑰的交換和共享密鑰的生成工做。下面是該函數的代碼片斷:

  1.  
    [./p2p/rlpx. go]
  2.  
    func (t *rlpx) doEncHandshake(prv *ecdsa.PrivateKey, dial *discover.Node) (discover.NodeID, error) {
  3.  
    ...
  4.  
    if dial == nil {
  5.  
    sec, err = receiverEncHandshake(t.fd, prv, nil)
  6.  
    } else {
  7.  
    sec, err = initiatorEncHandshake(t.fd, prv, dial.ID, nil)
  8.  
    }
  9.  
    ...
  10.  
    t.rw = newRLPXFrameRW(t.fd, sec)
  11.  
    ..
  12.  
    }

若是做爲服務端監聽鏈接,收到新鏈接後調用 receiverEncHandshake() 函數,若做爲客戶端向服務端發起請求,則調用 initiatorEncHandshake()函數;兩個函數區別不大,都將交換密鑰,並生成共享密鑰,initiatorEncHandshake() 僅僅是做爲發起數據的一端;最終執行完後,調用 newRLPXFrameRW() 建立幀處理器。

從服務端的角度來看,將調用 receiverEncHandshake() 函數來建立共享密鑰,如下是該函數的代碼片斷:

  1.  
    [./p2p/rlpx. go]
  2.  
    func receiverEncHandshake(conn io.ReadWriter, prv *ecdsa.PrivateKey, token []byte) (s secrets, err error) {
  3.  
    authPacket, err := readHandshakeMsg(authMsg, encAuthMsgLen, prv, conn)
  4.  
    ...
  5.  
    authRespMsg, err := h.makeAuthResp()
  6.  
    ...
  7.  
    if _, err = conn.Write(authRespPacket); err != nil {
  8.  
    return s, err
  9.  
    }
  10.  
    return h.secrets(authPacket, authRespPacket)
  11.  
    }

共享密鑰生成的過程:

  1. 在完成 TCP 鏈接後,客戶端使用服務端的公鑰(node_id)加密,發送本身的公鑰和包含臨時公鑰的簽名,還有一個隨機值 nonce。
  2. 服務端收到數據,得到客戶端的公鑰,使用橢圓曲線算法從簽名中得到客戶端的臨時公鑰;服務端將本身的臨時公鑰和隨機值 nonce 用客戶端的公鑰加密發送。
  3. 經過上述兩步的密鑰交換後,對於客戶端目前有本身的臨時公私鑰對和服務端的臨時公鑰,使用橢圓曲線算法從本身的臨時私鑰和服務端的臨時公鑰計算得出共享密鑰;同理,服務端按照相同的方式也能夠計算出共享密鑰。

如下是共享密鑰生成圖示:

得出共享密鑰後,客戶端和服務端就可使用共享密鑰作對稱加密,完成對通訊的加密。

0x05 RLPXFrameRW 幀

在共享密鑰生成完畢後,初始化了 RLPXFrameRW 幀處理器;其 RLPXFrameRW 幀的目的是爲了在單個鏈接上支持多路複用協議。其次,因爲幀分組的消息爲加密數據流產生了自然的分界點,更便於數據的解析,除此以外,還能夠對發送的數據進行驗證。

RLPXFrameRW 幀包含了兩個主要函數,WriteMsg() 用於發送數據,ReadMsg()用於讀取數據;如下是 WriteMsg() 的代碼片斷:

  1.  
    [./p2p/rlpx. go]
  2.  
    func (rw *rlpxFrameRW) WriteMsg(msg Msg) error {
  3.  
    ...
  4.  
    // write header
  5.  
    headbuf := make([]byte, 32)
  6.  
    ...
  7.  
    // write header MAC
  8.  
    copy(headbuf[16:], updateMAC(rw.egressMAC, rw.macCipher, headbuf[:16]))
  9.  
    if _, err := rw.conn.Write(headbuf); err != nil {
  10.  
    return err
  11.  
    }
  12.  
     
  13.  
    // write encrypted frame, updating the egress MAC hash with
  14.  
    // the data written to conn.
  15.  
    tee := cipher.StreamWriter{S: rw.enc, W: io.MultiWriter(rw.conn, rw.egressMAC)}
  16.  
    if _, err := tee.Write(ptype); err != nil {
  17.  
    return err
  18.  
    }
  19.  
    if _, err := io.Copy(tee, msg.Payload); err != nil {
  20.  
    return err
  21.  
    }
  22.  
    if padding := fsize % 16; padding > 0 {
  23.  
    if _, err := tee.Write(zero16[:16-padding]); err != nil {
  24.  
    return err
  25.  
    }
  26.  
    }
  27.  
     
  28.  
    // write frame MAC. egress MAC hash is up to date because
  29.  
    // frame content was written to it as well.
  30.  
    fmacseed := rw.egressMAC.Sum( nil)
  31.  
    mac := updateMAC(rw.egressMAC, rw.macCipher, fmacseed)
  32.  
    _, err := rw.conn.Write(mac)
  33.  
    return err
  34.  
    }

結合以太坊 RLPX 的文檔[2]和上述代碼,能夠分析出 RLPXFrameRW 幀的結構。在通常狀況下,發送一次數據將產生五個數據包:

  1.  
    header // 包含數據包大小和數據包源協議
  2.  
    header_mac // 頭部消息認證
  3.  
    frame // 具體傳輸的內容
  4.  
    padding // 使幀按字節對齊
  5.  
    frame_mac // 用於消息認證

接收方按照一樣的格式對數據包進行解析和驗證。

0x06 RLP 編碼

RLP編碼 (遞歸長度前綴編碼)提供了一種適用於任意二進制數據數組的編碼,RLP 已經成爲以太坊中對對象進行序列化的主要編碼方式,便於對數據結構的解析。比起 json 數據格式,RLP 編碼使用更少的字節。

在以太坊的網絡模塊中,全部的上層協議的數據包要交互給 p2p 鏈路時,都要首先經過 RLP 編碼;從 p2p 鏈路讀取數據,也要先進行解碼才能操做。

以太坊中 RLP 的編碼規則[3]。

0x07 LES 協議層

這裏以 LES 協議爲上層協議的表明,分析在以太坊網絡架構中應用協議的工做原理。

LES 服務由 Geth 初始化時啓動,調用源碼 les 下的 NewLesServer() 函數開啓一個 LES 服務並初始化,並經過 NewProtocolManager() 實現以太坊子協議的接口函數。其中 les/handle.go 包含了 LES 服務交互的大部分邏輯。

回顧上文 p2p 網絡架構,最終 p2p 底層經過 p.Run() 啓動協議,在 LES 協議中,也就是調用 LES 協議的 Run() 函數:

  1.  
    [./les/handle. go#NewProtocolManager()]
  2.  
    Run: func(p *p2p.Peer, rw p2p.MsgReadWriter) error {
  3.  
    ...
  4.  
    select {
  5.  
    case manager.newPeerCh <- peer:
  6.  
    ...
  7.  
    err := manager.handle(peer)
  8.  
    ...
  9.  
    case <-manager.quitSync:
  10.  
    ...
  11.  
    }
  12.  
    }

能夠看到重要的處理邏輯都被包含在 handle() 函數中,handle() 函數的主要功能包含 LES 協議握手和消息處理,下面是 handle() 函數片斷:

  1.  
    [./les/handle.go]
  2.  
    func (pm *ProtocolManager) handle(p *peer) error {
  3.  
    ...
  4.  
    if err := p.Handshake(td, hash, number, genesis.Hash(), pm.server); err != nil {
  5.  
    p. Log().Debug("Light Ethereum handshake failed", "err", err)
  6.  
    return err
  7.  
    }
  8.  
    ...
  9.  
    for {
  10.  
    if err := pm.handleMsg(p); err != nil {
  11.  
    p. Log().Debug("Light Ethereum message handling failed", "err", err)
  12.  
    return err
  13.  
    }
  14.  
    }
  15.  
    }

在 handle() 函數中首先進行協議握手,其實現函數是 ./les/peer.go#Handshake(),經過服務端和客戶端交換握手包,互相獲取信息,其中包含有:協議版本、網絡號、區塊頭哈希、創世塊哈希等值。隨後用無線循環處理通訊的數據,如下是報文處理的邏輯:

  1.  
    [./les/handle. go]
  2.  
    func (pm *ProtocolManager) handleMsg(p *peer) error {
  3.  
    msg, err := p.rw.ReadMsg()
  4.  
    ...
  5.  
    switch msg.Code {
  6.  
    case StatusMsg: ...
  7.  
    case AnnounceMsg: ...
  8.  
    case GetBlockHeadersMsg: ...
  9.  
    case BlockHeadersMsg: ...
  10.  
    case GetBlockBodiesMsg: ...
  11.  
    ...
  12.  
    }
  13.  
    }

處理一個請求的詳細流程是:

  1. 使用 RLPXFrameRW 幀處理器,獲取請求的數據。
  2. 使用共享密鑰解密數據。
  3. 使用 RLP 編碼將二進制數據序列化。
  4. 經過對 msg.Code 的判斷,執行相應的功能。
  5. 對響應數據進行 RLP 編碼,共享密鑰加密,轉換爲 RLPXFrameRW,最後發送給請求方。

下面是 LES 協議處理流程:

0x08 總結

經過本文的分析,對以太坊網絡架構有了大體的瞭解,便於後續的分析和代碼審計;在安全方面來說,由協議所帶的安全問題每每比本地的安全問題更爲嚴重,應該對網絡層面的安全問題給予更高的關注。

從本文也能夠看到,以太坊網絡架構很是的完善,具備極高的魯棒性,這也證實了以太坊是能夠被市場所承認的區塊鏈系統。除此以外,因爲 p2p 網絡方向的資料較少,以太坊的網絡架構也能夠做爲學習 p2p 網絡的資料。

相關文章
相關標籤/搜索