主页 > imtoken钱包正确的下载地址 > 以太坊网络架构分析

以太坊网络架构分析

0x00 前言

区块链的受欢迎程度一直在直线上升。 其中以区块链2.0——以太坊为代表,不断为传统行业带来革新以太坊密钥,也推动了区块链技术的发展。

区块链是分布式数据存储、点对点传输、共识机制、加密算法等计算机技术的一种新型应用模式。 这是一个典型的建立在p2p网络上的去中心化应用; 以学习以太坊的运行原理为目的,以以太坊的网络架构为切入点,一步步分析,最终对以太坊的网络架构有一个大概的了解。

通过学习以太坊网络架构,更容易对网络部分的源代码进行审计,便于后续协议分析发现未知的安全隐患; 另外,目前基于p2p网络的成熟应用非常少。 以以太坊网络架构为契机,学习一套成熟的p2p网络运行架构。

本文重点介绍数据链路的建立和交互,不涉及网络模块中的节点发现、区块同步、广播等功能模块。

0x01目录Geth启动网络架构共享密钥RLPXFrameRW帧RLP编码LES协议总结

其中,3、4、5三个小节是第2节“网络架构”的子内容,作为详细补充。

0x02 Geth开始

在介绍以太坊网络架构之前,先简单分析一下Geth的整体启动流程,以方便后续的理解和分析。

以太坊源代码目录

tree -d -L 1
.
├── accounts                账号相关
├── bmt                     实现二叉merkle树
├── build                   编译生成的程序
├── cmd                     geth程序主体
├── common                  工具函数库
├── consensus               共识算法
├── console                 交互式命令
├── containers              docker 支持相关
├── contracts               合约相关
├── core                    以太坊核心部分
├── crypto                  加密函数库
├── dashboard               统计
├── eth                     以太坊协议
├── ethclient               以太坊RPC客户端
├── ethdb                   底层存储
├── ethstats                统计报告
├── event                   事件处理
├── internal                RPC调用
├── les                     轻量级子协议 
├── light                   轻客户端部分功能
├── log                     日志模块
├── metrics                 服务监控相关
├── miner                   挖矿相关
├── mobile                  geth的移动端API
├── node                    接口节点
├── p2p                     p2p网络协议
├── params                  一些预设参数值
├── rlp                     RLP系列化格式 
├── rpc                     RPC接口
├── signer                  签名相关
├── swarm                   分布式存储
├── tests                   以太坊JSON测试
├── trie                    Merkle Patricia实现
├── vendor                  一些扩展库
└── whisper                 分布式消息
35 directories

初始化工作

Geth的main()函数非常简洁,通过app.Run()启动程序

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

以太坊密钥_以太坊和以太币有什么区别_以太坊联盟和以太坊的关系

} }

它的简洁是由于Geth使用了gopkg.in/urfave/cli.v1扩展包,用于管理程序启动和命令行解析,其中app是扩展包的一个实例。

在Go语言中,当有init()函数时,默认会先调用init()函数,然后再调用main()函数; Geth在./cmd/geth/main.go#init()中几乎完成了所有的初始化操作:设置程序的子命令集,设置程序入口函数等,我们来看一下init()函数片段:

[./cmd/geth/main.go]
func init() {
    // Initialize the CLI app and start Geth
    app.Action = geth
    app.HideVersion = true // we have a command to print the version 
    app.Copyright = "Copyright 2013-2018 The go-ethereum Authors"
    app.Commands = []cli.Command{
        // See chaincmd.go:
        initCommand,
        importCommand,
        exportCommand,
        importPreimagesCommand,
        ...
    }
    ...
}

上面代码中预设了app实例的值,其中app.Action = geth是app.Run()默认调用的函数,app.Commands保存的是子命令实例,不同的函数可以通过匹配command来调用行参数(不调用 app.Action),使用 Geth 的不同功能,例如:使用控制台打开 Geth,使用 Geth 创建创世块等。

节点启动流程

无论是通过geth()函数还是其他命令行参数启动节点,节点的启动过程大致相同。 这里以 geth() 为例:

[./cmd/geth/main.go]
func geth(ctx *cli.Context) error {
    node := makeFullNode(ctx)
    startNode(ctx, node)
    node.Wait()
    return nil
}

makeFullNode() 函数会返回一个节点实例,然后通过 startNode() 启动它。 在Geth中,每个功能模块都被视为一个服务,每个服务的正常运行驱动着Geth的各种功能; makeFullNode() 通过解析命令行参数注册指定的服务。 以下是 makeFullNode() 代码片段:

[./cmd/geth/config.go]
func makeFullNode(ctx *cli.Context) *node.Node {
    stack, cfg := makeConfigNode(ctx)
    utils.RegisterEthService(stack, &cfg.Eth)
    if ctx.GlobalBool(utils.DashboardEnabledFlag.Name) {
        utils.RegisterDashboardService(stack, &cfg.Dashboard, gitCommit)
    }
    ...
    // Add the Ethereum Stats daemon if requested.
    if cfg.Ethstats.URL != "" {
        utils.RegisterEthStatsService(stack, cfg.Ethstats.URL)
    }
    return stack
}

然后启动各种服务,通过startNode()运行节点。 以下为Geth启动流程图:

以太坊和以太币有什么区别_以太坊密钥_以太坊联盟和以太坊的关系

各个服务都正常运行,相互配合,共同组成Geth:

以太坊密钥_以太坊联盟和以太坊的关系_以太坊和以太币有什么区别

0x03 网络架构

通过main()函数的调用以太坊密钥,最终启动了p2p网络。 本节详细分析网络架构。

以太坊密钥_以太坊联盟和以太坊的关系_以太坊和以太币有什么区别

三层架构

以太坊是一个去中心化的数字货币系统,天然适用于p2p通信架构,支持多种协议。 在以太坊中,p2p作为上层协议传输的通信链路,可分为三层:

以太坊密钥_以太坊和以太币有什么区别_以太坊联盟和以太坊的关系

最上层是以太坊中各个协议的具体实现,比如eth协议和les协议。 第二层是以太坊中的p2p通信链路层,主要负责启动监听,处理新连接或维护连接,为上层协议提供通道。 最底层是Go语言提供的网络IO层,是TCP/IP对网络层及以下的封装。

p2p通信链路层

从底层开始,第三层是Go语言封装的网络IO层,这里略过,直接分析p2p通信链路层。 p2p通信链路层主要做三项工作:

以太坊和以太币有什么区别_以太坊密钥_以太坊联盟和以太坊的关系

来自上层协议的数据传递到p2p层后,首先经过RLP编码。 RLP 编码后的数据将通过共享密钥进行加密,以确保通信过程中的数据安全。 最后将数据流转换为RLPXFrameRW帧,便于数据的加密传输和分析。

(以上三点分析如下)

p2p源码分析

p2p作为Geth中的服务,也是通过“0x03 Geth Start”中的startNode()启动的,p2p是通过其Start()函数启动的。 以下是 Start() 函数的代码片段:

[./p2p/server.go]
func (srv *Server) Start() (err error) {
    ...
    if !srv.NoDiscovery {
        ...
    }
    if srv.DiscoveryV5 {
        ...
    }
    ...
    // listen/dial
    if srv.ListenAddr != "" {
        if err := srv.startListening(); err != nil {
            return err
        }
    }
    ...
    go srv.run(dialer)
    ...
}

上面代码中设置了p2p服务的基本参数,根据用户参数开启节点发现(节点发现不在本文讨论范围),然后开启p2p服务监控,以及最后,为消息处理启用了一个单独的协程。 下面分为服务监控和消息处理两个模块进行分析。

服务监控

通过startListening()的调用进入服务监听的过程,然后在这个函数中调用listenLoop接受连接,无限循环,再通过SetupConn()函数为正常连接建立p2p通信链路。 在SetupConn()中调用setupConn()做具体的工作,下面是setupConn()的代码片段:

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

在setupConn()函数中,doEncHandshake()函数主要是与客户端交换密钥,生成临时共享密钥用于加密本次通信,并创建帧处理器RLPXFrameRW; 然后调用doProtoHandshake()函数为本次通信协商遵循的规则和事务,包括版本号、名称、容量、端口号等信息。 通信链路建立成功并完成协议握手后,处理流程转入报文处理模块。

下面是调用服务监控函数的过程:

以太坊密钥_以太坊和以太币有什么区别_以太坊联盟和以太坊的关系

消息处理

p2p.Start() 通过调用 run() 函数来处理消息。 run() 函数使用无限循环来等待交易。 比如上面,新连接完成握手包后,这个函数就会负责。 run()函数支持多种命令的处理,包括服务出口清理、发送握手包、添加新节点、删除节点等命令。以下是run()函数结构:

[./p2p/server.go]
func (srv *Server) run(dialstate dialer) {
    ...
    for {
        select {

以太坊联盟和以太坊的关系_以太坊和以太币有什么区别_以太坊密钥

case <-srv.quit: ... case n := <-srv.addstatic: ... case n := <-srv.removestatic: ... case op := <-srv.peerOp: ... case t := <-taskdone: ... case c := <-srv.posthandshake: ... case c := <-srv.addpeer: ... case pd := <-srv.delpeer: ... } } }

为了弄清楚整个网络架构,本文直接讨论addpeer分支:当一个新节点添加一个server节点时,会进入这个分支,根据之前的握手信息为上层协议生成一个实例,然后调用runPeer(),最后通过p.run()进入消息的处理流程。

继续分析p.run()函数,该函数开启读取数据和ping两个协程,用于处理收到的消息和保持连接,然后通过调用startProtocols( )函数进入具体协议的处理流程。

以下是消息处理函数调用流程

以太坊密钥_以太坊和以太币有什么区别_以太坊联盟和以太坊的关系

p2p通信链接交互流程

这里整体看一下p2p通信链路的处理流程和数据包的封装。

以太坊密钥_以太坊和以太币有什么区别_以太坊联盟和以太坊的关系

0x04 共享密钥

在建立p2p通信链路的过程中,第一步是协商共享密钥。 本节介绍密钥生成过程。

Diffie-Hellman 密钥交换

p2p 网络中使用的是“Diffie-Hellman 密钥交换”技术[]。 迪菲-赫尔曼密钥交换(英文:Diffie–Hellman key exchange,缩写为DH)是一种安全协议。 它允许两方通过不安全的通道创建密钥,而无需来自另一方的任何先验信息。

简单来说,链路中的双方生成一个随机私钥,通过随机私钥得到公钥。 然后双方交换各自的公钥,这样双方就可以通过自己的随机私钥和对方的公钥生成相同的共享秘密。 后续通信使用此共享密钥作为对称加密算法的密钥。 其中,A和B的公私钥对满足数学等式:ECDH(A私钥,B公钥)==ECDH(B私钥,A公钥)。

共享密钥生成

在p2p网络中,密钥交换和共享密钥的生成都是通过doEncHandshake()方法完成的。 下面是该函数的代码片段:

[./p2p/rlpx.go]
func (t *rlpx) doEncHandshake(prv *ecdsa.PrivateKey, dial *discover.Node) (discover.NodeID, error) {
    ...
    if dial == nil {
        sec, err = receiverEncHandshake(t.fd, prv, nil)
    } else {
        sec, err = initiatorEncHandshake(t.fd, prv, dial.ID, nil)
    }
    ...
    t.rw = newRLPXFrameRW(t.fd, sec)
    ..
}

如果作为服务端监听连接,在收到新的连接后调用receiverEncHandshake()函数,作为客户端调用initiatorEncHandshake()函数向服务端发起请求; 两个函数没有太大区别,都会交换密钥并生成共享密钥,initiatorEncHandshake()只是作为数据发起的一端; 最后执行后,调用 newRLPXFrameRW() 创建帧处理程序。

从服务器的角度来看,将调用 receiverEncHandshake() 函数来创建共享密钥。 以下是该函数的代码片段:

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

共享密钥生成过程:

完成 TCP 连接后,客户端使用服务器的公钥(node_id)进行加密,发送自己的公钥和包含临时公钥的签名,以及一个随机值 nonce。 服务端接收数据,获取客户端的公钥,利用椭圆曲线算法从签名中获取客户端的临时公钥; 服务器发送自己的临时公钥和用客户端公钥加密的随机值 nonce。 经过以上两步密钥交换后,对于客户端当前拥有自己的临时公私密钥对和服务器的临时公钥,利用椭圆曲线算法从自己的临时私钥和临时公钥中计算出共享密钥服务器的公钥; 同样,服务端也可以用同样的方法计算出共享密钥。

下面是共享密钥生成的图示:

以太坊联盟和以太坊的关系_以太坊和以太币有什么区别_以太坊密钥

客户端和服务端获得共享密钥后,就可以使用共享密钥进行对称加密,完成通信的加密。

以太坊和以太币有什么区别_以太坊密钥_以太坊联盟和以太坊的关系

0x05 RLPXFrameRW 帧

生成共享密钥后,初始化 RLPXFrameRW 帧处理程序; 其 RLPXFrameRW 帧的目的是支持单个连接上的多路复用协议。 其次,由于帧组的消息为加密后的数据流创建了一个天然的分界点,更容易对数据进行分析。 此外,还可以验证发送的数据。

RLPXFrameRW帧包含两个主要函数,WriteMsg()用于发送数据,ReadMsg()用于读取数据; 以下是 WriteMsg() 的代码片段:

[./p2p/rlpx.go]
func (rw *rlpxFrameRW) WriteMsg(msg Msg) error {
    ...
    // write header
    headbuf := make([]byte, 32)
    ...
    // write header MAC
    copy(headbuf[16:], updateMAC(rw.egressMAC, rw.macCipher, headbuf[:16]))
    if _, err := rw.conn.Write(headbuf); err != nil {
        return err
    }
    // write encrypted frame, updating the egress MAC hash with
    // the data written to conn.
    tee := cipher.StreamWriter{S: rw.enc, W: io.MultiWriter(rw.conn, rw.egressMAC)}
    if _, err := tee.Write(ptype); err != nil {
        return err
    }
    if _, err := io.Copy(tee, msg.Payload); err != nil {
        return err
    }
    if padding := fsize % 16; padding > 0 {
        if _, err := tee.Write(zero16[:16-padding]); err != nil {
            return err
        }
    }
    // write frame MAC. egress MAC hash is up to date because
    // frame content was written to it as well.
    fmacseed := rw.egressMAC.Sum(nil)
    mac := updateMAC(rw.egressMAC, rw.macCipher, fmacseed)
    _, err := rw.conn.Write(mac)
    return err
}

结合以太坊RLPX的文档[]和上面的代码,可以分析出RLPXFrameRW框架的结构。 一般来说,发送一次数据会产生五个数据包:

header          // 包含数据包大小和数据包源协议
header_mac      // 头部消息认证
frame           // 具体传输的内容
padding         // 使帧按字节对齐
frame_mac       // 用于消息认证

接收方根据相同的格式解析和验证数据包。

0x06 RLP编码

RLP 编码(递归长度前缀编码)提供了一种适用于任意二进制数据数组的编码。 RLP成为以太坊中序列化对象的主要编码方式,方便了数据结构的分析。 RLP 编码比 json 数据格式使用更少的字节。

在以太坊的网络模块中,上层协议的所有数据包要交换到p2p链路时,首先要经过RLP编码; 要从 p2p 链接中读取数据,必须先在操作前对其进行解码。

以太坊中 RLP 的编码规则[]。

0x07 LES协议层

这里以LES协议作为上层协议的代表,分析应用协议在以太坊网络架构中的工作原理。

LES 服务在 Geth 初始化时启动。 调用源码文件下的NewLesServer()函数启动并初始化一个LES服务,通过NewProtocolManager()实现以太坊子协议的接口函数。 其中,les/handle.go包含了LES服务交互的大部分逻辑。

回顾上面的p2p网络架构,p2p的底层最终是通过p.Run()来启动协议的。 在LES协议中,它调用了LES协议的Run()函数:

[./les/handle.go#NewProtocolManager()]
Run: func(p *p2p.Peer, rw p2p.MsgReadWriter) error {
    ...

以太坊和以太币有什么区别_以太坊密钥_以太坊联盟和以太坊的关系

select { case manager.newPeerCh <- peer: ... err := manager.handle(peer) ... case <-manager.quitSync: ... } }

可见重要的处理逻辑都包含在handle()函数中。 handle()函数的主要功能包括LES协议握手和消息处理。 以下是 handle() 函数片段:

[./les/handle.go]
func (pm *ProtocolManager) handle(p *peer) error {
    ...
    if err := p.Handshake(td, hash, number, genesis.Hash(), pm.server); err != nil {
        p.Log().Debug("Light Ethereum handshake failed", "err", err)
        return err
    }
    ...
    for {
        if err := pm.handleMsg(p); err != nil {
            p.Log().Debug("Light Ethereum message handling failed", "err", err)
            return err
        }
    }
}

在 handle() 函数中,首先执行协议握手。 实现函数为./les/peer.go#Handshake()。 服务端和客户端交换握手包,获取对方的信息,包括:协议版本、网络号、区块头哈希、创世区块哈希等。然后使用无线循环处理通信数据,以下是逻辑消息处理:

[./les/handle.go]
func (pm *ProtocolManager) handleMsg(p *peer) error {
    msg, err := p.rw.ReadMsg()
    ...
    switch msg.Code {
        case StatusMsg: ...
        case AnnounceMsg: ...
        case GetBlockHeadersMsg: ...
        case BlockHeadersMsg: ...
        case GetBlockBodiesMsg: ...
        ...
    }
}

处理一个请求的详细过程是:

使用 RLPXFrameRW 帧处理程序获取请求的数据。 使用共享密钥解密数据。 使用 RLP 编码序列化二进制数据。 通过判断msg.Code执行相应的功能。 RLP对响应数据进行编码,用共享密钥加密,转换为RLPXFrameRW,最后发送给请求者。

下面是LES协议处理流程:

以太坊和以太币有什么区别_以太坊联盟和以太坊的关系_以太坊密钥

0x08 摘要

通过本文的分析,对以太坊网络架构有了一个大概的了解,方便后续的分析和代码审计; 在安全方面,协议带来的安全问题往往比本地的安全问题更为严重,网络应该对层面的安全问题给予更高的重视。

从这篇文章中也可以看出,以太坊网络架构非常完备,极其健壮,这也证明了以太坊是一个能够被市场认可的区块链系统。 此外,由于缺乏关于p2p网络方向的资料,以太坊的网络架构也可以作为学习p2p网络的资料。

针对目前主流的以太坊应用,智创宇提供专业、权威的智能合约审计服务,避免因合约安全问题造成的财产损失,为各类以太坊应用的安全保驾护航。

参考:

[1] WIKI.DH:–Hellman_key_exchange

[2] Github.rlpx:

[3] 维基.RLP:

[4] Github.ZtesoftCS:

[5]CSDN:

[6]CSDN:

[7] 乙醚:

[8] 比硕:

[9] Github.go-以太坊: