← 返回博客

Rotifer v0.9.1:局域网 P2P 的内容派生基因 ID(npm 0.27.0)

基因 ID 现在由内容算出,而不是由名字决定。局域网 P2P 的线格式已冻结,公告转发前先校验。两台机器、同一个 Wi-Fi,1.4 秒送达。

Rotifer v0.9.1:局域网 P2P 的内容派生基因 ID(npm 0.27.0)

v0.9.1 改变了「基因 ID」的含义。它不再是某个人敲上去的名字,而是 v2: 加上一段 SHA-256,由基因的契约和代码算出。携带这个 ID 的 P2P 公告现在有了冻结的线格式,每条公告在节点转发之前都会先校验。

我们按用户的用法验证了一遍:同一个 Wi-Fi 下的两台真机,都从 npm 安装 0.27.0。一台上发出的公告,1.36 秒内出现在另一台上,两端的基因 ID 一致。

v0.9.1 是协议版本号,对应的 npm 包是 @rotifer/playground v0.27.0,现已发布。


ID 由内容算出

问题

此前,P2P 公告用发布者自己给的一个字符串来标识基因,实际上就是它的名字。任何节点都可以公告 hello-world,也可以用同一个名字公告另一个完全不同的基因。名字是发布者自己声称的标签,接收方无法从中得知这个基因到底是什么。

解法

基因 ID v2 用基因的内容来命名它:

v2:127ed2e889c02b4a6e20180501b47bd27bf9b3b7d8515eff505d56da7ed0c5d2

这个 ID 是对两部分内容算出的 SHA-256:一部分是基因契约的规范化投影(domain、输入输出 schema、fidelity、语义要求、依赖、网络权限),用 JSON Canonicalization Scheme 序列化;另一部分是基因主体的摘要。Native 或 Hybrid 基因的主体摘要覆盖编译后 WASM 模块的全部标准段,而不只是代码段,所以模块无法在改动内嵌数据或导入项的同时保持 ID 不变。Wrapped 基因则覆盖它声明的源文件。

排除在外的部分同样是有意为之:作者、创建时间、SemVer 版本号、描述、标签。谁在什么时候发布了基因,并不改变这个基因是什么。契约和代码相同的两个基因,ID 相同;改动一个字节的逻辑或契约里的一个字段,ID 就变。

rotifer wrap 现在会打印它:

rotifer init demo-net && cd demo-net
rotifer wrap hello-world
✓ Gene 'hello-world' wrapped successfully
  Gene ID (v2): v2:127ed2e889c02b4a6e20180501b47bd27bf9b3b7d8515eff505d56da7ed0c5d2

任何人运行内置的 hello-world 基因,得到的都是这一串。源码构建、CI 构建和 npm 安装算出的结果一致。

边界

有些基因没有 v2 ID,CLI 会如实说明,而不是猜:没有编译过的 Native 基因、写成通配符的依赖、目录里有多个候选源文件却没有声明 sources 列表的 Wrapped 基因。这样的基因无法在网络上公告。

Wrapped 基因的 ID 覆盖的是它的封装层,而不是它调用的远程服务。如果那个服务改变了行为,ID 不会变。这正是 fidelity: "Wrapped" 一直在声明的事情,ID 不会把它掩盖掉。

完整定义(含字节布局)见 docs/gene-id-v2.md。Rust 实现旁边有一份独立的 Python 参考实现和共用的测试向量,所有实现都必须逐字节复现每一个向量。


冻结的线格式,转发前先校验

线上变了什么

公告现在是 protobuf,走新的 topic /rotifer/p2p/1/announcements。这个格式的第 1 版已冻结:以后的改动会换新版本,而不是悄悄改动这一版。消息里完全没有发布者字段,公告的发布者就是 GossipSub 认证过的来源,所以节点没法靠在载荷里写别人的名字来冒充。

节点转发公告之前会先校验。格式错误的载荷、过期或带未来时间的时间戳(超出五分钟窗口)、超大的消息、格式不合规的 v2 基因 ID,都会被拒收,不会被转发。接收端把名字和版本显示为标签,并提示基因 ID 是由内容算出的。

两端都要升级

旧的 JSON 公告(旧 topic)和新的 protobuf 公告互不相通。0.26.x 的节点看不到 0.27 的公告,反过来也一样。如果你有不止一台机器,请一起升级。

我们怎么攻击它

P2P 模块有一套对抗测试,在 CI 里用真实的 libp2p 节点(经本机回环)运行。每条敌意消息之后,同一个攻击者会再发一条合法的探针,所以「没送达」的意思是「被拒绝」,而不是「丢了」。场景包括:冒名、重放一小时前的公告、时间戳提前一小时、乱码和旧格式载荷、单个身份 100 条突发、把目录名冒充基因 ID、以及经中继向远端发送非法公告。在线格式 v1 上,每一种都被拦下,中继那一种就拦在中继处。

限制照实说:限流是按身份计的,而身份可以零成本生成,所以大量新身份仍可能挤占节点的已收列表;只拿到攻击者的引导地址的节点,也只能看到攻击者。这两点超出了局域网节点能做到的范围,本版都没有解决。


两台机器,一个 Wi-Fi

我们实际是这样跑的:同一网络下两台 Mac,各自全新执行 npm install -g @rotifer/playground@0.27.0。机器 B 启动节点;机器 A 启动节点并把 B 的地址作为 bootstrap,几秒内 rotifer network peers 就列出了 B。B 执行 rotifer network announce hello-world,A 上的 rotifer network received 显示:

hello-world@0.1.0: general · Wrapped · v2:127ed2e889c0… · from 12D3KooW9xLQhbKV…

前缀与 B 打印的完整 ID 一致,from 的 PeerId 就是 B。我们只用 A 一台机器的时钟计时,所以不存在两机时钟差:从按下回车到公告出现,用时 1.356 秒。这个数字是上限,其中包含 B 上启动 CLI 进程、计算 ID,以及 A 每 0.1 秒轮询一次结果的时间。

你可以用自己的两台机器复现:

# 两台机器都执行
npm install -g @rotifer/playground@0.27.0

# 机器 A
rotifer network start --host 0.0.0.0 --port 9878
rotifer network status                      # 复制 /ip4/192.168.x.x 那一行

# 机器 B
rotifer network start --host 0.0.0.0 --port 9878 \
  --bootstrap /ip4/192.168.x.x/tcp/9878
rotifer network peers

# 机器 A:在 `rotifer init` 生成的目录里公告
rotifer network announce hello-world

# 机器 B
rotifer network received

首次启动时,macOS 可能会询问是否允许 node 接受传入连接,请选择允许。含排错说明的分步指南见 docs/p2p-lan-demo.md。


现在走到哪一步

让基因彼此发现、按优劣竞争的网络,是目标。这个版本是其中一块更小、可检验的拼图:在一个局域网内,基因由内容标识,以冻结的格式公告,并在扩散之前被检查。

目前已有的:同一局域网内的机器、手动指定的 --bootstrap 地址、携带元数据和基因 ID 的公告(不含基因二进制)。目前还没有的:公共网络。NAT 穿透、任何人都能连上的引导节点、跨节点搜索,都是之后的工作,现在还不能从自己的网络之外加入。

本版同时:三个内置的 Wrapped 基因现在声明了源文件,因此有了 ID;沙箱引擎和 TLS 库也升级到了已修补的版本,以应对新披露的安全公告。


安装 / 升级

npm install -g @rotifer/playground@0.27.0

P2P 部署中的每台机器请一起升级。