加密实现参考
实际运行的加密比已经写好的加密要窄,而这一差距正是本页存在的意义。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):给定 A 和 G,在计算上无法恢复出 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
知道 A 与 B(两者都是公开的)的窃听者,若不解决 ECDLP 就无法计算出 S。这就是计算性 Diffie-Hellman(CDH)假设。
sealed box 就是把这个交换的一侧做成临时密钥并随即丢弃。下文的 X3DH 则是在不同的密钥对上把这个交换执行四次,使得结果在隐藏内容之外还能认证双方。
4. X3DH — 扩展三重 Diffie-Hellman
计划中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 算法
计划中一旦 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。