重构 M42

2763 words, 13 minutes to read / April 3, 2024

两年前突发奇想,自己动手开发了一个聊天软件,也就是 M42,主要功能是这样的:

  • 端到端加密
  • 一对一聊天
  • 无需登陆
  • 30Mb 以内的文件互传
  • 服务端、客户端都不存储聊天记录
  • 15 分钟无动作的房间会被销毁
  • 链接使用一次之后将会失效
  • 易于部署

算是一个尝试,但不是很成功,因为当中有很多安全性考量上的缺失,代码实现也不是很完美,或者说,不是特别的优雅。

比如,15 分钟无动作的判定靠的是服务端跑 setInterval 而不是依靠 WebSocket 心跳;一次性链接的实现是看服务端有没有拿到 WebSocket 的连接 ID,而这个连接 ID 存储在一个变量当中,如果服务重启那么变量则失效,链接又可以再次在别的设备上使用;文件最大只能传 30 Mb,不然会遇到性能问题,而且文件传输的过程会阻塞其他文本信息的传输;部署需要一个服务器,使用服务器就意味着需要花钱;以及客户端的 UI…… 有点难用,也有点难看。

所以呢,这两天再次突发奇想,加上最近一直在折腾 Cloudflare Workers,于是一个新的 idea 诞生了。

—— 构建一个基于 Cloudflare Workers 的端到端加密的聊天软件。


在动手开发之前想了想这次要优化的点:

  • 更加优雅的客户端
  • 更加优雅的服务端
  • 加密优化
  • 支持大文件传输且不阻塞信息传输
  • 聊天室强制 30 分钟自毁
  • 简化部署
  • 免费使用

既然想好了,那就动手开发吧!

客户端开发

除了错误处理页面和一个落地页,最重要的就是创建房间页面和聊天页面了。

关于创建房间的页面,其实之前有一个想法就是让用户在界面上随机的进行滚动操作,以收集到的滚动数据作为种子来生成房间链接,但是这样的想法很快就被我否决了。

因为第一,效果不是特别理想(我比较笨),算出来的随机字符串并不是那么的随机。

第二,用户必须经历一段非常无聊的滚动页面环节,然后才能进入聊天室,这和 M42 的化繁为简的设计理念不符(说白了就是我觉得太麻烦了 🫠)!

为了平衡安全性和用户体验,我最后决定直接使用 nanoid 生成两个 10 位的随机字符串来当作 Alice 和 Bob(Alice 和 Bob 是在讨论加密通信时约定俗成的两个假想的通信对象)的房间 ID。

create room page

聊天界面没啥可说的,搬了一些「只言」的样式,毛玻璃加噪点的效果确实很好看!后期准备上 PWA,可能还会再做一些调整。

chat page

服务端开发

WebSocket

WebSocket connection diagram

Cloudflare Workers 支持 WebSocket 非常简单,有兴趣的话可以看一下官网的教程

最重要的地方无非是创建一个 WebSocketPair,这个构建方法会返回两个 WebSocket handler,一个是 server,一个是 client。顾名思义,client就是在客户端用的,server就是在服务端用的,现在让我们沿用之前 NodeJS 的开发思路,创建一个查询字典,以 Alice 和 Bob 的 ID 为 key,以server为 value,这样我们就可以进行跨端通信了!

Error: Cannot perform I/O on behalf of a different request. I/O objects (such as streams, request/response bodies, and others) created in the context of one request handler cannot be accessed from a different request’s handler. This is a limitation of Cloudflare Workers which allows us to improve overall performance

wtf?

这是什么意思呢?让我们来捋一捋,先来看 NodeJs 环境下的结构:

WebSocket inside NodeJs

Node 环境中允许我们在请求与请求之间交换信息,而在 Workers 环境下,服务端虽然都是激活状态,但是请求与请求之间不能交换信息,这才导致了报错。这也说明,只靠 Cloudflare Workers,我们是不能进行跨端通信的。

WebSocket inside Workers

要解决这个问题,我们必须引入一个信息交换模块,Cloudflare Durable Objects 其实挺适合做这个工作,但是它不是免费的,想要用的话,首先得是 Workers 的付费用户,一番网络冲浪之后决定使用 Ably。有了 Ably 之后我们就可以进行真正的跨端通讯了!

Websocket inside Workers with Ably integrated

而我们 Workers WebSocket 的职责也不再是用来交换 Alice 和 Bob 的信息了,而是作为一个非常简单的判断当前用户是不是连接到服务器的标识,交换信息则通过/pub/sub接口在 Ably 的频道中发布或者接收消息。

在之后的迭代中,我会把 Ably 单独拎出来作为一个通讯模块来使用,完全与当前系统解耦。虽然 Ably 的服务有免费额度,但是一旦收起费来还是有点贵的,在大量使用 App 的情况下可能会显得不够灵活。

聊天室自毁

相较于上一个版本的 M42 那样傻傻的在服务器用 setInterval 计时,这次我学聪明了,采用了 Cloudflare D1 来做状态留存。D1 是一个基于 Sqlite 的 serverless 数据库,来看看都有哪些数据表吧:

  • rooms

对!就 1 个!

CREATE TABLE rooms (
  alice_id TEXT PRIMARY KEY,
  bob_id TEXT,
  room_id TEXT,
  created INTEGER,
  alice_password TEXT,
  bob_password TEXT
)

而判断房间是不是过期的字段就是created,每次 Alice 或者 Bob 调用/sub接口,服务端都会根据这个字段来决定要不要让用户订阅消息,如果过期则会返回404,客户端会跳转到错误页。因为 Workers 支持 CRON,所以我还设置了一个定时任务,每小时清理一下过期的房间。

优雅!

一次性链接的实现

前面说了,上一个版本的 M42 采用的是记录 WebSocket 连接 ID 的方式来判定用户的唯一性,这个办法能用,但是不稳定,原因就出在被记录的状态可能会因为一些不可预测的行为而被重置。解决方案就是 —— 记在数据库里,没错,就是alice_passwordbob_password这两个字段!

我们假定 Alice 是创建房间的那一方,当她调用/create_room接口时,服务端会下发一个密码,这个密码将会被同时存储在数据库和 Alice 的设备中,这样,当 Alice 下一次访问这个房间时,/sub请求就会自动带上下发的密码,而如果 Alice 更换了设备,或者说,一个黑客拿到了 Alice 使用过的链接,服务端会因为密码不匹配而返回401错误。

反观 Bob,我们假定他是打开链接的那一方,那么当他调用/sub接口时,我们就可以这样判断:

  1. Bob 没有提供密码,数据库也没有查询到 Bob 的密码,说明 Bob 是第一次打开这个链接,我们需要下发一个密码并存储到数据库和 Bob 的设备上
  2. Bob 没有提供密码,但是数据库查询到了 Bob 的密码,触发401错误
  3. Bob 提供了密码,但是密码和数据库中查到的不一致,触发401错误
  4. Bob 提供了密码,且密码和数据库中的密码一致,准允通行

这样,一次性链接的实现才算完整和可靠!

文件传输

这个功能在目前写博客的阶段还没有实现,但是已经有一些想法了,我们可以集成 Cloudflare R2 作为文件传输的中继,当然文件一定是经过加密的,且房间一经销毁,文件也会一并销毁。集成了 R2 之后我们可以把文件传输和信息传输分开来,这样就不会造成阻塞了。而且也能支持更大的文件传输,至少也得 300Mb 吧?🤥

加密优化

这次没有使用 Web 原生的 crypto API,为了更好的兼容性,我直接使用了 Jose,一个零依赖的 JSON 签名和加密库。

加密流程也进行了相对优化,之前版本的 M42 每次刷新页面都会生成一副新的密钥对,这确实很安全,但没有必要。新版本下,一个房间只生成一次密钥对,因为在未来,我打算为 M42 加上离线消息暂存的功能,因为服务端不存储任何聊天记录,我们只能在客户端做这样的暂存功能,当 Bob 离线时,Alice 发送的信息会暂存在她的设备上,而当 Bob 上线,客户端就会把这些暂存的消息发送给 Bob,此时两边的密钥对都必须保持稳定,不然就不能正确解密。

反向优化

在线状态的查询

先前版本中,由于我们使用的是正儿八经的服务器,请求和请求之间能够共享信息,所以查看对方是不是在线是非常简单的,我们只需要不停的 ping WebSocket,不停的更新用户状态即可,如果对方下线,那么状态就不会得到更新,这个流程的延迟基本不会超过 3 秒。

然而当我们转移到 Workers 环境,事情就变得有点棘手了,因为请求和请求之前不能共享信息,于是我把目光放到 D1 上,把先前 WebSocket ping 改成了对数据库的存,不过有一个问题,免费的版本的 Workers D1 支持每天只 10 万行的存操作,大概是个什么概念呢?如果使用 D1 来存储在线状态,那么 Alice 和 Bob 一次聊天下来可能就会用掉 1 万行的存操作。

思来想去决定还是把定时的在线状态更新,改成了用户动作(打字或滚动)触发在线状态更新,更新操作也不再使用数据库,而是使用 WebSocket(Ably channel)一问一答的方式,这样做的好处就是用户不会占用太多的免费额度,坏处就是,在线状态的更新做不到实时了,可能 Alice 或 Bob 会发现在自己输入过程中对方突然离线了。为了降低免费额度的使用率,目前看来只能这样权衡。


等新版本的 M42 开发结束,我会开源代码并且写一份详细的配置教程,应该不会太难,敬请期待吧!

🐦