用 Rust 写一个端到端加密聊天工具:从「能用」到「能信任」的踩坑实录
加密聊天这事,做出来不难,做到「能信任」很难。
我最开始的动机很简单:Rust 生态里缺一个好用的端到端加密聊天方案。现有的方案要么是 C++ 写的(难维护),要么是 Python 写的(性能差),要么就是商业产品(不透明)。想着用 Rust 写一个,给生态添砖加瓦,顺便练练手。
后来才发现,加密聊天这东西,技术上能跑通只是起点,真正要命的是那些「细节」——密钥怎么管、消息怎么同步、断线怎么恢复、多设备怎么同步……
这篇就聊聊我从「能发消息」到「敢真正用」这段路上踩过的坑。
先说结论:技术选型的几个关键决策
在写第一行代码之前,想清楚这几个问题能省很多弯路:
| 问题 | 我的选择 | 理由 |
|---|---|---|
| 加密方案 | Signal 协议(X3DH + Double Ratchet) | 别自己发明轮子,Signal 是经过实战检验的 |
| 传输层 | WebSocket | 能穿透大多数防火墙,浏览器也能用 |
| 消息存储 | SQLite + 本地加密 | 不搞中心服务器,每个节点自己存 |
| 身份验证 | Ed25519 签名 | 比 RSA 小快,证书也小 |
最大的教训:别想着「先做个简单版本,后面再加安全」。加密这事,架构错了后面基本推倒重来。
第一个坑:自己发明加密协议
我第一个版本是这么想的:
「先用 AES 加密消息,RSA 交换密钥,WebSocket 传输,多简单」
然后我就这么写了。能跑,两个客户端能互发加密消息。
直到我问自己几个问题:
- 密钥怎么安全地交给对方?RSA 直接传?中间人怎么办?
- 一个密钥泄露了,历史消息是不是全完了?
- 怎么证明对方就是对方,不是冒充的?
然后我发现自己要重新实现一遍密钥交换、前向保密、身份认证……
这个坑的教训:加密协议不是乐高,不能随便拼。Signal 协议之所以牛,是因为它解决了这些问题:
- 前向保密:即使长期密钥泄露,历史消息也解不开
- 未来保密:当前密钥泄露,未来的消息也解不开
- 身份验证:能确认对方身份,防中间人
所以我换成了 snow(Signal 协议的 Rust 实现),虽然学习曲线陡了点,但至少不用自己操心密码学了。
第二个坑:密钥管理太天真
第一版的密钥管理:
// 我最初的天真设计
struct ChatApp {
my_private_key: SecretKey,
peer_public_key: PublicKey,
}把私钥存在内存里,硬编码对方的公钥。
然后问题来了:
- 换设备怎么办? 私钥丢了,历史消息全没了
- 多设备怎么办? 手机和电脑怎么同步消息?
- 密钥轮换怎么办? 长期用一个密钥,泄露风险大
后来的方案:
- 身份密钥(长期):Ed25519,用来签名和身份验证
- 预共享密钥(PSK):X3DH 协议里的,解决初始信任问题
- 会话密钥(临时):Double Ratchet 每条消息都轮换
密钥存储用系统的 keychain(macOS 的 Keychain、Linux 的 Secret Service),不自己存文件。
// 现在的密钥管理
use keyring::Entry;
struct KeyManager {
identity_key: Entry, // 存在系统 keychain
session_store: SqliteDb, // 会话状态存本地 DB
}教训:密码学库帮你加密,但不管密钥管理。密钥管理做不好,加密等于没加密。
第三个坑:消息同步的复杂度
两个客户端点对点聊天,消息同步其实挺简单的——你发我收,我发你收。
但一旦加上这几个需求,复杂度指数级上升:
- 离线消息:对方不在线,消息怎么存?上线后怎么同步?
- 消息顺序:网络延迟可能导致后发先到,怎么保证顺序?
- 已读回执:怎么让对方知道我看了?已读状态怎么同步?
- 消息撤回:撤回了对方就不能看,但加密消息怎么「撤回」?
我的方案:
发送消息流程
├── 本地先存(状态: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?;然后发现:
- 断线重连:网络抖动一下就断了,用户得手动重连
- 心跳机制:不发心跳,NAT 映射过期,连接就废了
- 消息缓冲:断线期间的消息怎么缓存?
后来换成了更成熟的方案:
// 使用 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% 是安全、同步、用户体验的细节。
加密这事,密码学只是一小部分,更多的是:
- 密钥怎么管
- 消息怎么同步
- 断线怎么恢复
- 多设备怎么协调
- 数据怎么安全存储
如果你也想做类似的工具,建议:
- 先搞清楚需求:是要内部用还是公开发布?点对点还是群聊?
- 选对协议:别自己发明轮子,用 Signal 协议
- 分步实现:先单设备,再多设备;先文本,再文件
- 重视测试:加密的测试比功能测试更重要
最后,如果你也想给 Rust 生态贡献加密聊天方案,建议:
- 先调研现有方案:看看 matrix-rust-sdk、libsignal 等项目,站在巨人肩膀上
- 找到差异化:是性能更好?还是 API 更友好?还是集成更方便?
- 重视安全审计:密码学代码出了问题就是安全事故,上线前最好找专业团队审计
Rust 生态需要更多这样的基础设施,期待看到更多实践者。