Matrix E2E Encryption 指南与疑惑

最近在研究 Matrix ,遇到了很多问题,这篇文章想讲讲我配置 end to end encryption(下称 E2E Encryption) 时遇到的问题。我正在写一个简单的 Matrix Client 以熟悉 Matrix Client-Server API,在写到上传文件时,使用了自己的 synapse 服务器上的账号与 matrix.org 服务器上的账号通信进行测试。但是因为和 matrix.org 服务器上的账号所创建的房间 end-to-end encrypted(这是因为我 matrix.org 服务器上的账号是经过加密的),所以导致我无法发送文件,必须让我的 client 也开启 E2E Encryption。

我是使用的 matrix-jk-sdk 来写 client,因为想节省时间,所以没有准备在浏览器里整,直接就是一个 node 脚本。读者小伙伴可以尝试用其他的 Matrix SDK,甚至还有 rust version desu。但是猫猫没有测试过其他的 SDK 存不存在这个问题。~~

想要开启 E2E Encryption,猫先参考了这里 https://github.com/matrix-org/matrix-js-sdk#end-to-end-encryption-support

⚠⚠⚠警告:被线划掉的这个guidance目前完全过时,千万不要看这个,想要知道简单的正确配置方法请拉到本文章的总结部分!

具体成代码就是:

  1. 安装 olm 作为 dependency:

    1
    2
    cd ~/neko-matrix-client
    pnpm install https://packages.matrix.org/npm/olm/olm-3.2.1.tgz --save-optional

    olm 没有发布在 npmjs.com 上,是在 https://packages.matrix.org/npm/olm/ 上,可以自行替换为最新版本的链接(上方代码块内的链接为猫猫写作时此链接提供的最新版本)。optional 安装则是因为不开 E2E Encryption 这个简单的 client 也可以工作。

    没有使用 npm 而是使用 pnpm 是因为 pnpm 好用强烈安利 电梯:https://pnpm.io/

  2. 在引入 matrix-js-sdk 前引入 olm

    1
    global.Olm = require("olm");

    猫猫第一次见到直接对 global object 进行修改的引入操作……

    众所周知,非严格模式下,JS 世界的 var 导致了所有被定义的 variable 都是 global variable,这就导致了这些 variable 的管理不当会引发奇奇怪怪的 bug。这篇文章解释了 global variable 的一些隐患。在浏览器中,global variable 是 window,在 nodejs 中,global variable 是 global。正常的引入是 const path = require("path"); 这样的,path 对于引入它的模块(node 世界每个文件就是一个模块)来说是 private variable。而在 global object 上定义,在其他的“没有引入” olm 的地方也能够访问。那么为什么不在只是用到 olm 的地方引入 olm 呢?那是没有用的。matrix-js-sdk 一定需要 global variable 上有 olm 这个元素才可以使用,不然只会冷冰冰的报一个:UnhandledPromiseRejectionWarning: Error: End-to-end encryption not supported in this js-sdk build: did you remember to load the olm library?

    这让我感到非常的诡异,为什么一定要这样?Matrix 的通信中 E2E Encryption 诚然是非常重要的环节,如果开启,几乎所有的操作都要经过加密,那为什么不是封装,作为一个 createClient 的 option?(我看了一眼 olm 的源码,大量数学内容,看不懂,但这不影响)如果光看体积的话,build 之后的 olm 有 205KiB,而 SDK 是 2.32MB,确实 olm 有些大了,但是 Matrix 本身就是追求隐私安全的协议,olm 直接封装引入也不是很矛盾吧?猫猫问号……

  3. createClient() 后,startClient() 前开启加密

    1
    2
    3
    4
    5
    6
      const { createClient } = require("matrix-js-sdk");
    const client = createClient({/* your config object */})

    + client.initCrypto();

    client.startClient();

根据 matrix-js-sdk 的 docs 来看,我们的 E2E Encryption 配置已经完成了,跑一下试试吧~

哈~报错了↓

UnhandledPromiseRejectionWarning: Error: Cannot enable encryption: no sessionStore provided

猫看着这个提示是懵的……嘛,搜搜 issue 吧……于是乎搜到了这个↓

https://github.com/matrix-org/matrix-js-sdk/issues/731

image-20220401071908595

果不其然找到了解决方法:

https://github.com/matrix-org/matrix-js-sdk/issues/731#issuecomment-746301545

可怜这位老哥两年才发现如何解决……

挑重点就是:

1
2
3
4
5
6
7
8
9
10
11
12
+ const { WebStorageSessionStore } = require("matrix-js-sdk");
+ const { LocalStorageCryptoStore } = require('matrix-js-sdk/lib/crypto/store/localStorage-crypto-store');
+ const { LocalStorage } = require("node-localstorage");
+ const localStorage = new LocalStorage("./scratch");

const client = createClient({
baseUrl: "https://example.org",
accessToken: exampleToken,
userId: exampleId,
+ sessionStore: new sdk.WebStorageSessionStore(localStorage),
+ cryptoStore: new LocalStorageCryptoStore(localStorage),
});

我炒,我看完大受震撼,这都是些什么奇技淫巧or猫屎。

姑且不管上面 require 的都是些什么,我先去查了 createClient 接收的参数

image-20220401073642221

……根本没有啊摔。翻遍文档也没有 full list of options😢于是直接看 Intellisense↓

image-20220401074342873

好欸,是 ICreateClientOpts~

然后找 ICreateClientOpts源码~

首先是 sessionStore

image-20220401074646875

原来是要开启 sessionStore 才能打开 E2E crypto……文档教程什么的根本没说,气。但是后面 SessionStore 又是什么东西?那搜索一下这个家伙

image-20220401193723942

好吧再去找 WebStorageSessionStoreWebStorageSessionStore 是 matrix-js-sdk 内置的对象,那么我们来 new 一个吧。

1
2
3
4
5
6
7
8
9
- const { createClient } = require("matrix-js-sdk");
+ const { createClient, WebStorageSessionStore } = require("matrix-js-sdk");

const client = sdk.createClient({
baseUrl: "https://example.org",
accessToken: exampleToken,
userId: exampleId,
+ sessionStore: new WebStorageSessionStore(),
})

欸,WebStorageSessionStore 需要参数。那要传什么东西进去呢?继续搜源码

image-20220401194344549

1
2
* @param {WebStorage} webStore A web storage implementation, e.g.
* 'window.localStorage' or 'window.sessionStorage' or a custom implementation.

上文说过,window 是浏览器里的 global variable,然而我是 nodejs ……我思考了一下 Storage API 的本质,这里的 storage 完全可以是 JSON 或者 对象,为什么一定要是 Web Storage 或者是它的实现,为什么没有 for nodejs alternative……但是还是得要来解决这个问题。上面谈到了那个 issue 里面的解决方案,它是用的 node-localstorage 这个包来完成的。于是我们先来看看这个包(地铁猫咪看手机)↓

image-20220402015828151

hmmm怎么说呢,感觉有点稀奇古怪的……还是用 CoffeeScript 写的,有一点害怕😰

然后咕米老师建议我干脆自己来实现一个 Storage 对象。于是查了 Storage API Spec 就开搞了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function Storage() {
this._store = new Map();
this.length = 0;
}

Storage.prototype.key = function key(index) {
return Array.from(this._store.keys())[index];
};

Storage.prototype.getItem = function getItem(key) {
this.length++;
return this._store.get(key);
};

Storage.prototype.setItem = function setItem(key, value) {
this.length++;
this._store.set(key, value);
};

Storage.prototype.removeItem = function removeItem(key) {
this.length--;
this._store.delete(key);
};

Storage.prototype.clear = function clear() {
this.length = 0;
this._store.clear();
};

实现是实现了,但是得搞定存储问题呢,不能存了每次都丢了,所以不如用 fs 存到一个 JSON 里吧?(猫想)sqlite 狂热粉丝咕米老师跳出来说:“数据量大的话会很慢,所以最可靠的方法是用数据库,通常用 sqlite,sqlite 就是做这个的🤩”

然后我抵住了咕米老师的盛情推荐,用 JSON 先。

那么首先就是判断创建和读取这个 JSON:

要把 JSON 的内容转换为 Storage Object,所以需要取走内容新建一个实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const { existsSync, readFileSync } = require("fs");
const path = require("path");

const storaPath = path.join(/* path you like */);
let content;

const readContent = () => {
// 需要判断文件存在和内容不为空,不然 JSON.parse 会报错喵
if (existsSync(storePath) && Array.from(readFileSync(storePath)).length !== 0) {
content = new Storage(new Map(Object.entries(JSON.parse(readFileSync(storePath)))));
} else {
content = new Storage();
}
};

那么 Storage Object 的定义就改成这样:

1
2
3
4
5
  function Storage(content) {
- this._store = new Map();
+ this._store = content || new Map();
this.length = 0;
}

然后又因为感觉将 length 改为 getter 更加规范一些,但是这又不是 TS 我也没给这简单东西整上 Babel 于是就变成了这样:

1
2
3
4
5
  function Storage(content) {
this._store = content || new Map();
- this.length = 0;
+ Object.defineProperty(this, "length", { get: () => this._store.size });
}

(中间还研究了一下 TS 私有变量 desu,有兴趣的读者可以看看这个了解一下)

在修改 Storage 的情况时,异步存给 JSON:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  const { writeFile } = require("fs/promises");

Storage.prototype.setItem = function setItem(key, value) {
this.length++;
this._store.set(key, value);
+ writeFile(storePath, JSON.stringify(content));
};

Storage.prototype.removeItem = function removeItem(key) {
this.length--;
this._store.delete(key);
+ writeFile(storePath, JSON.stringify(content));
};

Storage.prototype.clear = function clear() {
this.length = 0;
this._store.clear();
+ writeFile(storePath, JSON.stringify(content));
};

别忘了导出 readContent 函数~

合并的效果就是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
  const { existsSync, readFileSync } = require("fs");
const { writeFile } = require("fs/promises");
const path = require("path");

const storePath = path.join(__dirname, "../sessionStore.json");

const readContent = () => {
if (existsSync(storePath) && Array.from(readFileSync(storePath)).length !== 0) {
- content = new Storage(new Map(Object.entries(JSON.parse(readFileSync(storePath)))));
+ return new Storage(new Map(Object.entries(JSON.parse(readFileSync(storePath)))));
} else {
- content = new Storage();
+ return new Storage();
}
};

function Storage(content) {
this._store = content || new Map();
Object.defineProperty(this, "length", { get: () => this._store.size });
}

Storage.prototype.key = function key(index) {
return Array.from(this._store.keys())[index];
};

Storage.prototype.getItem = function getItem(key) {
return this._store.get(key);
};

Storage.prototype.setItem = function setItem(key, value) {
this.length++;
this._store.set(key, value);
writeFile(storePath, JSON.stringify(content));
};

Storage.prototype.removeItem = function removeItem(key) {
this.length--;
this._store.delete(key);
writeFile(storePath, JSON.stringify(content));
};

Storage.prototype.clear = function clear() {
this.length = 0;
this._store.clear();
writeFile(storePath, JSON.stringify(content));
};

module.exports = { Storage, readContent };

这一步完成之后,我们引入实现的 Storage Object

1
2
3
4
5
6
7
8
9
10
11
12
  const { createClient, WebStorageSessionStore } = require("matrix-js-sdk");
+ const { Storage, readContent } = require("./src/storage");

const storage = readContent();

const client = sdk.createClient({
baseUrl: "https://example.org",
accessToken: exampleToken,
userId: exampleId,
- sessionStore: new WebStorageSessionStore(),
+ sessionStore: new WebStorageSessionStore(storage),
})

再次启动。log 有所变化,但是还是报错,看错误描述几乎全是 cryptoStore 相关,猜到也要去设置 cryptoStore了。那继续回到 ICreateClientOpts源码,来看 cryptoStore 的描述。

image-20220402231736088

好家伙,这个也是必须要开的。但是 CryptoStore 又是什么类型呢?回到 SDK 文档搜索如下:

image-20220402231921004

CryptoStore 不能直接拿来用,是 Internal module。那么就选用上面的几个 CrytoStore 的 child class。根据环境来看,选用 MemoryCryptoStore 是比较好的选择。那么就查看 MemoryCryptoStore 的源码

image-20220402233740112

可以看到实例化 MemoryCryptoStore 并没有要求参数,那么我们直接 new 一个。

1
2
3
4
5
6
7
8
9
10
11
12
+ const { createClient, WebStorageSessionStore, MemoryCryptoStore } = require("matrix-js-sdk");
const { Storage, readContent } = require("./src/storage");

const storage = readContent();

const client = sdk.createClient({
baseUrl: "https://example.org",
accessToken: exampleToken,
userId: exampleId,
sessionStore: new WebStorageSessionStore(storage),
+ cryptoStore: new MemoryCryptoStore(),
})

OK,那么现在应该不用再加什么配置了吧(猫咪生气)。再次跑起。零零星星还是有一些 error。可恶,吃猫猫拳!

稍微看了一下,可以发现是缺少 deviceId 。因为理论上来说,每个登录设备都应该保存 unique 的 deviceId 才对,但是我不知道 deviceId 要怎么去配置,直接搜源码得到的信息是这样的↓

image-20220403001916242

这和没说没区别,于是我又去翻了 Spec(Spec 真的是七零八落)。

image-20220403004056561

因为服务器也是我自己的,它说服务器自动生成的……那我直接 getDeviceId() 就能有了?但是 getDeviceId() 的结果是 null……好吧,它应该是直接读 client 给的 device_id 了,但是我没有设定所以就是 null,那我服务器没分配吗?迷。但是这不是重点,于是我就自己配了一个 device_id 给我的 client 来读。于是就变成了这样:

1
2
3
4
5
6
7
8
9
10
  const { deviceId } = require("./info.json");

const client = sdk.createClient({
baseUrl: "https://example.org",
accessToken: exampleToken,
userId: exampleId,
sessionStore: new sdk.WebStorageSessionStore(storage),
cryptoStore: new sdk.MemoryCryptoStore(),
+ deviceId: deviceId,
});

那么再跑一下试试。

log 出的信息显示 E2E 已经正常打开了,并且下载了其他群聊的加密信息。但是还是有 error。

Error uploading one-time keys TypeError: account.unpublished_fallback_key is not a function

这算是个什么信息…… 查看位置提示指向 matrix-js-sdk 里面的 OlmDevice.js

那再次查找源码。(给出的源码链接是编译前的 TS 文件,图中是编译后的 npm package 源码,所以稍有不同)

image-20220403151152732

这是 new 一个出来的对象怎么会没有上面那个函数呢……问题应该在那个 unpickle 的位置。那就只能看一下 Olm 的源码了。让我们来打开 Olm 的源码……

image-20220403151757452

淦,编译成这样看个大头鬼,遂去找 olm repo……直接全局搜索找到这个文件

image-20220403152048931

这不是好好定义在这里吗……太怪了吧。于是我又搜索了报错的 unpublished_fallback_key 这个函数,也是在的……那只能查 issue 了,遂搜索到了这条

image-20220403155831417

欸,所以要升级 olm……但是之前显示 olm package 下载的网址很久没有更新了……,最新的 package 在 https://gitlab.matrix.org/matrix-org/olm/-/releases 找。Matrix 的文档真的一言难尽呢……

虽然写作时间已经更新到了 3.2.10,但是只有 source code,我并不准备编译,所以选了 3.2.8 这个最新的有 npm package 的版本。

更新的方法:

  1. 删去 package.json 里的 olm 那一条

  2. 配置 .npmrc

    1
    echo @matrix-org:registry=https://gitlab.matrix.org/api/v4/packages/npm/ >> .npmrc

    直接选择在安装时指好 registry 也是可以的喵!

  3. 安装

    1
    2
    3
    4
    5
    6
    # 删去原来过时的 olm 包(忘记执行这一步会导致 require 到的还是原来的包desu,所以不要忘记了,不然就会遇到奇怪的问题,e.g.引入失败)
    # 但是我猜多半还是 node_modules 里面还是会有,所以建议暴力删除 node_modules
    # 不确定是 pnpm 的问题还是什么……
    pnpm install

    pnpm install @matrix-org/olm --save-optional
  4. 将引入 olm 改写成这样

    1
    global.Olm = require("@matrix-org/olm");

重新 start 一下看看。终于没有 error 了!!!!可喜可贺~🎊

总结

全过程:

  1. 引入 Olm

    1
    npm install @matrix-org/olm --registry=https://gitlab.matrix.org/api/v4/packages/npm/packages/npm/
    1
    2
    // Please require olm package before require matrix-js-sdk
    global.Olm = require("@matrix-org/olm");
  2. 为 createClient 添加三个 option:deviceIdcryptoStoresessionStore

    deviceId 配置请参考 Matrix Docs 了解 deviceId 是做什么的再配置。

    for Browser:

    sessionStore 使用 WebStorageSessionStore 实例化,其中需要填写任一一种 Web Storage(localStorage 或者 sessionStorage

    cryptoStore 可以选择 matrix-js-sdk 提供的 IndexedDBCryptoStore 或者 LocalStorageCryptoStore 或者 MemoryCryptoStore ,具体使用方法请参考对应的文档~

    1
    2
    3
    4
    5
    6
    7
    8
    const client = sdk.createClient({
    baseUrl: "https://example.org",
    accessToken: exampleToken,
    userId: exampleId,
    sessionStore: new sdk.WebStorageSessionStore(/* fill the initialization by yourself */),
    cryptoStore: new sdk.MemoryCryptoStore(),
    deviceId: deviceId,
    });

    for nodejs:

    因为 WebStorageSessionStore 实例化只接受 Web Storage 及其类似 Implementation 作为参数,所以对于 node 必须使用 node-localstorage 这样的包或者由你自己实现 Storage。

    个人比较推荐自己实现,根据 SDK 描述需要 keygetItemsetItemremoveItemlength 五个 API。如果不会请参考 Web Storage Spec 或者看猫怎么写的(觉得有帮助的话给猫的 repo 点个✨也是可以的喵ฅ•ω•ฅ)。

    1
    2
    3
    4
    5
    6
    7
    8
    const client = sdk.createClient({
    baseUrl: "https://example.org",
    accessToken: exampleToken,
    userId: exampleId,
    sessionStore: new sdk.WebStorageSessionStore(/* fill the initialization later */),
    cryptoStore: new sdk.MemoryCryptoStore(),
    deviceId: deviceId,
    });
  3. startClient() 之前,createClient() 之后初始化加密

    1
    client.initCrypto();

嘿嘿,希望对想要了解 Matrix 想要自己写 Client 的人有所帮助!!!猫猫会很开心哒~

Author: Alendia

Permalink: https://alendia.dev/2022/04/01/Matrix-E2E-guidance-and-doubt/

文章默认使用 CC BY-NC-SA 4.0 协议进行许可,使用时请注意遵守协议。

Comments

Unable to load Disqus, please make sure your network can access.