P2P 层

要与其他节点开始进行通信,节点必须加入网络,为此,节点必须知道已经参与到协议的其他节点;这个节点被称为启动节点(bootstrap node)。

连接到启动节点后,我们收到一个我们将用于网络通信的对等体列表。那些同伴被称为邻节点。邻节点列表以这些节点在线的方式进行维护,来自网络的任何节点都可以收到我们的信息。而且,信息应该被有效地传递。

为实现这一点,卡尔达诺结算层使用 Kademlia DHT 协议,尽管 Kademlia 提供了更多的功能,我们只是将其作为对等发现的一种方法。

Kademlia 协议概述

另请参阅:P2P 网络部分的技术概览 (TOREVIEW)

在 Kademlia 中,每个节点都与一个32字节的 ID(详细信息见 ID 结构)相关联。这些 ID 用于标识节点,但不必参考其网络地址。用于在 Kademlia 存储值的键也是32字节的标识符。

Kademlia 使用 XOR 度量来定义节点之间的距离。键值对存储在 ID 与『密钥』接近的节点中。这个距离也被用来有效地定位给定 ID 的节点。

在开始时,应该为 Kademlia 提供引导节点以加入网络。在实现中,该节点的地址可以是硬编码或由用户选择。之后,节点将尝试通过查询其邻节点(从引导节点发出的对等端的初始列表)来查找更多对等端。节点向对等节点发送信息,消息重新发送到靠近该 ID/key 的对等体。对等体的列表在启动之间保留。

完成之后,我们用 (Host, Port, ID) 表示地址,而用 (Host, Port) 表示网络地址

Kademlia 使用 UDP 协议传输消息。

要详细了解如何实现 Kademlia,请参考 Kademlia: 基于 XOR 度量的 P2P 信息系统

Kademlia 中使用的消息

每条消息被表示为一个最长长度为1200 字节 的二进制字符串(因此它不会超过 IPv6 数据包大小)。一个特殊情况是 RETURN_NODES:如果超过1200字节,节点列表被分成几个消息。消息的数量用一个字节表示。请参阅 serialize 功能的更多细节。

IDs, 键和值

在 Kademlia 中,ID 和键用相同的叫做 HashId 的结构表示:

字段大小 描述
18 Hash - PBKDF2 key generated from Nonce
14 Nonce - an arbitrary 14-bytes long binary string

请阅读地址部分了解更多详情。

卡尔达诺结算层不使用 Kademlia 作为键值存储。因此,我们只使用空字符串作为值。

PING

检查一个对等点是否仍然可以访问。在发送这个消息之后,节点期望收到 PONG 消息作为回复。Kademlia 周期性地 PING 每一个对等点来维护一个对等点网络。

字段大小 描述
1 0 1-byte value to determine message type
32   ID of our node

PONG

用做对 PING 消息的回复。

Field size 描述
1 1 1-byte value to determine message type
32   ID of our node

STORE

在 Kademlia 中存储给定的值。该消息被禁用,并被节点忽略。

字段大小 描述
1 2 1-byte value to determine message type
32   ID of our node
32   Key
0   Value (empty string in Cardano SL)

FIND_NODE

请求给定 ID 节点的网络地址。在发送这个消息之后,节点希望收到一个 RETURN_NODES 消息,其中包含最接近请求消息的节点列表(包括请求的节点本身)

字段大小 描述
1 3 1-byte value to determine message type
32   ID of our node
32   ID of node we are looking for

RETURN_NODES

发送一些节点的网络地址回复给 FIND_VALUEFIND_NODE。答案被分成几个消息,因为节点列表可以超过 IPv6 数据包大小。

首先,我们描述一个对等体的二进制表示:

字段大小 描述
32   Peer ID
1-255   Peer host name
1 32 Ascii code of “ “ to separate host name from port
2   Peer port

现在我们来描述 RETURN_NODES 消息的二进制表示。

字段大小 描述
1 4 1-byte value to determine message type
32   ID of our node
1   Total number of RETURN_NODES messages sent as answer to this request
32   ID of node that requested nodes
at most 1136   Several peers close to the requested ID (at most 1136 bytes to not exceed IPv6 datagram size)

FIND_VALUE

除了在查找成功的情况下也收到响应之外,其行为与 FIND_NODE 相同。目前它只用于卡尔达诺结算层寻找对等体。当节点开始工作时,它会生成一个随机密钥,并要求 Kademlia 找到他;这个搜索总是失败,但是它让节点发现一些初始的对等地址。

字段大小 描述
1 5 1-byte value to determine message type
32   ID of our node
32   Key we are looking for

RETURN_VALUE

STORE 请求的回复。卡尔达诺结算层没有使用此信息,因为它不会 Kademlia 中存储任何值。

字段大小 描述
1 6 1-byte value to determine message type
32   ID of our node
32   ID of node that requested value
0   Value (empty string in Cardano SL)

安全

因为 Kademlia 是开放式 P2P 网络协议,因此必须通过几种方式修改才能变得相当安全。

可能的攻击

eclipse 攻击是节点被攻击者节点包围的情况。

在 Kademlia 中,eclipse 攻击(针对特定的网络参与者)很难执行,但有可能。首先,启动靠近目标节点 ID 的100个节点 ID。这些节点将填充最低的 k(最初是空的),然后对目标节点 k 进行 DDoS 攻击(如果网络的拓扑结构变化不大,可以确定这些节点已经启动)。在成功的 DDoS 攻击之后,节点的剩余邻节点将称为攻击者代理。

请注意,Kademlia 的结构意味着靠近目标的引导节点不足以将其噬灭,节点列表由节点存储在 k-buckets 里(第 i 个 k 节点包含的节点数量不超过相对距离 2^i-1 < d < 2^i),只有当这些对应的 bucket 不满的时候,才会将新节点添加到相应的 bucket 中。Kademlia 更乐于长期在列表中的节点,看着它们还在线。没有一些节点下线,不太可能噬灭一个节点。

这种攻击是棘手的,在实践中不太可能发生,解决中的修改使得它更难。

100500攻击是一个比当前P2P网络节点数量大得多的攻击,或者说为了噬灭一些节点或通过泛洪网络拒绝服务的攻击。这种攻击不会对旧节点造成任何问题(不包括可能的网络开销),因为旧的节点保存它们的路由。但当一个新的节点加入到网络时,它就会被噬灭(孤立在敌对的子网中),因为旧的诚实的节点不会把它添加到它的 bucket 中(因为这些 bucket 已经被其他节点填充了),并且新的节点会只有攻击者才知道。

防止100500攻击仍然是一个悬而未决的问题,现在,我们通过一个复杂的禁止系统/攻击者检测来让它们几乎不可行。

解决

我们使用所谓的 HashId 作为节点 ID。由于它包含一个散列,因此为自己分配一个任意的 ID 是不可能的,这意味着100500攻击是进行噬灭攻击的唯一方法。

实现 Notes

HashId 是一个固定长度(32字节)的二进制字符串,如下所示:

+---------------+------------+
|    Hashing    |    Nonce   |
+---------------+------------+

|   18 bytes    |  14 bytes  |

其中:

  • Nonce 只是随机的14个字节(来自系统的熵源)。
  • Hashing 是哈希数据。

哈希数据基于 DerivingKeySalt,其中:

为生成 DerivingKey,我们使用这些参数:

  • prfPassword - 使用 HMAC(基于哈希的消息认证码)和 SHA-256 算法为 PBKDF2 提供 PRF(伪随机函数)
  • parameters - PBKDF2 参数:500次迭代,32字节作为结果输出。
  • Nonce 如上所述 - 如 password
  • Salt 如上所述 - 如 Salt

路由数据防伪

在 Kademlia 中,一个节点向其邻节点请求一个对等体列表,并接受它收到的第一个消息。攻击者可以伪造这些答复,提供攻击者节点的地址作为给定 ID 的最近节点。为了解决这个问题,我们让节点等待一段时间来收集尽可能多的回复,然后,这些回复被合并,k 节点从结果集合中选择最近的节点。这样,攻击者就被不得不为了伪造它所接收的对等体列表而噬灭一个节点。

实现笔记

为实现这个想法,我们在每个 lookuplookup 是一个被 FIND_NODEFIND_VALUE 用来找 k 个离给定 ID 最近的节点的函数)增加 k 个邻节点的待处理集合中。当我们收到 RETURN_NODES 信息,我们更新已知列表,使其包含当前最接近目标 ID 的 k 个节点。当没有挂起的节点时,该循环结束。我们在任何时期都不收集邻节点的答复。如果任何邻节点不给我们 RETURN_NODES 答复,我们收到 Timeout 信号,这个邻节点会由 waitForReply 函数处理。

请参阅 continueLookup 功能。这是 pendingknown 字段更新的地方,因此也是这个特性的核心逻辑。

路由表共享

当一个节点刚刚加入到网络时,它会请求一个邻节点列表(最靠近它的一组节点)。我们修改了 Kademlia,在这个列表中包含了一些额外的节点。具体来说,目前我们会随同邻节点选择一些随机节点并返回它们。这让被攻击节点包围的节点有额外的信息来恢复。

实现笔记

在我们的 Kademlia 实现中,有一个 findClosest 函数来寻找给定 ID 节点的 k 个节点。增加了 pickupRandom 功能。这个函数从 Kademlia 树中获取给定数量的随机节点。共享随机节点的确切数量是通过 Kademlia 配置中的 routingSharingN 字段来获得的。这样,RETURN_NODES 包含了 findClosestpickupRandom 调用的结果。

禁止节点

我们为 Kademlia 引入了禁止节点的功能。当我们检测到恶意行为时,我们将使用这个来禁止节点。

实现笔记

节点有三种可能的状态:

  1. NoBan
  2. BanTill
  3. BanForever

请看 BanState 类型。这种类型的值会传给 banNode 函数。

NoBan 用于接触已经被禁止的节点。但是,该操作不会将此节点重新插入到树结构,但可以使次节点再次出现在同级中。

BanTill 禁止某个节点(定义为 POSIX 时间)。

BanForever 永远禁止一个节点。

banNode 添加给定的节点到 KademliaState 类型的 banned 字段,并从树中删除它。isNodeBanned 功能检查当前是否禁止节点,如果节点已经解除禁止,或禁止已经过期,则删除节点。

如何处理禁止的节点:

  • 我们不能使用它作为我们的引导节点加入网络。请查看 joinNetwork 功能。
  • 我们忽略从禁止的节点收到的所有消息。请看 waitForReply 功能。
  • 我们不会把这个节点包含在树中,不会发任何消息给它,也不会把这个节点加入到 RETURN_NODES 消息中。