消息的岁月史书|Devlog · 16
littlesheepラムです
3/2/2026, 4:04:32 PM432 次阅读

消息的岁月史书|Devlog · 16

早上好中国,现在我们有新的一期 Devlog(是的我不知道怎么写简介了)

#devlog #solar-network

Solar Network 的聊天功能出来了很久,自从 v0 时代就有了。那时候还是网页版本,不用考虑什么消息同步的问题,所以设计就采用了很自觉的和帖子差不多的可修改设计。非常简单,非常好用,其他的动态修改通过 WebSocket 同步就可以了,不用考虑什么复杂的数据一致性情况。

Event-driven Message History

但是随着 Solar Network 的发展,很明显每次都要去云端取消息不切实际,并且性能不好。所以本地消息数据库出现了,同时引入了一个非常头疼的问题,数据一致性,对此我们 Solar Network 的解决方案是该用「Only-write No Update」模式。

具体来讲,就跟一些银行系统一样,不对现有的数据进行修改,只是一味的增加新数据(部份情况除外)。比如用户对一个消息做出了编辑,我们会添加一个新的「编辑消息」的消息然后发送给所有客户端。删除消息也是同样的道理,不过不同之处在于服务器会清空目标消息的内容,放上一个占位符。

现在,消息不只是一个消息,更像一个事件,表明什么事情发生了。因此我们把这种消息方式叫做事件驱动消息历史(event-driven message history)。这么做的好处有很多,在消息同步的时候不用再考虑历史的更改问题,只用专注于同步新的消息,在客户端拿到信息消息的时候找到有 side-effect 的 events 然后处理 side-effects 即可。

Dynamic Events

其实最开始这个设计并不完善,我在迁移的过程中还想过为了节省迁移开销而诞生的动态事件生成(dynamic events)但是很明显这个方案的效果并不好。

其原理是利用数据库审计条目 deleted_atupdated_at 找出有哪些条目被更新了,在同步的时候生成对应的 messages.updatemessages.delete 事件。但是这对同步的设计又是一个巨大的考验,SQL 查询又臭又长,扩张性还不够,所以我最终还是回到了数据存储所有 event 的方案。

同步

Solar Network 的消息同步基本上是基于时间的。客户端会保存一个最后同步的时间,并且结合本地数据库的最后一条信息的创建时间,向服务器的同步 Endpoint 要所有创建于这个时间之后的消息。

同步的时候不用管更新、删除等等,因为这些都会趁现在新的消息中,这也是 event-driven message history 好的地方。

加密

终于来到最重头戏——端到端加密(E2EE)。这个功能我曾经做出来了,然后又下掉了,然后这回在亲自阅读、亲自指导 AI 下又做出来了(?)。做这个的原因是 Codex 的配额太大了

在经过重新整理之后的最终的方案是完全客户端加密,服务器只负责两件事:

  1. 帮大家交换公钥和预密钥(Pass 模块)
  2. 老老实实当安洁莉娜(Messager 只存密文)

整体架构

  • Pass(身份验证服务):负责密钥分发、X3DH 会话建立、离线队列。
  • Messager(消息服务):负责房间、成员、时间线存储、群发。所有消息存进去的都是 ciphertext,服务器根本看不到你在写什么,就算小羊的数据库被别人拖走了也看不到你的鉴证言论(?)。

房间有三种加密模式可用:

  • 0:明文
  • 1:私信模式 E2EE
  • 2:群组模式 E2EE

私信是怎么加密的?

  1. 双方各自把公钥包上传到 POST /sphere/e2ee/keys/upload
  2. 发消息前,先去 GET /sphere/e2ee/keys/{对方}/bundle 拿对方的公钥 + 消耗一个一次性预密钥
  3. 客户端本地跑 X3DH + Double Ratchet(经典 Signal 流程)
  4. 用当前 ratchet key 把消息加密成一坨 ISLE2E1... 开头的二进制(实际用的是 XChaCha20-Poly1305
  5. 丢给 Messager 发送,服务器只存密文

对,我在抄袭 Singal 的流程(那咋了

群聊是怎么加密的?

我们用了 Sender Key 方案(Signal 以前也用过,后来改成 MLS,但我们觉得 Sender Key 刚好够用 Codex 选择了 Sender Key,对):

  1. 第一个发消息的人生成一个「发送者密钥」(chain key + Ed25519 签名密钥)
  2. 用自己和每个群成员的安全通道(就是上面的 pairwise session)把这个密钥加密后发过去(POST /pass/e2ee/groups/sender-key/distribute
  3. 以后发群消息就只用这个对称密钥做 ratchet → 加密 → 签名 → 发给 Messager
  4. 服务器直接 fan-out 密文,群里每个人用自己存的那份 sender key 解密

群成员进出的时候,Messager 会发一条明文的系统消息 system.e2ee.rotate_required,大家收到后立刻重新生成新 sender key 再分发一次,保证前向保密。

和「Only-write No Update」完美结合

最妙的是,加密完全不破坏我们之前的事件驱动历史设计!

  • 编辑 = 新增一条 content.edit 事件(密文)
  • 删除 = 新增一条 content.delete 事件(服务器把旧密文清空成占位符)
  • 所有操作都是只追加新消息,密文本身永远不可变

同步逻辑也完全不变:客户端只要按时间拉新消息,拿到 is_encrypted=true 的就本地解密就行。离线重连时直接走 GET /messager/e2ee/messages/pending,一样能收到所有队列里的密文。

实际效果

现在数据库里长这个样子(我截了一小段):

ciphertext: 0x49534C453245310C7C41B22F...(后面全是乱码)
encryption_scheme: pass.e2ee.chat.sender_key.v1
is_encrypted: true

整个加密模块做成了独立可复用的模块(DysonNetwork.Pass.E2EE),以后其他功能(文件、笔记、语音)想加 E2EE 只需要调几个接口就行,非常好用 desu

限制

不过因为工期原因,目前的 E2EE 还不完善:

  • 不能上传附件,因为附件是明文的,客户端没有对服务器的附件加密做好兼容(现在还没有)
  • 不能使用消息反应
  • 不是 MLS 而是一个比较旧的协议 但是这都是小问题的说

如果你想试试的话,就可以现在渠道任何一个聊天室,在详细页面点击三个点的部份,选择 Enable E2EE,但是请注意,这个操作不可逆。在使用这个功能之前请熟知上述限制。