Compare commits
15 Commits
v32.0.0
...
v32.1.0-rc.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c850ca3179 | |||
| 8438533532 | |||
| 475f82c5ce | |||
| 936e7c3072 | |||
| 82ed7bd86a | |||
| cb67eae858 | |||
| 5cdd524da7 | |||
| 0ff0093380 | |||
| b352405c89 | |||
| 0d73d0c6c7 | |||
| d2f76d4956 | |||
| c680dd7eb2 | |||
| e24bb0f50c | |||
| 1ed3b13f0d | |||
| 4f628bf64c |
@@ -16,7 +16,7 @@ concurrency:
|
||||
jobs:
|
||||
build-element-web:
|
||||
name: Build element-web
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.95.0
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.96.1
|
||||
with:
|
||||
matrix-js-sdk-sha: ${{ github.sha }}
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
name: Preview Changelog
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: mheap/github-action-required-labels@80a96a4863886addcbc9f681b5b295ba7f5424e1 # v5
|
||||
- uses: mheap/github-action-required-labels@132879b972cb7f2ac593006455875098e73cc7f2 # v5
|
||||
if: github.event_name != 'merge_group'
|
||||
with:
|
||||
labels: |
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
- name: Get draft release
|
||||
id: draft-release
|
||||
uses: cardinalby/git-get-release-action@cedef2faf69cb7c55b285bad07688d04430b7ada # v1
|
||||
uses: cardinalby/git-get-release-action@5172c3a026600b1d459b117738c605fabc9e4e44 # v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
- name: 🚀 Publish to npm
|
||||
id: npm-publish
|
||||
uses: JS-DevTools/npm-publish@4b07b26a2f6e0a51846e1870223e545bae91c552 # v3.0.1
|
||||
uses: JS-DevTools/npm-publish@19c28f1ef146469e409470805ea4279d47c3d35c # v3.1.1
|
||||
with:
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
access: public
|
||||
|
||||
@@ -90,3 +90,19 @@ jobs:
|
||||
path: _docs
|
||||
# We'll only use this in a workflow_run, then we're done with it
|
||||
retention-days: 1
|
||||
|
||||
analyse_dead_code:
|
||||
name: "Analyse Dead Code"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
|
||||
- name: Install Deps
|
||||
run: "yarn install --frozen-lockfile"
|
||||
|
||||
- name: Run linter
|
||||
run: "yarn run lint:knip"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { KnipConfig } from "knip";
|
||||
|
||||
export default {
|
||||
entry: [
|
||||
"src/index.ts",
|
||||
"src/types.ts",
|
||||
"src/browser-index.ts",
|
||||
"src/indexeddb-worker.ts",
|
||||
"scripts/**",
|
||||
"spec/**",
|
||||
"release.sh",
|
||||
// For now, we include all source files as entrypoints as we have been bad about gutwrenched imports
|
||||
"src/**",
|
||||
],
|
||||
project: ["**/*.{js,ts}"],
|
||||
ignore: ["examples/**"],
|
||||
ignoreDependencies: [
|
||||
// Required for `action-validator`
|
||||
"@action-validator/*",
|
||||
// Used for git pre-commit hooks
|
||||
"husky",
|
||||
// Used in script which only runs in environment with `@octokit/rest` installed
|
||||
"@octokit/rest",
|
||||
// Used by jest
|
||||
"jest-environment-jsdom",
|
||||
"babel-jest",
|
||||
"ts-node",
|
||||
// Used by `@babel/plugin-transform-runtime`
|
||||
"@babel/runtime",
|
||||
],
|
||||
ignoreBinaries: [
|
||||
// Used when available by reusable workflow `.github/workflows/release-make.yml`
|
||||
"dist",
|
||||
],
|
||||
ignoreExportsUsedInFile: true,
|
||||
} satisfies KnipConfig;
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "32.0.0",
|
||||
"version": "32.1.0-rc.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -19,6 +19,7 @@
|
||||
"lint:js-fix": "prettier --log-level=warn --write . && eslint --fix src spec",
|
||||
"lint:types": "tsc --noEmit",
|
||||
"lint:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) | xargs -I {} sh -c 'echo \"Linting {}\"; action-validator \"{}\"'",
|
||||
"lint:knip": "knip",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"coverage": "yarn test --coverage"
|
||||
@@ -80,7 +81,6 @@
|
||||
"@babel/plugin-transform-runtime": "^7.12.10",
|
||||
"@babel/preset-env": "^7.12.11",
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@babel/register": "^7.12.10",
|
||||
"@casualbot/jest-sonar-reporter": "2.2.7",
|
||||
"@matrix-org/olm": "3.2.15",
|
||||
"@peculiar/webcrypto": "^1.4.5",
|
||||
@@ -115,12 +115,13 @@
|
||||
"jest-environment-jsdom": "^29.0.0",
|
||||
"jest-localstorage-mock": "^2.4.6",
|
||||
"jest-mock": "^29.0.0",
|
||||
"knip": "^4.0.1",
|
||||
"lint-staged": "^15.0.2",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"prettier": "3.2.5",
|
||||
"rimraf": "^5.0.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typedoc": "^0.25.10",
|
||||
"typedoc-plugin-coverage": "^3.0.0",
|
||||
"typedoc-plugin-mdn-links": "^3.0.3",
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { createClient, ClientEvent, MatrixClient, MatrixEvent } from "../../../src";
|
||||
import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
|
||||
import { AddSecretStorageKeyOpts } from "../../../src/secret-storage";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
|
||||
describe("Device dehydration", () => {
|
||||
it("should rehydrate and dehydrate a device", async () => {
|
||||
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
cryptoCallbacks: {
|
||||
getSecretStorageKey: async (keys: any, name: string) => {
|
||||
return [[...Object.keys(keys.keys)][0], new Uint8Array(32)];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await initializeSecretStorage(matrixClient, "@alice:localhost", "http://test.server");
|
||||
|
||||
// count the number of times the dehydration key gets set
|
||||
let setDehydrationCount = 0;
|
||||
matrixClient.on(ClientEvent.AccountData, (event: MatrixEvent) => {
|
||||
if (event.getType() === "org.matrix.msc3814") {
|
||||
setDehydrationCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const crypto = matrixClient.getCrypto()!;
|
||||
fetchMock.config.overwriteRoutes = true;
|
||||
|
||||
// start dehydration -- we start with no dehydrated device, and we
|
||||
// store the dehydrated device that we create
|
||||
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
|
||||
status: 404,
|
||||
body: {
|
||||
errcode: "M_NOT_FOUND",
|
||||
error: "Not found",
|
||||
},
|
||||
});
|
||||
let dehydratedDeviceBody: any;
|
||||
let dehydrationCount = 0;
|
||||
let resolveDehydrationPromise: () => void;
|
||||
fetchMock.put("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", (_, opts) => {
|
||||
dehydratedDeviceBody = JSON.parse(opts.body as string);
|
||||
dehydrationCount++;
|
||||
if (resolveDehydrationPromise) {
|
||||
resolveDehydrationPromise();
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await crypto.startDehydration();
|
||||
|
||||
expect(dehydrationCount).toEqual(1);
|
||||
|
||||
// a week later, we should have created another dehydrated device
|
||||
const dehydrationPromise = new Promise<void>((resolve, reject) => {
|
||||
resolveDehydrationPromise = resolve;
|
||||
});
|
||||
jest.advanceTimersByTime(7 * 24 * 60 * 60 * 1000);
|
||||
await dehydrationPromise;
|
||||
expect(dehydrationCount).toEqual(2);
|
||||
|
||||
// restart dehydration -- rehydrate the device that we created above,
|
||||
// and create a new dehydrated device. We also set `createNewKey`, so
|
||||
// a new dehydration key will be set
|
||||
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
|
||||
device_id: dehydratedDeviceBody.device_id,
|
||||
device_data: dehydratedDeviceBody.device_data,
|
||||
});
|
||||
const eventsResponse = jest.fn((url, opts) => {
|
||||
// rehydrating should make two calls to the /events endpoint.
|
||||
// The first time will return a single event, and the second
|
||||
// time will return no events (which will signal to the
|
||||
// rehydration function that it can stop)
|
||||
const body = JSON.parse(opts.body as string);
|
||||
const nextBatch = body.next_batch ?? "0";
|
||||
const events = nextBatch === "0" ? [{ sender: "@alice:localhost", type: "m.dummy", content: {} }] : [];
|
||||
return {
|
||||
events,
|
||||
next_batch: nextBatch + "1",
|
||||
};
|
||||
});
|
||||
fetchMock.post(
|
||||
`path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device/${encodeURIComponent(dehydratedDeviceBody.device_id)}/events`,
|
||||
eventsResponse,
|
||||
);
|
||||
await crypto.startDehydration(true);
|
||||
expect(dehydrationCount).toEqual(3);
|
||||
|
||||
expect(setDehydrationCount).toEqual(2);
|
||||
expect(eventsResponse.mock.calls).toHaveLength(2);
|
||||
|
||||
matrixClient.stopClient();
|
||||
});
|
||||
});
|
||||
|
||||
/** create a new secret storage and cross-signing keys */
|
||||
async function initializeSecretStorage(
|
||||
matrixClient: MatrixClient,
|
||||
userId: string,
|
||||
homeserverUrl: string,
|
||||
): Promise<void> {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: {
|
||||
errcode: "M_NOT_FOUND",
|
||||
error: "Not found",
|
||||
},
|
||||
});
|
||||
const e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl);
|
||||
const e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
|
||||
e2eKeyResponder.addKeyReceiver(userId, e2eKeyReceiver);
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {});
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {});
|
||||
const accountData: Map<string, object> = new Map();
|
||||
fetchMock.get("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
|
||||
const name = url.split("/").pop()!;
|
||||
const value = accountData.get(name);
|
||||
if (value) {
|
||||
return value;
|
||||
} else {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
errcode: "M_NOT_FOUND",
|
||||
error: "Not found",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
fetchMock.put("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
|
||||
const name = url.split("/").pop()!;
|
||||
const value = JSON.parse(opts.body as string);
|
||||
accountData.set(name, value);
|
||||
matrixClient.emit(ClientEvent.AccountData, new MatrixEvent({ type: name, content: value }));
|
||||
return {};
|
||||
});
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
const crypto = matrixClient.getCrypto()! as RustCrypto;
|
||||
// we need to process a sync so that the OlmMachine will upload keys
|
||||
await crypto.preprocessToDeviceMessages([]);
|
||||
await crypto.onSyncCompleted({});
|
||||
|
||||
// create initial secret storage
|
||||
async function createSecretStorageKey() {
|
||||
return {
|
||||
keyInfo: {} as AddSecretStorageKeyOpts,
|
||||
privateKey: new Uint8Array(32),
|
||||
};
|
||||
}
|
||||
await matrixClient.bootstrapCrossSigning({ setupNewCrossSigning: true });
|
||||
await matrixClient.bootstrapSecretStorage({
|
||||
createSecretStorageKey,
|
||||
setupNewSecretStorage: true,
|
||||
setupNewKeyBackup: false,
|
||||
});
|
||||
}
|
||||
@@ -162,6 +162,110 @@ describe("MatrixClient.initRustCrypto", () => {
|
||||
expect(progressListener).toHaveBeenLastCalledWith(-1, -1);
|
||||
}, 60000);
|
||||
|
||||
describe("Private key backup migration", () => {
|
||||
it("should not migrate the backup private key if backup has changed", async () => {
|
||||
// Here we have a new backup server side, and the migrated account has the previous backup key.
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", MSK_NOT_CACHED_DATASET.newBackupResponse);
|
||||
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/query", MSK_NOT_CACHED_DATASET.keyQueryResponse);
|
||||
|
||||
await populateStore("test-store", MSK_NOT_CACHED_DATASET.dumpPath);
|
||||
const cryptoStore = new IndexedDBCryptoStore(indexedDB, "test-store");
|
||||
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: MSK_NOT_CACHED_DATASET.userId,
|
||||
deviceId: MSK_NOT_CACHED_DATASET.deviceId,
|
||||
cryptoStore,
|
||||
pickleKey: MSK_NOT_CACHED_DATASET.pickleKey,
|
||||
});
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
|
||||
const privateBackupKey = await matrixClient.getCrypto()?.getSessionBackupPrivateKey();
|
||||
expect(privateBackupKey).toBeNull();
|
||||
});
|
||||
|
||||
it("should not migrate the backup private key if backup has unknown algorithm", async () => {
|
||||
// Here we have a new backup server side, and the migrated account has the previous backup key.
|
||||
const backupResponse = {
|
||||
...MSK_NOT_CACHED_DATASET.backupResponse,
|
||||
algorithm: "m.megolm_backup.v8",
|
||||
};
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", backupResponse);
|
||||
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/query", MSK_NOT_CACHED_DATASET.keyQueryResponse);
|
||||
|
||||
await populateStore("test-store", MSK_NOT_CACHED_DATASET.dumpPath);
|
||||
const cryptoStore = new IndexedDBCryptoStore(indexedDB, "test-store");
|
||||
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: MSK_NOT_CACHED_DATASET.userId,
|
||||
deviceId: MSK_NOT_CACHED_DATASET.deviceId,
|
||||
cryptoStore,
|
||||
pickleKey: MSK_NOT_CACHED_DATASET.pickleKey,
|
||||
});
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
|
||||
const privateBackupKey = await matrixClient.getCrypto()?.getSessionBackupPrivateKey();
|
||||
expect(privateBackupKey).toBeNull();
|
||||
});
|
||||
|
||||
it("should not migrate the backup private key if the backup has been deleted", async () => {
|
||||
// The old backup has been deleted server side.
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: {
|
||||
errcode: "M_NOT_FOUND",
|
||||
error: "No backup found",
|
||||
},
|
||||
});
|
||||
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/query", MSK_NOT_CACHED_DATASET.keyQueryResponse);
|
||||
|
||||
await populateStore("test-store", MSK_NOT_CACHED_DATASET.dumpPath);
|
||||
const cryptoStore = new IndexedDBCryptoStore(indexedDB, "test-store");
|
||||
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: MSK_NOT_CACHED_DATASET.userId,
|
||||
deviceId: MSK_NOT_CACHED_DATASET.deviceId,
|
||||
cryptoStore,
|
||||
pickleKey: MSK_NOT_CACHED_DATASET.pickleKey,
|
||||
});
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
|
||||
const privateBackupKey = await matrixClient.getCrypto()?.getSessionBackupPrivateKey();
|
||||
expect(privateBackupKey).toBeNull();
|
||||
});
|
||||
|
||||
it("should migrate the backup private key if the backup matches", async () => {
|
||||
// The old backup has been deleted server side.
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", MSK_NOT_CACHED_DATASET.backupResponse);
|
||||
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/query", MSK_NOT_CACHED_DATASET.keyQueryResponse);
|
||||
|
||||
await populateStore("test-store", MSK_NOT_CACHED_DATASET.dumpPath);
|
||||
const cryptoStore = new IndexedDBCryptoStore(indexedDB, "test-store");
|
||||
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: MSK_NOT_CACHED_DATASET.userId,
|
||||
deviceId: MSK_NOT_CACHED_DATASET.deviceId,
|
||||
cryptoStore,
|
||||
pickleKey: MSK_NOT_CACHED_DATASET.pickleKey,
|
||||
});
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
|
||||
const privateBackupKey = await matrixClient.getCrypto()?.getSessionBackupPrivateKey();
|
||||
expect(privateBackupKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Legacy trust migration", () => {
|
||||
async function populateAndStartLegacyCryptoStore(dumpPath: string): Promise<IndexedDBCryptoStore> {
|
||||
const testStoreName = "test-store";
|
||||
|
||||
@@ -44,6 +44,7 @@ export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): Validated
|
||||
token_endpoint: issuer + "token",
|
||||
authorization_endpoint: issuer + "auth",
|
||||
registration_endpoint: issuer + "registration",
|
||||
device_authorization_endpoint: issuer + "device",
|
||||
jwks_uri: issuer + "jwks",
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
|
||||
@@ -245,6 +245,28 @@ const BACKUP_RESPONSE: KeyBackupInfo = {
|
||||
count: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* This was generated by doing a backup reset on the account.
|
||||
* This is a new valid backup for this account.
|
||||
*/
|
||||
const NEW_BACKUP_RESPONSE: KeyBackupInfo = {
|
||||
auth_data: {
|
||||
public_key: "CkDxWALi3lcChgjEZFEM6clYq5x768XBwsL++eaOzTI",
|
||||
signatures: {
|
||||
"@migration:localhost": {
|
||||
"ed25519:YVEGEYPYWX":
|
||||
"ZSYuQDdwgB9WKXQ+z5aWWfqSolBCGRw53kur1Vy956gFefgzCBkMbw5M0I2UgfU2Cukri7jZ4ig201zmLNmaAA",
|
||||
"ed25519:rXCrBin/+xyh+yW//vWte+2UV0et1ZHTWfalp/Ekack":
|
||||
"+UQ8EA507LoIqgK9rPsqPoGrj+iRBJeY2Oz0mMtXmVf8c1y8G0KWJNUWqvOysnOhsoJf1bt8ey48CxjjtSQ2AA",
|
||||
},
|
||||
},
|
||||
},
|
||||
version: "3",
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
etag: "0",
|
||||
count: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* A dataset containing the information for the tested user.
|
||||
* To be used during tests.
|
||||
@@ -256,5 +278,6 @@ export const MSK_NOT_CACHED_DATASET: DumpDataSetInfo = {
|
||||
keyQueryResponse: KEY_QUERY_RESPONSE,
|
||||
rotatedKeyQueryResponse: ROTATED_KEY_QUERY_RESPONSE,
|
||||
backupResponse: BACKUP_RESPONSE,
|
||||
newBackupResponse: NEW_BACKUP_RESPONSE,
|
||||
dumpPath: "spec/test-utils/test_indexeddb_cryptostore_dump/no_cached_msk_dump/dump.json",
|
||||
};
|
||||
|
||||
@@ -90,4 +90,31 @@ describe("registerOidcClient()", () => {
|
||||
OidcError.DynamicRegistrationInvalid,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw when required endpoints are unavailable", async () => {
|
||||
await expect(() =>
|
||||
registerOidcClient(
|
||||
{
|
||||
...delegatedAuthConfig,
|
||||
registrationEndpoint: undefined,
|
||||
},
|
||||
metadata,
|
||||
),
|
||||
).rejects.toThrow(OidcError.DynamicRegistrationNotSupported);
|
||||
});
|
||||
|
||||
it("should throw when required scopes are unavailable", async () => {
|
||||
await expect(() =>
|
||||
registerOidcClient(
|
||||
{
|
||||
...delegatedAuthConfig,
|
||||
metadata: {
|
||||
...delegatedAuthConfig.metadata,
|
||||
grant_types_supported: [delegatedAuthConfig.metadata.grant_types_supported[0]],
|
||||
},
|
||||
},
|
||||
metadata,
|
||||
),
|
||||
).rejects.toThrow(OidcError.DynamicRegistrationNotSupported);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
KeysClaimRequest,
|
||||
KeysQueryRequest,
|
||||
KeysUploadRequest,
|
||||
PutDehydratedDeviceRequest,
|
||||
RoomMessageRequest,
|
||||
SignatureUploadRequest,
|
||||
UploadSigningKeysRequest,
|
||||
@@ -233,6 +234,35 @@ describe("OutgoingRequestProcessor", () => {
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
});
|
||||
|
||||
it("should handle PutDehydratedDeviceRequest", async () => {
|
||||
// first, mock up a request as we might expect to receive it from the Rust layer ...
|
||||
const testReq = { foo: "bar" };
|
||||
const outgoingRequest = new PutDehydratedDeviceRequest(JSON.stringify(testReq));
|
||||
|
||||
// ... then poke the request into the OutgoingRequestProcessor under test
|
||||
const reqProm = processor.makeOutgoingRequest(outgoingRequest);
|
||||
|
||||
// Now: check that it makes a matching HTTP request.
|
||||
const testResponse = '{"result":1}';
|
||||
httpBackend
|
||||
.when("PUT", "/_matrix")
|
||||
.check((req) => {
|
||||
expect(req.path).toEqual(
|
||||
"https://example.com/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device",
|
||||
);
|
||||
expect(JSON.parse(req.rawData)).toEqual(testReq);
|
||||
expect(req.headers["Accept"]).toEqual("application/json");
|
||||
expect(req.headers["Content-Type"]).toEqual("application/json");
|
||||
})
|
||||
.respond(200, testResponse, true);
|
||||
|
||||
// PutDehydratedDeviceRequest does not need to be marked as sent, so no call to OlmMachine.markAsSent is expected.
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
await reqProm;
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
});
|
||||
|
||||
it("does not explode with unknown requests", async () => {
|
||||
const outgoingRequest = { id: "5678", type: 987 };
|
||||
const markSentCallPromise = awaitCallToMarkAsSent();
|
||||
|
||||
@@ -205,7 +205,20 @@ describe("initRustCrypto", () => {
|
||||
createMegolmSessions(legacyStore, nDevices, nSessionsPerDevice);
|
||||
await legacyStore.markSessionsNeedingBackup([{ senderKey: pad43("device5"), sessionId: "session5" }]);
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", { version: "45" });
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
auth_data: {
|
||||
public_key: "backup_key_public",
|
||||
},
|
||||
version: "45",
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
});
|
||||
// The cached key should be valid for the backup
|
||||
const mockBackupDecryptionKey: any = {
|
||||
megolmV1PublicKey: {
|
||||
publicKeyBase64: "backup_key_public",
|
||||
},
|
||||
};
|
||||
jest.spyOn(RustSdkCryptoJs.BackupDecryptionKey, "fromBase64").mockReturnValue(mockBackupDecryptionKey);
|
||||
|
||||
function legacyMigrationProgressListener(progress: number, total: number): void {
|
||||
logger.log(`migrated ${progress} of ${total}`);
|
||||
@@ -762,8 +775,11 @@ describe("RustCrypto", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (request instanceof RustSdkCryptoJs.UploadSigningKeysRequest) {
|
||||
// SigningKeysUploadRequest does not implement OutgoingRequest and does not need to be marked as sent.
|
||||
} else if (
|
||||
request instanceof RustSdkCryptoJs.UploadSigningKeysRequest ||
|
||||
request instanceof RustSdkCryptoJs.PutDehydratedDeviceRequest
|
||||
) {
|
||||
// These request types do not implement OutgoingRequest and do not need to be marked as sent.
|
||||
return;
|
||||
}
|
||||
if (request.id) {
|
||||
@@ -1395,6 +1411,34 @@ describe("RustCrypto", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("device dehydration", () => {
|
||||
it("should detect if dehydration is supported", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi());
|
||||
fetchMock.config.overwriteRoutes = true;
|
||||
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
|
||||
status: 404,
|
||||
body: {
|
||||
errcode: "M_UNRECOGNIZED",
|
||||
error: "Unknown endpoint",
|
||||
},
|
||||
});
|
||||
expect(await rustCrypto.isDehydrationSupported()).toBe(false);
|
||||
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
|
||||
status: 404,
|
||||
body: {
|
||||
errcode: "M_NOT_FOUND",
|
||||
error: "Not found",
|
||||
},
|
||||
});
|
||||
expect(await rustCrypto.isDehydrationSupported()).toBe(true);
|
||||
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
|
||||
device_id: "DEVICE_ID",
|
||||
device_data: "data",
|
||||
});
|
||||
expect(await rustCrypto.isDehydrationSupported()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Build a MatrixHttpApi instance */
|
||||
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import "oidc-client-ts";
|
||||
|
||||
declare module "oidc-client-ts" {
|
||||
interface OidcMetadata {
|
||||
// Add the missing device_authorization_endpoint field to the OidcMetadata interface
|
||||
device_authorization_endpoint?: string;
|
||||
}
|
||||
}
|
||||
@@ -496,6 +496,42 @@ export interface CryptoApi {
|
||||
* @param version - The backup version to delete.
|
||||
*/
|
||||
deleteKeyBackupVersion(version: string): Promise<void>;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Dehydrated devices
|
||||
//
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* Returns whether MSC3814 dehydrated devices are supported by the crypto
|
||||
* backend and by the server.
|
||||
*
|
||||
* This should be called before calling `startDehydration`, and if this
|
||||
* returns `false`, `startDehydration` should not be called.
|
||||
*/
|
||||
isDehydrationSupported(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Start using device dehydration.
|
||||
*
|
||||
* - Rehydrates a dehydrated device, if one is available.
|
||||
* - Creates a new dehydration key, if necessary, and stores it in Secret
|
||||
* Storage.
|
||||
* - If `createNewKey` is set to true, always creates a new key.
|
||||
* - If a dehydration key is not available, creates a new one.
|
||||
* - Creates a new dehydrated device, and schedules periodically creating
|
||||
* new dehydrated devices.
|
||||
*
|
||||
* This function must not be called unless `isDehydrationSupported` returns
|
||||
* `true`, and must not be called until after cross-signing and secret
|
||||
* storage have been set up.
|
||||
*
|
||||
* @param createNewKey - whether to force creation of a new dehydration key.
|
||||
* This can be used, for example, if Secret Storage is being reset. Defaults
|
||||
* to false.
|
||||
*/
|
||||
startDehydration(createNewKey?: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
/** A reason code for a failure to decrypt an event. */
|
||||
|
||||
@@ -871,6 +871,9 @@ export const algorithmsByName: Record<string, BackupAlgorithmClass> = {
|
||||
[Aes256.algorithmName]: Aes256,
|
||||
};
|
||||
|
||||
// the linter doesn't like this but knip does
|
||||
// eslint-disable-next-line tsdoc/syntax
|
||||
/** @alias */
|
||||
export const DefaultAlgorithm: BackupAlgorithmClass = Curve25519;
|
||||
|
||||
/**
|
||||
|
||||
@@ -4287,6 +4287,21 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
public getRoomEncryption(roomId: string): IRoomEncryption | null {
|
||||
return this.roomList.getRoomEncryption(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether dehydrated devices are supported by the crypto backend
|
||||
* and by the server.
|
||||
*/
|
||||
public async isDehydrationSupported(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub function -- dehydration is not implemented here, so throw error
|
||||
*/
|
||||
public async startDehydration(createNewKey?: boolean): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,6 +51,9 @@ export class Device {
|
||||
/** display name of the device */
|
||||
public readonly displayName?: string;
|
||||
|
||||
/** whether the device is a dehydrated device */
|
||||
public readonly dehydrated: boolean = false;
|
||||
|
||||
public constructor(opts: DeviceParameters) {
|
||||
this.deviceId = opts.deviceId;
|
||||
this.userId = opts.userId;
|
||||
@@ -59,6 +62,7 @@ export class Device {
|
||||
this.verified = opts.verified || DeviceVerification.Unverified;
|
||||
this.signatures = opts.signatures || new Map();
|
||||
this.displayName = opts.displayName;
|
||||
this.dehydrated = !!opts.dehydrated;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+18
-27
@@ -50,23 +50,31 @@ interface OidcRegistrationRequestBody {
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the client registration request
|
||||
* @param registrationEndpoint - URL as returned from issuer ./well-known/openid-configuration
|
||||
* @param clientMetadata - registration metadata
|
||||
* @returns resolves to the registered client id when registration is successful
|
||||
* @throws An `Error` with `message` set to an entry in {@link OidcError},
|
||||
* when the registration request fails, or the response is invalid.
|
||||
* Attempts dynamic registration against the configured registration endpoint
|
||||
* @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown}
|
||||
* @param clientMetadata - The metadata for the client which to register
|
||||
* @returns Promise<string> resolved with registered clientId
|
||||
* @throws when registration is not supported, on failed request or invalid response
|
||||
*/
|
||||
const doRegistration = async (
|
||||
registrationEndpoint: string,
|
||||
export const registerOidcClient = async (
|
||||
delegatedAuthConfig: OidcClientConfig,
|
||||
clientMetadata: OidcRegistrationClientMetadata,
|
||||
): Promise<string> => {
|
||||
if (!delegatedAuthConfig.registrationEndpoint) {
|
||||
throw new Error(OidcError.DynamicRegistrationNotSupported);
|
||||
}
|
||||
|
||||
const grantTypes: NonEmptyArray<string> = ["authorization_code", "refresh_token"];
|
||||
if (grantTypes.some((scope) => !delegatedAuthConfig.metadata.grant_types_supported.includes(scope))) {
|
||||
throw new Error(OidcError.DynamicRegistrationNotSupported);
|
||||
}
|
||||
|
||||
// https://openid.net/specs/openid-connect-registration-1_0.html
|
||||
const metadata: OidcRegistrationRequestBody = {
|
||||
client_name: clientMetadata.clientName,
|
||||
client_uri: clientMetadata.clientUri,
|
||||
response_types: ["code"],
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
grant_types: grantTypes,
|
||||
redirect_uris: clientMetadata.redirectUris,
|
||||
id_token_signed_response_alg: "RS256",
|
||||
token_endpoint_auth_method: "none",
|
||||
@@ -82,7 +90,7 @@ const doRegistration = async (
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(registrationEndpoint, {
|
||||
const response = await fetch(delegatedAuthConfig.registrationEndpoint, {
|
||||
method: Method.Post,
|
||||
headers,
|
||||
body: JSON.stringify(metadata),
|
||||
@@ -108,20 +116,3 @@ const doRegistration = async (
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts dynamic registration against the configured registration endpoint
|
||||
* @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown}
|
||||
* @param clientMetadata - The metadata for the client which to register
|
||||
* @returns Promise<string> resolved with registered clientId
|
||||
* @throws when registration is not supported, on failed request or invalid response
|
||||
*/
|
||||
export const registerOidcClient = async (
|
||||
delegatedAuthConfig: OidcClientConfig,
|
||||
clientMetadata: OidcRegistrationClientMetadata,
|
||||
): Promise<string> => {
|
||||
if (!delegatedAuthConfig.registrationEndpoint) {
|
||||
throw new Error(OidcError.DynamicRegistrationNotSupported);
|
||||
}
|
||||
return doRegistration(delegatedAuthConfig.registrationEndpoint, clientMetadata);
|
||||
};
|
||||
|
||||
@@ -83,6 +83,7 @@ export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuer
|
||||
requiredStringProperty(wellKnown, "revocation_endpoint"),
|
||||
optionalStringProperty(wellKnown, "registration_endpoint"),
|
||||
optionalStringProperty(wellKnown, "account_management_uri"),
|
||||
optionalStringProperty(wellKnown, "device_authorization_endpoint"),
|
||||
optionalStringArrayProperty(wellKnown, "account_management_actions_supported"),
|
||||
requiredArrayValue(wellKnown, "response_types_supported", "code"),
|
||||
requiredArrayValue(wellKnown, "grant_types_supported", "authorization_code"),
|
||||
@@ -118,6 +119,7 @@ export type ValidatedIssuerMetadata = Partial<OidcMetadata> &
|
||||
| "response_types_supported"
|
||||
| "grant_types_supported"
|
||||
| "code_challenge_methods_supported"
|
||||
| "device_authorization_endpoint"
|
||||
> & {
|
||||
// MSC2965 extensions to the OIDC spec
|
||||
account_management_uri?: string;
|
||||
@@ -176,7 +178,12 @@ const decodeIdToken = (token: string): IdTokenClaims => {
|
||||
* @param nonce - nonce used in the authentication request
|
||||
* @throws when id token is invalid
|
||||
*/
|
||||
export const validateIdToken = (idToken: string | undefined, issuer: string, clientId: string, nonce: string): void => {
|
||||
export const validateIdToken = (
|
||||
idToken: string | undefined,
|
||||
issuer: string,
|
||||
clientId: string,
|
||||
nonce: string | undefined,
|
||||
): void => {
|
||||
try {
|
||||
if (!idToken) {
|
||||
throw new Error("No ID token");
|
||||
@@ -201,7 +208,7 @@ export const validateIdToken = (idToken: string | undefined, issuer: string, cli
|
||||
* If a nonce value was sent in the Authentication Request, a nonce Claim MUST be present and its value checked
|
||||
* to verify that it is the same value as the one that was sent in the Authentication Request.
|
||||
*/
|
||||
if (claims.nonce !== nonce) {
|
||||
if (nonce !== undefined && claims.nonce !== nonce) {
|
||||
throw new Error("Invalid nonce");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
import { encodeUri } from "../utils";
|
||||
import { IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api";
|
||||
import { IToDeviceEvent } from "../sync-accumulator";
|
||||
import { ServerSideSecretStorage } from "../secret-storage";
|
||||
import { crypto } from "../crypto/crypto";
|
||||
import { decodeBase64, encodeUnpaddedBase64 } from "../base64";
|
||||
import { Logger } from "../logger";
|
||||
|
||||
/**
|
||||
* The response body of `GET /_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device`.
|
||||
*/
|
||||
interface DehydratedDeviceResp {
|
||||
device_id: string;
|
||||
device_data: {
|
||||
algorithm: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* The response body of `POST /_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device/events`.
|
||||
*/
|
||||
interface DehydratedDeviceEventsResp {
|
||||
events: IToDeviceEvent[];
|
||||
next_batch: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unstable URL prefix for dehydrated device endpoints
|
||||
*/
|
||||
export const UnstablePrefix = "/_matrix/client/unstable/org.matrix.msc3814.v1";
|
||||
/**
|
||||
* The name used for the dehydration key in Secret Storage
|
||||
*/
|
||||
const SECRET_STORAGE_NAME = "org.matrix.msc3814";
|
||||
|
||||
/**
|
||||
* The interval between creating dehydrated devices. (one week)
|
||||
*/
|
||||
const DEHYDRATION_INTERVAL = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Manages dehydrated devices
|
||||
*
|
||||
* We have one of these per `RustCrypto`. It's responsible for
|
||||
*
|
||||
* * determining server support for dehydrated devices
|
||||
* * creating new dehydrated devices when requested, including periodically
|
||||
* replacing the dehydrated device with a new one
|
||||
* * rehydrating a device when requested, and when present
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class DehydratedDeviceManager {
|
||||
/** the secret key used for dehydrating and rehydrating */
|
||||
private key?: Uint8Array;
|
||||
/** the ID of the interval for periodically replacing the dehydrated device */
|
||||
private intervalId?: ReturnType<typeof setInterval>;
|
||||
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
|
||||
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
|
||||
private readonly secretStorage: ServerSideSecretStorage,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Return whether the server supports dehydrated devices.
|
||||
*/
|
||||
public async isSupported(): Promise<boolean> {
|
||||
// call the endpoint to get a dehydrated device. If it returns an
|
||||
// M_UNRECOGNIZED error, then dehydration is unsupported. If it returns
|
||||
// a successful response, or an M_NOT_FOUND, then dehydration is supported.
|
||||
// Any other exceptions are passed through.
|
||||
try {
|
||||
await this.http.authedRequest<DehydratedDeviceResp>(
|
||||
Method.Get,
|
||||
"/dehydrated_device",
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
prefix: UnstablePrefix,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const err = error as MatrixError;
|
||||
if (err.errcode === "M_UNRECOGNIZED") {
|
||||
return false;
|
||||
} else if (err.errcode === "M_NOT_FOUND") {
|
||||
return true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start using device dehydration.
|
||||
*
|
||||
* - Rehydrates a dehydrated device, if one is available.
|
||||
* - Creates a new dehydration key, if necessary, and stores it in Secret
|
||||
* Storage.
|
||||
* - If `createNewKey` is set to true, always creates a new key.
|
||||
* - If a dehydration key is not available, creates a new one.
|
||||
* - Creates a new dehydrated device, and schedules periodically creating
|
||||
* new dehydrated devices.
|
||||
*
|
||||
* @param createNewKey - whether to force creation of a new dehydration key.
|
||||
* This can be used, for example, if Secret Storage is being reset.
|
||||
*/
|
||||
public async start(createNewKey?: boolean): Promise<void> {
|
||||
this.stop();
|
||||
try {
|
||||
await this.rehydrateDeviceIfAvailable();
|
||||
} catch (e) {
|
||||
// If rehydration fails, there isn't much we can do about it. Log
|
||||
// the error, and create a new device.
|
||||
this.logger.info("dehydration: Error rehydrating device:", e);
|
||||
}
|
||||
if (createNewKey) {
|
||||
await this.resetKey();
|
||||
}
|
||||
await this.scheduleDeviceDehydration();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether the dehydration key is stored in Secret Storage.
|
||||
*/
|
||||
public async isKeyStored(): Promise<boolean> {
|
||||
return Boolean(await this.secretStorage.isStored(SECRET_STORAGE_NAME));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the dehydration key.
|
||||
*
|
||||
* Creates a new key and stores it in secret storage.
|
||||
*/
|
||||
public async resetKey(): Promise<void> {
|
||||
const key = new Uint8Array(32);
|
||||
crypto.getRandomValues(key);
|
||||
await this.secretStorage.store(SECRET_STORAGE_NAME, encodeUnpaddedBase64(key));
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and cache the encryption key from secret storage.
|
||||
*
|
||||
* If `create` is `true`, creates a new key if no existing key is present.
|
||||
*
|
||||
* @returns the key, if available, or `null` if no key is available
|
||||
*/
|
||||
private async getKey(create: boolean): Promise<Uint8Array | null> {
|
||||
if (this.key === undefined) {
|
||||
const keyB64 = await this.secretStorage.get(SECRET_STORAGE_NAME);
|
||||
if (keyB64 === undefined) {
|
||||
if (!create) {
|
||||
return null;
|
||||
}
|
||||
await this.resetKey();
|
||||
} else {
|
||||
this.key = decodeBase64(keyB64);
|
||||
}
|
||||
}
|
||||
return this.key!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate the dehydrated device stored on the server.
|
||||
*
|
||||
* Checks if there is a dehydrated device on the server. If so, rehydrates
|
||||
* the device and processes the to-device events.
|
||||
*
|
||||
* Returns whether or not a dehydrated device was found.
|
||||
*/
|
||||
public async rehydrateDeviceIfAvailable(): Promise<boolean> {
|
||||
const key = await this.getKey(false);
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let dehydratedDeviceResp;
|
||||
try {
|
||||
dehydratedDeviceResp = await this.http.authedRequest<DehydratedDeviceResp>(
|
||||
Method.Get,
|
||||
"/dehydrated_device",
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
prefix: UnstablePrefix,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const err = error as MatrixError;
|
||||
// We ignore M_NOT_FOUND (there is no dehydrated device, so nothing
|
||||
// us to do) and M_UNRECOGNIZED (the server does not understand the
|
||||
// endpoint). We pass through any other errors.
|
||||
if (err.errcode === "M_NOT_FOUND" || err.errcode === "M_UNRECOGNIZED") {
|
||||
this.logger.info("dehydration: No dehydrated device");
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.logger.info("dehydration: dehydrated device found");
|
||||
|
||||
const rehydratedDevice = await this.olmMachine
|
||||
.dehydratedDevices()
|
||||
.rehydrate(
|
||||
key,
|
||||
new RustSdkCryptoJs.DeviceId(dehydratedDeviceResp.device_id),
|
||||
JSON.stringify(dehydratedDeviceResp.device_data),
|
||||
);
|
||||
|
||||
this.logger.info("dehydration: device rehydrated");
|
||||
|
||||
let nextBatch: string | undefined = undefined;
|
||||
let toDeviceCount = 0;
|
||||
let roomKeyCount = 0;
|
||||
const path = encodeUri("/dehydrated_device/$device_id/events", {
|
||||
$device_id: dehydratedDeviceResp.device_id,
|
||||
});
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const eventResp: DehydratedDeviceEventsResp = await this.http.authedRequest<DehydratedDeviceEventsResp>(
|
||||
Method.Post,
|
||||
path,
|
||||
undefined,
|
||||
nextBatch ? { next_batch: nextBatch } : {},
|
||||
{
|
||||
prefix: UnstablePrefix,
|
||||
},
|
||||
);
|
||||
|
||||
if (eventResp.events.length === 0) {
|
||||
break;
|
||||
}
|
||||
toDeviceCount += eventResp.events.length;
|
||||
nextBatch = eventResp.next_batch;
|
||||
const roomKeyInfos = await rehydratedDevice.receiveEvents(JSON.stringify(eventResp.events));
|
||||
roomKeyCount += roomKeyInfos.length;
|
||||
}
|
||||
this.logger.info(`dehydration: received ${roomKeyCount} room keys from ${toDeviceCount} to-device events`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and uploads a new dehydrated device.
|
||||
*
|
||||
* Creates and stores a new key in secret storage if none is available.
|
||||
*/
|
||||
public async createAndUploadDehydratedDevice(): Promise<void> {
|
||||
const key = (await this.getKey(true))!;
|
||||
|
||||
const dehydratedDevice = await this.olmMachine.dehydratedDevices().create();
|
||||
const request = await dehydratedDevice.keysForUpload("Dehydrated device", key);
|
||||
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(request);
|
||||
|
||||
this.logger.info("dehydration: uploaded device");
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule periodic creation of dehydrated devices.
|
||||
*/
|
||||
public async scheduleDeviceDehydration(): Promise<void> {
|
||||
// cancel any previously-scheduled tasks
|
||||
this.stop();
|
||||
|
||||
await this.createAndUploadDehydratedDevice();
|
||||
this.intervalId = setInterval(() => {
|
||||
this.createAndUploadDehydratedDevice().catch((error) => {
|
||||
this.logger.error("Error creating dehydrated device:", error);
|
||||
});
|
||||
}, DEHYDRATION_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the dehydrated device manager.
|
||||
*
|
||||
* Cancels any scheduled dehydration tasks.
|
||||
*/
|
||||
public stop(): void {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
KeysQueryRequest,
|
||||
KeysUploadRequest,
|
||||
OlmMachine,
|
||||
PutDehydratedDeviceRequest,
|
||||
RoomMessageRequest,
|
||||
SignatureUploadRequest,
|
||||
ToDeviceRequest,
|
||||
@@ -32,6 +33,7 @@ import { logDuration, QueryDict, sleep } from "../utils";
|
||||
import { AuthDict, UIAuthCallback } from "../interactive-auth";
|
||||
import { UIAResponse } from "../@types/uia";
|
||||
import { ToDeviceMessageId } from "../@types/event";
|
||||
import { UnstablePrefix as DehydrationUnstablePrefix } from "./DehydratedDeviceManager";
|
||||
|
||||
/**
|
||||
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
|
||||
@@ -62,7 +64,7 @@ export class OutgoingRequestProcessor {
|
||||
) {}
|
||||
|
||||
public async makeOutgoingRequest<T>(
|
||||
msg: OutgoingRequest | UploadSigningKeysRequest,
|
||||
msg: OutgoingRequest | UploadSigningKeysRequest | PutDehydratedDeviceRequest,
|
||||
uiaCallback?: UIAuthCallback<T>,
|
||||
): Promise<void> {
|
||||
let resp: string;
|
||||
@@ -102,6 +104,11 @@ export class OutgoingRequestProcessor {
|
||||
);
|
||||
// SigningKeysUploadRequest does not implement OutgoingRequest and does not need to be marked as sent.
|
||||
return;
|
||||
} else if (msg instanceof PutDehydratedDeviceRequest) {
|
||||
const path = DehydrationUnstablePrefix + "/dehydrated_device";
|
||||
await this.rawJsonRequest(Method.Put, path, {}, msg.body);
|
||||
// PutDehydratedDeviceRequest does not implement OutgoingRequest and does not need to be marked as sent.
|
||||
return;
|
||||
} else {
|
||||
logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
|
||||
resp = "";
|
||||
|
||||
@@ -80,6 +80,7 @@ export function rustDeviceToJsDevice(device: RustSdkCryptoJs.Device, userId: Rus
|
||||
verified,
|
||||
signatures,
|
||||
displayName: device.displayName,
|
||||
dehydrated: device.isDehydrated,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,10 @@ import { decryptAES, IEncryptedPayload } from "../crypto/aes";
|
||||
import { IHttpOpts, MatrixHttpApi } from "../http-api";
|
||||
import { requestKeyBackupVersion } from "./backup";
|
||||
import { IRoomEncryption } from "../crypto/RoomList";
|
||||
import { CrossSigningKeyInfo } from "../crypto-api";
|
||||
import { CrossSigningKeyInfo, Curve25519AuthData } from "../crypto-api";
|
||||
import { RustCrypto } from "./rust-crypto";
|
||||
import { KeyBackupInfo } from "../crypto-api/keybackup";
|
||||
import { sleep } from "../utils";
|
||||
|
||||
/**
|
||||
* Determine if any data needs migrating from the legacy store, and do so.
|
||||
@@ -105,7 +107,7 @@ export async function migrateFromLegacyCrypto(args: {
|
||||
|
||||
if (migrationState === MigrationState.NOT_STARTED) {
|
||||
logger.info("Migrating data from legacy crypto store. Step 1: base data");
|
||||
await migrateBaseData(args.http, args.userId, args.deviceId, legacyStore, pickleKey, args.storeHandle);
|
||||
await migrateBaseData(args.http, args.userId, args.deviceId, legacyStore, pickleKey, args.storeHandle, logger);
|
||||
|
||||
migrationState = MigrationState.INITIAL_DATA_MIGRATED;
|
||||
await legacyStore.setMigrationState(migrationState);
|
||||
@@ -144,6 +146,7 @@ async function migrateBaseData(
|
||||
legacyStore: CryptoStore,
|
||||
pickleKey: Uint8Array,
|
||||
storeHandle: RustSdkCryptoJs.StoreHandle,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
const migrationData = new RustSdkCryptoJs.BaseMigrationData();
|
||||
migrationData.userId = new RustSdkCryptoJs.UserId(userId);
|
||||
@@ -158,12 +161,41 @@ async function migrateBaseData(
|
||||
const recoveryKey = await getAndDecryptCachedSecretKey(legacyStore, pickleKey, "m.megolm_backup.v1");
|
||||
|
||||
// If we have a backup recovery key, we need to try to figure out which backup version it is for.
|
||||
// All we can really do is ask the server for the most recent version.
|
||||
// All we can really do is ask the server for the most recent version and check if the cached key we have matches.
|
||||
// It is possible that the backup has changed since last time his session was opened.
|
||||
if (recoveryKey) {
|
||||
const backupInfo = await requestKeyBackupVersion(http);
|
||||
if (backupInfo) {
|
||||
migrationData.backupVersion = backupInfo.version;
|
||||
migrationData.backupRecoveryKey = recoveryKey;
|
||||
let backupCallDone = false;
|
||||
let backupInfo: KeyBackupInfo | null = null;
|
||||
while (!backupCallDone) {
|
||||
try {
|
||||
backupInfo = await requestKeyBackupVersion(http);
|
||||
backupCallDone = true;
|
||||
} catch (e) {
|
||||
logger.info("Failed to get backup version during migration, retrying in 2 seconds", e);
|
||||
// Retry until successful, use simple constant delay
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
if (backupInfo && backupInfo.algorithm == "m.megolm_backup.v1.curve25519-aes-sha2") {
|
||||
// check if the recovery key matches, as the active backup version may have changed since the key was cached
|
||||
// and the migration started.
|
||||
try {
|
||||
const decryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(recoveryKey);
|
||||
const publicKey = (backupInfo.auth_data as Curve25519AuthData)?.public_key;
|
||||
const isValid = decryptionKey.megolmV1PublicKey.publicKeyBase64 == publicKey;
|
||||
if (isValid) {
|
||||
migrationData.backupVersion = backupInfo.version;
|
||||
migrationData.backupRecoveryKey = recoveryKey;
|
||||
} else {
|
||||
logger.debug(
|
||||
"The backup key to migrate does not match the active backup version",
|
||||
`Cached pub key: ${decryptionKey.megolmV1PublicKey.publicKeyBase64}`,
|
||||
`Active pub key: ${publicKey}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn("Failed to check if the backup key to migrate matches the active backup version", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ import { ISignatures } from "../@types/signed";
|
||||
import { encodeBase64 } from "../base64";
|
||||
import { OutgoingRequestsManager } from "./OutgoingRequestsManager";
|
||||
import { PerSessionKeyBackupDownloader } from "./PerSessionKeyBackupDownloader";
|
||||
import { DehydratedDeviceManager } from "./DehydratedDeviceManager";
|
||||
import { VerificationMethod } from "../types";
|
||||
|
||||
const ALL_VERIFICATION_METHODS = [
|
||||
@@ -107,9 +108,8 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
private crossSigningIdentity: CrossSigningIdentity;
|
||||
private readonly backupManager: RustBackupManager;
|
||||
private outgoingRequestsManager: OutgoingRequestsManager;
|
||||
|
||||
private readonly perSessionBackupDownloader: PerSessionKeyBackupDownloader;
|
||||
|
||||
private readonly dehydratedDeviceManager: DehydratedDeviceManager;
|
||||
private readonly reemitter = new TypedReEmitter<RustCryptoEvents, RustCryptoEventMap>(this);
|
||||
|
||||
public constructor(
|
||||
@@ -148,14 +148,19 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
|
||||
|
||||
this.backupManager = new RustBackupManager(olmMachine, http, this.outgoingRequestProcessor);
|
||||
|
||||
this.perSessionBackupDownloader = new PerSessionKeyBackupDownloader(
|
||||
this.logger,
|
||||
this.olmMachine,
|
||||
this.http,
|
||||
this.backupManager,
|
||||
);
|
||||
|
||||
this.dehydratedDeviceManager = new DehydratedDeviceManager(
|
||||
this.logger,
|
||||
olmMachine,
|
||||
http,
|
||||
this.outgoingRequestProcessor,
|
||||
secretStorage,
|
||||
);
|
||||
this.eventDecryptor = new EventDecryptor(this.logger, olmMachine, this.perSessionBackupDownloader);
|
||||
|
||||
this.reemitter.reEmit(this.backupManager, [
|
||||
@@ -212,6 +217,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
this.backupManager.stop();
|
||||
this.outgoingRequestsManager.stop();
|
||||
this.perSessionBackupDownloader.stop();
|
||||
this.dehydratedDeviceManager.stop();
|
||||
|
||||
// make sure we close() the OlmMachine; doing so means that all the Rust objects will be
|
||||
// cleaned up; in particular, the indexeddb connections will be closed, which means they
|
||||
@@ -1212,6 +1218,23 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
return await this.backupManager.importBackedUpRoomKeys(keys, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#isDehydrationSupported}.
|
||||
*/
|
||||
public async isDehydrationSupported(): Promise<boolean> {
|
||||
return await this.dehydratedDeviceManager.isSupported();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#startDehydration}.
|
||||
*/
|
||||
public async startDehydration(createNewKey?: boolean): Promise<void> {
|
||||
if (!(await this.isCrossSigningReady()) || !(await this.isSecretStorageReady())) {
|
||||
throw new Error("Device dehydration requires cross-signing and secret storage to be set up");
|
||||
}
|
||||
return await this.dehydratedDeviceManager.start(createNewKey);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// SyncCryptoCallbacks implementation
|
||||
|
||||
Reference in New Issue
Block a user