m42(Message For Two 的简称)是作者本人开发的一款基于 web 端的实时聊天软件(已开源),拥有端到端加密、不存储聊天记录等特性,今天就来分享一下所有关于 m42 的技术细节。
在说 m42 的设计思路之前,假定情况如下:
- Alice 与 Bob 为两个自然人
- Alice 与 Bob 已经可以通过互联网联系,但是环境并不安全
m42 针对以上情况给出的设计思路:
- Alice 通过 m42 创建聊天房间后生成 2 个链接,链接 A 和链接 B。链接 A 不对 Alice 显示,链接 B 通过 Alice 发送给 Bob,供其点击链接加入聊天室。Alice 在发送链接 B 之后,点击按钮进入链接 A。自此,双方成功进入聊天房间。
- 在双方上线的同时,m42 会在双方客户端各生成一个密钥对,生成密钥的过程完全在本地进行,不与服务器交互。生成完成后,保存密钥对到
localStorage
,并通过WebSocket
交换公钥。 - Alice 编辑发送消息,在发送前使用 Bob 的公钥对消息进行加密,加密过程在本地执行。Bob 接收到消息后,使用自己的私钥对消息进行解密。反之亦然。
再来看看后端:
- 开发语言:NodeJs
- Web 服务器:ExpressJs
在实现上遇到的唯一问题就是如何实现客户端间互发消息,感谢 StackOverflow 给出的答案:
// 核心代码
const express = require('express')
const app = express()
const { WebSocketServer } = require('ws')
const authenticate = function (req, next) {
next(null, req.headers['sec-websocket-key'])
}
const wss = new WebSocketServer({
noServer: true
})
let lookup = {}
wss.on('connection', function connection(ws, req, client) {
lookup[client] = ws
lookup[client].send(
JSON.stringify({
clientID: client
})
)
})
const server = app.listen(port, host, () => {
console.log(`app is on, http://${host}:${port}`)
})
server.on('upgrade', function upgrade(request, socket, head) {
authenticate(request, function next(err, client) {
if (err || !client) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
socket.destroy()
return
}
wss.handleUpgrade(request, socket, head, function done(ws) {
wss.emit('connection', ws, request, client)
})
})
})
这样每次连接 WebSocket 都会获得一个独一无二的 ClientID,身份验证的问题就解决了。
下面重点来说说前端:
端到端加密
这一步并不难,只是我走了很多弯路。我最初的设计是在双方客户端各生成一段密码,然后通过 WebSocket 交换,这种做法在现在看来根本不能叫做端到端加密,因为它把密码明文传输了,即使 Alice 和 Bob 之后的消息都是加密过的,但是只要中间人拿到了一开始双方互换密码的请求,那么一切都是无用功。同时这也暴露了我对于信息传输加密方面的无知。
经过一番查阅后,我理解了为什么端到端加密中一定会有一个交换公钥的过程,注意是交换公钥,而不是交换密码。密钥对的作用在这里就显现出来了,公钥用于加密,而私钥用于解密,也可以理解为,用于加密的密码和解密的密码完全不一样。以目前家用电脑的算力来讲,强行破解的可能性几乎为零。
Web API 中已经支持了很多加密方式,m42 采用的是 RSA-OAEP-256。具体用法可以看这个Repo。
加密超长文本
端到端加密的问题解决,但是在随后的开发中又遇到了新问题:RSA-OAEP-256 只能加密长度 190byte的数据,超出则报错。这个问题最后是通过切割 Blob 解决了。
// 将字符切割为数组
function splitAsChunk(size, str, cb) {
str = encodeURI(str)
let blob = new Blob([str], {
type: 'text/plain'
})
let splitSize = size || 150
let chunkArr = []
let loopTimes = Math.ceil(blob.size / splitSize)
for (let i = 0; i < loopTimes; i++) {
let el = blob.slice(i * splitSize, (i + 1) * splitSize)
el.text()
.then((res) => {
chunkArr[i] = res
if (i + 1 === loopTimes) {
cb && cb(null, chunkArr)
}
})
.catch((err) => {
cb && cb(err)
})
}
}
// 将上面方法切割好的数组重组为字符
function reformChunkAsString(chunks, cb) {
let blob = new Blob(chunks, {
type: 'text/plain'
})
blob
.text()
.then((res) => {
cb && cb(null, decodeURI(res))
})
.catch((err) => {
cb && cb(err)
})
}
文件加密传输
到这里,最基础的发送加密文字功能就算是完成了,但是如果就在这里停下来,这个聊天 App 未免也太素了,所以我又加上了发送文件功能。没错,文件分享也是端到端加密的!不过这里也走了一些弯路,起初我是想通过处理字符的方法来处理文件加密,但是后面发现,这性能确实太拉垮了!把一张 1Mb 的图片以每 190Byte 进行切片,需要循环超过 5000 次,且每次循环出来的切片都要使用公钥进行加密!这样的操作在电脑的浏览器上都要卡上 4 - 5 秒,更别说手机了!最后我给出解决方案是 —— 更换加密方法:
- 文件采用 Rabbit 加 128 位密码进行加密
- 使用 RSA-OAEP-256 对 Rabbit 密码进行加密
- 对 Rabbit 算法加密后的密文进行切片后加上加密后的密码一同通过 WebSocket 传输
最后实测 1Mb 的图片几乎可以瞬间加密完成,但是由于 WebSocket 传输文件效率的问题,能实际在 m42 传输的文件大小被我限制在了 30Mb,超过这个尺寸的文件还是建议大家使用专业的文件传输服务。不过这个解决方案也并不完美:
- 无法侦测文件传输进度
- 大量数据通过 WebSocket 传输会阻塞后续文本信息的传输
这两个问题可以通过服务器中转加密文件解决,但是这样也违背了 m42 的设计初衷 ——不在服务器保存任何用户资料。
m42 是我一时兴起而开发的项目,在使用上不保证绝对的安全,也希望大家不要用它来做坏事。
好了,今天就说到这里!拜拜!👋