剝開比原看程式碼04:如何連上一個比原節點

比原鏈Bytom發表於2018-07-30

作者:freewind

比原專案倉庫:

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

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

在上一篇我們已經知道了比原是如何監聽節點的p2p埠,本篇就要繼續在上篇中提到的問題:我們如何成功的連線上比原的節點,並且通過身份驗證,以便後續繼續交換資料?

在上一篇中,我們的比原節點是以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進行的。所以總體來說,這一塊邏輯有點複雜(亂),繞來繞去的。

這裡不先評價程式碼的好壞,我們還是先把比原的處理邏輯搞清楚吧。

下面還是從啟動開始,但是由於我們在前面已經出現過多次,所以我會盡量把不需要的程式碼省略掉,帶著大家快速到達目的地,然後再詳細分析。

首先是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. 中間人必須讀懂比原的程式碼並對此處每一個細節都清楚才可能寫出正確的工具

我覺得這基本上就杜絕了一大撥技術能力不過關的騙子。只要我們在使用的時候,再注意防範(比如不使用不安全的網路或者代理),我覺得基本上就沒什麼問題了。

程式碼流程圖

最後,把我閱讀這段程式碼過程中畫的流程圖分享出來,也許對你自己閱讀的時候有幫助:

相關文章