返回首页
密码学

加密实现参考

实际运行的加密比已经写好的加密要窄,而这一差距正是本页存在的意义。Railgun 真正执行的消息加密只有 libsodium 的 sealed box — 而且它只在原生 libsignal 模块不存在的客户端路径上执行。在 libsignal 确实会加载的 Electron 桌面版本中,负责加密私信的处理器仍然只是一个把明文做 base64 编码的占位实现。社区与频道消息在任何路径上都没有加密。本页后半部分描述的 Signal 式协议已经写好并经过审阅,但没有接入任何你能下载到的客户端。

这是一份架构参考,不是独立的安全审计。第 1 节和第 2 节描述已经交付的内容。第 4 节到第 7 节描述 Railgun 正在朝其构建的设计,并在全文中标注为计划中。此处的任何内容都不应被理解为:Railgun 客户端当前已经落实某项被标注为计划中的性质。

目前交付的内容

路径机制状态
私信 — 浏览器与开发版本libsodium sealed box — X25519 密钥协商,XSalsa20-Poly1305 认证加密。在原生 libsignal 模块不存在时被选用。已加密
私信 — Electron 桌面版本libsignal 会加载,因此 encryptDm 被路由到 Electron IPC 处理器 — 一个把明文做 base64 编码的占位实现。中继收到的是可读文本。未加密
社区与频道消息以 base64 包裹的明文发送。频道加密已经写好,但返回的是一个表示明文的标记。未加密
X3DH + Double Ratchet针对 libsignal 实现,但仅作为类型被引入。没有任何运行时路径会将其实例化。计划中
私钥存储密钥在设备上生成并保存在设备上。没有任何代码路径会传输私钥或身份密钥材料。已实现

1. sealed box — 实际运行的加密

Railgun 只要真的加密一条私信,用的就是 libsodium 的 sealed box — 也就是 crypto_box_seal 构造。它把 X25519 密钥协商与 XSalsa20-Poly1305 认证加密结合起来。这是真实且经过充分审阅的密码学。它同时也是一种刻意保持简单的构造,它的局限很重要。哪些客户端会走到这里是第 2 节的内容:浏览器与开发版本会,Electron 桌面版本不会。

sealed box 是如何构造的

// Sender has only the recipient's public key, PK_r

(pk_e, sk_e) = crypto_box_keypair() // fresh, single-use

nonce = BLAKE2b(pk_e || PK_r) // derived, not random

ct = crypto_box(plaintext, nonce, PK_r, sk_e)

sealed = pk_e || ct // ephemeral key is prepended

erase(sk_e)

接收方从消息开头取回临时公钥,用自己的私钥重新算出同一个共享密钥,然后打开这个盒子。Poly1305 标签意味着被篡改的密文会打不开,而不是解密成乱码。

它给你的

  • 针对中继以及线路上任何人的机密性。
  • 完整性 — 篡改会被检测出来,而不会被悄悄解密。
  • 发送者匿名性:临时密钥不会透露任何关于发送者的信息。
  • 无需建立会话,因此接收方离线时也能工作。

它不能给你的

  • 没有前向保密。接收方的长期私钥能打开曾经发给他的每一条消息。一旦该私钥泄露,被截获保存的密文也随之泄露。
  • 没有发送者认证。sealed box 在构造上就是匿名的;它无法证明是谁写的。
  • 没有被攻破后的恢复能力。不存在能让状态越过密钥泄露的棘轮。

这三处欠缺正是 X3DH 与 Double Ratchet 要弥补的目标,这也是这项工作正在进行的原因。在它落地之前,对 sealed box 私信最诚实的总结是:面对被动的网络观察者和中继足够强,面对最终拿到接收方设备密钥的对手则很弱。而这个总结只适用于这段代码真正运行的地方。

2. 哪些内容没有加密

社区与频道消息 — 大多数 Railgun 用户实际输入的绝大部分内容 — 今天在任何客户端路径上都没有加密。频道加密函数确实存在,而且它对自己很坦白:它记录一条警告,并在一个标记字符串之后返回用 base64 包裹的明文,好让下游没有人把它误当成密文。

// What encryptChannel actually returns

logger.warn("Channel encryption not yet implemented")

ciphertext = base64("[DEVCRYPTO:PLAINTEXT]" + plaintext)

实际后果是:就社区与频道内容而言,Railgun 服务器和任何一台普通聊天服务器处境相同。你写在那里的内容它读得到。群组密钥分发 — 把每个频道的密钥装进 sealed box 交给成员 — 是计划中的修复方案,尚未交付。

Electron 桌面版本上的私信

第二处欠缺是大多数人所用客户端上的私信,而这一点很容易说反,所以下面按代码实际执行的顺序给出选择逻辑。initCrypto() 会向主进程询问 libsignal 是否已加载。@signalapp/libsignal-client 是一个真实的依赖并在启动时被引入,因此在交付的 Electron 版本上它确实会加载 — 而正是这个答案导致选中了 Electron IPC 实现,而不是 sealed box 实现。它的 encryptDm 会转发给 crypto:encryptDm 处理器,而后者是一个占位实现:

// crypto:encryptDm, Electron main process

const plaintextBytes = new TextEncoder().encode(plaintext)

ciphertext: toBase64(plaintextBytes) // not encryption

因此,第 1 节里的 sealed box 只有在 libsignal 不存在时才会被走到 — 也就是在浏览器和开发环境中。在桌面客户端上,一条私信是以 base64 编码的明文到达中继的。那里加载 libsignal 只是为了生成身份密钥和预共享密钥包;它不建立任何会话,也不做任何棘轮 — crypto:ensureDmSession 是一条没有函数体的调试日志。

3. Curve25519 — 底层曲线

已经交付的 sealed box 与尚未交付的 X3DH 设计,都建立在同一条椭圆曲线上:由 Daniel J. Bernstein 为高速 Diffie-Hellman 密钥交换而设计的 Curve25519。本节描述曲线本身,对两者都适用。

曲线方程

y² = x³ + 486662x² + x

over the prime field 𝔽ₚ where p = 2²⁵⁵ − 19

之所以选择素数 2²⁵⁵ − 19,是因为它能带来极快的模运算。系数 486662 被选中,是因为它是能产生具备所需安全性质的安全曲线的最小值。

密钥生成

1. 私钥:使用 CSPRNG(密码学安全伪随机数生成器)生成 32 个随机字节。通过清除最低 3 位和最高位、并将次高位置 1 来对密钥进行钳位。这确保密钥是 8 的倍数并落在有效范围内。

a = random(32 bytes)

a[0] &= 248 // Clear low 3 bits

a[31] &= 127 // Clear high bit

a[31] |= 64 // Set second-highest bit

2. 公钥:计算私钥与基点 G = 9 的标量乘法。

A = a · G // Scalar multiplication on Curve25519

其安全性依赖于椭圆曲线离散对数问题(ECDLP):给定 AG,在计算上无法恢复出 a。Curve25519 提供约 128 位的安全强度

Diffie-Hellman 密钥交换

双方(爱丽丝与鲍勃)可以在从不传输共享密钥的情况下把它计算出来:

Alice: a (private), A = a·G (public)

Bob: b (private), B = b·G (public)

Alice computes: S = a · B = a · (b·G)

Bob computes: S = b · A = b · (a·G)

Both arrive at S = ab·G — the same shared secret

知道 AB(两者都是公开的)的窃听者,若不解决 ECDLP 就无法计算出 S。这就是计算性 Diffie-Hellman(CDH)假设。

sealed box 就是把这个交换的一侧做成临时密钥并随即丢弃。下文的 X3DH 则是在不同的密钥对上把这个交换执行四次,使得结果在隐藏内容之外还能认证双方。

4. X3DH — 扩展三重 Diffie-Hellman

计划中
针对 libsignal 写成,可以在代码库中审阅。它仅作为类型被引入 — 没有任何已交付的客户端会构建它。本节的一切都应视为目标设计,而非当前行为。

X3DH 解决了一个难题:当其中一方离线时,两个人如何建立加密会话?传统的 Diffie-Hellman 要求双方都在线。X3DH 使用预先上传的密钥包来实现异步密钥协商。

密钥类型

身份密钥(IK)

长期 Curve25519 密钥对。只生成一次,用于在密码学上标识用户。除非用户重新注册,否则永不改变。

已签名预共享密钥(SPK)

中期 Curve25519 密钥对,由身份密钥签名。定期轮换。签名证明它属于身份密钥的持有者。

一次性预共享密钥(OPK)

批量上传的临时 Curve25519 密钥对。每个只使用一次,随后删除。为首条消息提供额外一层前向保密。

临时密钥(EK)

发送方为每个新会话生成的全新 Curve25519 密钥对。绝不在服务器端存储。

X3DH 握手

当爱丽丝想给(可能离线的)鲍勃发消息时,她从服务器取回鲍勃的预共享密钥包,并执行四次 Diffie-Hellman 计算:

// Alice has: IKₐ (her identity key), EKₐ (fresh ephemeral)

// Bob published: IKᵦ, SPKᵦ, OPKᵦ

DH1 = DH(IKₐ, SPKᵦ) // her identity ↔ his signed pre-key

DH2 = DH(EKₐ, IKᵦ) // her ephemeral ↔ his identity

DH3 = DH(EKₐ, SPKᵦ) // her ephemeral ↔ his signed pre-key

DH4 = DH(EKₐ, OPKᵦ) // her ephemeral ↔ his one-time pre-key

SK = KDF(DH1 || DH2 || DH3 || DH4)

// SK is the shared secret, derived via HKDF-SHA-256

为什么需要四次 DH 运算?

  • DH1 提供双向认证(双方的身份密钥都参与其中)
  • DH2 确保临时密钥与鲍勃的身份绑定
  • DH3 通过临时密钥提供前向保密
  • DH4 提供额外的前向保密。如果没有可用的 OPK,X3DH 仅凭 DH1–DH3 仍然可以工作

DH1 正是 sealed box 所没有的那一步运算。这也是为什么已交付的路径无法告诉你一条消息来自谁。

KDF — 密钥派生函数

原始的 DH 输出通过 HKDF-SHA-256(基于 HMAC 的密钥派生函数)组合起来。HKDF 从拼接后的 DH 输出中提取熵,并把它扩展为一个均匀随机的共享密钥:

PRK = HKDF-Extract(salt="", input=DH1||DH2||DH3||DH4)

SK = HKDF-Expand(PRK, info="RailGunX3DH", length=32)

5. Double Ratchet 算法

计划中
今天没有任何 Railgun 发行版会对密钥做棘轮。在 sealed box 路径上,每条私信都加密给同一个长期接收方密钥;在 Electron 桌面路径上,它根本没有加密。

一旦 X3DH 建立了初始共享密钥,Double Ratchet 就接手。它使用两个相互咬合的「棘轮」为每条消息派生出一个新的唯一密钥。该设计保护先前的消息,并意在于一次新的棘轮步骤之后恢复对后续消息的保护。

DH 棘轮(非对称)

每当对话方向改变(爱丽丝 → 鲍勃,然后鲍勃 → 爱丽丝),就会使用全新的临时密钥进行一次新的 Diffie-Hellman 密钥交换。这会把根密钥向前「棘轮」一步:

dh_out = DH(my_ratchet_key, their_ratchet_key)

root_key, chain_key = KDF(root_key, dh_out)

这才是能提供被攻破后安全性的机制:窃取了当前状态的攻击者,会在新的 DH 棘轮步骤发生之后失去访问能力。

对称棘轮(哈希链)

在两次 DH 棘轮步骤之间,基于哈希的棘轮为每条消息从链密钥派生出一个新的消息密钥:

message_key = HMAC-SHA256(chain_key, 0x01)

chain_key = HMAC-SHA256(chain_key, 0x02)

旧的链密钥在每一步之后被删除。消息密钥只加密一条消息,然后被删除。这就是前向保密的机制。

棘轮的推进

画出来的话,Double Ratchet 是这样的。每个箭头都是一个单向函数 — 你可以往前走,但永远无法回退:

Root Key Chain:

RK₀ ──DH──→ RK₁ ──DH──→ RK₂ ──DH──→ RK₃ ...

Each RK step produces a Chain Key:

↓ ↓ ↓

CK₀ CK₁ CK₂

↓ ↓ ↓

Each CK step produces Message Keys:

CK₀→MK₀ CK₁→MK₃ CK₂→MK₅

CK₀→MK₁ CK₁→MK₄ CK₂→MK₆

CK₀→MK₂ CK₂→MK₇

每个 MKₙ 都只加密一条消息随后被删除,因此拿到 MK₄ 的攻击者无法推出 MK₃MK₅。而在今天已交付的 sealed box 路径上,既没有可偷的逐条消息密钥,也没有可以往回走的链 — 只有一把能打开一切的长期密钥。

6. 前向保密 — 需要什么才能实现

计划中

前向保密意味着长期密钥被攻破不会导致过去的会话密钥被攻破。Railgun 今天没有这项性质。有两种机制能够提供它,而它们都属于上文那套计划中的协议:

X3DH 中的临时密钥

发送方为每个新会话生成全新的临时密钥对,并在握手之后删除其中的私钥部分。日后即使攻击者窃取了双方的身份密钥,也仍然无法重建会话密钥。sealed box 在发送方一侧确实使用了全新的临时密钥 — 但交换中接收方的那一半是他们的长期密钥,这正是该性质不成立的原因。

棘轮密钥的删除

Double Ratchet 在使用之后会删除旧的链密钥和消息密钥。KDF 链是单向的 — 给定 CKₙ,你可以算出 CKₙ₊₁,但算不出 CKₙ₋₁,因此在时刻 T 发生的攻破不会泄露 T 之前发送的任何内容。

被攻破后安全性

DH 棘轮还提供未来保密性:攻破了当前会话状态的攻击者会在下一次棘轮步骤时失去它,因为该步骤引入了他们并不掌握的随机性。没有棘轮,被攻破的密钥就会无限期地对攻击者保持有用。

7. 中继与队列架构

Railgun 中继信封,并可能为离线投递而临时把它们放入队列。中继的构建方式让它不需要消息明文,但今天它在两种情况下会收到明文:没有任何客户端加密的频道消息,以及来自 Electron 桌面版本的私信 — 其 IPC 处理器做的是 base64 编码而不是加密。来自 sealed box 路径的私信到达时是中继打不开的密文。

服务器看得到什么

服务器持有:

  • 公钥包
  • 用户注册信息
  • 社区/频道的元数据与成员关系
  • 它打不开的 sealed box 密文(来自浏览器/开发路径的私信)
  • 来自 Electron 桌面版本的 base64 编码私信明文
  • 频道消息内容,直到频道加密交付为止
  • 认证审计记录

服务器从不接收:

  • 私钥或身份密钥
  • 客户端会话状态
  • 你的消息历史的持久副本

消息通过 WebSocket 实时中继给在线的接收方。对于离线设备,信封会以最长 30 天的 TTL 排入 Redis 队列,并在设备确认收到后删除。服务器端不存在消息归档 — 客户端历史保存在你自己设备的本地存储中,因此设备丢失就意味着历史丢失。

8. 安全性质总结

性质机制状态
私信机密性仅在浏览器/开发路径上使用 sealed box;Electron 桌面处理器做的是 base64 编码部分
私信完整性sealed box 路径上有 Poly1305 标签;Electron 桌面路径发送的是未经认证的 base64部分
密钥留在设备上本地密钥存储;没有任何代码路径会传输私钥已实现
离线投递信封以 30 天 TTL 排入 Redis 队列,确认后删除已实现
频道机密性群组密钥分发未实现;内容以可读形式发送未实现
发送者认证sealed box 在构造上就是匿名的未实现
前向保密需要 Double Ratchet,而没有任何客户端会将其实例化计划中
被攻破后的恢复需要 DH 棘轮,而没有任何客户端会将其实例化计划中
独立审计未声称有任何已完成的第三方密码学审计未完成
后量子保证未声称任何笼统的后量子安全保证未作出声明

本页的依据从何而来

上面的每一项状态都是从客户端源代码中读出来的,而不是来自设计文档。客户端仓库目前并未公开,因此我们无法请你自行核对 — 这也是应当把本页当作一份披露而非证明来看待的理由。对某一具体说法的疑问,欢迎发到 security@railgun.chat