剥开比原看代码04:如何连上一个比原节点

做者:freewindnode

比原项目仓库:react

Github地址:https://github.com/Bytom/bytomgit

Gitee地址:https://gitee.com/BytomBlockchain/bytomgithub

在上一篇咱们已经知道了比原是如何监听节点的p2p端口,本篇就要继续在上篇中提到的问题:咱们如何成功的链接上比原的节点,而且经过身份验证,以便后续继续交换数据?golang

在上一篇中,咱们的比原节点是以solonet这个chain_id启动的,它监听的是46658端口。咱们可使用telnet连上它:算法

$ telnet localhost 46658
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
ט�S��%�z?��_�端��݂���U[e

能够看到,它发过来了一些乱码。这些乱码是什么意思?咱们应该怎么应答它?这是本篇将要回答的问题。编程

定位发送代码

首先咱们得定位到比原向刚链接上来的节点发送数据的地方。说实话,这里实在是太绕了,山路十八弯,每次我想找到这段代码,都须要花好一阵功夫。因此下面这段流程,我以为你之后可能常常会过来看看。数组

总的来讲,在比原中有一个Switch类,它用于集中处理节点与外界交互的逻辑,而它的建立和启动,又都是在SyncManager中进行的。另外,监听p2p端口并拿到相应的链接对象的操做,与跟链接的对象进行数据交互的操做,又是分开的,前者是在建立SyncManager的时候进行的,后者是在SyncManager的启动(Start)方法里交由Switch进行的。因此整体来讲,这一块逻辑有点复杂(乱),绕来绕去的。安全

这里不先评价代码的好坏,咱们仍是先把比原的处理逻辑搞清楚吧。bash

下面仍是从启动开始,可是因为咱们在前面已经出现过屡次,因此我会尽可能把不须要的代码省略掉,带着你们快速到达目的地,而后再详细分析。

首先是bytomd node的入口函数:

cmd/bytomd/main.go#L54

func main() {
    cmd := cli.PrepareBaseCmd(commands.RootCmd, "TM", os.ExpandEnv(config.DefaultDataDir()))
    cmd.Execute()
}

转交给处理参数node的函数:

cmd/bytomd/commands/run_node.go#L41

func runNode(cmd *cobra.Command, args []string) error {
    // Create & start node
    n := node.NewNode(config)
    if _, err := n.Start(); err != nil {
    // ...
}

如前一篇所述,“监听端口”的操做是在node.NewNode(config)中完成的,此次发送数据的任务是在n.Start()中进行的。

可是咱们仍是须要看一下node.NewNode,由于它里在建立SyncManager对象的时候,生成了一个供当前链接使用的私钥,它会在后面用到,用于产生公钥。

node/node.go#L59-L142

func NewNode(config *cfg.Config) *Node {
    // ...
    syncManager, _ := netsync.NewSyncManager(config, chain, txPool, newBlockCh)
    // ...
}

netsync/handle.go#L42-L82

func NewSyncManager(config *cfg.Config, chain *core.Chain, txPool *core.TxPool, newBlockCh chan *bc.Hash) (*SyncManager, error) {
    manager := &SyncManager{
        txPool:     txPool,
        chain:      chain,
        privKey:    crypto.GenPrivKeyEd25519(),
        // ...
}

就是这个privKey,它是经过ed25519生成的,后面会用到。这个私钥仅在本次链接中使用,每一个链接都会生成一个新的。

让咱们再回到主线runNode,其中n.Start又将被转交到NodeOnStart方法:

node/node.go#L169

func (n *Node) OnStart() error {
    // ...
    n.syncManager.Start()
    // ...
}

转交到SyncManagerStart方法:

netsync/handle.go#L141

func (sm *SyncManager) Start() {
    go sm.netStart()
    // ...
}

而后在另外一个例程(goroutine)中调用了netStart()方法:

netsync/handle.go#L121

func (sm *SyncManager) netStart() error {
    // Start the switch
    _, err := sm.sw.Start()
    // ...
}

在这里终于调用了SwitchStart方法(sm.sw中的sw就是一个Switch对象):

p2p/switch.go#L186

func (sw *Switch) OnStart() error {
    // ...
    // Start listeners
    for _, listener := range sw.listeners {
        go sw.listenerRoutine(listener)
    }
    // ...
}

这里的sw.listeners,就包含了监听p2p端口的listener。而后调用listenerRoutine()方法,感受快到了。

p2p/switch.go#L496

func (sw *Switch) listenerRoutine(l Listener) {
    // ...
    err := sw.addPeerWithConnectionAndConfig(inConn, sw.peerConfig)
    // ...    
}

在这里拿到了链接到p2p端口的链接对象inConn们,传入一堆参数,准备大刑伺候:

p2p/switch.go#L643

func (sw *Switch) addPeerWithConnectionAndConfig(conn net.Conn, config *PeerConfig) error {
    // ...
    peer, err := newInboundPeerWithConfig(conn, sw.reactorsByCh, sw.chDescs, sw.StopPeerForError, sw.nodePrivKey, config)
    // ...
}

把须要的参数细化出来,再次传入:

p2p/peer.go#L87

func newInboundPeerWithConfig(conn net.Conn, reactorsByCh map[byte]Reactor, chDescs []*ChannelDescriptor, onPeerError func(*Peer, interface{}), ourNodePrivKey crypto.PrivKeyEd25519, config *PeerConfig) (*Peer, error) {
    return newPeerFromConnAndConfig(conn, false, reactorsByCh, chDescs, onPeerError, ourNodePrivKey, config)
}

再继续,立刻就到了。

p2p/peer.go#L91

func newPeerFromConnAndConfig(rawConn net.Conn, outbound bool, reactorsByCh map[byte]Reactor, chDescs []*ChannelDescriptor, onPeerError func(*Peer, interface{}), ourNodePrivKey crypto.PrivKeyEd25519, config *PeerConfig) (*Peer, error) {
    // ...
    // Encrypt connection
    if config.AuthEnc {
        // ...
        conn, err = MakeSecretConnection(conn, ourNodePrivKey)
        // ...
    }
    // ...
}

终于到了关键的函数MakeSecretConnection()了。因为config.AuthEnc的默认值是true,因此若是没有特别设置的话,它就会进入MakeSecretConnection,在这里完成身份验证等各类操做,它也是咱们本篇讲解的重点。

好,下面咱们开始。

详解MakeSecretConnection

这个函数的逻辑看起来是至关复杂的,引入了不少密钥和各类加解密,还屡次跟相应的peer进行数据发送和接收,若是不明白它为何要这么作,是很难理解清楚的。好在一旦理解之后,明白了它的意图,整个就简单了。

总的来讲,比原的节点之间的数据交互,是须要很高的安全性的,尤为是数据不能明文传送,不然一旦遇到了坏的“中间人”(能够理解为数据从一个节点到另外一个节点中途须要通过的各类网关、路由器、代理等等),数据就有可能被窃取甚至修改。考虑一下这个场景:用户A想把100万个比原从本身的账号转到用户B的账户,结果信息被中间人修改,最后转到了中间人指定的账户C,那么这损失就大了,甚至没法追回。(有同窗问,“区块链上的每一个交易不是会有多个节点验证吗?若是只有单一节点使坏,应该不会生效吧”。我考虑的是这样一种状况,好比某用户在笔记本上运行比原节点,而后在公开场合上网,使用了黑客提供的wifi。那么该节点与其它结点的全部链接均可以被中间人攻击,广播出去的交易能够同时被修改,这样其它节点拿到的都是修改后的交易。至于这种方法是否能够生效,还须要我读完更多的代码才能肯定,这里暂时算是一个猜测吧,等我之后再来确认)

因此比原节点之间传输信息的时候是加密的,使用了某些非对称加密的方法。这些方法须要在最开始的时候,节点双方都把本身的公钥转给对方,以后再发信息时就可使用对方的公钥加密,再由对方使用私钥解密。加密后的数据,虽然还会通过各类中间人的转发才能到达对方,可是只要中间人没有在最开始拿到双方的明文公钥并替换成本身的假冒公钥,它就没有办法知道真实的数据是什么,也就没有办法窃取或修改。

因此这个函数的最终目的,就是:把本身的公钥安全的发送给对方,同时安全得拿到对方的公钥。

若是仅仅是发送公钥,那本质上就是发送一些字节数据过去,应该很简单。可是比原为了达到安全的目的,还进行了以下的思考:

  1. 只发送公钥还不够,还须要先用个人私钥把一段数据签个名,一块儿发过去,让对方验证一下,以保证我发过去的公钥是正确的
  2. 明文发送公钥不安全,因此得把它加密一下再发送
  3. 为了加密发送,我和对方都须要生成另外一对一次性的公钥和私钥,专门用于此次加密,用完后就丢掉
  4. 为了让咱们双方都能正确的加解密,因此须要找到一种方式,在两边生成一样的用于签名的数据(challenge)和加解密时须要的参数(sharedSecret, sendNonce/recvNonce

另外还有一些过分的考虑:

  1. 在发送加密数据的时候,担忧每次要发送的数据过多,影响性能,因此把数据分红多个块发送
  2. 为了配合屡次发送和接收,还须要考虑如何让两边的sendNoncerecvNonce保持同步改变
  3. 在发送公钥及签名数据时,把它们包装成了一个对象,再进行额外的序列化和反序列化操做

我之因此认为这些是“过分”的考虑,是由于在这个交互过程当中,数据的长度是固定的,而且很短(只有100多个字节),根本不须要考虑分块。另外公钥和签名数据就是两个简单的、长度固定的字节数组,而且只在这里用一次,我以为能够直接发送两个数组便可,包装成对象及序列化后,咱们还须要考虑序列化以后的数组长度是如何变化的。

在查阅了相关的代码之后,我发现这一处逻辑只在这里使用了一次,没有必要提早考虑到通用但更复杂的状况,提早编码。毕竟那些状况有可能永远不会发生,而提早写好的代码所增长的复杂度以及可能多出来的bug倒是永远存在了。

《敏捷软件开发 原则、模式和实践》这本书告诉咱们:不要预先设计,尽可能用简单的办法实现,等到变化真的到来了,再考虑如何重构让它适应这种变化。

下面讲解“MakeSecretConnection”,因为该方法有点长,因此会分红几块:

p2p/listener.go#L52

func MakeSecretConnection(conn io.ReadWriteCloser, locPrivKey crypto.PrivKeyEd25519) (*SecretConnection, error) {

    locPubKey := locPrivKey.PubKey().Unwrap().(crypto.PubKeyEd25519)

首先注意的是参数locPrivKey,它就是在前面最开始的时候,在SyncManager中生成的用于本次链接通讯的私钥。而后根据该私钥,生成对应的公钥,对于同一个私钥,生成的公钥老是相同的。

这个私钥的长度是64字节,公钥是32字节,可见二者不是同样长的。公钥短一些,更适合加密(速度快一点)。

呆会儿在最后会使用该私钥对一段数据进行签名,而后跟这个公钥一块儿,通过加密后发送给peer,让他验证。成功以后,对方会一直持有这个公钥,向咱们发送数据前会用它对数据进行加密。

接着,

// Generate ephemeral keys for perfect forward secrecy.
    locEphPub, locEphPriv := genEphKeys()

这里生成了一对一次性的公私钥,用于本次链接中对开始那个公钥(和签名数据)进行加密。

待会儿会发把这里生成的locEphPub以明文的方式传给对方(为何是明文?由于必须得有一次明文发送,否则对方一开始就拿到加密的数据无法解开),它就咱们在本文开始经过telnet localhost 46658时收到的那一堆乱码。

genEphKeys(),对应于:

p2p/secret_connection.go#L189

func genEphKeys() (ephPub, ephPriv *[32]byte) {
    var err error
    ephPub, ephPriv, err = box.GenerateKey(crand.Reader)
    if err != nil {
        cmn.PanicCrisis("Could not generate ephemeral keypairs")
    }
    return
}

它调用了golang.org/x/crypto/nacl/boxGenerateKey函数,在内部使用了curve25519算法,生成的两个key的长度都是32字节。

能够看到,它跟前面的公私钥的长度不是彻底同样的,可见二者使用了不一样的加密算法。前面的是ed25519,而这里是curve25519

接着回到MakeSecretConnection,继续:

// Write local ephemeral pubkey and receive one too.
    // NOTE: every 32-byte string is accepted as a Curve25519 public key
    // (see DJB's Curve25519 paper: http://cr.yp.to/ecdh/curve25519-20060209.pdf)
    remEphPub, err := shareEphPubKey(conn, locEphPub)
    if err != nil {
        return nil, err
    }

这个shareEphPubKey就是把刚生成的一次性的locEphPub发给对方,同时也从对方那里读取对方生成的一次性公钥(长度为32字节):

p2p/secret_connection.go#L198

func shareEphPubKey(conn io.ReadWriteCloser, locEphPub *[32]byte) (remEphPub *[32]byte, err error) {
    var err1, err2 error

    cmn.Parallel(
        func() {
            _, err1 = conn.Write(locEphPub[:])
        },
        func() {
            remEphPub = new([32]byte)
            _, err2 = io.ReadFull(conn, remEphPub[:])
        },
    )

    if err1 != nil {
        return nil, err1
    }
    if err2 != nil {
        return nil, err2
    }

    return remEphPub, nil
}

因为MakeSecretConnection这个函数,是两个比原节点在创建起p2p链接时都会执行的,因此二者要作的事情都是同样的。若是我发了数据,则对方也会发相应的数据,而后两边都须要读取。因此我发了什么样的数据,我也要同时拿到什么样的数据。

再回想本文开始提到的telnet localhost 46658,当咱们接收到那一段乱码时,也须要给对方发过去32个字节,双方才能进行下一步。

再回到MakeSecretConnection,接着:

// Compute common shared secret.
    shrSecret := computeSharedSecret(remEphPub, locEphPriv)

双方拿到对方的一次性公钥后,都会和本身生成的一次性私钥(注意,是私钥)作一个运算,生成一个叫shrSecret的密钥在后面使用。怎么用呢?就是用它来对要发送的公钥及签名数据进行加密,以及对对方发过来的公钥和签名数据进行解密。

computeSharedSecret函数对应的代码是这样:

p2p/secret_connection.go#L221

func computeSharedSecret(remPubKey, locPrivKey *[32]byte) (shrSecret *[32]byte) {
    shrSecret = new([32]byte)
    box.Precompute(shrSecret, remPubKey, locPrivKey)
    return
}

它是经过对方的公钥和本身的私钥算出来的。

这里有一个神奇的地方,就是双方算出来的shrSecret是同样的!也就是说,假设这里使用该算法(curve25519)生成了两对公私钥:

privateKey1, publicKey1
privateKey2, publicKey2

而且

publicKey2 + privateKey1 ===> sharedSecret1
publicKey1 + privateKey2 ===> sharedSecret2

那么sharedSecret1sharedSecret2是同样的,因此双方才能够拿各自算出来的shrSecret去解密对方的加密数据。

再接着,会根据双方的一次性公钥作一些计算,以供后面使用。

// Sort by lexical order.
    loEphPub, hiEphPub := sort32(locEphPub, remEphPub)

首先是拿对方和本身的一次性公钥进行排序,这样两边获得的loEphPubhiEphPub就是同样的,后面在计算数值时就能获得相同的值。

而后是计算nonces,

// Generate nonces to use for secretbox.
    recvNonce, sendNonce := genNonces(loEphPub, hiEphPub, locEphPub == loEphPub)

nonces和前面的shrSecret都是在给公钥和签名数据加解密时使用的。其中shrSecret是固定的,而nonce在不一样的信息之间是应该不一样的,用于区别信息。

这里计算出来的recvNoncesendNonce,一个是用于接收数据后解密,一个是用于发送数据时加密。链接双方的这两个数据都是相反的,也就是说,一方的recvNonce与另外一方的sendNonce相等,这样当一方使用sendNonce加密后,另外一方才可使用相同数值的recvNonce进行解密。

在后面咱们还能够看到,当一方发送完数据后,其持有的sendNonce会增2,另外一方接收并解密后,其recvNonce也会增2,双方始终保持一致。(为何是增2而不是增1,后面有解答)

genNonces的代码以下:

p2p/secret_connection.go#L238

func genNonces(loPubKey, hiPubKey *[32]byte, locIsLo bool) (recvNonce, sendNonce *[24]byte) {
    nonce1 := hash24(append(loPubKey[:], hiPubKey[:]...))
    nonce2 := new([24]byte)
    copy(nonce2[:], nonce1[:])
    nonce2[len(nonce2)-1] ^= 0x01
    if locIsLo {
        recvNonce = nonce1
        sendNonce = nonce2
    } else {
        recvNonce = nonce2
        sendNonce = nonce1
    }
    return
}

能够看到,其中的一个nonce就是把前面排序后的loPubKeyhiPubKey组合起来,而另外一个nonce就是把最后一个bit的值由0变成1(或者由1变成0),这样二者就会是一个奇数一个偶数。然后来在对nonce进行自增操做的时候,每次都是增2,这样就保证了recvNoncesendNonce不会出现相等的状况,是一个很巧妙的设计。

后面又经过判断local is loPubKey,保证了两边获得的recvNoncesendNonce正好相反,且一边的recvNonce与另外一边的sendNonce正好相等。

再回到MakeSecretConnection,继续:

// Generate common challenge to sign.
    challenge := genChallenge(loEphPub, hiEphPub)

这里根据loEphPubhiEphPub计算出来challenge,在后面将会使用本身的私钥对它进行签名,再跟公钥一块儿发给对方,让对方验证。因为双方的loEphPubhiEphPub是相等的,因此算出来的challenge也是相等的。

p2p/secret_connection.go#L253

func genChallenge(loPubKey, hiPubKey *[32]byte) (challenge *[32]byte) {
    return hash32(append(loPubKey[:], hiPubKey[:]...))
}

能够看到genChallenge就是把两个一次性公钥放在一块儿,并作了一个hash操做,获得了一个32字节的数组。

其中的hash32采用了SHA256的算法,它生成摘要的长度就是32个字节。

p2p/secret_connection.go#L303

func hash32(input []byte) (res *[32]byte) {
    hasher := sha256.New()
    hasher.Write(input) // does not error
    resSlice := hasher.Sum(nil)
    res = new([32]byte)
    copy(res[:], resSlice)
    return
}

再回到MakeSecretConnection,继续:

// Construct SecretConnection.
    sc := &SecretConnection{
        conn:       conn,
        recvBuffer: nil,
        recvNonce:  recvNonce,
        sendNonce:  sendNonce,
        shrSecret:  shrSecret,
    }

这里是生成了一个SecretConnection的对象,把相关的nonces和shrSecret传过去,由于呆会儿对公钥及签名数据的加解密操做,都放在了那边,而这几个参数都是须要用上的。

前面通过了这么多的准备工做,终于差很少了。下面将会使用本身的私钥对challenge数据进行签名,而后跟本身的公钥一块儿发送给对方:

// Sign the challenge bytes for authentication.
    locSignature := signChallenge(challenge, locPrivKey)

    // Share (in secret) each other's pubkey & challenge signature
    authSigMsg, err := shareAuthSignature(sc, locPubKey, locSignature)
    if err != nil {
        return nil, err
    }

其中的signChallenge就是简单的使用本身的私钥对challenge数据进行签名,获得的是一个32字节的摘要:

func signChallenge(challenge *[32]byte, locPrivKey crypto.PrivKeyEd25519) (signature crypto.SignatureEd25519) {
    signature = locPrivKey.Sign(challenge[:]).Unwrap().(crypto.SignatureEd25519)
    return
}

而在shareAuthSignature中,则是把本身的公钥与签名后的数据locSignature一块儿,通过SecretConnection的加密后传给对方,也同时从对方那里读取他的公钥和签名数据,再解密。因为这一块代码涉及的东西比较多(有分块,加解密,序列化与反序列化),因此放在后面再讲。

再而后,

remPubKey, remSignature := authSigMsg.Key, authSigMsg.Sig
    if !remPubKey.VerifyBytes(challenge[:], remSignature) {
        return nil, errors.New("Challenge verification failed")
    }

从对方传过来的数据中拿出对方的公钥和对方签过名的数据,对它们进行验证。因为对方在签名时,使用的challenge数据和咱们这边产生的challenge同样,因此能够直接拿出本地的challenge使用。

最后,若是验证经过的话,则把对方的公钥也加到SecretConnection对象中,供之后使用。

// We've authorized.
    sc.remPubKey = remPubKey.Unwrap().(crypto.PubKeyEd25519)
    return sc, nil
}

到这里,咱们就能够回答最开始的问题了:咱们应该怎样链接一个比原节点呢?

答案就是:

  1. 先连上对方的p2p端口
  2. 读取32个字节,这是对方的一次性公钥
  3. 把本身生成的一次性公钥发给对方
  4. 读取对方通过加密后的公钥+签名数据,并验证
  5. 把本身的公钥和签名数据通过加密后,发送给对方,等待对方验证
  6. 若是两边都没有断开,则说明验证经过,后面就能够进行更多的数据交互啦

关于shareAuthSignature的细节

前面说到,当使用本身的私钥把challenge签名获得locSignature后,将经过shareAuthSignature把它和本身的公钥一块儿发给对方。它里作了不少事,咱们在这一节详细讲解一下。

shareAuthSignature的代码以下:

p2p/secret_connection.go#L267

func shareAuthSignature(sc *SecretConnection, pubKey crypto.PubKeyEd25519, signature crypto.SignatureEd25519) (*authSigMessage, error) {
    var recvMsg authSigMessage
    var err1, err2 error

    cmn.Parallel(
        func() {
            msgBytes := wire.BinaryBytes(authSigMessage{pubKey.Wrap(), signature.Wrap()})
            _, err1 = sc.Write(msgBytes)
        },
        func() {
            readBuffer := make([]byte, authSigMsgSize)
            _, err2 = io.ReadFull(sc, readBuffer)
            if err2 != nil {
                return
            }
            n := int(0) // not used.
            recvMsg = wire.ReadBinary(authSigMessage{}, bytes.NewBuffer(readBuffer), authSigMsgSize, &n, &err2).(authSigMessage)
        })

    if err1 != nil {
        return nil, err1
    }
    if err2 != nil {
        return nil, err2
    }

    return &recvMsg, nil
}

能够看到,它作了这样几件事:

  1. 首先是把公钥和签名数据组合成了一个authSigMessage对象:authSigMessage{pubKey.Wrap(), signature.Wrap()}
  2. 而后经过一个叫go-wire的第三方库,把它序列化成了一个字节数组
  3. 而后调用SecretConnection.Write()方法,把这个数组发给对方。须要注意的是,在这个方法内部,将对数据进行分块,并使用Go语言的secretBox.Seal对数据进行加密。
  4. 同时从对方读取指定长度的数据(其中的authSigMsgSize为常量,值为const authSigMsgSize = (32 + 1) + (64 + 1)
  5. 而后经过SecretConnection对象中的方法读取它,同时进行解密
  6. 而后再经过go-wire把它变成一个authSigMessage对象
  7. 若是一切正常,把authSigMessage返回给调用者MakeSecretConnection

这里我以为没有必要使用go-wire对数据进行序列化和反序列化,由于要发送的两个数组长度是肯定的(一个32,一个64),不管是发送仍是读取,都很容易肯定长度和拆分规则。而引入了go-wire之后,就须要知道它的工做细节(好比它产生的字节个数是(32 + 1) + (64 + 1)),而这个复杂性是没有必要引入的。

SecretConnectionReadWrite

在上一段,对于发送数据时的分块和加解密相关的操做,都放在了SecretConnection的方法中。好比sc.Write(msgBytes)io.ReadFull(sc, readBuffer)(其中的sc都是指SecretConnection对象),用到的就是SecretConnectionWriteRead

p2p/secret_connection.go#L110

func (sc *SecretConnection) Write(data []byte) (n int, err error) {
    for 0 < len(data) {
        var frame []byte = make([]byte, totalFrameSize)
        var chunk []byte
        if dataMaxSize < len(data) {
            chunk = data[:dataMaxSize]
            data = data[dataMaxSize:]
        } else {
            chunk = data
            data = nil
        }
        chunkLength := len(chunk)
        binary.BigEndian.PutUint16(frame, uint16(chunkLength))
        copy(frame[dataLenSize:], chunk)

        // encrypt the frame
        var sealedFrame = make([]byte, sealedFrameSize)
        secretbox.Seal(sealedFrame[:0], frame, sc.sendNonce, sc.shrSecret)
        // fmt.Printf("secretbox.Seal(sealed:%X,sendNonce:%X,shrSecret:%X\n", sealedFrame, sc.sendNonce, sc.shrSecret)
        incr2Nonce(sc.sendNonce)
        // end encryption

        _, err := sc.conn.Write(sealedFrame)
        if err != nil {
            return n, err
        } else {
            n += len(chunk)
        }
    }
    return
}

Write里面,除了向链接对象写入数据(sc.conn.Write(sealedFrame))外,它主要作了三件事:

  1. 首先是若是数据过长(长度超过dataMaxSize,即1024),则要把它分红多个块。因为最后一个块的数据可能填不满,因此每一个块的最开始要用2个字节写入本块中实际数据的长度。
  2. 而后是调用Go的secretbox.Seal方法,对块数据进行加密,用到了sendNonceshrSecret这两个参数
  3. 最后是对sendNonce进行自增操做,这样可保证每次发送时使用的nonce都不同;另外每次增2,这样可保证它不会跟recvNonce重复

SecretConnectionRead操做,跟前面正好相反:

p2p/secret_connection.go#L143

func (sc *SecretConnection) Read(data []byte) (n int, err error) {
    if 0 < len(sc.recvBuffer) {
        n_ := copy(data, sc.recvBuffer)
        sc.recvBuffer = sc.recvBuffer[n_:]
        return
    }

    sealedFrame := make([]byte, sealedFrameSize)
    _, err = io.ReadFull(sc.conn, sealedFrame)
    if err != nil {
        return
    }

    // decrypt the frame
    var frame = make([]byte, totalFrameSize)
    // fmt.Printf("secretbox.Open(sealed:%X,recvNonce:%X,shrSecret:%X\n", sealedFrame, sc.recvNonce, sc.shrSecret)
    _, ok := secretbox.Open(frame[:0], sealedFrame, sc.recvNonce, sc.shrSecret)
    if !ok {
        return n, errors.New("Failed to decrypt SecretConnection")
    }
    incr2Nonce(sc.recvNonce)
    // end decryption

    var chunkLength = binary.BigEndian.Uint16(frame) // read the first two bytes
    if chunkLength > dataMaxSize {
        return 0, errors.New("chunkLength is greater than dataMaxSize")
    }
    var chunk = frame[dataLenSize : dataLenSize+chunkLength]

    n = copy(data, chunk)
    sc.recvBuffer = chunk[n:]
    return
}

它除了正常的读取字节外,也是作了三件事:

  1. 按块读取,每次读满sealedFrameSize个字节,并按前两个字节指定的长度来确认有效数据
  2. 对数据进行解密,使用secretbox.Open以及recvNonceshrSecret这两个参数
  3. recvNonce进行自增2的操做,以便与对方的sendNonce保持一致,供下次解密使用

须要注意的是,这个函数返回的n(已读取数据),是指的解密以后的,因此要比真实读取的数据小一点。另外,在前面的shareAuthSignature中,使用的是io.ReadFull(sc),而且要读满authSigMsgSize个字节,因此假如数据过长的话,这个Read方法可能要被调用屡次。

在这一块,因为做者假设了发送的数据的长度可能过长,因此才须要这么复杂的分块操做,而其实是不须要的。若是咱们简单点处理,是能够作到如下两个简化的:

  1. 不须要分块,发送一次就够了
  2. 也所以不须要计算和维护recvNoncesendNonce,直接给个常量便可,反正只用一次,不会存在冲突

逻辑能够简单不少。并且我查了一下,这块代码在整个项目中,目前只使用了一次。若是将来真的须要,到时候再加也不迟。

目前的作法是否足够安全

从上面的分析咱们能够看到,比原为了保证节点间通讯的安全性,是作了大量的工做的。那么,当前的作法,是否能够彻底杜绝中间人攻击呢?

按个人理解,仍是不行的,由于若是有人彻底清楚了比原的验证流程,仍是能够写出相应的工具。好比,中间人能够按照下面的方式:

  1. 中间人首先本身生成一对一次性公钥和一对最后用于签名和验证的公私钥(后面称为长期公钥),用于假冒节点密钥
  2. 当双方节点创建起链接时,中间人能够拿到双方的一次性公钥,由于它们是明文的
  3. 中间人把本身生成的一次性公钥发给双方,假冒是来自对方节点的
  4. 双方节点使用本身和中间人的一次性公钥,对数据进行加密传给对方,此时中间人拿到数据后,能够利用本身生成的假冒一次性公钥以及双方以前发过来的一次性公钥对其解密,从而拿到双方的长期公钥
  5. 中间人将本身生成的长期公钥以及利用本身的长期私钥签名的数据发给双方节点
  6. 双方节点拿到了中间人的长期公钥和签名数据,并验证经过
  7. 最后双方节点都信任对方(其实是信任了骗子中间人)
  8. 以后双方节点向对方发送的信息(使用骗子提供的长期公钥加密),会被中间人使用相应的长期私钥解密,从而被窃取,甚至修改后再通过加密后转发给另外一方,而另外一方彻底信任,会执行,从而致使损失

这个过程可使用下图来辅助理解: 那么这是否说明比原的作法白作了呢?不,我认为比原的作法已经够用了。

按我目前的了解,对于防范中间人,并无彻底完美的办法(由于如何保证安全的把公钥经过网络发送给另外一方自己就是一个充满挑战的问题),目前多数是证书等作法。对于比原来讲,若是采用这种作法,会让节点的部署和维护麻烦不少。而目前的作法,虽然不能彻底杜绝,可是其实已经解决了大部分的问题:

  1. 没有明文发送真正的公钥,使得一些通用型的中间人工具没法使用
  2. 在发送公钥时,以及对签名进行认证时,使用了两种不一样类型的加密方案,而且它们在Go之外的语言的实现中,可能不太兼容,这就使得骗子必须也会使用Go来编程
  3. 中间人必须读懂比原的代码并对此处每个细节都清楚才可能写出正确的工具

我以为这基本上就杜绝了一大拨技术能力不过关的骗子。只要咱们在使用的时候,再注意防范(好比不使用不安全的网络或者代理),我以为基本上就没什么问题了。

代码流程图

最后,把我阅读这段代码过程当中画的流程图分享出来,也许对你本身阅读的时候有帮助:

相关文章
相关标签/搜索