Bytom p2p 网络通信协议分析

数据同步协议

比原链网络数据同步协议栈如下图所示。协议栈基于tcp/ip,Encryption完成数据的加密传输,Wire Protocol完成数据序列化,最上层为同步协议。

Tx Sync/Block Sync/Fast Sync/Spv
Wire Protocol
Encryption
TCP/IP

基于tcp/ip同步协议

数据同步首先会在节点之间建立连接MConnection,建立连接后会对连接进行加密处理,区块,交易数据序列化为二进制数据流通过加密通道传递给其它节点。

建立加密连接

建立多路复用连接

MConnection 是在单个tcp连接上支持多个独立流传输的多路复用连接,并且每个流提供了单独的服务质量保证。每个流称为Channel,每个Channel具有全局唯一的 byte id 。每个channel也具有决定服务质量的相对优先级。byte id 和每个Channel的相对优先级在初始化时配置。

MConnection支持三种数据包类型:

  • Ping
  • Pong
  • Msg

Ping和Pong

ping和pong消息向连接写入单个字节;分别为0x1和0x2。

当我们在pingTimeout周期没有及时收到MConnection上的任何消息时,我们发送一条ping消息。
当在MConnection上收到ping消息时,会发送一个pong作为响应。如果在ping之后没有及时收到pong消息,则节点将断开连接。

Msg

通道中的消息被切割成较小的msgPacket 以进行多路复用。

1
2
3
4
5
type msgPacket struct {
ChannelID byte
EOF byte // 1 means message ends here.
Bytes []byte
}

msgPacket
go-wire进行序列化,并以0x3为前缀。接收到的一组数据包的“字节”被附加在一起直到收到带有EOF = 1 的数据包,然后完整的序列化消息由相应channelonReceive函数处理。

多路复用

消息从sendRoutine 发送,它循环在select状态上并发送ping,pong或msg消息。该批数据消息可以包括来自多个channel的消息。消息字节排队等待在各自的通道中发送,每个通道一次取一个未发送的消息。从最近发送的字节与信道优先级的比最低的信道选择一个消息发送。

发送消息

发送消息有两种方法:

1
2
func (m MConnection) Send(chID byte, msg interface{}) bool {}
func (m MConnection) TrySend(chID byte, msg interface{}) bool {}

Send(chID,msg)是一个阻塞调用,等待msg成功排队到给定id字节chID的通道。消息msg被序列化使用wire子模块的WriteBinary()反射函数。

TrySend(chID,msg)是一个非阻塞调用,它将消息msg排入chID通道如果队列未满;否则立即回false

Send()TrySend()对每个Peer可见。

Peer

每个peer都有一个MConnection实例,并含有其他信息,例如是否是outbound(主动拨号其它节点),关于节点的各种身份信息,以及reactor使用的其他更高级别的线程安全数据。

Switch/Reactor

Switch 控制peer连接,以在Reactor上接收传入消息。每个Reactor负责处理一个或多个channel传入的信息。因此,通常通过peer发送消息,在Reactor上接收传入的消息。

新添加peer后,给定reactor的传入消息将通过该reactorReceive方法处理,并且输出消息由每个节点的Reactor直接发送。 reactor使用节点之间的go-routing来处理这些。

连接加密及身份确认

在节点拨号成功后,执行两次握手:第一次进行通道加密、身份验证,第二次进行版本、网络类型验证。

Peer Identity

节点每次启动都会随机产生一个public key作为节点的id。当尝试连接到peer时,我们使用PeerURL:<ID> @ <IP>:<PORT>。我们将尝试连接IP:PORT上的节点,并验证身份,通过经过身份id的签名,只有拥有相应私钥的节点可以建立连接。这可以防止对节点的中间人攻击。

通信加密、身份验证

节点建立加密连接时使用Diffie-Helman密钥交换协议生成共享秘钥,使用NACL SecretBox对通信数据进行对称加密。
工作流程如下:

  • 生成一个临时的ED25519密钥对
  • 将临时的公钥发送给对等方
  • 等待接收对等方的临时公钥
  • 使用对方临时公钥和我们的临时私钥计算Diffie-Hellman共享密钥
  • 生成两个用于加密(发送和接收)的随机数,流程如下:
    • 按升序对临时的公钥进行排序并将它们连接起来
    • 进行RIPEMD160运算
    • 附加4个空字节(将散列扩展为24个字节)
    • 结果是nonce1
    • 翻转nonce1的最后一位以获得nonce2
    • 如果我们有一个较小的临时pubkey,使用nonce1接收,nonce2发送;否则相反
  • 从现在开始的所有通信都使用共享密钥和随机数进行加密,其中每个随机数每次使用时增加2
  • 我们现在有一个加密通道,但仍需要进行身份验证
  • 签名共同挑战:
    • 对排序和连接的短暂pubkey进行SHA256运算
  • 使用我们的持久私钥签署共同挑战
  • 将go-wire编码的持久性pubkey和签名发送给节点
  • 等待从节点接收持久公钥和签名
  • 使用节点的持久公钥验证消息签名

如果这是一个outgoing连接(主动连接其它节点)并且使用了节点ID,然后最后验证节点的持久公钥是否与我们拨号的节点ID相对应,即。 peer.PubKey.Address() == <ID>

现在连接现已通过身份验证,并且所有流量都已加密。

注意:只有拨号节点可以验证节点的身份,但这是我们关心的,因为当我们加入网络时我们希望确保已经连接了目标节点(而不是被中间人攻击)。

版本确认

版本确认允许节点交换其NodeInfo:

1
2
3
4
5
6
7
8
type NodeInfo struct {
PubKey crypto.PubKeyEd25519
ListenAddr string
Network string
Version string
Moniker string
Other []string
}

如果出现以下情况则断开连接:

  • peer.NodeInfo.Version 未格式化为X-X-X,其中X是称为Major,Minor和Revision的整数。
  • peer.NodeInfo.Version 主版本号与我们的不一样。
  • peer.NodeInfo.Network 网络类型与我们的不一样。

此时,如果没有断开连接,则节点有效。它通过AddPeer方法添加到switch中,因此被添加到所有reactor中。

数据序列化协议

支持的类型

  • 原始类型
    • uint8 (aka byte), uint16, uint32, uint64
    • int8, int16, int32, int64
    • uint, int: variable length (un)signed integers
    • string, []byte
    • time
  • 派生类型
    • structs
    • 特定类型的变长数组
    • 特定类型的固定长度数组
    • interfaces:注册的联合类型,前面是type byte
    • 指针

二进制编码

固定长度基本类型 用1,2,3或4个大端字节编码。

  • uint8(又名byte),uint16uint32uint64:分别占用1,2,3和4个字节
  • int8int16int32int64:分别占用1,2,3和4个字节
  • timeint64 表示自纪元以来的纳秒

可变长度整数 用一个前导字节编码,表示后续大端字节的长度。对于有符号的负整数,前导字节的最高有效位为1。

  • uint:1字节长度前缀可变大小(0~255字节)无符号整数
  • int:1字节长度前缀变量大小(0~127字节)有符号整数

注意:虽然数字0(零)用单个字节x00编码,但数字1用两个字节表示:x0101。这不是最高效的表示,但规则更容易记住。

号码 二进制uint 二进制int
0 x00 x00
1 x0101 x0101
2 x0102 x0102
256 x020100 x020100
2 ^(127 * 8)-1 x7FFFFF ... x7FFFFF ...
2 ^(127 * 8) x800100 ...... 溢出
2 ^(255 * 8)-1 xFFFFFF ... 溢出
-1 不适用 x8101
-2 不适用 x8102
-256 不适用 x820100

Structures 通过按声明顺序对字段值进行编码来编码。

1
2
3
4
5
6
7
8
9
10
11
type Foo struct {
    MyString string
    MyUint32 uint32
}
var foo = Foo {“626172”,math.MaxUint32}

foo的二进制表示:
 0103626172FFFFFFFF
 0103`int`编码的字符串长度,这里是3
     6261723个字节的字符串“bar”
           FFFFFFFF:uint32 MaxUint32的4个字节

可变长度数组 用前导“int”编码,表示数组的长度,后跟项目的二进制表示。 固定长度数组 类似,但前面没有前导int

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
foos:= [] Foo {foo,foo}

foos的二进制表示:
 01020103626172FFFFFFFF0103626172FFFFFFFF
 0102`int`编码的数组长度,这里2
     0103626172FFFFFFFF:第一个`foo`
                       0103626172FFFFFFFF:第二个`foo`


foos:= [2] Foo {foo,foo} //固定长度数组

foos的二进制表示:
 0103626172FFFFFFFF0103626172FFFFFFFF
 0103626172FFFFFFFF:第一个`foo`
                   0103626172FFFFFFFF:第二个`foo`

接口 可以代表任意数量的具体类型之一。必须首先使用相应的type byte声明接口的具体类型。然后使用前导“类型字节”对接口进行编码,然后对底层具体类型进行二进制编码。

注意:字节x00保留用于nil接口值和nil指针值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Animal interface{}
type Dog uint32
type Cat string

RegisterInterface(
struct{ Animal }{}, // Convenience for referencing the 'Animal' interface
ConcreteType{Dog(0), 0x01}, // Register the byte 0x01 to denote a Dog
ConcreteType{Cat(""), 0x02}, // Register the byte 0x02 to denote a Cat


var animal Animal = Dog(02

The binary representation of animal:
010102
01: the type byte for a `Dog`
0102: the bytes of Dog(02)

指针 用一个前导字节x00编码为nil指针,否则用前导字节x01编码,然后是指向的值的二进制编码。

注意:将指针类型转换为接口类型很容易,因为type byte x00总是nil

JSON编码

JSON编解码器与[binary](#binary)编解码器兼容,如果您已经熟悉golang的JSON编码,则相当直观。下面提到了一些特殊规定:

  • 可变长度和固定长度字节编码为大写十六进制字符串
  • 接口值被编码为两个项的数组:[type_byte,concrete_value]
  • 次数被编码为rfc2822字符串

同步协议

bytom 目前支持普通同步模式,快速同步模式,SPV Proof。

Normal Fast Sync SPV
BlocMessage HeadersMessage FilterLoadMessage
StatusMessage BlocksMessage FilterClearMessage
TransationMessage FilterAddMessage
MineBlockMessage MerkleBlockMessage

节点协议握手

handshake

节点协议握手首先会向对方发送状态信息,同时通过状态信息获取对方当前状态,同步协议在获取状态之后。

StatusRequestMessage

Bytes Name Data Type Description
0 null 消息体为空,用于向对方获取状态信息

StatusResponseMessage

Bytes Name Data Type Description
8byte Height uint64 当前本地高度
32byte RawHash [32]byte 当前最高区块hash
[32]byte GenesisHash [32]byte 创世块hash

在握手后会进行交易池同步,交易池同步会把当前池中的交易打包发给对方,发送交易使用TransactionMessage

TransactionMessage

Bytes Name Data Type Description
Varies RawTx []byte 交易消息

同步协议

blocksync

目前支持普通同步和快速同步两种模式,区块同步程序定时检查所有连接的节点状态,判断是否需要同步,当需要同步时,判断节点满足快速同步条件时则进行快速同步,否则进行普通同步。为了使挖矿区块能快速同步到全网,当收到挖矿区块时会触发同步流程,使新区块快速上链,并及时更新挖矿区块高度,从而减少孤儿块产生的概率。

MineBlockMessage

Bytes Name Data Type Description
Varies RawBlock []byte 挖矿产生的区块信息

普通同步模式

normalsync

普通同步模式下,节点按高度获取高度并进行全区块验证。使用GetBlockMessageBlockMessage消息。
GetBlockMessage

Bytes Name Data Type Description
8byte Height uint64 使用高度获取区块,如果高度为0,则使用hash获取区块
4byte RawHash [32]byte 使用hash获取区块

BlockMessage

Bytes Name Data Type Description
Varies RawBlock []byte 序列化的区块信息

快速同步模式

fastsync

快速同步模式下,通过在代码中加入checkpoint(已确认的区块的hash),这样同步时只需要比较某些高度区块hash是否和checkpoint区块hash一致,即可判断区块头正确性。通过计算区块中交易merkle树roothash是否和区块头中roothash一致,即可判断区块中的交易正确性。快速同步模式下批量获取区块头以及区块,可以极大提高同步速度。

GetHeadersMessage

Bytes Name Data Type Description
Varies RawBlockLocator [][32]byte 区块头定位器,用于定位获取区块头的开始位置
32 byte RawStopHash [32]byte 用于定位获取区块头结束的位置。

HeadersMessage

Bytes Name Data Type Description
Varies HeadersMessage [][]byte 打包的区块头信息

GetBlocksMessage

Bytes Name Data Type Description
Varies RawBlockLocator [][32]byte 区块定位器,用于定位获取区块的开始位置
32 byte RawStopHash [32]byte 用于定位获取区块结束的位置。

BlocksMessage

Bytes Name Data Type Description
Varies RawBlocks [][]byte 打包的区块信息

SPV Proof

简单支付验证(SPV)是Satoshi Nakamoto的论文中描述的一种技术。 SPV允许轻量级客户端验证区块链中是否包含交易,而无需下载整个区块链。 SPV客户端只需要下载块头,这些块头比完整块小得多。 为了验证交易是否在块中,SPV客户端以Merkle block的形式请求包含交易证明。
SPV提供了两个关键要素:a)它确保您的交易处于一个区块中; b)它提供了区块被添加到链中的确认(工作证明)。
SPV Proof

SPV 轻客户端首先连接全节点,当与全节点成功建立连接后。轻客户端向全节点注册地址过滤器,过滤器是一个地址集合,包含SPV节点账户的地址。全节点使用地址过滤器对交易进行过滤,并将相关交易发送给轻客户端。轻客户端使用GetMerkleBlockMessage命令向全节点获取MerkleBlockMessage消息。

FilterLoadMessage

Bytes Name Data Type Description
Varies Addresses [][]byte 地址集合,用于SPV客户端向全节点注册需要过滤的地址

FilterAddMessage

Bytes Name Data Type Description
Varies Address []byte 地址信息,用于SPV客户端向全节点添加需要过滤的地址

FilterClearMessage

Bytes Name Data Type Description
0 null 消息体为空,用于SPV客户端向全节点发送清除地址过滤器消息

GetMerkleBlockMessage

Bytes Name Data Type Description
8byte Height uint64 根据高度获取merkle block,如果为0则通过hash获取
32byte RawHash [32]byte 根据hash获取merkle block

MerkleBlockMessage

Bytes Name Data Type Description
Varies RawBlockHeader []byte 区块头信息
Varies TxHashes [][32]byte 交易或交易merkle树 node hash,用于计算交易merkle root
Varies RawTxDatas [][]byte 满足地址过滤器的交易
Varies StatusHashes [][32]byte 状态或状态merkle树 node hash,用于计算状态merkle root
Varies Flags []byte 用于分配TxHashes和StatusHashes到merkle树的特定node