闪电网络中的 “洋葱路由” 及其工作原理
作者:LORENZO
一个网络中的计算机依据协议跟彼此交流。在这里,“协议” 指的是一套规则系统,指定了消息应该如何传输和解读。闪电网络协议中的支付消息传输部分由 BOLT#4 描述,也叫 “洋葱路由协议(Onion Rounting Protocol)”。
洋葱路由是一种先于闪电网络 25 年诞生的技术。它也被用在 Tor 中,正是 “Tor” 这个名字(“The Onion Router”)的由来。闪电网络使用的是一个稍微修改之后的版本,叫做 “基于来源的洋葱路由”,缩写是 “SPHINX”。在这篇文章中,我们就要讲讲洋葱路由是怎么工作的。
为什么要使用洋葱路由?
世界上存在许多不同的通信协议,但因为闪电网络是一个支付网络,选择一个尽可能少揭示正被转发的支付的信息的协议,就是合理的。
如果闪电网络使用跟互联网一样的协议,每一个中间人都会知道谁是支付的发送者、谁是接收者、整条路径上的其他中间人是谁。洋葱路由是一个好的选择,因为其特性保证了中间节点:
-
只知道自己的上一个节点(谁给自己发来了消息)和下一个节点(要把消息转发到哪里去)。
-
不知道整条路径的长度;
-
不知道自己在路径中的位置。
概述洋葱路由
我们用包裹作为类比,解释一下洋葱路由是怎么工作的。
假设 Alice 要给 Dina 支付。首先,Alice 要为自己的支付找出一条可行的路径:
Alice → Bob → Chan → Dina
然后,她构造出一个 “洋葱”。她要从 Dina 开始(从路径的末端开始)。她把一个秘密消息(支付内容)放在一个发送给 Dina 的包裹中,并且使用一个只有她和 Dina 知道的密钥来上锁。现在,她把这个包裹放到另一个准备发送给 Chan 的包裹中,并且使用只有她和 Chan 知道的密钥,给这个发送给 Chan 的包裹上锁。对以此类推。
Alice 把最终的洋葱(包裹)发给路径上的第一个中间人,Bob。Bob 使用自己的密钥解锁自己的包裹,然后看到下一个包裹是发送给 Chan 的。于是他把包裹转发给 Chan。Chan 也一样,解开包裹之后,把里面那个包裹转发给 Dina。最后,Dina 打开属于自己的包裹,发现其中的支付消息。
在洋葱路由中,像 Bob 和 Chan 这样的中间人,并不知道给 Dina 的信息的内容,也不知道整条支付路径的长度。他们唯一知道的,就是给他们转发这个包裹的人,以及下一个接收包裹的人。这保证了消息的隐私性和路径的机密性。每一个中间人都只能触及专门为 TA 制作的那一层消息。
在闪电网络的基于来源的洋葱路由中,发送者选择支付路径,并为这条路径构造出完整的洋葱,这可以被视为隐私漏洞(译者注:接收者的网络位置必须向发送者曝光)。别的路由方案比如 “盲化路由”(中文译本),通过向发送者混淆部分支付路径来解决这个问题。不过,在这篇文章中,我们专讲 SPHINX。
组装洋葱
现在,我们来了解一下洋葱路由的规范。在一开始,我们需要定义这些东西:
-
发送者是 “最初节点”(Alice);
-
接收者是 “最终节点”(Dina);
-
支付路径上的每一个中间节点都是一 “跳”(Bob 和 Chan);
-
每一跳之间的通信信息,叫做 “跳的负载”。
建构跳的负载
一旦 Alice 选出了一条支付路径,她就从 gossip 协议中获得每一条支付通道的信息,以创建每一跳的负载,本质上这就是在告诉每一跳,如何为正在转发的支付创建 HTLC(哈希时间锁合约)。
为了建立一个合适的 HTLC,每一跳都需要:
-
需要转发的数额;
-
支付的秘密值;
-
继续发送洋葱的支付通道的 ID;
-
时间锁的长度。
这些数据中的大部分,都来自 “通道更新” 消息,这样的消息包含了关于路由手续费、事件所要求、支付通道 ID 的信息。需要转发的总数额,是支付的数额加上后续每一跳所收取的手续费总和;而支付的秘密值则是由 Dina 计算出来并嵌进支付发票中的(由洋葱消息告知路径上的每一跳)。
Alice 从最终节点 Dina 开始。她在包裹中包含转发数额、时间锁时长数值、支付秘密值以及支付数额。注意,她不需要再加入通道 ID,因为 Dina 就是最终节点,不需要再将支付转发给其他人。
乍看起来,提供转发数额是多余的,因为这个数额跟支付数额是一样的,但是,多路径(multipath)支付会将支付总额通过多条路径送达,那时候两个数值就会不一致。
在 Chan 的负载中,Alice 加入 Chan 跟 Dina 的通道 ID。她还添加了转发数额以及时间锁数值。最后,Alice 创建给 Bob 的负载。Chan 为通过自己跟 Dina 的通道的支付收取 100 聪,因此,Alice 需要告诉 Bob 的转发数额是支付额加上手续费。根据 Chan 的通道更新消息,时间锁的数值也提高了 20(以区块为单位)。最后,Alice 也要考虑 Bob 的手续费和时间锁要求,给他一个时间锁长度为 700040、价值为 100200 聪的 HTLC。
共享秘密值与密钥生成
下一笔,Alice 通过为每一跳(包括最终节点)生成一个共享秘密值(shared secret),准备好洋葱。这个共享秘密值可以由 Alice 和目标那一跳各自生成出来,办法就是用自己的私钥与对方的公钥相乘。
共享秘密值对洋葱路由来说是必要的,这让 Alice 和每一跳可以推导出相同的密钥。然后,Alice 使用这些密钥来混淆洋葱的每一层,而那一跳则使用密钥来解开混淆。
为了保护 Alice 的隐私,她会为一个洋葱创建一个一次性的会话密钥,而不是使用自己的节点公钥,以推导共享秘密值。她给第一跳使用这个会话密钥,然后,对后续的每一跳,Alice 都将最新的密钥乘以一个盲化因子,从而确定性地随机化密钥。这些用来创建共享秘密值密钥,我们叫做 “临时密钥” 。
Bob、Chan 和 Dina,都需要跟 Alice 得到相同的秘密值,因此,他们需要知晓用在自己的会话中的临时密钥。Alice 只将第一个密钥放到洋葱中,以节约消息的体积。每一跳都计算下一个临时密钥,并将它嵌在给下一个节点的洋葱中。各跳可以使用自己的公钥和共享秘密值计算出 Alice 所用的盲化因子,从而确定下一个临时密钥。
如前所述,共享秘密值会被用来生成一些密钥,Alice 和对应跳可以用这些密钥对洋葱做一些操作。我们来看看每一个密钥的用途。
Rho key
Rho key 被 Alice 用来加密一层洋葱;这样会混淆负载的内容,使外人无法解读。只有 rho key 的主人可以解密负载。这就是收到洋葱的节点要做的事:使用跟 Alice 的共享秘密值推导出 rho key,然后解密洋葱、阅读内容。
Mu key
Alice 使用 mu key 来为每一个负载创建一个校验和。她也会把校验和交给接收洋葱的那一跳。反过来,这一跳会使用 mu key 生成所收到的负载的校验和,检查是否与 Alice 给出的相匹配。这是为了检查负载的完整性,验证它没有被篡改过。
Pad key
这个密钥仅为 Alice 所用,用来生成随机的 “垃圾” 数据。这些数据也是洋葱的一部分,而且它跟支付路径的长度、洋葱已经通过多少跳无关,它让洋葱总是保持相同的体积,即使其某些内容需是无关紧要的。这就是洋葱路由如何隐藏路径长度的,实际上就是在保护发送者和接收者的隐私。
Um key
这个密钥也用来检查洋葱内包含的数据的完整性,但仅在回传错误时使用。没错,它叫做 “um” 是因为这是 “mu” 的倒写。在支付出错的情形中,发现错误的那一跳将使用 um key 创建一个校验和,当前一个节点收到这个报错时,也使用 um key 来验证消息的完整性。
封装洋葱层
最终的洋葱包裹看起来是这样的:
现在,Alice 拥有了给每一跳的负载,以及给每一跳的共享秘密值。我们来看看 Alice 如何将这些信息转化为最终的洋葱。她先从最终节点开始,然后一步一步往回推。
她先创建一个空的、长为 1300 字节的域,这也是所有洋葱负载的总长。然后,她使用 pad key创建一段长为 1300 字节的随机串,这就是对任何一跳都没用的垃圾。做这一步,是为了确保每一层洋葱看起来都是一样的,所以既无法看出路径的总长(有多少跳),也看不出谁是发送者、谁是接收者。
然后,她给需要使用的负载创建一个校验和,并放在负载的末尾。在给最终节点的消息中,校验和全部为 0,以告知 Dina,她就是这个洋葱的最终接收者。把校验和添加到负载的末尾之后,Alice 就把负载(以及校验和)放到垃圾的开头,并删去整条消息超过 1300 字节的部分,以保证整个消息的长度就是 1300 字节。
然后,Alice 使用 rho key 创建一个随机字节串,并对上一步得到的洋葱负载使用异或(XOR)运算,得到混淆后的负载。负载的原文可以通过对混淆文使用这个随机字节串的 XOR 运算得到(译者注:换言之,这里的 XOR 就是对称加密的算法,而随机字节串就是密钥)。XOR 操作会逐比特对比洋葱负载和(由 rho key 生成的)随机字节串,仅当其中一个数据的比特是 1 时,才会输出 1;这就得出了一个混淆后的负载。XOR 操作巧妙的地方在于,只要你得到了对的那个随机字节串以及混淆后的负载,只需用两者再次运行 XOR 操作,就可以得到混淆之前的负载。
因为收到洋葱的节点可以推导出相同的 rho key,可以他们可以生成跟 Alice 一样的随机字节串。这就是沿路的各个节点可以解开混淆、读到内容的办法。
准备好一跳的混淆洋葱后,Alice 就给下一个节点重复相同的步骤。关键区别在于,完成 Dina 的洋葱之后,她就不再需要生成垃圾了。她只需在有用的负载和校验和之后接上上一步所生成的混淆洋葱,再剪去超过 1300 字节的部分。
最后,Alice 拿到最终的混淆洋葱并添加一个校验和,这样 Bob 就可以验证这个洋葱的完整性。然后,Alice 加入会话公钥,这样 Bob 就可以使用这个公钥来计算共享秘密值。最后,她还要加上一个表示版本的字节,告知其它节点如何解读其中的数据。对 BOLT#4 所描述的版本来说,版本字节应为 0。
转发洋葱
为了发送这个洋葱包裹,发送者创建一条 update_add_htlc
消息,包含下列字段:
-
通道 ID:这个消息所关乎的具体通道。
-
ID:这个 HTLC 的标识符。
-
数额:这个 HTLC 的价值。
-
支付哈希值:由支付的接收方创建。
-
过期时间:这个 HTLC 将在一定区块之后过期。
-
洋葱包裹:为这笔支付创建的洋葱,也就是上面讲到的东西。
-
额外的数据:用来指定额外的数据。
准备好消息后,Alice 就把消息发送个 Bob。收到消息后,Bob 就可以开始解码属于自己的洋葱了。他先从洋葱包裹中获得会话密钥,然后使用它推导出跟 Alice 的共享秘密值。
有了共享秘密值,Bob 生成 mu key,以验证嵌在洋葱包裹中、负载的校验和。如果负载没有被篡改过,校验和应该能匹配上。
为了防止路径中的其他节点知道路径有多长,Bob 会在洋葱包裹内增加一个 1300 字节长、充满了 0 的字段。然后,Bob 从 rho key 中生成一个 2600 字节长的随机字节串。Bob 使用这个随机字节串,对填充了 0 的洋葱负载作 “异或” 运算。
还记得我怎么跟你说混淆洋葱负载的吗?使用混淆后的洋葱负载作为输入,跟相同的字节串运行 “异或” 操作,就能得到混淆前的洋葱负载。因为 Alice 和 Bob 使用相同的共享秘密值,生成了相同的 rho key,Bob 可以解开混淆。这样做的额外好处是,它又将 1300 字节长的填充字符变成了随机字节。
Bob 解开混淆的负载中包括了他这一跳的负载数据以及一个指纹。Bob 保存这个指纹,以便将它添加到发送给 Chan 的洋葱包裹中。在 Bob 将属于自己的负载从洋葱消息中分离出来后,他将洋葱包裹转回 1300 字节的原始大小,并跟 Alice 一样随机化自己的会话密钥。最后,Bob 加上版本字节、会话密钥以及他准备放在洋葱负载中的指纹,就通过 update_add_htlc
消息将洋葱包裹转发给 Chan。
这个过程会一直持续,直至消息送到最终节点,Dina。当 Dina 收到 update_add_htlc
消息时,她可以揣进到自己所生成的秘密值的哈希值,这说明这个 HTLC 就是要发给她的。因此,Dina 只需检查指纹、解开洋葱消息、揭晓属于自己的负载。
故障处理
我们介绍的是一个成功案例,也就是一切都按部就班的案例,但如果这个过程发生了一些错误,那就必须一路回传一条消息,以通知所有节点出了问题。这个过程跟常规的洋葱路由类似。发现一个错误的故节点需要从共享秘密值中推导出 um key,并使用它生成一个随机字节串,然后使用异或运算来混淆返回的洋葱包裹。
发现错误的节点将给支付路径的上一个节点回传一条消息。每一跳都使用 um key 和 ammag key 作相同的操作,直到发送者收到这个包裹。最后,发送者分别使用 ammag key 和 um key 解开包裹的混淆并验证。
错误可能由洋葱包裹、节点或通道引起。如果你经常使用闪电网络,你可能遇到过这样的错误,比如 “通道不可用” 或 “手续费不足”。