返回文章列表

用 Rust 写一个端到端加密聊天工具:从「能用」到「能信任」的踩坑实录

381·3 分钟阅读
Rust加密聊天Signal 协议WebSocketSQLiteEd25519

加密聊天这事,做出来不难,做到「能信任」很难。

我最开始的动机很简单:Rust 生态里缺一个好用的端到端加密聊天方案。现有的方案要么是 C++ 写的(难维护),要么是 Python 写的(性能差),要么就是商业产品(不透明)。想着用 Rust 写一个,给生态添砖加瓦,顺便练练手。

后来才发现,加密聊天这东西,技术上能跑通只是起点,真正要命的是那些「细节」——密钥怎么管、消息怎么同步、断线怎么恢复、多设备怎么同步……

这篇就聊聊我从「能发消息」到「敢真正用」这段路上踩过的坑。


先说结论:技术选型的几个关键决策

在写第一行代码之前,想清楚这几个问题能省很多弯路:

问题 我的选择 理由
加密方案 Signal 协议(X3DH + Double Ratchet) 别自己发明轮子,Signal 是经过实战检验的
传输层 WebSocket 能穿透大多数防火墙,浏览器也能用
消息存储 SQLite + 本地加密 不搞中心服务器,每个节点自己存
身份验证 Ed25519 签名 比 RSA 小快,证书也小

最大的教训:别想着「先做个简单版本,后面再加安全」。加密这事,架构错了后面基本推倒重来。


第一个坑:自己发明加密协议

我第一个版本是这么想的:

「先用 AES 加密消息,RSA 交换密钥,WebSocket 传输,多简单」

然后我就这么写了。能跑,两个客户端能互发加密消息。

直到我问自己几个问题:

  1. 密钥怎么安全地交给对方?RSA 直接传?中间人怎么办?
  2. 一个密钥泄露了,历史消息是不是全完了?
  3. 怎么证明对方就是对方,不是冒充的?

然后我发现自己要重新实现一遍密钥交换、前向保密、身份认证……

这个坑的教训:加密协议不是乐高,不能随便拼。Signal 协议之所以牛,是因为它解决了这些问题:

  • 前向保密:即使长期密钥泄露,历史消息也解不开
  • 未来保密:当前密钥泄露,未来的消息也解不开
  • 身份验证:能确认对方身份,防中间人

所以我换成了 snow(Signal 协议的 Rust 实现),虽然学习曲线陡了点,但至少不用自己操心密码学了。


第二个坑:密钥管理太天真

第一版的密钥管理:

// 我最初的天真设计
struct ChatApp {
    my_private_key: SecretKey,
    peer_public_key: PublicKey,
}

把私钥存在内存里,硬编码对方的公钥。

然后问题来了:

  1. 换设备怎么办? 私钥丢了,历史消息全没了
  2. 多设备怎么办? 手机和电脑怎么同步消息?
  3. 密钥轮换怎么办? 长期用一个密钥,泄露风险大

后来的方案:

  • 身份密钥(长期):Ed25519,用来签名和身份验证
  • 预共享密钥(PSK):X3DH 协议里的,解决初始信任问题
  • 会话密钥(临时):Double Ratchet 每条消息都轮换

密钥存储用系统的 keychain(macOS 的 Keychain、Linux 的 Secret Service),不自己存文件。

// 现在的密钥管理
use keyring::Entry;
 
struct KeyManager {
    identity_key: Entry,    // 存在系统 keychain
    session_store: SqliteDb, // 会话状态存本地 DB
}

教训:密码学库帮你加密,但不管密钥管理。密钥管理做不好,加密等于没加密。


第三个坑:消息同步的复杂度

两个客户端点对点聊天,消息同步其实挺简单的——你发我收,我发你收。

但一旦加上这几个需求,复杂度指数级上升:

  1. 离线消息:对方不在线,消息怎么存?上线后怎么同步?
  2. 消息顺序:网络延迟可能导致后发先到,怎么保证顺序?
  3. 已读回执:怎么让对方知道我看了?已读状态怎么同步?
  4. 消息撤回:撤回了对方就不能看,但加密消息怎么「撤回」?

我的方案:

发送消息流程
├── 本地先存(状态:sending)
├── WebSocket 发送
├── 对方 ACK(状态:sent)
└── 对方已读(状态:read)
 
离线消息流程
├── 消息存本地 DB(状态:pending)
├── 定时检查连接
├── 连上后批量发送
└── 对方 ACK 后更新状态

消息顺序用逻辑时钟(Lamport Clock),每条消息带一个递增的序列号。

消息撤回其实是个伪命题——加密消息一旦发出去,对方客户端已经解密了,你没法真正「删除」。只能在 UI 层面「标记撤回」,同时约定删除本地数据。

教训:加密聊天的难点不是加密,是同步。端到端加密意味着服务器是「瞎」的,它没法帮你做消息同步,你得自己搞。


第四个坑:WebSocket 连接管理

最初用的 tungstenite,简单直接:

let (mut ws_stream, _) = connect(Url::parse("ws://127.0.0.1:8080")?).await?;

然后发现:

  1. 断线重连:网络抖动一下就断了,用户得手动重连
  2. 心跳机制:不发心跳,NAT 映射过期,连接就废了
  3. 消息缓冲:断线期间的消息怎么缓存?

后来换成了更成熟的方案:

// 使用 tokio-tungstenite + 自定义重连逻辑
struct ConnectionManager {
    reconnect_interval: Duration,
    max_retries: u32,
    message_buffer: VecDeque<Message>,
}

心跳:每 30 秒发一次 ping,如果 3 次没收到 pong,判定断线,触发重连。

消息缓冲:断线期间的消息存在本地 DB,重连后按序列号重新发送。

教训:WebSocket 不是你想的那样「连上就能用」。生产环境要考虑的事情太多了:心跳、重连、缓冲、加密(WSS)……


第五个坑:多设备同步

一开始没考虑多设备,直到我同时用手机和电脑。

问题是:密钥是设备绑定的,消息怎么跨设备同步?

Signal 的做法是:每个设备都有自己的身份密钥,消息会发给所有设备。但这样太复杂了,我简化成了:

  • 一个主设备(手机):持有主密钥
  • 从设备(电脑):通过主设备授权,获取派生密钥
  • 消息同步:主设备在线时,从设备通过主设备同步
设备拓扑
├── 手机(主设备)
│   ├── 持有身份密钥
│   ├── 持有所有会话密钥
│   └── 消息分发给从设备

└── 电脑(从设备)
    ├── 持有派生密钥
    ├── 请求同步
    └── 接收主设备分发的消息

这个方案的好处是:主设备挂了,消息不会丢;从设备挂了,不影响主设备。

教训:多设备同步是个分布式系统问题,加密只是其中一层。先把同步想清楚,再考虑加密。


最终架构

反复迭代之后,最终的架构是这样的:

加密聊天工具架构
├── 加密层
│   ├── snow(Signal 协议)
│   ├── Ed25519 身份密钥
│   └── X3DH 密钥交换

├── 传输层
│   ├── tokio-tungstenite(WebSocket)
│   ├── 心跳 + 重连
│   └── 消息缓冲

├── 存储层
│   ├── SQLite(消息 + 会话)
│   ├── rusqlite(异步封装)
│   └── 加密存储(SQLCipher)

├── 同步层
│   ├── 逻辑时钟(Lamport Clock)
│   ├── 离线消息队列
│   └── 多设备同步

└── UI 层
    ├── egui(桌面端)
    └── Dioxus(可选,Web 端)

几点实用建议

1. 别自己写加密,用经过审计的库

[dependencies]
snow = "0.9"           # Signal 协议
ed25519-dalek = "2.0"  # 签名
x25519-dalek = "2.0"   # 密钥交换
chacha20poly1305 = "0.10" # 对称加密

这些库都经过社区审计,比自己写的靠谱。

2. 密钥管理用系统 Keychain

use keyring::Entry;
 
// 存密钥
let entry = Entry::new("my-chat", "identity-key")?;
entry.set_password(&hex::encode(&private_key.to_bytes()))?;
 
// 取密钥
let key_hex = entry.get_password()?;
let private_key = SecretKey::from_hex(&key_hex)?;

别自己写文件存储,系统 keychain 更安全。

3. 先做单设备,再做多设备

多设备同步是分布式系统问题,复杂度很高。先做好单设备,确保加密和消息同步没问题,再考虑多设备。

4. 消息加密存储用 SQLCipher

// SQLite 加密
use rusqlite::Connection;
 
let conn = Connection::open("chat.db")?;
conn.execute_batch("PRAGMA key = 'your-password';")?;

即使数据库文件被偷走,没有密码也解不开。

5. 测试加密正确性

#[test]
fn test_encryption_roundtrip() {
    let key = x25519_dalek::StaticSecret::random_from_rng(rand::thread_rng());
    let plaintext = b"hello world";
 
    // 加密
    let ciphertext = encrypt(&key, plaintext);
 
    // 解密
    let decrypted = decrypt(&key, &ciphertext);
 
    assert_eq!(&decrypted, plaintext);
}

加密的测试比普通代码更重要——出 bug 了不是崩溃,是数据泄露。


结论

写加密聊天工具这段时间,最大的体会是:

技术上能跑通只是 10%,剩下 90% 是安全、同步、用户体验的细节。

加密这事,密码学只是一小部分,更多的是:

  • 密钥怎么管
  • 消息怎么同步
  • 断线怎么恢复
  • 多设备怎么协调
  • 数据怎么安全存储

如果你也想做类似的工具,建议:

  1. 先搞清楚需求:是要内部用还是公开发布?点对点还是群聊?
  2. 选对协议:别自己发明轮子,用 Signal 协议
  3. 分步实现:先单设备,再多设备;先文本,再文件
  4. 重视测试:加密的测试比功能测试更重要

最后,如果你也想给 Rust 生态贡献加密聊天方案,建议:

  1. 先调研现有方案:看看 matrix-rust-sdklibsignal 等项目,站在巨人肩膀上
  2. 找到差异化:是性能更好?还是 API 更友好?还是集成更方便?
  3. 重视安全审计:密码学代码出了问题就是安全事故,上线前最好找专业团队审计

Rust 生态需要更多这样的基础设施,期待看到更多实践者。