两年前突发奇想,自己动手开发了一个聊天软件,也就是 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。
聊天界面没啥可说的,搬了一些「只言」的样式,毛玻璃加噪点的效果确实很好看!后期准备上 PWA,可能还会再做一些调整。
服务端开发
WebSocket
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
这是什么意思呢?让我们来捋一捋,先来看 NodeJs 环境下的结构:
Node 环境中允许我们在请求与请求之间交换信息,而在 Workers 环境下,服务端虽然都是激活状态,但是请求与请求之间不能交换信息,这才导致了报错。这也说明,只靠 Cloudflare Workers,我们是不能进行跨端通信的。
要解决这个问题,我们必须引入一个信息交换模块,Cloudflare Durable Objects 其实挺适合做这个工作,但是它不是免费的,想要用的话,首先得是 Workers 的付费用户,一番网络冲浪之后决定使用 Ably。有了 Ably 之后我们就可以进行真正的跨端通讯了!
而我们 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_password
和bob_password
这两个字段!
我们假定 Alice 是创建房间的那一方,当她调用/create_room
接口时,服务端会下发一个密码,这个密码将会被同时存储在数据库和 Alice 的设备中,这样,当 Alice 下一次访问这个房间时,/sub
请求就会自动带上下发的密码,而如果 Alice 更换了设备,或者说,一个黑客拿到了 Alice 使用过的链接,服务端会因为密码不匹配而返回401
错误。
反观 Bob,我们假定他是打开链接的那一方,那么当他调用/sub
接口时,我们就可以这样判断:
- Bob 没有提供密码,数据库也没有查询到 Bob 的密码,说明 Bob 是第一次打开这个链接,我们需要下发一个密码并存储到数据库和 Bob 的设备上
- Bob 没有提供密码,但是数据库查询到了 Bob 的密码,触发
401
错误 - Bob 提供了密码,但是密码和数据库中查到的不一致,触发
401
错误 - 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 开发结束,我会开源代码并且写一份详细的配置教程,应该不会太难,敬请期待吧!
🐦