关于m42的技术细节

1269 words, 6 minutes to read / December 18, 2022

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 是我一时兴起而开发的项目,在使用上不保证绝对的安全,也希望大家不要用它来做坏事。

好了,今天就说到这里!拜拜!👋