Berty/Wesh协议详解
info Wesh协议概述
Wesh协议是一个点对点的安全通信协议,最初被称为Berty协议。该协议在属于同一账户的设备之间提供安全通信,在一对一对话中提供联系人之间的通信,以及在多成员群组中提供多个用户之间的通信。Wesh协议采用分布式和异步的方式实现这些功能,无论是否有互联网访问,都可以使用IPFS和BLE等直接传输方式进行通信。
Wesh协议的核心特点包括:
- 端到端加密:所有交换的消息都经过端到端加密
- 完美前向保密性:即使密钥泄露,过去的通信内容也不会被解密
- 去中心化:不依赖中央服务器,难以被关闭或监控
- 离线通信:通过直接传输方式(如蓝牙低功耗)实现无互联网连接时的通信
- 多设备支持:同一账户可在多个设备上使用,并保持数据同步
layers 协议栈详解
IPFS
IPFS(InterPlanetary File System)是一个用于存储和共享数据的分布式文件系统的点对点网络。Wesh协议使用IPFS进行即时消息传输,这提供了两个主要优势:
- 几乎不可能阻止或关闭:任何人都可以在几秒钟内在其计算机上启动一个节点,而同一局域网内的两个节点仍然能够在没有互联网连接的情况下进行通信
- 难以监控:没有中央服务器可供监视,也没有中央目录可供破坏,因此元数据收集大大减少
然而,使用IPFS也带来了一些技术限制:
- 内容可用性:由于没有中央存储,所有消息都存储在用户设备上,无法访问存储在离线设备上的内容
- 异步性:没有中央服务器来管理时间线,因此时间戳除了用于非关键任务外不能用于其他目的
- 权威性:没有中央权威来仲裁操作及由此产生的任何冲突,也无法管理用户身份和权限
汇合点
汇合点是一个在点对点网络上的易变地址,两个设备可以在那里相遇。对等方可以在给定的汇合点上注册它们的对等方ID,并/或获取已注册对等方的列表。通过这种方式,需要连接在一起在对话中交换消息的对等方,可以找到彼此。
在Wesh协议中,一个会晤点的地址由两个值生成:
- 一个资源ID
- 一个基于时间生成的令牌,该令牌由32字节的种子生成
基于时间生成令牌的过程遵循RFC 6238的核心原则。以下是生成会晤点的代码示例:
func rendezvousPoint(id, seed []byte, date time.Time) []byte {
buf := make([]byte, 32)
mac := hmac.New(sha256.New, seed)
binary.BigEndian.PutUint64(buf, uint64(date.Unix()))
mac.Write(buf)
sum := mac.Sum(nil)
rendezvousPoint := sha256.Sum256(append(id, sum...))
return rendezvousPoint[:]
}
Wesh协议中有两种类型的汇合点:
- 公共会面点:用于账户接收联系人请求。此处使用的资源ID是账户ID,种子可以由用户任意更新,因此可以撤销向仅拥有先前种子的用户发送联系人请求的能力
- 群组会面点:用于群组内交换消息。此处使用的资源ID是群组ID,种子不能更改
该协议依赖于三种不同的会面点系统:
- DHT基础:完全分布式,几乎不可能被关闭,可以在没有互联网访问的情况下运行,但可能较慢
- 去中心化服务器:非点对点/分布式,比DHT更容易关闭,离线时无法访问但速度更快
- 本地记录:与蓝牙低功耗等直接传输方式结合使用,几乎能即时生效,但会引发隐私问题
直接传输
当没有互联网连接时,在一定的物理距离限制下,仍然可以通过直接传输进行通信。这些传输直接与IPFS集成,更具体地说是与其网络层:libp2p集成。
这些直接传输基于:
- Android设备的Android Nearby
- iOS设备的Multipeer Connectivity
- 蓝牙低功耗(BLE)进行跨操作系统通信
通过Android Nearby和Multipeer Connectivity,消息可以通过Wi-Fi直连进行交换,这比通过BLE要快得多,也更可靠。无需访问互联网也能完全使用Wesh协议:创建账户,添加联系人,加入对话并发送消息,只要蓝牙范围内有Wesh用户即可。
无冲突复制数据类型(CRDT)
由于可以通过直接传输进行在线和离线通信,因此需要一种方法来保持所有消息之间的连贯性和顺序,尤其是在有多参与者参与的对话中。这个问题的解决方案是冲突复制数据类型(CRDT),这是一种允许对数据进行一致排序的数据结构,用于分布式系统上的消息。Wesh依赖于OrbitDB,该协议实现了CRDT。
CRDT提供乐观复制和强最终一致性,这确保了一旦同步,每个节点都将拥有相同版本的消息列表。每条消息都链接到其父消息,即当前时刻连接在一起的节点中某一方发送的最后一条消息。当在线版本和离线版本的对话同步时会出现问题:某些消息链接到相同的父消息,链表变成一个有向无环图。
OrbitDB通过使用兰伯特时钟实现这一点:每条消息将包含一个兰伯特时钟,合并后的列表将根据其值排序。兰伯特时钟是一个包含两个字段的结构体:一个身份公钥和一个计数器,该计数器为关联用户/身份发布的每条消息递增。
type lamportClock struct {
time int
id crypto.PublicKey
}
比较函数非常简单,它首先会检查计数器值之间的距离,如果没有,则会检查身份公钥之间的字典序距离:
func compareClock(a, b lamportClock) int {
dist := a.time - b.time
if dist == 0 {
dist = comparePubKey(a.id, b.id) // Returns lexicographic distance
}
return dist
}
account_circle 账户系统
账户创建
为了使用Wesh协议,用户必须创建一个账户。账户创建不需要任何个人信息。在整个Wesh协议中,所有密钥对都将使用X25519进行加密和Ed25519进行签名。
创建账户步骤:
- 生成账户ID密钥对。此操作不会重复。该密钥对是账户的身份,因此无法更改。
- 生成别名密钥对。操作不会重复。有关别名密钥对的更多详细信息,请参阅别名身份部分。
- 在用于创建账户的设备上生成设备ID密钥对。此操作将在每台新设备上重复进行。该密钥对是设备的身份。
- 生成公共RDV种子。RDV种子用于生成RDV点以接收联系人请求。此操作可以随时重复。
由于没有中央目录,创建账户和发送/接收联系人请求不需要访问互联网。如果两个用户离线创建账户,然后通过直接传输连接,他们将交换他们的公共会晤点(用于联系人请求),因此将能够互加为联系人。
连接设备
在Wesh协议中,用户可以在同一个账号下使用多个设备,这意味着这些设备需要相互链接,以便同步账号的联系人列表、群组列表、设置等。要在设备A上向现有账号添加设备B,将遵循以下链接步骤:
- 第一步是在新设备B上生成一个设备ID密钥对。
- 然后设备A需要生成一个包含A的peerID的邀请,例如,以URL或二维码的形式。
- 设备B必须扫描A提供的二维码或遵循URL来获取A的peerID,与A建立连接,然后向A发送包含B设备ID的连接请求,启动握手过程。
设备A可以向设备B发送三种不同类型的挑战:
- 二维码:B必须显示一个包含其设备ID指纹的二维码,用户需使用设备A扫描此二维码。如果二维码与A先前收到的ID匹配,则连接成功。如果设备A具备正常工作的摄像头,则应优先使用此挑战,因为它既更安全也更方便用户。
- PIN:必须显示一个用户需要在设备B上输入的PIN码。然后B使用其设备ID向A发送PIN码的签名。最后,A使用B的设备ID验证PIN码的签名,链接即成功。这种挑战方式安全但用户不便。
- Fingerprint:B和A必须显示B的ID指纹,然后用户需要手动验证两个指纹是否相同,并使用复选框确认。这种挑战方式安全性较低,因为用户可以在不仔细阅读指纹的情况下确认其相等性,因此不应向用户提出,除非他们希望自动化此过程。
- 设备吊销:请注意,无法吊销设备。一旦设备被链接,它就拥有与其他所有设备相同的信息和密钥。因此,它具备与其他所有设备相同的功能,例如链接其他设备、发送消息、加入群组或添加联系人。与账户链接的设备之间不存在层级关系。
- 设备同步:由于Wesh是一种异步协议,两个设备需要同时在线才能同步。然而,可以使用复制设备来缓解这个问题,这些设备的唯一目的是提供内容的高可用性。这些设备无法解密消息,它们所能做的就是验证消息的真实性。
添加联系人
如果账户A想要与账户B开始一对一对话,它必须先添加B为联系人。A需要向B发送一个联系人请求,B必须接受该请求后对话才能开始。
联系请求
当账户A(请求者)想要将账户B(响应者)添加到其联系人列表时,它需要知道响应者的公共会面点。这个会面点是由RDV种子和账户ID派生出来的。因此,响应者首先需要将他的RDV种子和账户ID分享给请求者,以便后者可以计算RDV点。这些信息可以通过不同的方式发送:通过消息发送的URL、响应者设备上显示的二维码并由请求者的智能手机扫描,等等……
响应者可以随时更新他们的RDV种子。如果它这样做,请求者将无法再发送联系人请求,除非响应者共享他们新的RDV种子。响应者还可以通过从其公共汇合点注销设备来完全禁用传入的联系人请求。
握手
以下是请求者向响应者发送联系人请求时发生的握手过程:
- 请求者你好:请求者将其临时公钥发送给响应者。临时密钥仅用于一次握手,然后被丢弃。它们保证了消息的新鲜性,以防止重放攻击。
- 响应者你好:响应者将其临时公钥发送给请求者。现在请求者和响应者都能够计算两个共享密钥。
- 请求者认证:请求者发送一个包含其ID公钥签名的秘密盒子,他们使用ID公钥来验证自己的身份。密钥盒被密封,并且在这个步骤中,请求者也证明了他们知道响应者的ID。
- 响应者接受:响应者发送一个包含其ID公钥签名的秘密盒子。该秘密盒子用新的秘密密封,这证明了响应者已经有效接收并解密了前一条消息。
- 请求者确认:为了通知响应者,请求者在验证响应者接受的内容时没有遇到任何错误,请求者发送一个确认。完成这一步后,双方都认为握手有效。
- 中间人攻击:握手过程不会受到中间人攻击的威胁。实际上,请求者已经通过可信方式(例如直接扫描响应者的设备上的二维码)知道了响应者的身份。为了验证彼此的身份,请求者和响应者可以稍后见面并检查他们共享密钥的指纹。
- DDoS攻击:如果响应者拒绝接触请求,他们可以阻止请求者的ID,使其无法再与对方进行握手。如果响应者收到的接触请求过多,他们也可以更改RDV Seed,并使用旧的RDV节点变得无法联系。
- 重放攻击:使用临时密钥对可以保证攻击者无法重用先前窃取的消息来欺骗请求者或响应者。
group 群组系统
概念
该协议强烈基于群组的概念。群组是一个逻辑结构,成员及其设备连接到其中交换消息和元数据。元数据种类繁多,其中一些用于通知群组有新成员或新成员的设备加入了群组,另一些则用于成员之间交换加密密钥等。消息和元数据通过OrbitDB提供的两个不可变日志进行交换。
组结构
一个组分为两个日志:消息日志和元数据日志。
- 消息日志:包含群组内所有交换的消息。群组成员可以选择只下载部分消息日志(例如仅最后1000条消息)。此外,由于对称密钥旋转协议,成员无法解密在他们到达之前发送的消息。
- 元数据日志:包含群组的所有元数据。由于它包含重要信息,群组成员应当下载整个元数据日志。秘密信息在这个日志上交换。新成员的加入也会在这个日志上宣布,所以如果新成员没有下载整个元数据日志,他们将不知道完整的成员列表,因此他们将无法与他们交换秘密信息,从而无法解密他们的消息。
群组类型
在Wesh协议中,有三种不同的群组类型:账户群组、联系人群组和多成员群组。群组成员是群组中的Wesh用户(一个账户)。群组对于Wesh协议中的通信至关重要,它们拥有自己的密钥和秘密信息,并在所有群组成员之间共享:
- 群组秘密:群组秘密是一个对称密钥,用于加密/解密群组负载。
- 群组ID密钥对:群组ID用于派生群组RDV点。私钥仅用于生成群组密钥签名并签名创建者成员ID,然后被丢弃(仅限多成员群组)
- 附件密钥:附件密钥是对称密钥,用于加密/解密附加到消息的文件的内容ID。附件密钥不用于加密文件。
- 群组密钥签名:由群组ID私钥对群组密钥的签名(仅用于多成员群组)
账户群组
账户组是由所有连接到同一账户的设备组成的群组。同一账户下的设备需要在一个私密群组中才能相互通信并共享账户信息,例如发送和接收的消息、加入的群组、添加的联系人的信息等……账户组仅由一个群组成员组成,即拥有所有设备的账户。每次有新设备连接到账户时,它都会加入账户组。账户组的密钥和密钥秘密在账户创建时随机生成,与多成员群组的方式相同。
联系组
一个联系组是由恰好两名互为联系人的组成员组成的群组。当一名账户将另一名账户添加为联系人时,联系组即被创建。群组密钥、群组ID和附件密钥由发送联系人请求的一方使用X25519密钥协商协议生成。
多成员群组
一个多成员群组是由若干个群组成员组成的,这些成员可能是彼此的联系人,也可能不是。多成员群组的一个特点是,用户在群组中不会使用他们的账户ID,而是会使用一个特定于该群组的成员ID(由群组ID和一些秘密账户派生而来)。同样地,他们的设备会使用一个成员设备ID(随机生成)。因此,知道彼此账户ID(即联系人)的用户在多成员群组中无法互相识别,除非他们愿意这样做(参见别名身份)。
多成员群组的密钥和秘密在群组创建者创建群组时随机生成。一旦秘密生成,群组创建者会在元数据日志上发布一个初始化成员条目。如上图所示,初始化成员条目由一个用群组密钥封存的密钥盒组成,其中包含以下元素:
- 成员ID公钥
- 成员ID公钥由群组ID私钥签名
在将此条目发布到元数据日志后,群组创建者丢弃群组ID私钥。群组中的所有成员具有相同的状态,除了可以用此初始化成员条目识别的群组创建者。但它并没有赋予他们比其他成员更多的权利或能力。
别名身份
在多成员组中,用户不会在组中使用他们的账户ID,而是会使用一个特定于该组的成员ID(由组ID和某个账户的秘密派生而来)。同样地,他们的设备将使用一个成员设备ID(随机生成)。因此,知道某人账户ID的用户将无法在多成员组中识别他们。但是,如果他们希望被联系人识别,他们可以向组共享一个别名条目,该条目由别名解析器和别名证明组成:
- 别名解析器:别名公钥和组ID的HMAC。
- 别名证明:由别名私钥对别名解析器签名的证明。
别名密钥对在账户创建时生成,一旦联系人请求被接受,别名公钥就会与联系人共享。通过别名条目,群组中知道某人别名公钥的每个人都能识别他们的成员ID。当用户加入一个新的多成员群组时,它会为每个联系人计算别名解析器,以便每当有其他成员披露别名条目时,匹配过程都能即时完成。
message 消息加密与交换
加密
在Wesh协议中,所有通信均使用对称密钥旋转机制进行端到端加密。每次用户想要向某人发送消息时,都会使用HKDF从其链密钥中导出消息密钥。HKDF每次导出后也会更新链密钥。消息密钥随后用于加密消息,且不会重复用于加密其他消息。
每个群组成员的设备拥有不同的链密钥。群组ID包含在HKDF的参数中,以使导出的密钥具有上下文相关性。对话开始时,成员们与其他参与者共享其设备的链密钥。为了解密其他参与者发送的消息,他们必须遵循相同的过程,并从链密钥中导出消息密钥。发送方每收到一条消息的HKDF密钥。
func deriveNextKeys(currChainKey [32]byte, salt [64]byte, groupID []byte)
(nextChainKey, nextMsgKey [32]byte) {
// Salt length must be equal to hash length (64 bytes for sha256)
hash := sha256.New
// Generate Pseudo Random Key using currChainKey as IKM and salt
prk := hkdf.Extract(hash, currChainKey[:], salt[:])
// Expand using extracted prk and groupID as info (kind of namespace)
kdf := hkdf.Expand(hash, prk, groupID)
// Generate next chain and message keys
io.ReadFull(kdf, nextChainKey[:])
io.ReadFull(kdf, nextMsgKey[:])
return nextChainKey, nextMsgKey
}
加入群组
要与其他设备(或用户)通信,设备(或用户)必须加入一个群组。只有拥有邀请函,设备(或用户)才能加入群组。
邀请
一个邀请由群组ID、群组密钥、群组密钥签名和附件密钥组成。因此,邀请可以由群组的任何成员创建。通过邀请,Wesh用户可以计算汇合点,该组的标识符,它源自组ID。
一旦RDV点计算完成,用户便能够下载群组的元数据日志,并使用群组密钥解密其部分条目。他将会获得所有群组成员的列表,这对于与他们会话并能够解密他们的消息至关重要。
新成员加入
一旦用户收到加入多成员群的邀请并下载了元数据日志,他们必须宣布自己的加入。为此,他们需要在元数据日志上为每个设备发布一个成员条目。一个成员条目由一个用群组密钥密封的秘密盒子组成,其中包含以下元素:
- 使用成员ID密钥对组ID和成员设备ID公钥进行签名
- 成员ID公钥用于验证签名
- 设备ID公钥用于验证新成员
现在新成员宣布到达后,需要与其他成员交换他们的链密钥。为此,对于他们的每台设备,他们会在每个已在群组中的成员的元数据日志上发布一个秘密条目。一个秘密入口由一个用群组密钥封印的秘密盒子组成,其中包含以下元素:
- 发送设备ID公钥
- 接收成员ID公钥以便群组内的每个人都知道秘密条目是写给谁的
- 一个用发送者和接收者都能计算的共享秘密封印的秘密盒子,包含发送者的链密钥及其当前计数器
这个操作是双向的,一旦一个成员在元数据日志上获取了一个秘密条目,他们也会发布一个针对新成员的秘密条目。最终,每个人都拥有新成员的链密钥,新成员也拥有群组中每个成员的当前链密钥。
交换消息
现在群组中的每个成员都拥有新成员的链密钥,新成员也拥有群组中每个成员的链密钥,每个人都能够发送和接收消息。为此,他们必须首先按照对称棘轮协议从他们的链密钥中导出消息密钥。
想要向群组发送消息的成员必须在消息日志上发布一个消息条目。一个秘密条目由一个用群组秘密封印的秘密盒子组成,其中包含以下元素:
- 一个用消息密钥封印的秘密盒子,包含消息
- 用设备ID私钥对这个秘密盒子的签名
- 用于验证上述签名并识别发送者的设备ID公钥
- 对应于消息密钥的计数器
一旦成员从消息日志中获取了消息条目,他们必须使用发送者的链密钥解密它。首先,他们需要旋转发送者的链密钥,直到达到消息计数器。然后,他们需要使用消息密钥来解密消息。一旦消息密钥被使用,它就会被丢弃,因为它只能解密一条消息。
- 邀请过期:如前所述,由于Wesh协议的异步性质,其中没有过期时间。因此邀请不会过期,不是指定性的,可以多次使用。此外,与账户RDV点不同,群组RDV点不能由成员随意更新,因此不能用于逃避不受欢迎的到达。
- 成员移除:成员不能从群组中移除,因为这涉及更新所有秘密,包括成员的链密钥,同时被禁止的成员不得知道交换的新秘密,并且一些成员可能长时间未与群组的其余部分同步。对用户来说,最简单、最不容易出错且更清晰的方法,只是简单地创建一个没有不受欢迎成员的新群组。成员可以自愿离开群组,但对于其他人来说,没有密码学保证成员已经有效离开群组并且不再拥有秘密。
- 可扩展性:由于新成员必须为群组的每个成员发布一个秘密条目,然后必须从群组的每个成员接收一个秘密条目,随着群组成员越来越多,多成员群组中的新到达可能成为一个非常昂贵的过程。因此,可能需要在多成员群组中设置成员限制,以确保使用Wesh协议的应用程序的有效运行。
- 后泄露保密性:目前,Wesh协议中没有后泄露保密性,原因与没有成员移除相同。然而,可以更新所有成员的链密钥,例如每发送一百条消息,以减轻最终未被注意到的泄露。
cloud_queue 高可用性解决方案
由于Wesh协议中没有中央服务器,消息和文件只存储在用户设备上。因此,如果某个设备拥有某些信息并且处于离线状态,其他设备将无法获取这些信息。例如,如果用户使用设备A添加了一个联系人,然后将设备A离线并使用设备B,设备B将不会知道这个新联系人,也无法与其通信。
为了缓解这个问题并提供高可用性,用户可以使用以下配置之一设置专用设备:机器人账户、链接设备、复制设备和复制服务器。
机器人账户
机器人账户是在专用设备(例如服务器)上创建的账户,并作为联系人添加到用户账户中。用户必须手动将机器人联系人添加到其所有多成员群组中。由于机器人账户与用户账户没有任何区别,这个账户可以执行用户账户可以执行的所有操作,包括发送和读取消息。机器人账户仅为多成员群组提供高可用性,因为它不能被添加到联系人群组中。
链接设备
链接设备可以专用于复制,但这只是一个使用上的差异,没有任何东西将这个设备与链接到账户的其他设备区分开来,这意味着它们可以读取和发送消息,以及加入群组或将新设备链接到账户。链接设备为账户所属的每个群组提供高可用性。
复制设备
复制设备是链接到账户的专用设备,但不会获取所有账户秘密,并且无法发送联系人请求或链接新设备。它会自动添加到账户所属的所有群组中,但它不会获得任何群组秘密,除了群组ID公钥、群组签名和附件密钥。因此,它无法读取消息,也无法发送消息。它所能做的就是存储消息并使用群组ID公钥验证其真实性,以避免存储来自群组外部的垃圾邮件。它还需要附件密钥来解密附件cID并存储附件。
复制服务器
复制服务器基本上是一个复制设备,它不链接到用户账户,而是由第三方拥有并提供。任何人都可以将现有的复制服务器添加到群组(通过API)以提供高可用性。与复制设备一样,复制服务器在被添加到群组时只获得群组ID公钥、群组签名和附件密钥,因此无法解密消息。
vpn_key 密码学基础
密钥类型
Wesh协议中使用的所有密钥对都是X25519用于加密和Ed25519用于签名,主要有两个原因:
- 这些密钥对在相同安全级别下比RSA密钥对更小,这意味着存储的数据更少,通过网络发送的负载更小。
- 椭圆曲线密码学也比RSA算法更快,特别是在私钥操作上,这意味着CPU消耗更少,因此在移动设备上电池寿命更长。
Golang包
Wesh协议中使用的大多数加密库都是包含在标准Go库中的包:
- crypto/sha256
- crypto/rand
- x/crypto/nacl/box
- x/crypto/nacl/secretbox
- x/crypto/hkdf
- x/crypto/ed25519
Wesh协议中使用的唯一非标准包是以下两个,尽管它们是由专家编写的并经过社区的广泛审查:
- libp2p/go-libp2p-core/crypto
- agl/ed25519/extra25519
直接传输的特殊性
当Wesh用户通过直接传输连接到另一个Wesh用户时,他们首先要做的是发送他们正在监听的所有RDV点列表:他们的公共RDV点(用于联系人请求)以及他们所属的所有群组的RDV点。如果第二个用户已经在其中一些RDV点上监听,他们将能够使用直接传输在这些群组中进行通信。
例如,如果Alice和Bob是联系人,他们都将监听他们联系人群组的RDV点,因此当Alice将她所有的RDV点发送给Bob时,他将看到他们有一个共同的RDV点,他将能够在他们的联系人群组中向她发送消息。这对于多成员群组也是有效的。
因此,直接传输不像IPFS那样使用DHT,这是一种同步通信协议,因为两个设备需要相互连接才能交换消息。除此之外,Wesh协议的工作方式完全相同。