Compare commits
86 Commits
v29.0.0-rc.1
...
v30.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 430e6cae94 | |||
| e01a1d533c | |||
| 46a6a76a41 | |||
| fe3f969698 | |||
| 96c6c99644 | |||
| 55230dd0ea | |||
| 5707b48fd2 | |||
| 8ac918c10f | |||
| 1cd8bed705 | |||
| e0dacf7529 | |||
| 29d9bdac61 | |||
| 88d066a10c | |||
| ce7b7bf44f | |||
| 07a9eb3c96 | |||
| f8f22a3edd | |||
| 084beaa947 | |||
| 73a87652fe | |||
| 4a4b454f27 | |||
| 6f82f08c7b | |||
| c41949de15 | |||
| f941fd896e | |||
| d750e33ec9 | |||
| a370442328 | |||
| bddf2b9682 | |||
| 74a2e694c3 | |||
| 748d03ba11 | |||
| 2f3f0b340e | |||
| 12e479a93e | |||
| 6e2ac03f7e | |||
| 6359e10bcf | |||
| b3a2b8b8c4 | |||
| 30a9119e31 | |||
| 7a52dba86c | |||
| d6177cdfc9 | |||
| c4f3fd3289 | |||
| 31f38550e3 | |||
| 0643f38592 | |||
| c0264954ed | |||
| 7501e28dec | |||
| febc4c9ad6 | |||
| 6b1d53cc14 | |||
| 04fcd5880b | |||
| 4bcea2cead | |||
| 6468d79458 | |||
| a871376350 | |||
| 6beb693616 | |||
| 11661bbc8d | |||
| 2d57f28d5a | |||
| c52f857599 | |||
| 5d016c1e4f | |||
| 9f04c0555c | |||
| 9293986e3b | |||
| 8426d8cae1 | |||
| 3baf6ec2c6 | |||
| 38cd6f93e6 | |||
| a3a6742c67 | |||
| 4ce837b20e | |||
| 884bd2585a | |||
| c306d87f80 | |||
| b94d137398 | |||
| 5595e8497f | |||
| 5d233f3863 | |||
| 0f4fa5ad51 | |||
| 1de6de05a1 | |||
| c8f8fb587d | |||
| 2f79e6c056 | |||
| 42be793a56 | |||
| 7c2a12085c | |||
| 3cf6f568f3 | |||
| 4db08cb78e | |||
| 25e5d79cf6 | |||
| 6c8e3d0707 | |||
| 3139f5729b | |||
| bb8a894105 | |||
| 223dfffdfb | |||
| f19f0a8793 | |||
| a5224c1820 | |||
| 513201b9c1 | |||
| 02ca5c78cf | |||
| af63d9bd05 | |||
| 95baccfbc1 | |||
| 10b6c2463d | |||
| 6e8d15e5ed | |||
| 2e4276437a | |||
| 6a761af867 | |||
| 6eec2ceeeb |
@@ -20,7 +20,7 @@ jobs:
|
||||
# from creeping in. They take a long time to run and consume 4 concurrent runners.
|
||||
if: github.event.workflow_run.event == 'merge_group'
|
||||
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@66854039a33ed6cfe1fc635ff2daa8bb261c0b56
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@v3.83.0-rc.1
|
||||
permissions:
|
||||
actions: read
|
||||
issues: read
|
||||
|
||||
@@ -20,7 +20,7 @@ concurrency:
|
||||
jobs:
|
||||
build-element-web:
|
||||
name: Build element-web
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.80.1
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.82.0
|
||||
with:
|
||||
matrix-js-sdk-sha: ${{ github.sha }}
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
if: github.event.action == 'opened'
|
||||
steps:
|
||||
- name: Check membership
|
||||
uses: tspascoal/get-user-teams-membership@37c08f7b52a72ca95d12af2e7ab2553ca9adf13b # v2
|
||||
uses: tspascoal/get-user-teams-membership@ba78054988f58bea69b7c6136d563236f8ed2fc0 # v3
|
||||
id: teams
|
||||
with:
|
||||
username: ${{ github.event.pull_request.user.login }}
|
||||
|
||||
@@ -12,18 +12,19 @@ env:
|
||||
ENABLE_COVERAGE: ${{ github.event_name != 'merge_group' }}
|
||||
jobs:
|
||||
jest:
|
||||
name: "Jest [${{ matrix.specs }}] (Node ${{ matrix.node }})"
|
||||
name: "Jest [${{ matrix.specs }}] (Node ${{ matrix.node == '*' && 'latest' || matrix.node }})"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
specs: [integ, unit]
|
||||
node: [18, latest]
|
||||
node: [18, "*"]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
id: setupNode
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
cache: "yarn"
|
||||
@@ -51,7 +52,7 @@ jobs:
|
||||
|
||||
- name: Move coverage files into place
|
||||
if: env.ENABLE_COVERAGE == 'true'
|
||||
run: mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
|
||||
run: mv coverage/lcov.info coverage/${{ steps.setupNode.output.node-version }}-${{ matrix.specs }}.lcov.info
|
||||
|
||||
- name: Upload Artifact
|
||||
if: env.ENABLE_COVERAGE == 'true'
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
name: Upgrade Dependencies
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
workflow_call:
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN:
|
||||
required: true
|
||||
jobs:
|
||||
upgrade:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
cache: "yarn"
|
||||
|
||||
- name: Upgrade
|
||||
run: yarn upgrade && yarn install
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 # v5
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
branch: actions/upgrade-deps
|
||||
delete-branch: true
|
||||
title: Upgrade dependencies
|
||||
labels: |
|
||||
Dependencies
|
||||
T-Task
|
||||
|
||||
- name: Enable automerge
|
||||
run: gh pr merge --merge --auto "$PR_NUMBER"
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"*.(ts|tsx)": ["eslint --fix", "prettier --write"],
|
||||
"*.(py|md|yaml)": ["prettier --write"]
|
||||
}
|
||||
+49
-2
@@ -1,5 +1,52 @@
|
||||
Changes in [29.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v29.0.0-rc.1) (2023-10-03)
|
||||
============================================================================================================
|
||||
Changes in [30.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30.0.1) (2023-11-13)
|
||||
==================================================================================================
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Ensure `setUserCreator` is called when a store is assigned ([\#3867](https://github.com/matrix-org/matrix-js-sdk/pull/3867)). Fixes vector-im/element-web#26520. Contributed by @MidhunSureshR.
|
||||
|
||||
Changes in [30.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30.0.0) (2023-11-07)
|
||||
==================================================================================================
|
||||
|
||||
## 🚨 BREAKING CHANGES
|
||||
* Refactor & make base64 functions browser-safe ([\#3818](https://github.com/matrix-org/matrix-js-sdk/pull/3818)).
|
||||
|
||||
## 🦖 Deprecations
|
||||
* Deprecate `MatrixEvent.toJSON` ([\#3801](https://github.com/matrix-org/matrix-js-sdk/pull/3801)).
|
||||
|
||||
## ✨ Features
|
||||
* Element-R: Add the git sha of the binding crate to `CryptoApi#getVersion` ([\#3838](https://github.com/matrix-org/matrix-js-sdk/pull/3838)). Contributed by @florianduros.
|
||||
* Element-R: Wire up `globalBlacklistUnverifiedDevices` field to rust crypto encryption settings ([\#3790](https://github.com/matrix-org/matrix-js-sdk/pull/3790)). Fixes vector-im/element-web#26315. Contributed by @florianduros.
|
||||
* Element-R: Wire up room rotation ([\#3807](https://github.com/matrix-org/matrix-js-sdk/pull/3807)). Fixes vector-im/element-web#26318. Contributed by @florianduros.
|
||||
* Element-R: Add current version of the rust-sdk and vodozemac ([\#3825](https://github.com/matrix-org/matrix-js-sdk/pull/3825)). Contributed by @florianduros.
|
||||
* Element-R: Wire up room history visibility ([\#3805](https://github.com/matrix-org/matrix-js-sdk/pull/3805)). Fixes vector-im/element-web#26319. Contributed by @florianduros.
|
||||
* Element-R: log when we send to-device messages ([\#3810](https://github.com/matrix-org/matrix-js-sdk/pull/3810)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix reemitter not being correctly wired on user objects created in storage classes ([\#3796](https://github.com/matrix-org/matrix-js-sdk/pull/3796)). Contributed by @MidhunSureshR.
|
||||
* Element-R: silence log errors when viewing a pending event ([\#3824](https://github.com/matrix-org/matrix-js-sdk/pull/3824)).
|
||||
* Don't emit a closed event if the indexeddb is closed by Element ([\#3832](https://github.com/matrix-org/matrix-js-sdk/pull/3832)). Fixes vector-im/element-web#25941. Contributed by @dhenneke.
|
||||
* Element-R: silence log errors when viewing a decryption failure ([\#3821](https://github.com/matrix-org/matrix-js-sdk/pull/3821)).
|
||||
|
||||
Changes in [29.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v29.1.0) (2023-10-24)
|
||||
==================================================================================================
|
||||
|
||||
## ✨ Features
|
||||
* OIDC: refresh tokens ([\#3764](https://github.com/matrix-org/matrix-js-sdk/pull/3764)). Contributed by @kerryarchibald.
|
||||
* OIDC: add `prompt` param to auth url creation ([\#3794](https://github.com/matrix-org/matrix-js-sdk/pull/3794)). Contributed by @kerryarchibald.
|
||||
* Allow applications to specify their own logger instance ([\#3792](https://github.com/matrix-org/matrix-js-sdk/pull/3792)). Fixes #1899.
|
||||
* Export AutoDiscoveryError and fix type of ALL_ERRORS ([\#3768](https://github.com/matrix-org/matrix-js-sdk/pull/3768)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix sending call member events on leave ([\#3799](https://github.com/matrix-org/matrix-js-sdk/pull/3799)). Fixes vector-im/element-call#1763.
|
||||
* Don't use event.sender in CallMembership ([\#3793](https://github.com/matrix-org/matrix-js-sdk/pull/3793)).
|
||||
* Element-R: Don't mark QR code verification as done until it's done ([\#3791](https://github.com/matrix-org/matrix-js-sdk/pull/3791)). Fixes vector-im/element-web#26293.
|
||||
* Element-R: Connect device to key backup when crypto is created ([\#3784](https://github.com/matrix-org/matrix-js-sdk/pull/3784)). Fixes vector-im/element-web#26316. Contributed by @florianduros.
|
||||
* Element-R: Avoid errors in `VerificationRequest.generateQRCode` when QR code is unavailable ([\#3779](https://github.com/matrix-org/matrix-js-sdk/pull/3779)). Fixes vector-im/element-web#26300. Contributed by @florianduros.
|
||||
* ElementR: Check key backup when user identity changes ([\#3760](https://github.com/matrix-org/matrix-js-sdk/pull/3760)). Fixes vector-im/element-web#26244. Contributed by @florianduros.
|
||||
* Element-R: emit `VerificationRequestReceived` on incoming request ([\#3762](https://github.com/matrix-org/matrix-js-sdk/pull/3762)). Fixes vector-im/element-web#26245.
|
||||
|
||||
Changes in [29.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v29.0.0) (2023-10-10)
|
||||
==================================================================================================
|
||||
|
||||
## 🚨 BREAKING CHANGES
|
||||
* Remove browserify builds ([\#3759](https://github.com/matrix-org/matrix-js-sdk/pull/3759)).
|
||||
|
||||
@@ -351,7 +351,7 @@ First, you need to pull in the right build tools:
|
||||
|
||||
## Building
|
||||
|
||||
To build a browser version from scratch when developing::
|
||||
To build a browser version from scratch when developing:
|
||||
|
||||
```
|
||||
$ yarn build
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Summary
|
||||
|
||||
- [Introduction](../README.md)
|
||||
|
||||
# Deep dive
|
||||
|
||||
- [Storage notes](storage-notes.md)
|
||||
- [Unverified devices](warning-on-unverified-devices.md)
|
||||
@@ -1,31 +1,29 @@
|
||||
Random notes from Matthew on the two possible approaches for warning users about unexpected
|
||||
unverified devices popping up in their rooms....
|
||||
|
||||
Original idea...
|
||||
================
|
||||
# Original idea...
|
||||
|
||||
Warn when an existing user adds an unknown device to a room.
|
||||
|
||||
Warn when a user joins the room with unverified or unknown devices.
|
||||
|
||||
Warn when you initial sync if the room has any unverified devices in it.
|
||||
^ this is good enough if we're doing local storage.
|
||||
OR, better:
|
||||
^ this is good enough if we're doing local storage.
|
||||
OR, better:
|
||||
Warn when you initial sync if the room has any new undefined devices since you were last there.
|
||||
=> This means persisting the rooms that devices are in, across initial syncs.
|
||||
=> This means persisting the rooms that devices are in, across initial syncs.
|
||||
|
||||
|
||||
Updated idea...
|
||||
===============
|
||||
# Updated idea...
|
||||
|
||||
Warn when the user tries to send a message:
|
||||
- If the room has unverified devices which the user has not yet been told about in the context of this room
|
||||
...or in the context of this user? currently all verification is per-user, not per-room.
|
||||
|
||||
- If the room has unverified devices which the user has not yet been told about in the context of this room
|
||||
...or in the context of this user? currently all verification is per-user, not per-room.
|
||||
...this should be good enough.
|
||||
|
||||
- so track whether we have warned the user or not about unverified devices - blocked, unverified, verified, unverified_warned.
|
||||
- so track whether we have warned the user or not about unverified devices - blocked, unverified, verified, unverified_warned.
|
||||
throw an error when trying to encrypt if there are pure unverified devices there
|
||||
app will have to search for the devices which are pure unverified to warn about them - have to do this from MembersList anyway?
|
||||
- or megolm could warn which devices are causing the problems.
|
||||
- or megolm could warn which devices are causing the problems.
|
||||
|
||||
Why do we wait to establish outbound sessions? It just makes a horrible pause when we first try to send a message... but could otherwise unnecessarily consume resources?
|
||||
Why do we wait to establish outbound sessions? It just makes a horrible pause when we first try to send a message... but could otherwise unnecessarily consume resources?
|
||||
+8
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "29.0.0-rc.1",
|
||||
"version": "30.0.1",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -51,7 +51,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^1.2.3-alpha.0",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^2.2.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^5.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
@@ -78,8 +78,8 @@
|
||||
"@babel/preset-env": "^7.12.11",
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@babel/register": "^7.12.10",
|
||||
"@casualbot/jest-sonar-reporter": "^2.2.5",
|
||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz",
|
||||
"@casualbot/jest-sonar-reporter": "2.2.7",
|
||||
"@matrix-org/olm": "3.2.15",
|
||||
"@types/bs58": "^4.0.1",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/debug": "^4.1.7",
|
||||
@@ -95,7 +95,7 @@
|
||||
"babelify": "^10.0.0",
|
||||
"debug": "^4.3.4",
|
||||
"domexception": "^4.0.0",
|
||||
"eslint": "8.49.0",
|
||||
"eslint": "8.51.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
@@ -106,12 +106,14 @@
|
||||
"eslint-plugin-tsdoc": "^0.2.17",
|
||||
"eslint-plugin-unicorn": "^48.0.0",
|
||||
"exorcist": "^2.0.0",
|
||||
"fake-indexeddb": "^4.0.0",
|
||||
"fake-indexeddb": "^5.0.0",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"husky": "^8.0.3",
|
||||
"jest": "^29.0.0",
|
||||
"jest-environment-jsdom": "^29.0.0",
|
||||
"jest-localstorage-mock": "^2.4.6",
|
||||
"jest-mock": "^29.0.0",
|
||||
"lint-staged": "^15.0.2",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"prettier": "2.8.8",
|
||||
"rimraf": "^5.0.0",
|
||||
|
||||
@@ -23,7 +23,14 @@ import { MockResponse, MockResponseFunction } from "fetch-mock";
|
||||
import Olm from "@matrix-org/olm";
|
||||
|
||||
import * as testUtils from "../../test-utils/test-utils";
|
||||
import { CRYPTO_BACKENDS, getSyncResponse, InitCrypto, syncPromise } from "../../test-utils/test-utils";
|
||||
import {
|
||||
advanceTimersUntil,
|
||||
CRYPTO_BACKENDS,
|
||||
getSyncResponse,
|
||||
InitCrypto,
|
||||
mkEventCustom,
|
||||
syncPromise,
|
||||
} from "../../test-utils/test-utils";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import {
|
||||
BOB_SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
@@ -37,6 +44,7 @@ import {
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { logger } from "../../../src/logger";
|
||||
import {
|
||||
Category,
|
||||
ClientEvent,
|
||||
createClient,
|
||||
CryptoEvent,
|
||||
@@ -53,11 +61,12 @@ import {
|
||||
Room,
|
||||
RoomMember,
|
||||
RoomStateEvent,
|
||||
HistoryVisibility,
|
||||
} from "../../../src/matrix";
|
||||
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { escapeRegExp } from "../../../src/utils";
|
||||
import { defer, escapeRegExp } from "../../../src/utils";
|
||||
import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import {
|
||||
@@ -66,13 +75,19 @@ import {
|
||||
mockSetupMegolmBackupRequests,
|
||||
} from "../../test-utils/mockEndpoints";
|
||||
import { AddSecretStorageKeyOpts } from "../../../src/secret-storage";
|
||||
import { CrossSigningKey, CryptoCallbacks, KeyBackupInfo } from "../../../src/crypto-api";
|
||||
import {
|
||||
CrossSigningKey,
|
||||
CryptoCallbacks,
|
||||
EventShieldColour,
|
||||
EventShieldReason,
|
||||
KeyBackupInfo,
|
||||
} from "../../../src/crypto-api";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { DecryptionError } from "../../../src/crypto/algorithms";
|
||||
import { IKeyBackup } from "../../../src/crypto/backup";
|
||||
import {
|
||||
createOlmSession,
|
||||
createOlmAccount,
|
||||
createOlmSession,
|
||||
encryptGroupSessionKey,
|
||||
encryptMegolmEvent,
|
||||
encryptMegolmEventRawPlainText,
|
||||
@@ -80,6 +95,7 @@ import {
|
||||
establishOlmSession,
|
||||
getTestOlmAccountKeys,
|
||||
} from "./olm-utils";
|
||||
import { ToDevicePayload } from "../../../src/models/ToDeviceMessage";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
@@ -146,6 +162,27 @@ async function expectSendRoomKey(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the event received on rooms/{roomId}/send/m.room.encrypted endpoint.
|
||||
* See https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid
|
||||
* @returns the content of the encrypted event
|
||||
*/
|
||||
function expectEncryptedSendMessage() {
|
||||
return new Promise<IContent>((resolve) => {
|
||||
fetchMock.putOnce(
|
||||
new RegExp("/send/m.room.encrypted/"),
|
||||
(url, request) => {
|
||||
const content = JSON.parse(request.body as string);
|
||||
resolve(content);
|
||||
return { event_id: "$event_id" };
|
||||
},
|
||||
// append to the list of intercepts on this path (since we have some tests that call
|
||||
// this function multiple times)
|
||||
{ overwriteRoutes: false },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Expect that the client sends an encrypted event
|
||||
*
|
||||
@@ -159,22 +196,7 @@ async function expectSendRoomKey(
|
||||
async function expectSendMegolmMessage(
|
||||
inboundGroupSessionPromise: Promise<Olm.InboundGroupSession>,
|
||||
): Promise<Partial<IEvent>> {
|
||||
const encryptedMessageContent = await new Promise<IContent>((resolve) => {
|
||||
fetchMock.putOnce(
|
||||
new RegExp("/send/m.room.encrypted/"),
|
||||
(url: string, opts: RequestInit): MockResponse => {
|
||||
resolve(JSON.parse(opts.body as string));
|
||||
return {
|
||||
event_id: "$event_id",
|
||||
};
|
||||
},
|
||||
{
|
||||
// append to the list of intercepts on this path (since we have some tests that call
|
||||
// this function multiple times)
|
||||
overwriteRoutes: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
const encryptedMessageContent = await expectEncryptedSendMessage();
|
||||
|
||||
// In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now.
|
||||
const inboundGroupSession = await inboundGroupSessionPromise;
|
||||
@@ -922,6 +944,210 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should send a m.unverified code in toDevice messages to an unverified device when globalBlacklistUnverifiedDevices=true", async () => {
|
||||
aliceClient.getCrypto()!.globalBlacklistUnverifiedDevices = true;
|
||||
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount);
|
||||
|
||||
// Tell alice we share a room with bob
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"]));
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// Force alice to download bob keys
|
||||
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
|
||||
|
||||
// Wait to receive the toDevice message and return bob device content
|
||||
const toDevicePromise = new Promise<ToDevicePayload>((resolve) => {
|
||||
fetchMock.putOnce(new RegExp("/sendToDevice/m.room_key.withheld/"), (url, request) => {
|
||||
const content = JSON.parse(request.body as string);
|
||||
resolve(content.messages["@bob:xyz"]["DEVICE_ID"]);
|
||||
return {};
|
||||
});
|
||||
});
|
||||
|
||||
// Mock endpoint of message sending
|
||||
fetchMock.put(new RegExp("/send/"), { event_id: "$event_id" });
|
||||
|
||||
await aliceClient.sendTextMessage(ROOM_ID, "test");
|
||||
|
||||
// Finally, check that the toDevice message has the m.unverified code
|
||||
const toDeviceContent = await toDevicePromise;
|
||||
expect(toDeviceContent.code).toBe("m.unverified");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Session should rotate according to encryption settings", () => {
|
||||
/**
|
||||
* Send a message to bob and get the encrypted message
|
||||
* @returns {Promise<IContent>} The encrypted message
|
||||
*/
|
||||
async function sendEncryptedMessage(): Promise<IContent> {
|
||||
const [encryptedMessage] = await Promise.all([
|
||||
expectEncryptedSendMessage(),
|
||||
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
||||
]);
|
||||
return encryptedMessage;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
newBackendOnly("should rotate the session after 2 messages", async () => {
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
const p2pSession = await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount);
|
||||
|
||||
const syncResponse = getSyncResponse(["@bob:xyz"]);
|
||||
// Every 2 messages in the room, the session should be rotated
|
||||
syncResponse.rooms[Category.Join][ROOM_ID].state.events[0].content = {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
rotation_period_msgs: 2,
|
||||
};
|
||||
|
||||
// Tell alice we share a room with bob
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// Force alice to download bob keys
|
||||
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
|
||||
|
||||
// Send a message to bob and get the encrypted message
|
||||
const [encryptedMessage] = await Promise.all([
|
||||
sendEncryptedMessage(),
|
||||
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
|
||||
]);
|
||||
|
||||
// Check that the session id exists
|
||||
const sessionId = encryptedMessage.session_id;
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
// Send a second message to bob and get the current message
|
||||
const secondEncryptedMessage = await sendEncryptedMessage();
|
||||
|
||||
// Check that the same session id is shared between the two messages
|
||||
const secondSessionId = secondEncryptedMessage.session_id;
|
||||
expect(secondSessionId).toBe(sessionId);
|
||||
|
||||
// The session should be rotated, we are expecting the room key to be sent
|
||||
const [thirdEncryptedMessage] = await Promise.all([
|
||||
sendEncryptedMessage(),
|
||||
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
|
||||
]);
|
||||
|
||||
// The session is rotated every 2 messages, we should have a new session id
|
||||
const thirdSessionId = thirdEncryptedMessage.session_id;
|
||||
expect(thirdSessionId).not.toBe(sessionId);
|
||||
});
|
||||
|
||||
newBackendOnly("should rotate the session after 1h", async () => {
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
const p2pSession = await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount);
|
||||
|
||||
// We need to fake the timers to advance the time
|
||||
jest.useFakeTimers();
|
||||
|
||||
const syncResponse = getSyncResponse(["@bob:xyz"]);
|
||||
|
||||
// The minimum rotation period is 1h
|
||||
// https://github.com/matrix-org/matrix-rust-sdk/blob/f75b2cd1d0981db42751dadb08c826740af1018e/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs#L410-L415
|
||||
const oneHourInMs = 60 * 60 * 1000;
|
||||
|
||||
// Every 1h the session should be rotated
|
||||
syncResponse.rooms[Category.Join][ROOM_ID].state.events[0].content = {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
rotation_period_ms: oneHourInMs,
|
||||
};
|
||||
|
||||
// Tell alice we share a room with bob
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// Force alice to download bob keys
|
||||
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
|
||||
|
||||
// Send a message to bob and get the encrypted message
|
||||
const [encryptedMessage] = await Promise.all([
|
||||
sendEncryptedMessage(),
|
||||
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
|
||||
]);
|
||||
|
||||
// Check that the session id exists
|
||||
const sessionId = encryptedMessage.session_id;
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
// Advance the time by 1h
|
||||
jest.advanceTimersByTime(oneHourInMs);
|
||||
|
||||
// Send a second message to bob and get the encrypted message
|
||||
const [secondEncryptedMessage] = await Promise.all([
|
||||
sendEncryptedMessage(),
|
||||
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
|
||||
]);
|
||||
|
||||
// The session should be rotated
|
||||
const secondSessionId = secondEncryptedMessage.session_id;
|
||||
expect(secondSessionId).not.toBe(sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
newBackendOnly("should rotate the session when the history visibility changes", async () => {
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
const p2pSession = await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount);
|
||||
|
||||
// Tell alice we share a room with bob
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"]));
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// Force alice to download bob keys
|
||||
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
|
||||
|
||||
// Send a message to bob and get the current session id
|
||||
let [, , encryptedMessage] = await Promise.all([
|
||||
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
||||
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
|
||||
expectEncryptedSendMessage(),
|
||||
]);
|
||||
|
||||
// Check that the session id exists
|
||||
const sessionId = encryptedMessage.session_id;
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
// Change history visibility in sync response
|
||||
const syncResponse = getSyncResponse([]);
|
||||
syncResponse.rooms[Category.Join][ROOM_ID].timeline.events.push(
|
||||
mkEventCustom({
|
||||
sender: TEST_USER_ID,
|
||||
type: "m.room.history_visibility",
|
||||
state_key: "",
|
||||
content: {
|
||||
history_visibility: HistoryVisibility.Invited,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Update the new visibility
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// Resend a message to bob and get the new session id
|
||||
[, , encryptedMessage] = await Promise.all([
|
||||
aliceClient.sendTextMessage(ROOM_ID, "test"),
|
||||
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
|
||||
expectEncryptedSendMessage(),
|
||||
]);
|
||||
|
||||
// Check that the new session id exists
|
||||
const newSessionId = encryptedMessage.session_id;
|
||||
expect(newSessionId).toBeDefined();
|
||||
|
||||
// Check that the session id has changed
|
||||
expect(sessionId).not.toEqual(newSessionId);
|
||||
});
|
||||
|
||||
oldBackendOnly("We should start a new megolm session when a device is blocked", async () => {
|
||||
@@ -1700,6 +1926,105 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
await sendEventPromise;
|
||||
});
|
||||
|
||||
describe("getEncryptionInfoForEvent", () => {
|
||||
it("handles outgoing events", async () => {
|
||||
aliceClient.setGlobalErrorOnUnknownDevices(false);
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
// Alice shares a room with Bob
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"]));
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// Once we send the message, Alice will check Bob's device list (twice, because reasons) ...
|
||||
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
|
||||
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
|
||||
|
||||
// ... and claim one of his OTKs ...
|
||||
expectAliceKeyClaim(getTestKeysClaimResponse("@bob:xyz"));
|
||||
|
||||
// ... and send an m.room_key message ...
|
||||
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount);
|
||||
|
||||
// ... and finally, send the room key. We block the response until `sendRoomMessageDefer` completes.
|
||||
const sendRoomMessageDefer = defer<MockResponse>();
|
||||
const reqProm = new Promise<IContent>((resolve) => {
|
||||
fetchMock.putOnce(
|
||||
new RegExp("/send/m.room.encrypted/"),
|
||||
async (url: string, opts: RequestInit): Promise<MockResponse> => {
|
||||
resolve(JSON.parse(opts.body as string));
|
||||
return await sendRoomMessageDefer.promise;
|
||||
},
|
||||
{
|
||||
// append to the list of intercepts on this path (since we have some tests that call
|
||||
// this function multiple times)
|
||||
overwriteRoutes: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Now we start to send the message
|
||||
const sendProm = aliceClient.sendTextMessage(testData.TEST_ROOM_ID, "test");
|
||||
|
||||
// and wait for the outgoing requests
|
||||
const inboundGroupSession = await inboundGroupSessionPromise;
|
||||
const encryptedMessageContent = await reqProm;
|
||||
const msg: any = inboundGroupSession.decrypt(encryptedMessageContent!.ciphertext);
|
||||
logger.log("Decrypted received megolm message", msg);
|
||||
|
||||
// at this point, the request to send the room message has been made, but not completed.
|
||||
// get hold of the pending event, and see what getEncryptionInfoForEvent makes of it
|
||||
const pending = aliceClient.getRoom(testData.TEST_ROOM_ID)!.getPendingEvents();
|
||||
expect(pending.length).toEqual(1);
|
||||
const encInfo = await aliceClient.getCrypto()!.getEncryptionInfoForEvent(pending[0]);
|
||||
expect(encInfo!.shieldColour).toEqual(EventShieldColour.NONE);
|
||||
expect(encInfo!.shieldReason).toBeNull();
|
||||
|
||||
// release the send request
|
||||
const resp = { event_id: "$event_id" };
|
||||
sendRoomMessageDefer.resolve(resp);
|
||||
expect(await sendProm).toEqual(resp);
|
||||
|
||||
// still pending at this point
|
||||
expect(aliceClient.getRoom(testData.TEST_ROOM_ID)!.getPendingEvents().length).toEqual(1);
|
||||
|
||||
// echo the event back
|
||||
const fullEvent = {
|
||||
event_id: "$event_id",
|
||||
type: "m.room.encrypted",
|
||||
sender: aliceClient.getUserId(),
|
||||
origin_server_ts: Date.now(),
|
||||
content: encryptedMessageContent,
|
||||
};
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 1,
|
||||
rooms: { join: { [testData.TEST_ROOM_ID]: { timeline: { events: [fullEvent] } } } },
|
||||
});
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const timelineEvents = aliceClient.getRoom(testData.TEST_ROOM_ID)!.getLiveTimeline()!.getEvents();
|
||||
const lastEvent = timelineEvents[timelineEvents.length - 1];
|
||||
expect(lastEvent.getId()).toEqual("$event_id");
|
||||
|
||||
// now check getEncryptionInfoForEvent again
|
||||
const encInfo2 = await aliceClient.getCrypto()!.getEncryptionInfoForEvent(lastEvent);
|
||||
let expectedEncryptionInfo;
|
||||
if (backend === "rust-sdk") {
|
||||
// rust crypto does not trust its own device until it is cross-signed.
|
||||
expectedEncryptionInfo = {
|
||||
shieldColour: EventShieldColour.RED,
|
||||
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
|
||||
};
|
||||
} else {
|
||||
expectedEncryptionInfo = {
|
||||
shieldColour: EventShieldColour.NONE,
|
||||
shieldReason: null,
|
||||
};
|
||||
}
|
||||
expect(encInfo2).toEqual(expectedEncryptionInfo);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Lazy-loading member lists", () => {
|
||||
let p2pSession: Olm.Session;
|
||||
|
||||
@@ -2483,7 +2808,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", keyBackupData);
|
||||
|
||||
// should be able to restore from 4S
|
||||
const importResult = await aliceClient.restoreKeyBackupWithSecretStorage(check!.backupInfo!);
|
||||
const importResult = await advanceTimersUntil(
|
||||
aliceClient.restoreKeyBackupWithSecretStorage(check!.backupInfo!),
|
||||
);
|
||||
expect(importResult.imported).toStrictEqual(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -23,10 +23,17 @@ import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
|
||||
import { awaitDecryption, CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../../test-utils/test-utils";
|
||||
import {
|
||||
advanceTimersUntil,
|
||||
awaitDecryption,
|
||||
CRYPTO_BACKENDS,
|
||||
InitCrypto,
|
||||
syncPromise,
|
||||
} from "../../test-utils/test-utils";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { KeyBackupInfo } from "../../../src/crypto-api/keybackup";
|
||||
import { IKeyBackup } from "../../../src/crypto/backup";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
|
||||
const ROOM_ID = testData.TEST_ROOM_ID;
|
||||
|
||||
@@ -110,9 +117,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
/** an object which intercepts `/keys/query` requests on the test homeserver */
|
||||
let e2eKeyResponder: E2EKeyResponder;
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
@@ -134,6 +141,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
fetchMock.mockReset();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function initTestClient(opts: Partial<ICreateClientOpts> = {}): Promise<MatrixClient> {
|
||||
@@ -149,48 +157,131 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
return client;
|
||||
}
|
||||
|
||||
it("Alice checks key backups when receiving a message she can't decrypt", async function () {
|
||||
const syncResponse = {
|
||||
describe("Key backup check on UTD message", () => {
|
||||
// sync response which contains an encrypted event
|
||||
const SYNC_RESPONSE = {
|
||||
next_batch: 1,
|
||||
rooms: {
|
||||
join: {
|
||||
[ROOM_ID]: {
|
||||
timeline: {
|
||||
events: [testData.ENCRYPTED_EVENT],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
rooms: { join: { [ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } } } },
|
||||
};
|
||||
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
testData.CURVE25519_KEY_BACKUP_DATA,
|
||||
);
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
const EXPECTED_URL =
|
||||
[
|
||||
"https://alice-server.com/_matrix/client/v3/room_keys/keys",
|
||||
encodeURIComponent(testData.TEST_ROOM_ID),
|
||||
encodeURIComponent(testData.MEGOLM_SESSION_DATA.session_id),
|
||||
].join("/") + "?version=1";
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"));
|
||||
/** Flush promises enough times to get the crypto stacks to make the backup request */
|
||||
async function flushBackupRequest() {
|
||||
// we have to run flushPromises lots of times. It seems like each time the rust code touches indexeddb,
|
||||
// it needs another round of flushPromises to progress, or something.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await flushPromises();
|
||||
}
|
||||
}
|
||||
|
||||
// start after saving the private key
|
||||
await aliceClient.startClient();
|
||||
beforeEach(async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup, and re-check the backup.
|
||||
// XXX: should we automatically re-check after a device becomes verified?
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
|
||||
// ignore requests to send room key requests
|
||||
fetchMock.put("express:/_matrix/client/v3/sendToDevice/m.room_key_request/:request_id", {});
|
||||
|
||||
// Now, send Alice a message that she won't be able to decrypt, and check that she fetches the key from the backup.
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
await syncPromise(aliceClient);
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
);
|
||||
|
||||
const room = aliceClient.getRoom(ROOM_ID)!;
|
||||
const event = room.getLiveTimeline().getEvents()[0];
|
||||
await awaitDecryption(event, { waitOnDecryptionFailure: true });
|
||||
// start after saving the private key
|
||||
await aliceClient.startClient();
|
||||
|
||||
expect(event.getContent()).toEqual(testData.CLEAR_EVENT.content);
|
||||
// tell Alice to trust the dummy device that signed the backup, and re-check the backup.
|
||||
// XXX: should we automatically re-check after a device becomes verified?
|
||||
await waitForDeviceList();
|
||||
await aliceClient.getCrypto()!.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
|
||||
});
|
||||
|
||||
it("Alice checks key backups when receiving a message she can't decrypt", async () => {
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id", (url, request) => {
|
||||
// check that the version is correct
|
||||
const version = new URLSearchParams(new URL(url).search).get("version");
|
||||
if (version == "1") {
|
||||
return testData.CURVE25519_KEY_BACKUP_DATA;
|
||||
} else {
|
||||
return {
|
||||
status: 403,
|
||||
body: {
|
||||
current_version: "1",
|
||||
errcode: "M_WRONG_ROOM_KEYS_VERSION",
|
||||
error: "Wrong backup version.",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Send Alice a message that she won't be able to decrypt, and check that she fetches the key from the backup.
|
||||
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const room = aliceClient.getRoom(ROOM_ID)!;
|
||||
const event = room.getLiveTimeline().getEvents()[0];
|
||||
await advanceTimersUntil(awaitDecryption(event, { waitOnDecryptionFailure: true }));
|
||||
|
||||
expect(event.getContent()).toEqual(testData.CLEAR_EVENT.content);
|
||||
});
|
||||
|
||||
it("handles error on backup query gracefully", async () => {
|
||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
{ status: 404, body: { errcode: "M_NOT_FOUND" } },
|
||||
{ name: "getKey" },
|
||||
);
|
||||
|
||||
// Send Alice a message that she won't be able to decrypt
|
||||
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
|
||||
await flushBackupRequest();
|
||||
|
||||
const calls = fetchMock.calls("getKey");
|
||||
expect(calls.length).toEqual(1);
|
||||
expect(calls[0][0]).toEqual(EXPECTED_URL);
|
||||
|
||||
await flushBackupRequest();
|
||||
|
||||
// we should not have logged an error.
|
||||
// eslint-disable-next-line no-console
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Only queries once", async () => {
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
{ status: 404, body: { errcode: "M_NOT_FOUND" } },
|
||||
{ name: "getKey" },
|
||||
);
|
||||
|
||||
// Send Alice a message that she won't be able to decrypt
|
||||
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
|
||||
await flushBackupRequest();
|
||||
const calls = fetchMock.calls("getKey");
|
||||
expect(calls.length).toEqual(1);
|
||||
expect(calls[0][0]).toEqual(EXPECTED_URL);
|
||||
|
||||
fetchMock.resetHistory();
|
||||
|
||||
// another message
|
||||
const event2 = { ...testData.ENCRYPTED_EVENT, event_id: "$event2" };
|
||||
const syncResponse2 = {
|
||||
next_batch: 1,
|
||||
rooms: { join: { [ROOM_ID]: { timeline: { events: [event2] } } } },
|
||||
};
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse2);
|
||||
await flushBackupRequest();
|
||||
expect(fetchMock.calls("getKey").length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recover from backup", () => {
|
||||
@@ -224,14 +315,16 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
onKeyCached = resolve;
|
||||
});
|
||||
|
||||
const result = await aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
testData.BACKUP_DECRYPTION_KEY_BASE58,
|
||||
undefined,
|
||||
undefined,
|
||||
check!.backupInfo!,
|
||||
{
|
||||
cacheCompleteCallback: () => onKeyCached(),
|
||||
},
|
||||
const result = await advanceTimersUntil(
|
||||
aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
testData.BACKUP_DECRYPTION_KEY_BASE58,
|
||||
undefined,
|
||||
undefined,
|
||||
check!.backupInfo!,
|
||||
{
|
||||
cacheCompleteCallback: () => onKeyCached(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.imported).toStrictEqual(1);
|
||||
@@ -239,7 +332,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
await awaitKeyCached;
|
||||
|
||||
// The key should be now cached
|
||||
const afterCache = await aliceClient.restoreKeyBackupWithCache(undefined, undefined, check!.backupInfo!);
|
||||
const afterCache = await advanceTimersUntil(
|
||||
aliceClient.restoreKeyBackupWithCache(undefined, undefined, check!.backupInfo!),
|
||||
);
|
||||
|
||||
expect(afterCache.imported).toStrictEqual(1);
|
||||
});
|
||||
@@ -262,11 +357,13 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
|
||||
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
const result = await aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
testData.BACKUP_DECRYPTION_KEY_BASE58,
|
||||
ROOM_ID,
|
||||
testData.MEGOLM_SESSION_DATA.session_id,
|
||||
check!.backupInfo!,
|
||||
const result = await advanceTimersUntil(
|
||||
aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
testData.BACKUP_DECRYPTION_KEY_BASE58,
|
||||
ROOM_ID,
|
||||
testData.MEGOLM_SESSION_DATA.session_id,
|
||||
check!.backupInfo!,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.imported).toStrictEqual(1);
|
||||
@@ -645,6 +742,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
await aliceClient.startClient();
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
);
|
||||
|
||||
const result = await aliceCrypto.isKeyBackupTrusted(testData.SIGNED_BACKUP_DATA);
|
||||
@@ -658,6 +756,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
await aliceClient.startClient();
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
);
|
||||
|
||||
const backup: KeyBackupInfo = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
|
||||
|
||||
@@ -17,10 +17,11 @@ limitations under the License.
|
||||
import Olm from "@matrix-org/olm";
|
||||
import anotherjson from "another-json";
|
||||
|
||||
import { IContent, IDeviceKeys, IEvent, MatrixClient } from "../../../src";
|
||||
import { IContent, IDeviceKeys, IDownloadKeyResult, IEvent, Keys, MatrixClient, SigningKeys } from "../../../src";
|
||||
import { IE2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { ISyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { syncPromise } from "../../test-utils/test-utils";
|
||||
import { KeyBackupInfo } from "../../../src/crypto-api";
|
||||
|
||||
/**
|
||||
* @module
|
||||
@@ -60,6 +61,117 @@ export function getTestOlmAccountKeys(olmAccount: Olm.Account, userId: string, d
|
||||
return testDeviceKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap cross signing for the given Olm account.
|
||||
*
|
||||
* Will generate the cross signing keys and sign them with the master key, and returns the `IDownloadKeyResult`
|
||||
* that can be directly fed into a test e2eKeyResponder.
|
||||
*
|
||||
* The cross-signing keys are randomly generated, similar to how the olm account keys are generated. There may not
|
||||
* be any value in using static vectors, as the device keys change at every test run.
|
||||
*
|
||||
* If some `KeyBackupInfo` are provided, the `auth_data` of each backup info will be signed with the
|
||||
* master key, meaning the backups will be then trusted after verification.
|
||||
*
|
||||
* @param olmAccount - The Olm account object to use for signing the device keys.
|
||||
* @param userId - The user ID to associate with the device keys.
|
||||
* @param deviceId - The device ID to associate with the device keys.
|
||||
* @param keyBackupInfo - Optional key backup infos to sign with the master key.
|
||||
* @returns A valid keys/query response that can be fed into a test e2eKeyResponder.
|
||||
*/
|
||||
export function bootstrapCrossSigningTestOlmAccount(
|
||||
olmAccount: Olm.Account,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
keyBackupInfo: KeyBackupInfo[] = [],
|
||||
): Partial<IDownloadKeyResult> {
|
||||
const olmAliceMSK = new global.Olm.PkSigning();
|
||||
const masterPrivkey = olmAliceMSK.generate_seed();
|
||||
const masterPubkey = olmAliceMSK.init_with_seed(masterPrivkey);
|
||||
|
||||
const olmAliceUSK = new global.Olm.PkSigning();
|
||||
const userPrivkey = olmAliceUSK.generate_seed();
|
||||
const userPubkey = olmAliceUSK.init_with_seed(userPrivkey);
|
||||
|
||||
const olmAliceSSK = new global.Olm.PkSigning();
|
||||
const sskPrivkey = olmAliceSSK.generate_seed();
|
||||
const sskPubkey = olmAliceSSK.init_with_seed(sskPrivkey);
|
||||
|
||||
const mskInfo: Keys = {
|
||||
user_id: userId,
|
||||
usage: ["master"],
|
||||
keys: {
|
||||
["ed25519:" + masterPubkey]: masterPubkey,
|
||||
},
|
||||
};
|
||||
|
||||
const sskInfo: Partial<SigningKeys> = {
|
||||
user_id: userId,
|
||||
usage: ["self_signing"],
|
||||
keys: {
|
||||
["ed25519:" + sskPubkey]: sskPubkey,
|
||||
},
|
||||
};
|
||||
// sign the ssk with the msk
|
||||
const sskSig = olmAliceMSK.sign(anotherjson.stringify(sskInfo));
|
||||
sskInfo.signatures = {
|
||||
[userId]: {
|
||||
["ed25519:" + masterPubkey]: sskSig,
|
||||
},
|
||||
};
|
||||
|
||||
const uskInfo: Partial<SigningKeys> = {
|
||||
user_id: userId,
|
||||
usage: ["user_signing"],
|
||||
keys: {
|
||||
["ed25519:" + userPubkey]: userPubkey,
|
||||
},
|
||||
};
|
||||
|
||||
// sign the usk with the msk
|
||||
const uskSig = olmAliceMSK.sign(anotherjson.stringify(uskInfo));
|
||||
uskInfo.signatures = {
|
||||
[userId]: {
|
||||
["ed25519:" + masterPubkey]: uskSig,
|
||||
},
|
||||
};
|
||||
|
||||
// get the device keys and sign them with the ssk (the device is then cross signed)
|
||||
const deviceKeys = getTestOlmAccountKeys(olmAccount, userId, deviceId);
|
||||
|
||||
const copy = Object.assign({}, deviceKeys);
|
||||
delete copy.signatures;
|
||||
const crossSignature = olmAliceSSK.sign(anotherjson.stringify(copy));
|
||||
|
||||
// add the signature
|
||||
deviceKeys.signatures![userId]["ed25519:" + sskPubkey] = crossSignature;
|
||||
|
||||
// if we have some key backup info, sign them with the msk
|
||||
keyBackupInfo.forEach((info) => {
|
||||
const unsignedAuthData = Object.assign({}, info.auth_data);
|
||||
delete unsignedAuthData.signatures;
|
||||
const backupSignature = olmAliceMSK.sign(anotherjson.stringify(unsignedAuthData));
|
||||
|
||||
info.auth_data.signatures = {
|
||||
[userId]: {
|
||||
["ed25519:" + masterPubkey]: backupSignature,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// clean the olm resources as we don't need them anymore
|
||||
olmAliceMSK.free();
|
||||
olmAliceSSK.free();
|
||||
olmAliceUSK.free();
|
||||
|
||||
return {
|
||||
master_keys: { [userId]: mskInfo },
|
||||
user_signing_keys: { [userId]: uskInfo as SigningKeys },
|
||||
self_signing_keys: { [userId]: sskInfo as SigningKeys },
|
||||
device_keys: { [userId]: { [deviceId]: deviceKeys } },
|
||||
};
|
||||
}
|
||||
|
||||
/** start an Olm session with a given recipient */
|
||||
export async function createOlmSession(
|
||||
olmAccount: Olm.Account,
|
||||
@@ -218,6 +330,47 @@ export function encryptGroupSessionKey(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test utility to correctly encrypt a secret send event to a test device using the provided p2p session.
|
||||
*
|
||||
* @param opts - the options for the secret send event
|
||||
* @returns the to-device event, ready to be returned in a sync response for the test device.
|
||||
*/
|
||||
export function encryptSecretSend(opts: {
|
||||
/** the sender's user id */
|
||||
sender: string;
|
||||
/** recipient's user id */
|
||||
recipient: string;
|
||||
/** the recipient's curve25519 key */
|
||||
recipientCurve25519Key: string;
|
||||
/** the recipient's ed25519 key */
|
||||
recipientEd25519Key: string;
|
||||
/** sender's olm account */
|
||||
olmAccount: Olm.Account;
|
||||
/** sender's olm session with the recipient */
|
||||
p2pSession: Olm.Session;
|
||||
/** The requestId of the secret request that this secret send is replying. */
|
||||
requestId: string;
|
||||
/** The secret value */
|
||||
secret: string;
|
||||
}): ToDeviceEvent {
|
||||
const senderKeys = JSON.parse(opts.olmAccount.identity_keys());
|
||||
return encryptOlmEvent({
|
||||
sender: opts.sender,
|
||||
senderKey: senderKeys.curve25519,
|
||||
senderSigningKey: senderKeys.ed25519,
|
||||
recipient: opts.recipient,
|
||||
recipientCurve25519Key: opts.recipientCurve25519Key,
|
||||
recipientEd25519Key: opts.recipientEd25519Key,
|
||||
p2pSession: opts.p2pSession,
|
||||
plaincontent: {
|
||||
request_id: opts.requestId,
|
||||
secret: opts.secret,
|
||||
},
|
||||
plaintype: "m.secret.send",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish an Olm Session with the test user
|
||||
*
|
||||
|
||||
@@ -26,6 +26,7 @@ import Olm from "@matrix-org/olm";
|
||||
import {
|
||||
createClient,
|
||||
CryptoEvent,
|
||||
DeviceVerification,
|
||||
IContent,
|
||||
ICreateClientOpts,
|
||||
IEvent,
|
||||
@@ -43,7 +44,7 @@ import {
|
||||
Verifier,
|
||||
VerifierEvent,
|
||||
} from "../../../src/crypto-api/verification";
|
||||
import { escapeRegExp } from "../../../src/utils";
|
||||
import { defer, escapeRegExp } from "../../../src/utils";
|
||||
import {
|
||||
awaitDecryption,
|
||||
CRYPTO_BACKENDS,
|
||||
@@ -54,10 +55,12 @@ import {
|
||||
} from "../../test-utils/test-utils";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import {
|
||||
BACKUP_DECRYPTION_KEY_BASE64,
|
||||
BOB_ONE_TIME_KEYS,
|
||||
BOB_SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
BOB_SIGNED_TEST_DEVICE_DATA,
|
||||
BOB_TEST_USER_ID,
|
||||
CURVE25519_KEY_BACKUP_DATA,
|
||||
MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64,
|
||||
SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
SIGNED_TEST_DEVICE_DATA,
|
||||
@@ -69,7 +72,16 @@ import {
|
||||
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { createOlmSession, encryptGroupSessionKey, encryptMegolmEvent, ToDeviceEvent } from "./olm-utils";
|
||||
import {
|
||||
bootstrapCrossSigningTestOlmAccount,
|
||||
createOlmSession,
|
||||
encryptGroupSessionKey,
|
||||
encryptMegolmEvent,
|
||||
encryptSecretSend,
|
||||
ToDeviceEvent,
|
||||
} from "./olm-utils";
|
||||
import { KeyBackupInfo } from "../../../src/crypto-api";
|
||||
import { encodeBase64 } from "../../../src/base64";
|
||||
|
||||
// The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations
|
||||
// to ensure that we don't end up with dangling timeouts.
|
||||
@@ -148,6 +160,12 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
beforeEach(async () => {
|
||||
// pretend that we have another device, which we will verify
|
||||
e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA);
|
||||
|
||||
fetchMock.put(
|
||||
new RegExp(`/_matrix/client/(r0|v3)/sendToDevice/${escapeRegExp("m.secret.request")}`),
|
||||
{ ok: false, status: 404 },
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
});
|
||||
|
||||
// test with (1) the default verification method list, (2) a custom verification method list.
|
||||
@@ -419,6 +437,15 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
});
|
||||
|
||||
it("can verify another via QR code with an untrusted cross-signing key", async () => {
|
||||
// This is a slightly weird thing to test; if we don't trust the cross-signing key, normally we would
|
||||
// spam out a verification request to all devices rather than targeting a single device. Still, it's
|
||||
// a thing both the Matrix protocol and the js-sdk API support, so we may as well test it.
|
||||
//
|
||||
// Since we don't yet trust the master key, this is a type 0x02 QR code:
|
||||
// "self-verifying in which the current device does not yet trust the master key"
|
||||
//
|
||||
// By the end of it, we should trust the master key.
|
||||
|
||||
aliceClient = await startTestClient();
|
||||
// QRCode fails if we don't yet have the cross-signing keys, so make sure we have them now.
|
||||
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
|
||||
@@ -490,12 +517,51 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
reciprocateQRCodeCallbacks.confirm();
|
||||
await sendToDevicePromise;
|
||||
|
||||
// at this point, on legacy crypto, the master key is already marked as trusted, and the request is "Done".
|
||||
// Rust crypto, on the other hand, waits for the 'done' to arrive from the other side.
|
||||
if (request.phase === VerificationPhase.Done) {
|
||||
// legacy crypto: we're all done
|
||||
const userVerificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(TEST_USER_ID);
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(userVerificationStatus.isCrossSigningVerified()).toBeTruthy();
|
||||
await verificationPromise;
|
||||
} else {
|
||||
// rust crypto: still in flight
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(request.phase).toEqual(VerificationPhase.Started);
|
||||
}
|
||||
|
||||
// the dummy device replies with its own 'done'
|
||||
returnToDeviceMessageFromSync(buildDoneMessage(transactionId));
|
||||
|
||||
// ... and the whole thing should be done!
|
||||
// ... and now we're really done.
|
||||
await verificationPromise;
|
||||
expect(request.phase).toEqual(VerificationPhase.Done);
|
||||
const userVerificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(TEST_USER_ID);
|
||||
expect(userVerificationStatus.isCrossSigningVerified()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("can try to generate a QR code when QR code is not supported", async () => {
|
||||
aliceClient = await startTestClient();
|
||||
// we need cross-signing keys for a QR code verification
|
||||
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
|
||||
await waitForDeviceList();
|
||||
|
||||
// Alice sends a m.key.verification.request
|
||||
const [, request] = await Promise.all([
|
||||
expectSendToDeviceMessage("m.key.verification.request"),
|
||||
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
|
||||
]);
|
||||
const transactionId = request.transactionId!;
|
||||
|
||||
// The dummy device replies with an m.key.verification.ready, indicating it can only use SaS
|
||||
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.sas.v1"]));
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(VerificationPhase.Ready);
|
||||
|
||||
// Alice tries to generate a QR Code but it's unavailable
|
||||
const qrCodeBuffer = await request.generateQRCode();
|
||||
expect(qrCodeBuffer).toBeUndefined();
|
||||
});
|
||||
|
||||
newBackendOnly("can verify another by scanning their QR code", async () => {
|
||||
@@ -980,12 +1046,43 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
});
|
||||
}
|
||||
|
||||
it("Verification request not found", async () => {
|
||||
// Expect to not find any verification request
|
||||
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
|
||||
expect(request).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores old verification requests", async () => {
|
||||
const eventHandler = jest.fn();
|
||||
aliceClient.on(CryptoEvent.VerificationRequestReceived, eventHandler);
|
||||
|
||||
const verificationRequestEvent = createVerificationRequestEvent();
|
||||
verificationRequestEvent.origin_server_ts -= 1000000;
|
||||
returnRoomMessageFromSync(TEST_ROOM_ID, verificationRequestEvent);
|
||||
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// make sure the event has arrived
|
||||
const room = aliceClient.getRoom(TEST_ROOM_ID)!;
|
||||
const matrixEvent = room.getLiveTimeline().getEvents()[0];
|
||||
expect(matrixEvent.getId()).toEqual(verificationRequestEvent.event_id);
|
||||
|
||||
// check that an event has not been raised, and that the request is not found
|
||||
expect(eventHandler).not.toHaveBeenCalled();
|
||||
expect(
|
||||
aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz"),
|
||||
).not.toBeDefined();
|
||||
});
|
||||
|
||||
it("Plaintext verification request from Bob to Alice", async () => {
|
||||
// Add verification request from Bob to Alice in the DM between them
|
||||
returnRoomMessageFromSync(TEST_ROOM_ID, createVerificationRequestEvent());
|
||||
|
||||
// Wait for the sync response to be processed
|
||||
await syncPromise(aliceClient);
|
||||
// Wait for the request to be received
|
||||
const request1 = await emitPromise(aliceClient, CryptoEvent.VerificationRequestReceived);
|
||||
expect(request1.roomId).toBe(TEST_ROOM_ID);
|
||||
expect(request1.isSelfVerification).toBe(false);
|
||||
expect(request1.otherUserId).toBe("@bob:xyz");
|
||||
|
||||
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
|
||||
// Expect to find the verification request received during the sync
|
||||
@@ -994,12 +1091,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
expect(request?.otherUserId).toBe("@bob:xyz");
|
||||
});
|
||||
|
||||
it("Verification request not found", async () => {
|
||||
// Expect to not find any verification request
|
||||
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
|
||||
expect(request).not.toBeDefined();
|
||||
});
|
||||
|
||||
it("Encrypted verification request from Bob to Alice", async () => {
|
||||
const p2pSession = await createOlmSession(testOlmAccount, e2eKeyReceiver);
|
||||
const groupSession = new Olm.OutboundGroupSession();
|
||||
@@ -1021,14 +1112,19 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
await awaitDecryption(matrixEvent);
|
||||
expect(matrixEvent.getContent().msgtype).toEqual("m.bad.encrypted");
|
||||
|
||||
const requestEventPromise = emitPromise(aliceClient, CryptoEvent.VerificationRequestReceived);
|
||||
|
||||
// Send Bob the room keys
|
||||
returnToDeviceMessageFromSync(toDeviceEvent);
|
||||
|
||||
// advance the clock, because the devicelist likes to sleep for 5ms during key downloads
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Wait for the message to be decrypted
|
||||
await awaitDecryption(matrixEvent, { waitOnDecryptionFailure: true });
|
||||
// Wait for the request to be decrypted
|
||||
const request1 = await requestEventPromise;
|
||||
expect(request1.roomId).toBe(TEST_ROOM_ID);
|
||||
expect(request1.isSelfVerification).toBe(false);
|
||||
expect(request1.otherUserId).toBe("@bob:xyz");
|
||||
|
||||
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
|
||||
// Expect to find the verification request received during the sync
|
||||
@@ -1076,6 +1172,292 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
);
|
||||
});
|
||||
|
||||
describe("Secrets are gossiped after verification", () => {
|
||||
// We use a legacy olm session as the existing session.
|
||||
// This will give us access to low level olm functions in order to
|
||||
// simulate a backup key request with proper olm encryption.
|
||||
let testOlmAccount: Olm.Account;
|
||||
const olmDeviceId = "OLM_DEVICE";
|
||||
let usermasterPubKey: string;
|
||||
|
||||
const matchingBackupInfo: KeyBackupInfo = {
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
version: "1",
|
||||
auth_data: {
|
||||
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
},
|
||||
};
|
||||
|
||||
const nonMatchingBackupInfo: KeyBackupInfo = {
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
version: "1",
|
||||
auth_data: {
|
||||
public_key: "EjDwCYkwp1R0i33ctD73Wg2/Og0mOBr066Spjqqaqqo",
|
||||
},
|
||||
};
|
||||
|
||||
const unknownAlgorithmBackupInfo: KeyBackupInfo = {
|
||||
algorithm: "m.megolm_backup.foo_bar",
|
||||
version: "1",
|
||||
auth_data: {
|
||||
public_key: "EjDwCYkwp1R0i33ctD73Wg2/Og0mOBr066Spjqqaqqo",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
|
||||
await Olm.init();
|
||||
testOlmAccount = new Olm.Account();
|
||||
testOlmAccount.create();
|
||||
|
||||
const bootstrapped = bootstrapCrossSigningTestOlmAccount(testOlmAccount, TEST_USER_ID, olmDeviceId, [
|
||||
matchingBackupInfo,
|
||||
nonMatchingBackupInfo,
|
||||
]);
|
||||
|
||||
e2eKeyResponder.addDeviceKeys(bootstrapped.device_keys![TEST_USER_ID]![olmDeviceId]);
|
||||
e2eKeyResponder.addCrossSigningData(bootstrapped);
|
||||
|
||||
usermasterPubKey = Object.values(bootstrapped.master_keys![TEST_USER_ID].keys)[0];
|
||||
|
||||
aliceClient = await startTestClient();
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse([TEST_USER_ID]));
|
||||
await syncPromise(aliceClient);
|
||||
// DeviceList has a sleep(5) which we need to make happen
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// The client should now know about the olm device
|
||||
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]);
|
||||
expect(devices.get(TEST_USER_ID)!.keys()).toContain(olmDeviceId);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
aliceClient?.stopClient();
|
||||
testOlmAccount?.free();
|
||||
|
||||
// Allow in-flight things to complete before we tear down the test
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
newBackendOnly("Should request cross signing keys after verification", async () => {
|
||||
const requestPromises = mockSecretRequestAndGetPromises();
|
||||
|
||||
await doInteractiveVerification();
|
||||
|
||||
// The secret must have been requested
|
||||
await requestPromises.get("m.cross_signing.master");
|
||||
await requestPromises.get("m.cross_signing.user_signing");
|
||||
await requestPromises.get("m.cross_signing.self_signing");
|
||||
});
|
||||
|
||||
newBackendOnly("Should accept the backup decryption key gossip if valid", async () => {
|
||||
const requestPromises = mockSecretRequestAndGetPromises();
|
||||
|
||||
await doInteractiveVerification();
|
||||
|
||||
const requestId = await requestPromises.get("m.megolm_backup.v1");
|
||||
|
||||
await sendBackupGossipAndExpectVersion(requestId!, BACKUP_DECRYPTION_KEY_BASE64, matchingBackupInfo);
|
||||
|
||||
// We are lacking a way to signal that the secret has been received, so we wait a bit..
|
||||
jest.useRealTimers();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
jest.useFakeTimers();
|
||||
|
||||
// the backup secret should be cached
|
||||
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
expect(cachedKey).toBeTruthy();
|
||||
expect(encodeBase64(cachedKey!)).toEqual(BACKUP_DECRYPTION_KEY_BASE64);
|
||||
});
|
||||
|
||||
newBackendOnly("Should not accept the backup decryption key gossip if private key do not match", async () => {
|
||||
const requestPromises = mockSecretRequestAndGetPromises();
|
||||
|
||||
await doInteractiveVerification();
|
||||
|
||||
const requestId = await requestPromises.get("m.megolm_backup.v1");
|
||||
|
||||
await sendBackupGossipAndExpectVersion(requestId!, BACKUP_DECRYPTION_KEY_BASE64, nonMatchingBackupInfo);
|
||||
|
||||
// We are lacking a way to signal that the secret has been received, so we wait a bit..
|
||||
jest.useRealTimers();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
jest.useFakeTimers();
|
||||
|
||||
// the backup secret should not be cached
|
||||
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
expect(cachedKey).toBeNull();
|
||||
});
|
||||
|
||||
newBackendOnly("Should not accept the backup decryption key gossip if backup not trusted", async () => {
|
||||
const requestPromises = mockSecretRequestAndGetPromises();
|
||||
|
||||
await doInteractiveVerification();
|
||||
|
||||
const requestId = await requestPromises.get("m.megolm_backup.v1");
|
||||
|
||||
const infoCopy = Object.assign({}, matchingBackupInfo);
|
||||
delete infoCopy.auth_data.signatures;
|
||||
|
||||
await sendBackupGossipAndExpectVersion(requestId!, BACKUP_DECRYPTION_KEY_BASE64, infoCopy);
|
||||
|
||||
// We are lacking a way to signal that the secret has been received, so we wait a bit..
|
||||
jest.useRealTimers();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
jest.useFakeTimers();
|
||||
|
||||
// the backup secret should not be cached
|
||||
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
expect(cachedKey).toBeNull();
|
||||
});
|
||||
|
||||
newBackendOnly("Should not accept the backup decryption key gossip if backup algorithm unknown", async () => {
|
||||
const requestPromises = mockSecretRequestAndGetPromises();
|
||||
|
||||
await doInteractiveVerification();
|
||||
|
||||
const requestId = await requestPromises.get("m.megolm_backup.v1");
|
||||
|
||||
await sendBackupGossipAndExpectVersion(
|
||||
requestId!,
|
||||
BACKUP_DECRYPTION_KEY_BASE64,
|
||||
unknownAlgorithmBackupInfo,
|
||||
);
|
||||
|
||||
// We are lacking a way to signal that the secret has been received, so we wait a bit..
|
||||
jest.useRealTimers();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
jest.useFakeTimers();
|
||||
|
||||
// the backup secret should not be cached
|
||||
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
expect(cachedKey).toBeNull();
|
||||
});
|
||||
|
||||
newBackendOnly("Should not accept an invalid backup decryption key", async () => {
|
||||
const requestPromises = mockSecretRequestAndGetPromises();
|
||||
|
||||
await doInteractiveVerification();
|
||||
|
||||
const requestId = await requestPromises.get("m.megolm_backup.v1");
|
||||
|
||||
await sendBackupGossipAndExpectVersion(requestId!, "InvalidSecret", matchingBackupInfo);
|
||||
|
||||
// We are lacking a way to signal that the secret has been received, so we wait a bit..
|
||||
jest.useRealTimers();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
jest.useFakeTimers();
|
||||
|
||||
// the backup secret should not be cached
|
||||
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
expect(cachedKey).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* Common test setup for gossiping secrets.
|
||||
* Creates a peer to peer session, sends the secret, mockup the version API, send the secret back from sync, then await for the backup check.
|
||||
*/
|
||||
async function sendBackupGossipAndExpectVersion(
|
||||
requestId: string,
|
||||
secret: string,
|
||||
expectBackup: KeyBackupInfo,
|
||||
) {
|
||||
const p2pSession = await createOlmSession(testOlmAccount, e2eKeyReceiver);
|
||||
|
||||
const toDeviceEvent = encryptSecretSend({
|
||||
sender: aliceClient.getUserId()!,
|
||||
recipient: aliceClient.getUserId()!,
|
||||
recipientCurve25519Key: e2eKeyReceiver.getDeviceKey(),
|
||||
recipientEd25519Key: e2eKeyReceiver.getSigningKey(),
|
||||
p2pSession: p2pSession,
|
||||
olmAccount: testOlmAccount,
|
||||
requestId: requestId!,
|
||||
secret: secret,
|
||||
});
|
||||
|
||||
const expectBackupCheck = new Promise((resolve) => {
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/version",
|
||||
(url, request) => {
|
||||
resolve(undefined);
|
||||
return expectBackup;
|
||||
},
|
||||
{
|
||||
overwriteRoutes: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", CURVE25519_KEY_BACKUP_DATA);
|
||||
|
||||
// The dummy device sends the secret
|
||||
returnToDeviceMessageFromSync(toDeviceEvent);
|
||||
|
||||
await expectBackupCheck;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do an interactive verification between alice and the dummy device.
|
||||
*/
|
||||
async function doInteractiveVerification(): Promise<void> {
|
||||
// Do a QR code verification for simplicity
|
||||
|
||||
// Alice sends a m.key.verification.request
|
||||
const [, request] = await Promise.all([
|
||||
expectSendToDeviceMessage("m.key.verification.request"),
|
||||
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, olmDeviceId),
|
||||
]);
|
||||
const transactionId = request.transactionId!;
|
||||
|
||||
// The dummy device replies with an m.key.verification.ready, indicating it can show a QR code
|
||||
returnToDeviceMessageFromSync(
|
||||
buildReadyMessage(transactionId, ["m.qr_code.show.v1", "m.reciprocate.v1"], olmDeviceId),
|
||||
);
|
||||
await waitForVerificationRequestChanged(request);
|
||||
|
||||
const currentDeviceKey = e2eKeyReceiver.getSigningKey();
|
||||
// the dummy device shows a QR code
|
||||
const sharedSecret = "SUPERSEKRET";
|
||||
// use mode 0x01, self-verifying in which the current device does trust the master key
|
||||
const mode = 0x01;
|
||||
const qrCodeBuffer = buildQRCode(transactionId, usermasterPubKey, currentDeviceKey, sharedSecret, mode);
|
||||
|
||||
// Alice scans the QR code
|
||||
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.start");
|
||||
const verifier = await request.scanQRCode(qrCodeBuffer);
|
||||
|
||||
await sendToDevicePromise;
|
||||
|
||||
const verificationPromise = verifier.verify();
|
||||
// the dummy device confirms that Alice scanned the QR code, by replying with a done
|
||||
returnToDeviceMessageFromSync(buildDoneMessage(transactionId));
|
||||
|
||||
// Alice also replies with a 'done'
|
||||
await expectSendToDeviceMessage("m.key.verification.done");
|
||||
|
||||
// ... and the whole thing should be done!
|
||||
await verificationPromise;
|
||||
|
||||
// The other device should now be verified.
|
||||
const otherDevice = (await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]))
|
||||
.get(TEST_USER_ID)!
|
||||
.get(olmDeviceId);
|
||||
expect(otherDevice?.verified).toEqual(DeviceVerification.Verified);
|
||||
}
|
||||
});
|
||||
|
||||
async function startTestClient(opts: Partial<ICreateClientOpts> = {}): Promise<MatrixClient> {
|
||||
const client = createClient({
|
||||
baseUrl: TEST_HOMESERVER_URL,
|
||||
@@ -1137,6 +1519,52 @@ function expectSendToDeviceMessage(msgtype: string): Promise<{ messages: any }>
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to add all needed mocks for secret requesting (to-device of type `m.secret.request`).
|
||||
*
|
||||
* The following secrets are mocked: `m.cross_signing.master`, `m.cross_signing.self_signing`,
|
||||
* `m.cross_signing.user_signing`, `m.megolm_backup.v1`.
|
||||
*
|
||||
* @returns a map of secret name to promise that will resolve (with the id of the secret request) when the secret is requested.
|
||||
*/
|
||||
function mockSecretRequestAndGetPromises(): Map<string, Promise<string>> {
|
||||
const mskRequestDefer = defer<string>();
|
||||
const sskRequestDefer = defer<string>();
|
||||
const uskRequestDefer = defer<string>();
|
||||
const backupKeyRequestDefer = defer<string>();
|
||||
|
||||
fetchMock.put(
|
||||
new RegExp(`/_matrix/client/(r0|v3)/sendToDevice/m.secret.request`),
|
||||
(url: string, opts: RequestInit): MockResponse => {
|
||||
const messages = JSON.parse(opts.body as string).messages[TEST_USER_ID];
|
||||
// rust crypto broadcasts to all devices, old crypto to a specific device, take the first one
|
||||
const content = Object.values(messages)[0] as any;
|
||||
if (content.action == "request") {
|
||||
const name = content.name;
|
||||
const requestId = content.request_id;
|
||||
if (name == "m.cross_signing.user_signing") {
|
||||
uskRequestDefer.resolve(requestId);
|
||||
} else if (name == "m.cross_signing.master") {
|
||||
mskRequestDefer.resolve(requestId);
|
||||
} else if (name == "m.cross_signing.self_signing") {
|
||||
sskRequestDefer.resolve(requestId);
|
||||
} else if (name == "m.megolm_backup.v1") {
|
||||
backupKeyRequestDefer.resolve(requestId);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const promiseMap = new Map<string, Promise<string>>();
|
||||
promiseMap.set("m.cross_signing.master", mskRequestDefer.promise);
|
||||
promiseMap.set("m.cross_signing.self_signing", sskRequestDefer.promise);
|
||||
promiseMap.set("m.cross_signing.user_signing", uskRequestDefer.promise);
|
||||
promiseMap.set("m.megolm_backup.v1", backupKeyRequestDefer.promise);
|
||||
return promiseMap;
|
||||
}
|
||||
|
||||
/** wait for the verification request to emit a 'Change' event */
|
||||
function waitForVerificationRequestChanged(request: VerificationRequest): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
@@ -1181,12 +1609,16 @@ function buildRequestMessage(transactionId: string): { type: string; content: ob
|
||||
};
|
||||
}
|
||||
|
||||
/** build an m.key.verification.ready to-device message originating from the dummy device */
|
||||
function buildReadyMessage(transactionId: string, methods: string[]): { type: string; content: object } {
|
||||
/** build an m.key.verification.ready to-device message originating from the given `fromDevice` (default to `TEST_DEVICE_ID` if not provided) */
|
||||
function buildReadyMessage(
|
||||
transactionId: string,
|
||||
methods: string[],
|
||||
fromDevice?: string,
|
||||
): { type: string; content: object } {
|
||||
return {
|
||||
type: "m.key.verification.ready",
|
||||
content: {
|
||||
from_device: TEST_DEVICE_ID,
|
||||
from_device: fromDevice || TEST_DEVICE_ID,
|
||||
methods: methods,
|
||||
transaction_id: transactionId,
|
||||
},
|
||||
@@ -1284,14 +1716,20 @@ function buildDoneMessage(transactionId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildQRCode(transactionId: string, key1Base64: string, key2Base64: string, sharedSecret: string): Uint8Array {
|
||||
function buildQRCode(
|
||||
transactionId: string,
|
||||
key1Base64: string,
|
||||
key2Base64: string,
|
||||
sharedSecret: string,
|
||||
mode = 0x02,
|
||||
): Uint8Array {
|
||||
// https://spec.matrix.org/v1.7/client-server-api/#qr-code-format
|
||||
|
||||
const qrCodeBuffer = Buffer.alloc(150); // oversize
|
||||
let idx = 0;
|
||||
idx += qrCodeBuffer.write("MATRIX", idx, "ascii");
|
||||
idx = qrCodeBuffer.writeUInt8(0x02, idx); // version
|
||||
idx = qrCodeBuffer.writeUInt8(0x02, idx); // mode
|
||||
idx = qrCodeBuffer.writeUInt8(mode, idx); // mode
|
||||
idx = qrCodeBuffer.writeInt16BE(transactionId.length, idx);
|
||||
idx += qrCodeBuffer.write(transactionId, idx, "ascii");
|
||||
|
||||
|
||||
@@ -194,6 +194,37 @@ describe("MatrixClient events", function () {
|
||||
expect(fired).toBe(true);
|
||||
});
|
||||
|
||||
it("should emit User events when presence data is absent in first sync", async () => {
|
||||
const MODIFIED_SYNC_DATA: any = structuredClone(SYNC_DATA);
|
||||
delete MODIFIED_SYNC_DATA["presence"];
|
||||
const MODIFIED_NEXT_SYNC_DATA: any = structuredClone(NEXT_SYNC_DATA);
|
||||
MODIFIED_NEXT_SYNC_DATA.presence = {
|
||||
events: [
|
||||
utils.mkPresence({
|
||||
user: "@foo:bar",
|
||||
name: "Foo Bar",
|
||||
presence: "online",
|
||||
}),
|
||||
],
|
||||
};
|
||||
httpBackend!.when("GET", "/sync").respond(200, MODIFIED_SYNC_DATA);
|
||||
httpBackend!.when("GET", "/sync").respond(200, MODIFIED_NEXT_SYNC_DATA);
|
||||
let fired = false;
|
||||
client!.on(UserEvent.Presence, function (event, user) {
|
||||
fired = true;
|
||||
expect(user).toBeTruthy();
|
||||
expect(event).toBeTruthy();
|
||||
if (!user || !event) {
|
||||
return;
|
||||
}
|
||||
expect(event.event).toEqual(MODIFIED_NEXT_SYNC_DATA.presence.events[0]);
|
||||
expect(user.presence).toEqual(MODIFIED_NEXT_SYNC_DATA.presence.events[0]?.content?.presence);
|
||||
});
|
||||
client!.startClient();
|
||||
await httpBackend!.flushAllExpected();
|
||||
expect(fired).toBe(true);
|
||||
});
|
||||
|
||||
it("should emit Room events", function () {
|
||||
httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA);
|
||||
httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA);
|
||||
|
||||
@@ -537,8 +537,6 @@ export async function awaitDecryption(
|
||||
});
|
||||
}
|
||||
|
||||
export const emitPromise = (e: EventEmitter, k: string): Promise<any> => new Promise((r) => e.once(k, r));
|
||||
|
||||
export const mkPusher = (extra: Partial<IPusher> = {}): IPusher => ({
|
||||
app_display_name: "app",
|
||||
app_id: "123",
|
||||
@@ -561,3 +559,25 @@ CRYPTO_BACKENDS["rust-sdk"] = (client: MatrixClient) => client.initRustCrypto();
|
||||
if (global.Olm) {
|
||||
CRYPTO_BACKENDS["libolm"] = (client: MatrixClient) => client.initCrypto();
|
||||
}
|
||||
|
||||
export const emitPromise = (e: EventEmitter, k: string): Promise<any> => new Promise((r) => e.once(k, r));
|
||||
|
||||
/**
|
||||
* Advance the fake timers in a loop until the given promise resolves or rejects.
|
||||
*
|
||||
* Returns the result of the promise.
|
||||
*
|
||||
* This can be useful when there are multiple steps in the code which require an iteration of the event loop.
|
||||
*/
|
||||
export async function advanceTimersUntil<T>(promise: Promise<T>): Promise<T> {
|
||||
let resolved = false;
|
||||
promise.finally(() => {
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
while (!resolved) {
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
}
|
||||
|
||||
return await promise;
|
||||
}
|
||||
|
||||
@@ -15,16 +15,44 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { TextEncoder, TextDecoder } from "util";
|
||||
import NodeBuffer from "node:buffer";
|
||||
|
||||
import { decodeBase64, encodeBase64, encodeUnpaddedBase64 } from "../../../src/common-crypto/base64";
|
||||
import { decodeBase64, encodeBase64, encodeUnpaddedBase64 } from "../../src/base64";
|
||||
|
||||
describe.each(["browser", "node"])("Base64 encoding (%s)", (env) => {
|
||||
let origBuffer = Buffer;
|
||||
|
||||
beforeAll(() => {
|
||||
if (env === "browser") {
|
||||
origBuffer = Buffer;
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line no-global-assign
|
||||
Buffer = undefined;
|
||||
|
||||
global.atob = NodeBuffer.atob;
|
||||
global.btoa = NodeBuffer.btoa;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// eslint-disable-next-line no-global-assign
|
||||
Buffer = origBuffer;
|
||||
// @ts-ignore
|
||||
global.atob = undefined;
|
||||
// @ts-ignore
|
||||
global.btoa = undefined;
|
||||
});
|
||||
|
||||
describe("Crypto Base64 encoding", () => {
|
||||
it("Should decode properly encoded data", async () => {
|
||||
const toEncode = "encoding hello world";
|
||||
const encoded = encodeBase64(new TextEncoder().encode(toEncode));
|
||||
const decoded = new TextDecoder().decode(decodeBase64(encoded));
|
||||
const decoded = new TextDecoder().decode(decodeBase64("ZW5jb2RpbmcgaGVsbG8gd29ybGQ="));
|
||||
|
||||
expect(decoded).toStrictEqual(toEncode);
|
||||
expect(decoded).toStrictEqual("encoding hello world");
|
||||
});
|
||||
|
||||
it("Should decode URL-safe base64", async () => {
|
||||
const decoded = new TextDecoder().decode(decodeBase64("Pz8_Pz8="));
|
||||
|
||||
expect(decoded).toStrictEqual("?????");
|
||||
});
|
||||
|
||||
it("Encode unpadded should not have padding", async () => {
|
||||
@@ -115,6 +115,16 @@ describe("Crypto", function () {
|
||||
expect(Crypto.getOlmVersion()[0]).toEqual(3);
|
||||
});
|
||||
|
||||
it("getVersion() should return the current version of the olm library", async () => {
|
||||
const client = new TestClient("@alice:example.com", "deviceid").client;
|
||||
await client.initCrypto();
|
||||
|
||||
const olmVersionTuple = Crypto.getOlmVersion();
|
||||
expect(client.getCrypto()?.getVersion()).toBe(
|
||||
`Olm ${olmVersionTuple[0]}.${olmVersionTuple[1]}.${olmVersionTuple[2]}`,
|
||||
);
|
||||
});
|
||||
|
||||
describe("encrypted events", function () {
|
||||
it("provides encryption information for events from unverified senders", async function () {
|
||||
const client = new TestClient("@alice:example.com", "deviceid").client;
|
||||
@@ -1106,7 +1116,7 @@ describe("Crypto", function () {
|
||||
|
||||
describe("Secret storage", function () {
|
||||
it("creates secret storage even if there is no keyInfo", async function () {
|
||||
jest.spyOn(logger, "log").mockImplementation(() => {});
|
||||
jest.spyOn(logger, "debug").mockImplementation(() => {});
|
||||
jest.setTimeout(10000);
|
||||
const client = new TestClient("@a:example.com", "dev").client;
|
||||
await client.initCrypto();
|
||||
|
||||
@@ -229,6 +229,22 @@ describe("MegolmBackup", function () {
|
||||
);
|
||||
});
|
||||
|
||||
test("fail if given backup has no version", async () => {
|
||||
const client = makeTestClient(cryptoStore);
|
||||
await client.initCrypto();
|
||||
const data = {
|
||||
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
|
||||
auth_data: {
|
||||
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
},
|
||||
};
|
||||
const key = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
await client.getCrypto()!.storeSessionBackupPrivateKey(key, "1");
|
||||
await expect(client.restoreKeyBackupWithCache(undefined, undefined, data)).rejects.toThrow(
|
||||
"Backup version must be defined",
|
||||
);
|
||||
});
|
||||
|
||||
it("automatically calls the key back up", function () {
|
||||
const groupSession = new Olm.OutboundGroupSession();
|
||||
groupSession.create();
|
||||
|
||||
@@ -28,6 +28,7 @@ import { DeviceInfo } from "../../../src/crypto/deviceinfo";
|
||||
import { ISignatures } from "../../../src/@types/signed";
|
||||
import { ICurve25519AuthData } from "../../../src/crypto/keybackup";
|
||||
import { SecretStorageKeyDescription, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
|
||||
import { decodeBase64 } from "../../../src/base64";
|
||||
|
||||
async function makeTestClient(
|
||||
userInfo: { userId: string; deviceId: string },
|
||||
@@ -275,13 +276,13 @@ describe("Secrets", function () {
|
||||
|
||||
describe("bootstrap", function () {
|
||||
// keys used in some of the tests
|
||||
const XSK = new Uint8Array(olmlib.decodeBase64("3lo2YdJugHjfE+Or7KJ47NuKbhE7AAGLgQ/dc19913Q="));
|
||||
const XSK = new Uint8Array(decodeBase64("3lo2YdJugHjfE+Or7KJ47NuKbhE7AAGLgQ/dc19913Q="));
|
||||
const XSPubKey = "DRb8pFVJyEJ9OWvXeUoM0jq/C2Wt+NxzBZVuk2nRb+0";
|
||||
const USK = new Uint8Array(olmlib.decodeBase64("lKWi3hJGUie5xxHgySoz8PHFnZv6wvNaud/p2shN9VU="));
|
||||
const USK = new Uint8Array(decodeBase64("lKWi3hJGUie5xxHgySoz8PHFnZv6wvNaud/p2shN9VU="));
|
||||
const USPubKey = "CUpoiTtHiyXpUmd+3ohb7JVxAlUaOG1NYs9Jlx8soQU";
|
||||
const SSK = new Uint8Array(olmlib.decodeBase64("1R6JVlXX99UcfUZzKuCDGQgJTw8ur1/ofgPD8pp+96M="));
|
||||
const SSK = new Uint8Array(decodeBase64("1R6JVlXX99UcfUZzKuCDGQgJTw8ur1/ofgPD8pp+96M="));
|
||||
const SSPubKey = "0DfNsRDzEvkCLA0gD3m7VAGJ5VClhjEsewI35xq873Q";
|
||||
const SSSSKey = new Uint8Array(olmlib.decodeBase64("XrmITOOdBhw6yY5Bh7trb/bgp1FRdIGyCUxxMP873R0="));
|
||||
const SSSSKey = new Uint8Array(decodeBase64("XrmITOOdBhw6yY5Bh7trb/bgp1FRdIGyCUxxMP873R0="));
|
||||
|
||||
it("bootstraps when no storage or cross-signing keys locally", async function () {
|
||||
const key = new Uint8Array(16);
|
||||
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import "../../../olm-loader";
|
||||
import { MatrixClient, MatrixEvent } from "../../../../src/matrix";
|
||||
import { encodeBase64 } from "../../../../src/crypto/olmlib";
|
||||
import { encodeBase64 } from "../../../../src/base64";
|
||||
import "../../../../src/crypto"; // import this to cycle-break
|
||||
import { CrossSigningInfo } from "../../../../src/crypto/CrossSigning";
|
||||
import { VerificationRequest } from "../../../../src/crypto/verification/request/VerificationRequest";
|
||||
|
||||
@@ -34,6 +34,7 @@ describe("eventMapperFor", function () {
|
||||
getRoom(roomId: string): Room | null {
|
||||
return rooms.find((r) => r.roomId === roomId) ?? null;
|
||||
},
|
||||
setUserCreator(_) {},
|
||||
} as IStore,
|
||||
scheduler: {
|
||||
setProcessFunction: jest.fn(),
|
||||
|
||||
@@ -14,14 +14,22 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
import { Mocked } from "jest-mock";
|
||||
|
||||
import { FetchHttpApi } from "../../../src/http-api/fetch";
|
||||
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
|
||||
import { ClientPrefix, HttpApiEvent, HttpApiEventHandlerMap, IdentityPrefix, IHttpOpts, Method } from "../../../src";
|
||||
import {
|
||||
ClientPrefix,
|
||||
HttpApiEvent,
|
||||
HttpApiEventHandlerMap,
|
||||
IdentityPrefix,
|
||||
IHttpOpts,
|
||||
MatrixError,
|
||||
Method,
|
||||
} from "../../../src";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { defer, QueryDict } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { Logger } from "../../../src/logger";
|
||||
|
||||
describe("FetchHttpApi", () => {
|
||||
const baseUrl = "http://baseUrl";
|
||||
@@ -231,13 +239,145 @@ describe("FetchHttpApi", () => {
|
||||
});
|
||||
|
||||
describe("authedRequest", () => {
|
||||
it("should not include token if unset", () => {
|
||||
const fetchFn = jest.fn();
|
||||
it("should not include token if unset", async () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn });
|
||||
api.authedRequest(Method.Post, "/account/password");
|
||||
await api.authedRequest(Method.Post, "/account/password");
|
||||
expect(fetchFn.mock.calls[0][1].headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("with refresh token", () => {
|
||||
const accessToken = "test-access-token";
|
||||
const refreshToken = "test-refresh-token";
|
||||
|
||||
describe("when an unknown token error is encountered", () => {
|
||||
const unknownTokenErrBody = {
|
||||
errcode: "M_UNKNOWN_TOKEN",
|
||||
error: "Token is not active",
|
||||
soft_logout: false,
|
||||
};
|
||||
const unknownTokenErr = new MatrixError(unknownTokenErrBody, 401);
|
||||
const unknownTokenResponse = {
|
||||
ok: false,
|
||||
status: 401,
|
||||
headers: {
|
||||
get(name: string): string | null {
|
||||
return name === "Content-Type" ? "application/json" : null;
|
||||
},
|
||||
},
|
||||
text: jest.fn().mockResolvedValue(JSON.stringify(unknownTokenErrBody)),
|
||||
};
|
||||
const okayResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
};
|
||||
|
||||
describe("without a tokenRefreshFunction", () => {
|
||||
it("should emit logout and throw", async () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
jest.spyOn(emitter, "emit");
|
||||
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn, accessToken, refreshToken });
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
unknownTokenErr,
|
||||
);
|
||||
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with a tokenRefreshFunction", () => {
|
||||
it("should emit logout and throw when token refresh fails", async () => {
|
||||
const error = new Error("uh oh");
|
||||
const tokenRefreshFunction = jest.fn().mockRejectedValue(error);
|
||||
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
jest.spyOn(emitter, "emit");
|
||||
const api = new FetchHttpApi(emitter, {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
tokenRefreshFunction,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
unknownTokenErr,
|
||||
);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
|
||||
it("should refresh token and retry request", async () => {
|
||||
const newAccessToken = "new-access-token";
|
||||
const newRefreshToken = "new-refresh-token";
|
||||
const tokenRefreshFunction = jest.fn().mockResolvedValue({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
});
|
||||
const fetchFn = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(unknownTokenResponse)
|
||||
.mockResolvedValueOnce(okayResponse);
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
jest.spyOn(emitter, "emit");
|
||||
const api = new FetchHttpApi(emitter, {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
tokenRefreshFunction,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
const result = await api.authedRequest(Method.Post, "/account/password");
|
||||
expect(result).toEqual(okayResponse);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
// uses new access token
|
||||
expect(fetchFn.mock.calls[1][1].headers.Authorization).toEqual("Bearer new-access-token");
|
||||
expect(emitter.emit).not.toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
|
||||
it("should only try to refresh the token once", async () => {
|
||||
const newAccessToken = "new-access-token";
|
||||
const newRefreshToken = "new-refresh-token";
|
||||
const tokenRefreshFunction = jest.fn().mockResolvedValue({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
});
|
||||
|
||||
// fetch doesn't like our new or old tokens
|
||||
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
|
||||
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
jest.spyOn(emitter, "emit");
|
||||
const api = new FetchHttpApi(emitter, {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
tokenRefreshFunction,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
unknownTokenErr,
|
||||
);
|
||||
|
||||
// tried to refresh the token once
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
// uses new access token on retry
|
||||
expect(fetchFn.mock.calls[1][1].headers.Authorization).toEqual("Bearer new-access-token");
|
||||
|
||||
// logged out after refreshed access token is rejected
|
||||
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUrl()", () => {
|
||||
@@ -300,22 +440,29 @@ describe("FetchHttpApi", () => {
|
||||
jest.useFakeTimers();
|
||||
const deferred = defer<Response>();
|
||||
const fetchFn = jest.fn().mockReturnValue(deferred.promise);
|
||||
jest.spyOn(logger, "debug").mockImplementation(() => {});
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
logger: mockLogger,
|
||||
});
|
||||
const prom = api.requestOtherUrl(Method.Get, "https://server:8448/some/path?query=param#fragment");
|
||||
jest.advanceTimersByTime(1234);
|
||||
deferred.resolve({ ok: true, status: 200, text: () => Promise.resolve("RESPONSE") } as Response);
|
||||
await prom;
|
||||
expect(logger.debug).not.toHaveBeenCalledWith("fragment");
|
||||
expect(logger.debug).not.toHaveBeenCalledWith("query");
|
||||
expect(logger.debug).not.toHaveBeenCalledWith("param");
|
||||
expect(logger.debug).toHaveBeenCalledTimes(2);
|
||||
expect(mocked(logger.debug).mock.calls[0]).toMatchInlineSnapshot(`
|
||||
expect(mockLogger.debug).not.toHaveBeenCalledWith("fragment");
|
||||
expect(mockLogger.debug).not.toHaveBeenCalledWith("query");
|
||||
expect(mockLogger.debug).not.toHaveBeenCalledWith("param");
|
||||
expect(mockLogger.debug).toHaveBeenCalledTimes(2);
|
||||
expect(mockLogger.debug.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
[
|
||||
"FetchHttpApi: --> GET https://server:8448/some/path?query=xxx",
|
||||
]
|
||||
`);
|
||||
expect(mocked(logger.debug).mock.calls[1]).toMatchInlineSnapshot(`
|
||||
expect(mockLogger.debug.mock.calls[1]).toMatchInlineSnapshot(`
|
||||
[
|
||||
"FetchHttpApi: <-- GET https://server:8448/some/path?query=xxx [1234ms 200]",
|
||||
]
|
||||
|
||||
@@ -328,6 +328,7 @@ describe("MatrixClient", function () {
|
||||
"storeFilter",
|
||||
"startup",
|
||||
"deleteAllData",
|
||||
"setUserCreator",
|
||||
] as const
|
||||
).reduce((r, k) => {
|
||||
r[k] = jest.fn();
|
||||
|
||||
@@ -29,9 +29,7 @@ const membershipTemplate: CallMembershipData = {
|
||||
function makeMockEvent(originTs = 0): MatrixEvent {
|
||||
return {
|
||||
getTs: jest.fn().mockReturnValue(originTs),
|
||||
sender: {
|
||||
userId: "@alice:example.org",
|
||||
},
|
||||
getSender: jest.fn().mockReturnValue("@alice:example.org"),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
|
||||
@@ -675,6 +675,69 @@ describe("Thread", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("addEvent", () => {
|
||||
describe("Given server support for threads", () => {
|
||||
let previousThreadHasServerSideSupport: FeatureSupport;
|
||||
|
||||
beforeAll(() => {
|
||||
previousThreadHasServerSideSupport = Thread.hasServerSideSupport;
|
||||
Thread.hasServerSideSupport = FeatureSupport.Stable;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Thread.hasServerSideSupport = previousThreadHasServerSideSupport;
|
||||
});
|
||||
|
||||
it("Adds events even if they appear out of order", async () => {
|
||||
// Given a thread exists
|
||||
const client = createClient();
|
||||
const user = "@alice:matrix.org";
|
||||
const room = "!room:z";
|
||||
const thread = await createThread(client, user, room);
|
||||
const prevNumEvents = thread.timeline.length;
|
||||
|
||||
// When two messages come in but the later one has an older timestamp
|
||||
const message1 = createThreadMessage(thread.id, user, room, "message1");
|
||||
const message2 = createThreadMessage(thread.id, user, room, "message2");
|
||||
message2.localTimestamp -= 10000;
|
||||
|
||||
await thread.addEvent(message1, false);
|
||||
await thread.addEvent(message2, false);
|
||||
|
||||
// Then both events end up in the timeline
|
||||
expect(thread.timeline.length - prevNumEvents).toEqual(2);
|
||||
const lastEvent = thread.timeline.at(-1)!;
|
||||
const secondLastEvent = thread.timeline.at(-2)!;
|
||||
expect(lastEvent).toBe(message2);
|
||||
expect(secondLastEvent).toBe(message1);
|
||||
});
|
||||
|
||||
it("Adds events to start even if they appear out of order", async () => {
|
||||
// Given a thread exists
|
||||
const client = createClient();
|
||||
const user = "@alice:matrix.org";
|
||||
const room = "!room:z";
|
||||
const thread = await createThread(client, user, room);
|
||||
const prevNumEvents = thread.timeline.length;
|
||||
|
||||
// When two messages come in but the later one has an older timestamp
|
||||
const message1 = createThreadMessage(thread.id, user, room, "message1");
|
||||
const message2 = createThreadMessage(thread.id, user, room, "message2");
|
||||
message2.localTimestamp -= 10000;
|
||||
|
||||
await thread.addEvent(message1, false);
|
||||
await thread.addEvent(message2, true);
|
||||
|
||||
// Then both events end up in the timeline
|
||||
expect(thread.timeline.length - prevNumEvents).toEqual(2);
|
||||
const lastEvent = thread.timeline.at(-1)!;
|
||||
const firstEvent = thread.timeline.at(0)!;
|
||||
expect(lastEvent).toBe(message1);
|
||||
expect(firstEvent).toBe(message2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -134,6 +134,25 @@ describe("oidc authorization", () => {
|
||||
|
||||
expect(authUrl.searchParams.get("code_challenge")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should generate url with create prompt", async () => {
|
||||
const nonce = "abc123";
|
||||
|
||||
const metadata = delegatedAuthConfig.metadata;
|
||||
|
||||
const authUrl = new URL(
|
||||
await generateOidcAuthorizationUrl({
|
||||
metadata,
|
||||
homeserverUrl: baseUrl,
|
||||
clientId,
|
||||
redirectUri: baseUrl,
|
||||
nonce,
|
||||
prompt: "create",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(authUrl.searchParams.get("prompt")).toEqual("create");
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeAuthorizationCodeGrant", () => {
|
||||
@@ -284,6 +303,7 @@ describe("oidc authorization", () => {
|
||||
expires_at: result.tokenResponse.expires_at,
|
||||
scope,
|
||||
},
|
||||
idTokenClaims: result.idTokenClaims,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,6 +345,7 @@ describe("oidc authorization", () => {
|
||||
expires_at: result.tokenResponse.expires_at,
|
||||
scope,
|
||||
},
|
||||
idTokenClaims: result.idTokenClaims,
|
||||
});
|
||||
|
||||
expect(result.tokenResponse.token_type).toEqual("Bearer");
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
/*
|
||||
Copyright 2023 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 fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { OidcTokenRefresher } from "../../../src";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
|
||||
|
||||
describe("OidcTokenRefresher", () => {
|
||||
// OidcTokenRefresher props
|
||||
// see class declaration for info
|
||||
const authConfig = {
|
||||
issuer: "https://issuer.org/",
|
||||
};
|
||||
const clientId = "test-client-id";
|
||||
const redirectUri = "https://test.org";
|
||||
const deviceId = "abc123";
|
||||
const idTokenClaims = {
|
||||
exp: Date.now() / 1000 + 100000,
|
||||
aud: clientId,
|
||||
iss: authConfig.issuer,
|
||||
sub: "123",
|
||||
iat: 123,
|
||||
};
|
||||
// used to mock a valid token response, as consumed by OidcClient library
|
||||
const scope = `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${deviceId}`;
|
||||
|
||||
// auth config used in mocked calls to OP .well-known
|
||||
const config = makeDelegatedAuthConfig(authConfig.issuer);
|
||||
|
||||
const makeTokenResponse = (accessToken: string, refreshToken?: string) => ({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: 300,
|
||||
scope: scope,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata);
|
||||
fetchMock.get(`${config.metadata.issuer}jwks`, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
keys: [],
|
||||
});
|
||||
|
||||
fetchMock.post(config.metadata.token_endpoint, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...makeTokenResponse("new-access-token", "new-refresh-token"),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
fetchMock.resetBehavior();
|
||||
});
|
||||
|
||||
it("throws when oidc client cannot be initialised", async () => {
|
||||
jest.spyOn(logger, "error");
|
||||
fetchMock.get(
|
||||
`${config.metadata.issuer}.well-known/openid-configuration`,
|
||||
{
|
||||
ok: false,
|
||||
status: 404,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await expect(refresher.oidcClientReady).rejects.toThrow();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"Failed to initialise OIDC client.",
|
||||
// error from OidcClient
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it("initialises oidc client", async () => {
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
|
||||
// @ts-ignore peek at private property to see we initialised the client correctly
|
||||
expect(refresher.oidcClient.settings).toEqual(
|
||||
expect.objectContaining({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
authority: authConfig.issuer,
|
||||
scope,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("doRefreshAccessToken()", () => {
|
||||
it("should throw when oidcClient has not been initialised", async () => {
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow(
|
||||
"Cannot get new token before OIDC client is initialised.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should refresh the tokens", async () => {
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
|
||||
const result = await refresher.doRefreshAccessToken("refresh-token");
|
||||
|
||||
expect(fetchMock).toHaveFetched(config.metadata.token_endpoint, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("should persist the new tokens", async () => {
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
// spy on our stub
|
||||
jest.spyOn(refresher, "persistTokens");
|
||||
|
||||
await refresher.doRefreshAccessToken("refresh-token");
|
||||
|
||||
expect(refresher.persistTokens).toHaveBeenCalledWith({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("should only have one inflight refresh request at once", async () => {
|
||||
fetchMock
|
||||
.postOnce(
|
||||
config.metadata.token_endpoint,
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...makeTokenResponse("first-new-access-token", "first-new-refresh-token"),
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
)
|
||||
.postOnce(
|
||||
config.metadata.token_endpoint,
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...makeTokenResponse("second-new-access-token", "second-new-refresh-token"),
|
||||
},
|
||||
{ overwriteRoutes: false },
|
||||
);
|
||||
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
// reset call counts
|
||||
fetchMock.resetHistory();
|
||||
|
||||
const refreshToken = "refresh-token";
|
||||
const first = refresher.doRefreshAccessToken(refreshToken);
|
||||
const second = refresher.doRefreshAccessToken(refreshToken);
|
||||
|
||||
const result1 = await second;
|
||||
const result2 = await first;
|
||||
|
||||
// only one call to token endpoint
|
||||
expect(fetchMock).toHaveFetchedTimes(1, config.metadata.token_endpoint);
|
||||
expect(result1).toEqual({
|
||||
accessToken: "first-new-access-token",
|
||||
refreshToken: "first-new-refresh-token",
|
||||
});
|
||||
// same response
|
||||
expect(result1).toEqual(result2);
|
||||
|
||||
// call again after first request resolves
|
||||
const third = await refresher.doRefreshAccessToken("first-new-refresh-token");
|
||||
|
||||
// called token endpoint, got new tokens
|
||||
expect(third).toEqual({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("should log and rethrow when token refresh fails", async () => {
|
||||
fetchMock.post(
|
||||
config.metadata.token_endpoint,
|
||||
{
|
||||
status: 503,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
|
||||
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should make fresh request after a failed request", async () => {
|
||||
// make sure inflight request is cleared after a failure
|
||||
fetchMock
|
||||
.postOnce(
|
||||
config.metadata.token_endpoint,
|
||||
{
|
||||
status: 503,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
)
|
||||
.postOnce(
|
||||
config.metadata.token_endpoint,
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...makeTokenResponse("second-new-access-token", "second-new-refresh-token"),
|
||||
},
|
||||
{ overwriteRoutes: false },
|
||||
);
|
||||
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
// reset call counts
|
||||
fetchMock.resetHistory();
|
||||
|
||||
// first call fails
|
||||
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow();
|
||||
|
||||
// call again after first request resolves
|
||||
const result = await refresher.doRefreshAccessToken("first-new-refresh-token");
|
||||
|
||||
// called token endpoint, got new tokens
|
||||
expect(result).toEqual({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ import MockHttpBackend from "matrix-mock-request";
|
||||
|
||||
import { MAIN_ROOM_TIMELINE, ReceiptType, WrappedReceipt } from "../../src/@types/read_receipts";
|
||||
import { MatrixClient } from "../../src/client";
|
||||
import { EventType, MatrixEvent, Room } from "../../src/matrix";
|
||||
import { EventType, MatrixEvent, RelationType, Room, threadIdForReceipt } from "../../src/matrix";
|
||||
import { synthesizeReceipt } from "../../src/models/read-receipt";
|
||||
import { encodeUri } from "../../src/utils";
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
@@ -258,4 +258,200 @@ describe("Read receipt", () => {
|
||||
expect(room.getEventReadUpTo(userId)).toBe(mainTimelineReceipt.eventId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Determining the right thread ID for a receipt", () => {
|
||||
it("provides the thread root ID for a normal threaded message", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: "@bob:matrix.org",
|
||||
room: "!roomx",
|
||||
content: {
|
||||
"body": "Hello from a thread",
|
||||
"m.relates_to": {
|
||||
"event_id": "$thread1",
|
||||
"m.in_reply_to": {
|
||||
event_id: "$thread1",
|
||||
},
|
||||
"rel_type": "m.thread",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(threadIdForReceipt(event)).toEqual("$thread1");
|
||||
});
|
||||
|
||||
it("provides 'main' for a non-thread message", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: "@bob:matrix.org",
|
||||
room: "!roomx",
|
||||
content: { body: "Hello" },
|
||||
});
|
||||
|
||||
expect(threadIdForReceipt(event)).toEqual("main");
|
||||
});
|
||||
|
||||
it("provides 'main' for a thread root", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: "@bob:matrix.org",
|
||||
room: "!roomx",
|
||||
content: { body: "Hello" },
|
||||
});
|
||||
// Set thread ID to this event's ID, meaning this is the thread root
|
||||
event.setThreadId(event.getId());
|
||||
|
||||
expect(threadIdForReceipt(event)).toEqual("main");
|
||||
});
|
||||
|
||||
it("provides 'main' for a reaction to a thread root", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.Reaction,
|
||||
user: "@bob:matrix.org",
|
||||
room: "!roomx",
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: "$thread1",
|
||||
key: Math.random().toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Set thread Id, meaning this looks like it's in the thread (this
|
||||
// happens for relations like this, so that they appear in the
|
||||
// thread's timeline).
|
||||
event.setThreadId("$thread1");
|
||||
|
||||
// But because it's a reaction to the thread root, it's in main
|
||||
expect(threadIdForReceipt(event)).toEqual("main");
|
||||
});
|
||||
|
||||
it("provides the thread ID for a reaction to a threaded message", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.Reaction,
|
||||
user: "@bob:matrix.org",
|
||||
room: "!roomx",
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: "$withinthread2",
|
||||
key: Math.random().toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Set thread Id, to say this message is in the thread. This happens
|
||||
// when the message arrived and is classified.
|
||||
event.setThreadId("$thread1");
|
||||
|
||||
// It's in the thread because it refers to something else, not the
|
||||
// thread root
|
||||
expect(threadIdForReceipt(event)).toEqual("$thread1");
|
||||
});
|
||||
|
||||
it("(suprisingly?) provides 'main' for a redaction of a threaded message", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomRedaction,
|
||||
content: {
|
||||
reason: "Spamming",
|
||||
},
|
||||
redacts: "$withinthread2",
|
||||
room: "!roomx",
|
||||
user: "@bob:matrix.org",
|
||||
});
|
||||
|
||||
// Set thread Id, to say this message is in the thread.
|
||||
event.setThreadId("$thread1");
|
||||
|
||||
// Because redacting a message removes all its m.relations, the
|
||||
// message is no longer in the thread, so we must send a receipt for
|
||||
// it in the main timeline.
|
||||
//
|
||||
// This is surprising, but it follows the spec (at least up to
|
||||
// current latest room version, 11). In fact, the event should no
|
||||
// longer have a thread ID set on it, so this testcase should not
|
||||
// come up. (At time of writing, this is not the case though - it
|
||||
// does still have threadId set.)
|
||||
expect(threadIdForReceipt(event)).toEqual("main");
|
||||
});
|
||||
|
||||
it("provides the thread ID for an edit of a threaded message", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomRedaction,
|
||||
content: {
|
||||
"body": "Edited!",
|
||||
"m.new_content": {
|
||||
body: "Edited!",
|
||||
},
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Replace,
|
||||
event_id: "$withinthread2",
|
||||
},
|
||||
},
|
||||
room: "!roomx",
|
||||
user: "@bob:matrix.org",
|
||||
});
|
||||
|
||||
// Set thread Id, to say this message is in the thread.
|
||||
event.setThreadId("$thread1");
|
||||
|
||||
// It's in the thread, because it redacts something inside the
|
||||
// thread (not the thread root)
|
||||
expect(threadIdForReceipt(event)).toEqual("$thread1");
|
||||
});
|
||||
|
||||
it("provides 'main' for an edit of a thread root", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomRedaction,
|
||||
content: {
|
||||
"body": "Edited!",
|
||||
"m.new_content": {
|
||||
body: "Edited!",
|
||||
},
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Replace,
|
||||
event_id: "$thread1",
|
||||
},
|
||||
},
|
||||
room: "!roomx",
|
||||
user: "@bob:matrix.org",
|
||||
});
|
||||
|
||||
// Set thread Id, to say this message is in the thread.
|
||||
event.setThreadId("$thread1");
|
||||
|
||||
// It's in the thread, because it redacts something inside the
|
||||
// thread (not the thread root)
|
||||
expect(threadIdForReceipt(event)).toEqual("main");
|
||||
});
|
||||
|
||||
it("provides 'main' for a redaction of the thread root", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomRedaction,
|
||||
content: {
|
||||
reason: "Spamming",
|
||||
},
|
||||
redacts: "$thread1",
|
||||
room: "!roomx",
|
||||
user: "@bob:matrix.org",
|
||||
});
|
||||
|
||||
// Set thread Id, to say this message is in the thread.
|
||||
event.setThreadId("$thread1");
|
||||
|
||||
// It's in the thread, because it redacts something inside the
|
||||
// thread (not the thread root)
|
||||
expect(threadIdForReceipt(event)).toEqual("main");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import "../../olm-loader";
|
||||
import { RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
|
||||
import { MSC3903ECDHPayload, MSC3903ECDHv2RendezvousChannel } from "../../../src/rendezvous/channels";
|
||||
import { decodeBase64 } from "../../../src/crypto/olmlib";
|
||||
import { decodeBase64 } from "../../../src/base64";
|
||||
import { DummyTransport } from "./DummyTransport";
|
||||
|
||||
function makeTransport(name: string) {
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
MSC3886SimpleHttpRendezvousTransportDetails,
|
||||
} from "../../../src/rendezvous/transports";
|
||||
import { DummyTransport } from "./DummyTransport";
|
||||
import { decodeBase64 } from "../../../src/crypto/olmlib";
|
||||
import { decodeBase64 } from "../../../src/base64";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
|
||||
|
||||
|
||||
@@ -82,12 +82,6 @@ describe("OutgoingRequestProcessor", () => {
|
||||
"https://example.com/_matrix/client/v3/keys/signatures/upload",
|
||||
],
|
||||
["KeysBackupRequest", KeysBackupRequest, "PUT", "https://example.com/_matrix/client/v3/room_keys/keys"],
|
||||
[
|
||||
"SigningKeysUploadRequest",
|
||||
SigningKeysUploadRequest,
|
||||
"POST",
|
||||
"https://example.com/_matrix/client/v3/keys/device_signing/upload",
|
||||
],
|
||||
];
|
||||
|
||||
test.each(tests)(`should handle %ss`, async (_, RequestClass, expectedMethod, expectedPath) => {
|
||||
@@ -121,7 +115,7 @@ describe("OutgoingRequestProcessor", () => {
|
||||
|
||||
it("should handle ToDeviceRequests", async () => {
|
||||
// first, mock up the ToDeviceRequest as we might expect to receive it from the Rust layer ...
|
||||
const testBody = '{ "foo": "bar" }';
|
||||
const testBody = '{ "messages": { "user": {"device": "bar" }}}';
|
||||
const outgoingRequest = new ToDeviceRequest("1234", "test/type", "test/txnid", testBody);
|
||||
|
||||
// ... then poke it into the OutgoingRequestProcessor under test.
|
||||
@@ -179,10 +173,37 @@ describe("OutgoingRequestProcessor", () => {
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
});
|
||||
|
||||
it("should handle SigningKeysUploadRequests without UIA", async () => {
|
||||
// first, mock up a request as we might expect to receive it from the Rust layer ...
|
||||
const testReq = { foo: "bar" };
|
||||
const outgoingRequest = new SigningKeysUploadRequest(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("POST", "/_matrix")
|
||||
.check((req) => {
|
||||
expect(req.path).toEqual("https://example.com/_matrix/client/v3/keys/device_signing/upload");
|
||||
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);
|
||||
|
||||
// SigningKeysUploadRequest does not need to be marked as sent, so no call to OlmMachine.markAsSent is expected.
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
await reqProm;
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
});
|
||||
|
||||
it("should handle SigningKeysUploadRequests with UIA", async () => {
|
||||
// first, mock up a request as we might expect to receive it from the Rust layer ...
|
||||
const testReq = { foo: "bar" };
|
||||
const outgoingRequest = new SigningKeysUploadRequest("1234", JSON.stringify(testReq));
|
||||
const outgoingRequest = new SigningKeysUploadRequest(JSON.stringify(testReq));
|
||||
|
||||
// also create a UIA callback
|
||||
const authCallback: UIAuthCallback<Object> = async (makeRequest) => {
|
||||
@@ -192,7 +213,7 @@ describe("OutgoingRequestProcessor", () => {
|
||||
// ... then poke the request into the OutgoingRequestProcessor under test
|
||||
const reqProm = processor.makeOutgoingRequest(outgoingRequest, authCallback);
|
||||
|
||||
// Now: check that it makes a matching HTTP request ...
|
||||
// Now: check that it makes a matching HTTP request.
|
||||
const testResponse = '{"result":1}';
|
||||
httpBackend
|
||||
.when("POST", "/_matrix")
|
||||
@@ -204,12 +225,10 @@ describe("OutgoingRequestProcessor", () => {
|
||||
})
|
||||
.respond(200, testResponse, true);
|
||||
|
||||
// ... and that it calls OlmMachine.markAsSent.
|
||||
const markSentCallPromise = awaitCallToMarkAsSent();
|
||||
await httpBackend.flushAllExpected();
|
||||
// SigningKeysUploadRequest does not need to be marked as sent, so no call to OlmMachine.markAsSent is expected.
|
||||
|
||||
await Promise.all([reqProm, markSentCallPromise]);
|
||||
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", outgoingRequest.type, testResponse);
|
||||
await httpBackend.flushAllExpected();
|
||||
await reqProm;
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
*
|
||||
* Copyright 2023 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 { HistoryVisibility as RustHistoryVisibility } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { HistoryVisibility } from "../../../src";
|
||||
import { toRustHistoryVisibility } from "../../../src/rust-crypto/RoomEncryptor";
|
||||
|
||||
it.each([
|
||||
[HistoryVisibility.Invited, RustHistoryVisibility.Invited],
|
||||
[HistoryVisibility.Joined, RustHistoryVisibility.Joined],
|
||||
[HistoryVisibility.Shared, RustHistoryVisibility.Shared],
|
||||
[HistoryVisibility.WorldReadable, RustHistoryVisibility.WorldReadable],
|
||||
])("JS HistoryVisibility to Rust HistoryVisibility: converts %s to %s", (historyVisibility, expected) => {
|
||||
expect(toRustHistoryVisibility(historyVisibility)).toBe(expected);
|
||||
});
|
||||
@@ -44,10 +44,12 @@ import {
|
||||
EventShieldColour,
|
||||
EventShieldReason,
|
||||
ImportRoomKeysOpts,
|
||||
KeyBackupCheck,
|
||||
VerificationRequest,
|
||||
} from "../../../src/crypto-api";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { defer } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger";
|
||||
|
||||
const TEST_USER = "@alice:example.com";
|
||||
const TEST_DEVICE_ID = "TEST_DEVICE";
|
||||
@@ -62,6 +64,9 @@ describe("initRustCrypto", () => {
|
||||
return {
|
||||
registerRoomKeyUpdatedCallback: jest.fn(),
|
||||
registerUserIdentityUpdatedCallback: jest.fn(),
|
||||
getSecretsFromInbox: jest.fn().mockResolvedValue(["dGhpc2lzYWZha2VzZWNyZXQ="]),
|
||||
deleteSecretsFromInbox: jest.fn(),
|
||||
registerReceiveSecretCallback: jest.fn(),
|
||||
outgoingRequests: jest.fn(),
|
||||
} as unknown as Mocked<OlmMachine>;
|
||||
}
|
||||
@@ -71,6 +76,7 @@ describe("initRustCrypto", () => {
|
||||
jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine);
|
||||
|
||||
await initRustCrypto(
|
||||
logger,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
@@ -93,6 +99,7 @@ describe("initRustCrypto", () => {
|
||||
jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine);
|
||||
|
||||
await initRustCrypto(
|
||||
logger,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
@@ -104,9 +111,35 @@ describe("initRustCrypto", () => {
|
||||
|
||||
expect(OlmMachine.initialize).toHaveBeenCalledWith(expect.anything(), expect.anything(), undefined, undefined);
|
||||
});
|
||||
|
||||
it("Should get secrets from inbox on start", async () => {
|
||||
const testOlmMachine = makeTestOlmMachine() as OlmMachine;
|
||||
jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine);
|
||||
|
||||
await initRustCrypto(
|
||||
logger,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
"storePrefix",
|
||||
"storePassphrase",
|
||||
);
|
||||
|
||||
expect(testOlmMachine.getSecretsFromInbox).toHaveBeenCalledWith("m.megolm_backup.v1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RustCrypto", () => {
|
||||
it("getVersion() should return the current version of the rust sdk and vodozemac", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
const versions = RustSdkCryptoJs.getVersions();
|
||||
expect(rustCrypto.getVersion()).toBe(
|
||||
`Rust SDK ${versions.matrix_sdk_crypto} (${versions.git_sha}), Vodozemac ${versions.vodozemac}`,
|
||||
);
|
||||
});
|
||||
|
||||
describe(".importRoomKeys and .exportRoomKeys", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
@@ -315,6 +348,7 @@ describe("RustCrypto", () => {
|
||||
} as unknown as Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
olmMachine,
|
||||
{} as MatrixHttpApi<any>,
|
||||
TEST_USER,
|
||||
@@ -444,6 +478,7 @@ describe("RustCrypto", () => {
|
||||
getRoomEventEncryptionInfo: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
olmMachine,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
@@ -478,6 +513,26 @@ describe("RustCrypto", () => {
|
||||
expect(olmMachine.getRoomEventEncryptionInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle decryption failures", async () => {
|
||||
const event = mkEvent({
|
||||
event: true,
|
||||
type: "m.room.encrypted",
|
||||
content: { algorithm: "fake_alg" },
|
||||
room: "!room:id",
|
||||
});
|
||||
event.event.event_id = "$event:id";
|
||||
const mockCryptoBackend = {
|
||||
decryptEvent: () => {
|
||||
throw new Error("UISI");
|
||||
},
|
||||
};
|
||||
await event.attemptDecryption(mockCryptoBackend as unknown as CryptoBackend);
|
||||
|
||||
const res = await rustCrypto.getEncryptionInfoForEvent(event);
|
||||
expect(res).toBe(null);
|
||||
expect(olmMachine.getRoomEventEncryptionInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the event into the OlmMachine", async () => {
|
||||
const encryptedEvent = await makeEncryptedEvent();
|
||||
const res = await rustCrypto.getEncryptionInfoForEvent(encryptedEvent);
|
||||
@@ -498,7 +553,7 @@ describe("RustCrypto", () => {
|
||||
[RustSdkCryptoJs.ShieldColor.Red, EventShieldColour.RED],
|
||||
])("gets the right shield color (%i)", async (rustShield, expectedShield) => {
|
||||
const mockEncryptionInfo = {
|
||||
shieldState: jest.fn().mockReturnValue({ color: rustShield, message: null }),
|
||||
shieldState: jest.fn().mockReturnValue({ color: rustShield, message: undefined }),
|
||||
} as unknown as RustSdkCryptoJs.EncryptionInfo;
|
||||
olmMachine.getRoomEventEncryptionInfo.mockResolvedValue(mockEncryptionInfo);
|
||||
|
||||
@@ -509,7 +564,7 @@ describe("RustCrypto", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, null],
|
||||
[undefined, null],
|
||||
["Encrypted by an unverified user.", EventShieldReason.UNVERIFIED_IDENTITY],
|
||||
["Encrypted by a device not verified by its owner.", EventShieldReason.UNSIGNED_DEVICE],
|
||||
[
|
||||
@@ -618,6 +673,7 @@ describe("RustCrypto", () => {
|
||||
getDevice: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
olmMachine,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
@@ -783,10 +839,23 @@ describe("RustCrypto", () => {
|
||||
it("can save and restore a key", async () => {
|
||||
const key = "testtesttesttesttesttesttesttest";
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await rustCrypto.storeSessionBackupPrivateKey(new TextEncoder().encode(key));
|
||||
await rustCrypto.storeSessionBackupPrivateKey(
|
||||
new TextEncoder().encode(key),
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
);
|
||||
const fetched = await rustCrypto.getSessionBackupPrivateKey();
|
||||
expect(new TextDecoder().decode(fetched!)).toEqual(key);
|
||||
});
|
||||
|
||||
it("fails to save a key if version not provided", async () => {
|
||||
const key = "testtesttesttesttesttesttesttest";
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await expect(() => rustCrypto.storeSessionBackupPrivateKey(new TextEncoder().encode(key))).rejects.toThrow(
|
||||
"storeSessionBackupPrivateKey: version is required",
|
||||
);
|
||||
const fetched = await rustCrypto.getSessionBackupPrivateKey();
|
||||
expect(fetched).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActiveSessionBackupVersion", () => {
|
||||
@@ -823,6 +892,7 @@ describe("RustCrypto", () => {
|
||||
getIdentity: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
olmMachine,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
@@ -850,6 +920,57 @@ describe("RustCrypto", () => {
|
||||
expect(userVerificationStatus.wasCrossSigningVerified()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("key backup", () => {
|
||||
it("is started when rust crypto is created", async () => {
|
||||
// `RustCrypto.checkKeyBackupAndEnable` async call is made in background in the RustCrypto constructor.
|
||||
// We don't have an instance of the rust crypto yet, we spy directly in the prototype.
|
||||
const spyCheckKeyBackupAndEnable = jest
|
||||
.spyOn(RustCrypto.prototype, "checkKeyBackupAndEnable")
|
||||
.mockResolvedValue({} as KeyBackupCheck);
|
||||
|
||||
await makeTestRustCrypto();
|
||||
|
||||
expect(spyCheckKeyBackupAndEnable).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("raises KeyBackupStatus event when identify change", async () => {
|
||||
// Return the key backup
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
const mockHttpApi = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||
baseUrl: "http://server/",
|
||||
prefix: "",
|
||||
onlyData: true,
|
||||
});
|
||||
|
||||
const olmMachine = {
|
||||
getIdentity: jest.fn(),
|
||||
// Force the backup to be trusted by the olmMachine
|
||||
verifyBackup: jest.fn().mockResolvedValue({ trusted: jest.fn().mockReturnValue(true) }),
|
||||
isBackupEnabled: jest.fn().mockReturnValue(true),
|
||||
getBackupKeys: jest.fn(),
|
||||
enableBackupV1: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
const rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
olmMachine,
|
||||
mockHttpApi,
|
||||
testData.TEST_USER_ID,
|
||||
testData.TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
);
|
||||
|
||||
// Wait for the key backup to be available
|
||||
const keyBackupStatusPromise = new Promise<boolean>((resolve) =>
|
||||
rustCrypto.once(CryptoEvent.KeyBackupStatus, resolve),
|
||||
);
|
||||
await rustCrypto.onUserIdentityUpdated(new RustSdkCryptoJs.UserId(testData.TEST_USER_ID));
|
||||
expect(await keyBackupStatusPromise).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** build a basic RustCrypto instance for testing
|
||||
@@ -863,5 +984,5 @@ async function makeTestRustCrypto(
|
||||
secretStorage: ServerSideSecretStorage = {} as ServerSideSecretStorage,
|
||||
cryptoCallbacks: CryptoCallbacks = {} as CryptoCallbacks,
|
||||
): Promise<RustCrypto> {
|
||||
return await initRustCrypto(http, userId, deviceId, secretStorage, cryptoCallbacks, null, undefined);
|
||||
return await initRustCrypto(logger, http, userId, deviceId, secretStorage, cryptoCallbacks, null, undefined);
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@ limitations under the License.
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import "jest-localstorage-mock";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
|
||||
import { IndexedDBStore, IStateEventWithRoomId, MemoryStore } from "../../../src";
|
||||
import { IndexedDBStore, IStateEventWithRoomId, MemoryStore, User, UserEvent } from "../../../src";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
|
||||
import { defer } from "../../../src/utils";
|
||||
@@ -76,6 +77,62 @@ describe("IndexedDBStore", () => {
|
||||
expect(await store.getOutOfBandMembers(roomId)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("Should load presence events on startup", async () => {
|
||||
// 1. Create idb database
|
||||
const indexedDB = new IDBFactory();
|
||||
const setupDefer = defer<Event>();
|
||||
const req = indexedDB.open("matrix-js-sdk:db3", 1);
|
||||
let db: IDBDatabase;
|
||||
req.onupgradeneeded = () => {
|
||||
db = req.result;
|
||||
db.createObjectStore("users", { keyPath: ["userId"] });
|
||||
db.createObjectStore("accountData", { keyPath: ["type"] });
|
||||
db.createObjectStore("sync", { keyPath: ["clobber"] });
|
||||
};
|
||||
req.onsuccess = setupDefer.resolve;
|
||||
await setupDefer.promise;
|
||||
|
||||
// 2. Fill in user presence data
|
||||
const writeDefer = defer<Event>();
|
||||
const transaction = db!.transaction(["users"], "readwrite");
|
||||
const objectStore = transaction.objectStore("users");
|
||||
const request = objectStore.put({
|
||||
userId: "@alice:matrix.org",
|
||||
event: {
|
||||
content: {
|
||||
presence: "online",
|
||||
},
|
||||
sender: "@alice:matrix.org",
|
||||
type: "m.presence",
|
||||
},
|
||||
});
|
||||
request.onsuccess = writeDefer.resolve;
|
||||
await writeDefer.promise;
|
||||
|
||||
// 3. Close database
|
||||
req.result.close();
|
||||
|
||||
// 2. Check if the code loads presence events
|
||||
const store = new IndexedDBStore({
|
||||
indexedDB: indexedDB,
|
||||
dbName: "db3",
|
||||
localStorage,
|
||||
});
|
||||
let userCreated = false;
|
||||
let presenceEventEmitted = false;
|
||||
store.setUserCreator((id: string) => {
|
||||
userCreated = true;
|
||||
const user = new User(id);
|
||||
user.on(UserEvent.Presence, () => {
|
||||
presenceEventEmitted = true;
|
||||
});
|
||||
return user;
|
||||
});
|
||||
await store.startup();
|
||||
expect(userCreated).toBe(true);
|
||||
expect(presenceEventEmitted).toBe(true);
|
||||
});
|
||||
|
||||
it("should use MemoryStore methods for pending events if no localStorage", async () => {
|
||||
jest.spyOn(MemoryStore.prototype, "setPendingEvents");
|
||||
jest.spyOn(MemoryStore.prototype, "getPendingEvents");
|
||||
|
||||
@@ -41,7 +41,7 @@ export enum AutoDiscoveryAction {
|
||||
FAIL_ERROR = "FAIL_ERROR",
|
||||
}
|
||||
|
||||
enum AutoDiscoveryError {
|
||||
export enum AutoDiscoveryError {
|
||||
Invalid = "Invalid homeserver discovery response",
|
||||
GenericFailure = "Failed to get autodiscovery configuration from server",
|
||||
InvalidHsBaseUrl = "Invalid base_url for m.homeserver",
|
||||
@@ -114,7 +114,7 @@ export class AutoDiscovery {
|
||||
|
||||
public static readonly ERROR_HOMESERVER_TOO_OLD = AutoDiscoveryError.HomeserverTooOld;
|
||||
|
||||
public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError);
|
||||
public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError) as AutoDiscoveryError[];
|
||||
|
||||
/**
|
||||
* The auto discovery failed. The client is expected to communicate
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
Copyright 2023 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base64 encoding and decoding utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Encode a typed array of uint8 as base64.
|
||||
* @param uint8Array - The data to encode.
|
||||
* @returns The base64.
|
||||
*/
|
||||
export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
// A brief note on the state of base64 encoding in Javascript.
|
||||
// As of 2023, there is still no common native impl between both browsers and
|
||||
// node. Older Webpack provides an impl for Buffer and there is a polyfill class
|
||||
// for it. There are also plenty of pure js impls, eg. base64-js which has 2336
|
||||
// dependents at current count. Using this would probably be fine although it's
|
||||
// a little under-docced and run by an individual. The node impl works fine,
|
||||
// the browser impl works but predates Uint8Array and so only uses strings.
|
||||
// Right now, switching between native (or polyfilled) impls like this feels
|
||||
// like the least bad option, but... *shrugs*.
|
||||
if (typeof Buffer === "function") {
|
||||
return Buffer.from(uint8Array).toString("base64");
|
||||
} else if (typeof btoa === "function" && uint8Array instanceof Uint8Array) {
|
||||
// ArrayBuffer is a node concept so the param should always be a Uint8Array on
|
||||
// the browser. We need to check because ArrayBuffers don't have reduce.
|
||||
return btoa(uint8Array.reduce((acc, current) => acc + String.fromCharCode(current), ""));
|
||||
} else {
|
||||
throw new Error("No base64 impl found!");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a typed array of uint8 as unpadded base64.
|
||||
* @param uint8Array - The data to encode.
|
||||
* @returns The unpadded base64.
|
||||
*/
|
||||
export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
return encodeBase64(uint8Array).replace(/={1,2}$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64 string to a typed array of uint8.
|
||||
* @param base64 - The base64 to decode.
|
||||
* @returns The decoded data.
|
||||
*/
|
||||
export function decodeBase64(base64: string): Uint8Array {
|
||||
// See encodeBase64 for a short treatise on base64 en/decoding in JS
|
||||
if (typeof Buffer === "function") {
|
||||
return Buffer.from(base64, "base64");
|
||||
} else if (typeof atob === "function") {
|
||||
const itFunc = function* (): Generator<number> {
|
||||
const decoded = atob(
|
||||
// built-in atob doesn't support base64url: convert so we support either
|
||||
base64.replace("-", "+").replace("_", "/"),
|
||||
);
|
||||
for (let i = 0; i < decoded.length; ++i) {
|
||||
yield decoded.charCodeAt(i);
|
||||
}
|
||||
};
|
||||
return Uint8Array.from(itFunc());
|
||||
} else {
|
||||
throw new Error("No base64 impl found!");
|
||||
}
|
||||
}
|
||||
+181
-78
@@ -47,12 +47,12 @@ import { Direction, EventTimeline } from "./models/event-timeline";
|
||||
import { IActionsObject, PushProcessor } from "./pushprocessor";
|
||||
import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery";
|
||||
import * as olmlib from "./crypto/olmlib";
|
||||
import { decodeBase64, encodeBase64 } from "./crypto/olmlib";
|
||||
import { decodeBase64, encodeBase64 } from "./base64";
|
||||
import { IExportedDevice as IExportedOlmDevice } from "./crypto/OlmDevice";
|
||||
import { IOlmDevice } from "./crypto/algorithms/megolm";
|
||||
import { TypedReEmitter } from "./ReEmitter";
|
||||
import { IRoomEncryption, RoomList } from "./crypto/RoomList";
|
||||
import { logger } from "./logger";
|
||||
import { logger, Logger } from "./logger";
|
||||
import { SERVICE_TYPES } from "./service-types";
|
||||
import {
|
||||
Body,
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
IdentityPrefix,
|
||||
IHttpOpts,
|
||||
IRequestOpts,
|
||||
TokenRefreshFunction,
|
||||
MatrixError,
|
||||
MatrixHttpApi,
|
||||
MediaPrefix,
|
||||
@@ -294,6 +295,14 @@ export interface ICreateClientOpts {
|
||||
deviceId?: string;
|
||||
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
|
||||
/**
|
||||
* Function used to attempt refreshing access and refresh tokens
|
||||
* Called by http-api when a possibly expired token is encountered
|
||||
* and a refreshToken is found
|
||||
*/
|
||||
tokenRefreshFunction?: TokenRefreshFunction;
|
||||
|
||||
/**
|
||||
* Identity server provider to retrieve the user's access token when accessing
|
||||
@@ -415,6 +424,12 @@ export interface ICreateClientOpts {
|
||||
* so that livekit media can be used in the application layert (js-sdk contains no livekit code).
|
||||
*/
|
||||
useLivekitForGroupCalls?: boolean;
|
||||
|
||||
/**
|
||||
* A logger to associate with this MatrixClient.
|
||||
* Defaults to the built-in global logger.
|
||||
*/
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
export interface IMatrixClientCreateOpts extends ICreateClientOpts {
|
||||
@@ -1207,10 +1222,12 @@ const SSO_ACTION_PARAM = new UnstableValue("action", "org.matrix.msc3824.action"
|
||||
export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHandlerMap> {
|
||||
public static readonly RESTORE_BACKUP_ERROR_BAD_KEY = "RESTORE_BACKUP_ERROR_BAD_KEY";
|
||||
|
||||
private readonly logger: Logger;
|
||||
|
||||
public reEmitter = new TypedReEmitter<EmittedEvents, ClientEventHandlerMap>(this);
|
||||
public olmVersion: [number, number, number] | null = null; // populated after initCrypto
|
||||
public usingExternalCrypto = false;
|
||||
public store: Store;
|
||||
private _store!: Store;
|
||||
public deviceId: string | null;
|
||||
public credentials: { userId: string | null };
|
||||
|
||||
@@ -1312,6 +1329,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public constructor(opts: IMatrixClientCreateOpts) {
|
||||
super();
|
||||
|
||||
// If a custom logger is provided, use it. Otherwise, default to the global
|
||||
// one in logger.ts.
|
||||
this.logger = opts.logger ?? logger;
|
||||
|
||||
opts.baseUrl = utils.ensureNoTrailingSlash(opts.baseUrl);
|
||||
opts.idBaseUrl = utils.ensureNoTrailingSlash(opts.idBaseUrl);
|
||||
|
||||
@@ -1332,26 +1353,29 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
baseUrl: opts.baseUrl,
|
||||
idBaseUrl: opts.idBaseUrl,
|
||||
accessToken: opts.accessToken,
|
||||
refreshToken: opts.refreshToken,
|
||||
tokenRefreshFunction: opts.tokenRefreshFunction,
|
||||
prefix: ClientPrefix.V3,
|
||||
onlyData: true,
|
||||
extraParams: opts.queryParams,
|
||||
localTimeoutMs: opts.localTimeoutMs,
|
||||
useAuthorizationHeader: opts.useAuthorizationHeader,
|
||||
logger: this.logger,
|
||||
});
|
||||
|
||||
if (opts.deviceToImport) {
|
||||
if (this.deviceId) {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"not importing device because device ID is provided to " +
|
||||
"constructor independently of exported data",
|
||||
);
|
||||
} else if (this.credentials.userId) {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"not importing device because user ID is provided to " +
|
||||
"constructor independently of exported data",
|
||||
);
|
||||
} else if (!opts.deviceToImport.deviceId) {
|
||||
logger.warn("not importing device because no device ID in exported data");
|
||||
this.logger.warn("not importing device because no device ID in exported data");
|
||||
} else {
|
||||
this.deviceId = opts.deviceToImport.deviceId;
|
||||
this.credentials.userId = opts.deviceToImport.userId;
|
||||
@@ -1482,6 +1506,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
this._secretStorage = new ServerSideSecretStorageImpl(this, opts.cryptoCallbacks ?? {});
|
||||
}
|
||||
|
||||
public set store(newStore: Store) {
|
||||
this._store = newStore;
|
||||
this._store.setUserCreator((userId) => User.createUser(userId, this));
|
||||
}
|
||||
|
||||
public get store(): Store {
|
||||
return this._store;
|
||||
}
|
||||
|
||||
/**
|
||||
* High level helper method to begin syncing and poll for new events. To listen for these
|
||||
* events, add a listener for {@link ClientEvent.Event}
|
||||
@@ -1523,7 +1556,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
if (this.syncApi) {
|
||||
// This shouldn't happen since we thought the client was not running
|
||||
logger.error("Still have sync object whilst not running: stopping old one");
|
||||
this.logger.error("Still have sync object whilst not running: stopping old one");
|
||||
this.syncApi.stop();
|
||||
}
|
||||
|
||||
@@ -1537,7 +1570,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
Thread.setServerSideListSupport(list);
|
||||
Thread.setServerSideFwdPaginationSupport(fwdPagination);
|
||||
} catch (e) {
|
||||
logger.error("Can't fetch server versions, continuing to initialise sync, this will be retried later", e);
|
||||
this.logger.error(
|
||||
"Can't fetch server versions, continuing to initialise sync, this will be retried later",
|
||||
e,
|
||||
);
|
||||
}
|
||||
|
||||
this.clientOpts = opts ?? {};
|
||||
@@ -1553,7 +1589,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
if (this.clientOpts.hasOwnProperty("experimentalThreadSupport")) {
|
||||
logger.warn("`experimentalThreadSupport` has been deprecated, use `threadSupport` instead");
|
||||
this.logger.warn("`experimentalThreadSupport` has been deprecated, use `threadSupport` instead");
|
||||
}
|
||||
|
||||
// If `threadSupport` is omitted and the deprecated `experimentalThreadSupport` has been passed
|
||||
@@ -1565,7 +1601,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
this.clientOpts.threadSupport = this.clientOpts.experimentalThreadSupport;
|
||||
}
|
||||
|
||||
this.syncApi.sync().catch((e) => logger.info("Sync startup aborted with an error:", e));
|
||||
this.syncApi.sync().catch((e) => this.logger.info("Sync startup aborted with an error:", e));
|
||||
|
||||
if (this.clientOpts.clientWellKnownPollPeriod !== undefined) {
|
||||
this.clientWellKnownIntervalID = setInterval(() => {
|
||||
@@ -1604,7 +1640,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
if (!this.clientRunning) return; // already stopped
|
||||
|
||||
logger.log("stopping MatrixClient");
|
||||
this.logger.debug("stopping MatrixClient");
|
||||
|
||||
this.clientRunning = false;
|
||||
|
||||
@@ -1654,7 +1690,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
if (!getDeviceResult.device_data || !getDeviceResult.device_id) {
|
||||
logger.info("no dehydrated device found");
|
||||
this.logger.info("no dehydrated device found");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1662,16 +1698,16 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
try {
|
||||
const deviceData = getDeviceResult.device_data;
|
||||
if (deviceData.algorithm !== DEHYDRATION_ALGORITHM) {
|
||||
logger.warn("Wrong algorithm for dehydrated device");
|
||||
this.logger.warn("Wrong algorithm for dehydrated device");
|
||||
return;
|
||||
}
|
||||
logger.log("unpickling dehydrated device");
|
||||
this.logger.debug("unpickling dehydrated device");
|
||||
const key = await this.cryptoCallbacks.getDehydrationKey(deviceData, (k) => {
|
||||
// copy the key so that it doesn't get clobbered
|
||||
account.unpickle(new Uint8Array(k), deviceData.account);
|
||||
});
|
||||
account.unpickle(key, deviceData.account);
|
||||
logger.log("unpickled device");
|
||||
this.logger.debug("unpickled device");
|
||||
|
||||
const rehydrateResult = await this.http.authedRequest<{ success: boolean }>(
|
||||
Method.Post,
|
||||
@@ -1687,7 +1723,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
if (rehydrateResult.success) {
|
||||
this.deviceId = getDeviceResult.device_id;
|
||||
logger.info("using dehydrated device");
|
||||
this.logger.info("using dehydrated device");
|
||||
const pickleKey = this.pickleKey || "DEFAULT_KEY";
|
||||
this.exportedOlmDeviceToImport = {
|
||||
pickledAccount: account.pickle(pickleKey),
|
||||
@@ -1698,12 +1734,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.deviceId;
|
||||
} else {
|
||||
account.free();
|
||||
logger.info("not using dehydrated device");
|
||||
this.logger.info("not using dehydrated device");
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
account.free();
|
||||
logger.warn("could not unpickle", e);
|
||||
this.logger.warn("could not unpickle", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1723,7 +1759,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
logger.info("could not get dehydrated device", e);
|
||||
this.logger.info("could not get dehydrated device", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1745,7 +1781,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
deviceDisplayName?: string,
|
||||
): Promise<void> {
|
||||
if (!this.crypto) {
|
||||
logger.warn("not dehydrating device if crypto is not enabled");
|
||||
this.logger.warn("not dehydrating device if crypto is not enabled");
|
||||
return;
|
||||
}
|
||||
return this.crypto.dehydrationManager.setKeyAndQueueDehydration(key, keyInfo, deviceDisplayName);
|
||||
@@ -1766,7 +1802,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
deviceDisplayName?: string,
|
||||
): Promise<string | undefined> {
|
||||
if (!this.crypto) {
|
||||
logger.warn("not dehydrating device if crypto is not enabled");
|
||||
this.logger.warn("not dehydrating device if crypto is not enabled");
|
||||
return;
|
||||
}
|
||||
await this.crypto.dehydrationManager.setKey(key, keyInfo, deviceDisplayName);
|
||||
@@ -1775,7 +1811,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
public async exportDevice(): Promise<IExportedDevice | undefined> {
|
||||
if (!this.crypto) {
|
||||
logger.warn("not exporting device if crypto is not enabled");
|
||||
this.logger.warn("not exporting device if crypto is not enabled");
|
||||
return;
|
||||
}
|
||||
return {
|
||||
@@ -1818,10 +1854,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
`${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`,
|
||||
]) {
|
||||
const prom = new Promise((resolve, reject) => {
|
||||
logger.info(`Removing IndexedDB instance ${dbname}`);
|
||||
this.logger.info(`Removing IndexedDB instance ${dbname}`);
|
||||
const req = indexedDB.deleteDatabase(dbname);
|
||||
req.onsuccess = (_): void => {
|
||||
logger.info(`Removed IndexedDB instance ${dbname}`);
|
||||
this.logger.info(`Removed IndexedDB instance ${dbname}`);
|
||||
resolve(0);
|
||||
};
|
||||
req.onerror = (e): void => {
|
||||
@@ -1830,11 +1866,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// database that did not allow mutations."
|
||||
//
|
||||
// it seems like the only thing we can really do is ignore the error.
|
||||
logger.warn(`Failed to remove IndexedDB instance ${dbname}:`, e);
|
||||
this.logger.warn(`Failed to remove IndexedDB instance ${dbname}:`, e);
|
||||
resolve(0);
|
||||
};
|
||||
req.onblocked = (e): void => {
|
||||
logger.info(`cannot yet remove IndexedDB instance ${dbname}`);
|
||||
this.logger.info(`cannot yet remove IndexedDB instance ${dbname}`);
|
||||
};
|
||||
});
|
||||
await prom;
|
||||
@@ -2141,7 +2177,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
if (this.cachedCapabilities && !fresh) {
|
||||
if (now < this.cachedCapabilities.expiration) {
|
||||
logger.log("Returning cached capabilities");
|
||||
this.logger.debug("Returning cached capabilities");
|
||||
return Promise.resolve(this.cachedCapabilities.capabilities);
|
||||
}
|
||||
}
|
||||
@@ -2153,7 +2189,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
.authedRequest<Response>(Method.Get, "/capabilities")
|
||||
.catch((e: Error): Response => {
|
||||
// We swallow errors because we need a default object anyhow
|
||||
logger.error(e);
|
||||
this.logger.error(e);
|
||||
return {};
|
||||
})
|
||||
.then((r = {}) => {
|
||||
@@ -2168,7 +2204,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
expiration: now + cacheMs,
|
||||
};
|
||||
|
||||
logger.log("Caching capabilities: ", capabilities);
|
||||
this.logger.debug("Caching capabilities: ", capabilities);
|
||||
return capabilities;
|
||||
});
|
||||
}
|
||||
@@ -2191,7 +2227,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
if (this.cryptoBackend) {
|
||||
logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
|
||||
this.logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2200,11 +2236,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
throw new Error(`Cannot enable encryption: no cryptoStore provided`);
|
||||
}
|
||||
|
||||
logger.log("Crypto: Starting up crypto store...");
|
||||
this.logger.debug("Crypto: Starting up crypto store...");
|
||||
await this.cryptoStore.startup();
|
||||
|
||||
// initialise the list of encrypted rooms (whether or not crypto is enabled)
|
||||
logger.log("Crypto: initialising roomlist...");
|
||||
this.logger.debug("Crypto: initialising roomlist...");
|
||||
await this.roomList.init();
|
||||
|
||||
const userId = this.getUserId();
|
||||
@@ -2244,7 +2280,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
CryptoEvent.KeysChanged,
|
||||
]);
|
||||
|
||||
logger.log("Crypto: initialising crypto object...");
|
||||
this.logger.debug("Crypto: initialising crypto object...");
|
||||
await crypto.init({
|
||||
exportedOlmDevice: this.exportedOlmDeviceToImport,
|
||||
pickleKey: this.pickleKey,
|
||||
@@ -2260,7 +2296,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// upload our keys in the background
|
||||
this.crypto.uploadDeviceKeys().catch((e) => {
|
||||
// TODO: throwing away this error is a really bad idea.
|
||||
logger.error("Error uploading device keys", e);
|
||||
this.logger.error("Error uploading device keys", e);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2281,7 +2317,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*/
|
||||
public async initRustCrypto({ useIndexedDB = true }: { useIndexedDB?: boolean } = {}): Promise<void> {
|
||||
if (this.cryptoBackend) {
|
||||
logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
|
||||
this.logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2304,6 +2340,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// needed.
|
||||
const RustCrypto = await import("./rust-crypto");
|
||||
const rustCrypto = await RustCrypto.initRustCrypto(
|
||||
this.logger,
|
||||
this.http,
|
||||
userId,
|
||||
deviceId,
|
||||
@@ -2382,7 +2419,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @deprecated Does nothing.
|
||||
*/
|
||||
public async uploadKeys(): Promise<void> {
|
||||
logger.warn("MatrixClient.uploadKeys is deprecated");
|
||||
this.logger.warn("MatrixClient.uploadKeys is deprecated");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2651,12 +2688,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* This API is currently UNSTABLE and may change or be removed without notice.
|
||||
*
|
||||
* It has no effect with the Rust crypto implementation.
|
||||
*
|
||||
* @param value - whether error on unknown devices
|
||||
*
|
||||
* @deprecated Prefer direct access to {@link CryptoApi.globalBlacklistUnverifiedDevices}:
|
||||
*
|
||||
* ```ts
|
||||
* client.getCrypto().globalBlacklistUnverifiedDevices = value;
|
||||
* client.getCrypto().globalErrorOnUnknownDevices = value;
|
||||
* ```
|
||||
*/
|
||||
public setGlobalErrorOnUnknownDevices(value: boolean): void {
|
||||
@@ -3462,7 +3499,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
if (opts.secureSecretStorage) {
|
||||
await this.secretStorage.store("m.megolm_backup.v1", encodeBase64(privateKey));
|
||||
logger.info("Key backup private key stored in secret storage");
|
||||
this.logger.info("Key backup private key stored in secret storage");
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -3531,7 +3568,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// sessions.
|
||||
await this.checkKeyBackup();
|
||||
if (!this.getKeyBackupEnabled()) {
|
||||
logger.error("Key backup not usable even though we just created it");
|
||||
this.logger.error("Key backup not usable even though we just created it");
|
||||
}
|
||||
|
||||
return res;
|
||||
@@ -3864,6 +3901,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
|
||||
if (!backupInfo.version) {
|
||||
throw new Error("Backup version must be defined");
|
||||
}
|
||||
|
||||
let totalKeyCount = 0;
|
||||
let keys: IMegolmSessionData[] = [];
|
||||
|
||||
@@ -3881,9 +3922,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// Cache the key, if possible.
|
||||
// This is async.
|
||||
this.cryptoBackend
|
||||
.storeSessionBackupPrivateKey(privKey)
|
||||
.storeSessionBackupPrivateKey(privKey, backupInfo.version)
|
||||
.catch((e) => {
|
||||
logger.warn("Error caching session backup key:", e);
|
||||
this.logger.warn("Error caching session backup key:", e);
|
||||
})
|
||||
.then(cacheCompleteCallback);
|
||||
|
||||
@@ -3930,7 +3971,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
key.session_id = targetSessionId!;
|
||||
keys.push(key);
|
||||
} catch (e) {
|
||||
logger.log("Failed to decrypt megolm session from backup", e);
|
||||
this.logger.debug("Failed to decrypt megolm session from backup", e);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -3976,7 +4017,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const roomEncryption = this.roomList.getRoomEncryption(roomId);
|
||||
if (!roomEncryption) {
|
||||
// unknown room, or unencrypted room
|
||||
logger.error("Unknown room. Not sharing decryption keys");
|
||||
this.logger.error("Unknown room. Not sharing decryption keys");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3991,7 +4032,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (alg.sendSharedHistoryInboundSessions) {
|
||||
await alg.sendSharedHistoryInboundSessions(devicesByUser);
|
||||
} else {
|
||||
logger.warn("Algorithm does not support sharing previous keys", roomEncryption.algorithm);
|
||||
this.logger.warn("Algorithm does not support sharing previous keys", roomEncryption.algorithm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4574,7 +4615,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
const type = localEvent.getType();
|
||||
logger.log(`sendEvent of type ${type} in ${roomId} with txnId ${txnId}`);
|
||||
this.logger.debug(`sendEvent of type ${type} in ${roomId} with txnId ${txnId}`);
|
||||
|
||||
localEvent.setTxnId(txnId);
|
||||
localEvent.setStatus(EventStatus.SENDING);
|
||||
@@ -4646,7 +4687,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return promise;
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("Error sending event", err.stack || err);
|
||||
this.logger.error("Error sending event", err.stack || err);
|
||||
try {
|
||||
// set the error on the event before we update the status:
|
||||
// updating the status emits the event, so the state should be
|
||||
@@ -4654,7 +4695,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
event.error = err;
|
||||
this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
|
||||
} catch (e) {
|
||||
logger.error("Exception in error handler!", (<Error>e).stack || err);
|
||||
this.logger.error("Exception in error handler!", (<Error>e).stack || err);
|
||||
}
|
||||
if (err instanceof MatrixError) {
|
||||
err.event = event;
|
||||
@@ -4767,7 +4808,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.http
|
||||
.authedRequest<ISendEventResponse>(Method.Put, path, undefined, event.getWireContent())
|
||||
.then((res) => {
|
||||
logger.log(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`);
|
||||
this.logger.debug(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
@@ -5125,24 +5166,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
$eventId: event.getId()!,
|
||||
});
|
||||
|
||||
if (!unthreaded && this.supportsThreads()) {
|
||||
// XXX: the spec currently says a threaded read receipt can be sent for the root of a thread,
|
||||
// but in practice this isn't possible and the spec needs updating.
|
||||
const isThread =
|
||||
!!event.threadRootId &&
|
||||
// A thread cannot be just a thread root and a thread root can only be read in the main timeline
|
||||
!event.isThreadRoot &&
|
||||
// Similarly non-thread relations upon the thread root (reactions, edits) should also be for the main timeline.
|
||||
event.isRelation() &&
|
||||
(event.isRelation(THREAD_RELATION_TYPE.name) || event.relationEventId !== event.threadRootId);
|
||||
body = {
|
||||
...body,
|
||||
// Only thread replies should define a specific thread. Thread roots can only be read in the main timeline.
|
||||
thread_id: isThread ? event.threadRootId : MAIN_ROOM_TIMELINE,
|
||||
};
|
||||
}
|
||||
// Unless we're explicitly making an unthreaded receipt or we don't
|
||||
// support threads, include the `thread_id` property in the body.
|
||||
const shouldAddThreadId = !unthreaded && this.supportsThreads();
|
||||
const fullBody = shouldAddThreadId ? { ...body, thread_id: threadIdForReceipt(event) } : body;
|
||||
|
||||
const promise = this.http.authedRequest<{}>(Method.Post, path, undefined, body || {});
|
||||
const promise = this.http.authedRequest<{}>(Method.Post, path, undefined, fullBody || {});
|
||||
|
||||
const room = this.getRoom(event.getRoomId());
|
||||
if (room && this.credentials.userId) {
|
||||
@@ -5867,7 +5896,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const mapper = this.getEventMapper();
|
||||
const event = mapper(res.event);
|
||||
if (event.isRelation(THREAD_RELATION_TYPE.name)) {
|
||||
logger.warn("Tried loading a regular timeline at the position of a thread event");
|
||||
this.logger.warn("Tried loading a regular timeline at the position of a thread event");
|
||||
return undefined;
|
||||
}
|
||||
const events = [
|
||||
@@ -7004,7 +7033,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// cleanup locks
|
||||
this.syncLeftRoomsPromise
|
||||
.then(() => {
|
||||
logger.log("Marking success of sync left room request");
|
||||
this.logger.debug("Marking success of sync left room request");
|
||||
this.syncedLeftRooms = true; // flip the bit on success
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -7209,14 +7238,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
let credentialsGood = false;
|
||||
const remainingTime = this.turnServersExpiry - Date.now();
|
||||
if (remainingTime > TURN_CHECK_INTERVAL) {
|
||||
logger.debug("TURN creds are valid for another " + remainingTime + " ms: not fetching new ones.");
|
||||
this.logger.debug("TURN creds are valid for another " + remainingTime + " ms: not fetching new ones.");
|
||||
credentialsGood = true;
|
||||
} else {
|
||||
logger.debug("Fetching new TURN credentials");
|
||||
this.logger.debug("Fetching new TURN credentials");
|
||||
try {
|
||||
const res = await this.turnServer();
|
||||
if (res.uris) {
|
||||
logger.log("Got TURN URIs: " + res.uris + " refresh in " + res.ttl + " secs");
|
||||
this.logger.debug("Got TURN URIs: " + res.uris + " refresh in " + res.ttl + " secs");
|
||||
// map the response to a format that can be fed to RTCPeerConnection
|
||||
const servers: ITurnServer = {
|
||||
urls: res.uris,
|
||||
@@ -7230,10 +7259,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
this.emit(ClientEvent.TurnServers, this.turnServers);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Failed to get TURN URIs", err);
|
||||
this.logger.error("Failed to get TURN URIs", err);
|
||||
if ((<HTTPError>err).httpStatus === 403) {
|
||||
// We got a 403, so there's no point in looping forever.
|
||||
logger.info("TURN access unavailable for this account: stopping credentials checks");
|
||||
this.logger.info("TURN access unavailable for this account: stopping credentials checks");
|
||||
if (this.checkTurnServersIntervalID !== null) global.clearInterval(this.checkTurnServersIntervalID);
|
||||
this.checkTurnServersIntervalID = undefined;
|
||||
this.emit(ClientEvent.TurnServersError, <HTTPError>err, true); // fatal
|
||||
@@ -7695,6 +7724,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.http.opts.accessToken || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the refresh token associated with this account.
|
||||
* @returns The refresh_token or null
|
||||
*/
|
||||
public getRefreshToken(): string | null {
|
||||
return this.http.opts.refreshToken ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the access token associated with this account.
|
||||
* @param token - The new access token.
|
||||
@@ -7972,7 +8009,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
try {
|
||||
while ((await this.crypto.backupManager.backupPendingKeys(200)) > 0);
|
||||
} catch (err) {
|
||||
logger.error("Key backup request failed when logging out. Some keys may be missing from backup", err);
|
||||
this.logger.error(
|
||||
"Key backup request failed when logging out. Some keys may be missing from backup",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8133,7 +8173,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
templatedUrl += "/$eventType";
|
||||
}
|
||||
} else if (eventType !== null) {
|
||||
logger.warn(`eventType: ${eventType} ignored when fetching
|
||||
this.logger.warn(`eventType: ${eventType} ignored when fetching
|
||||
relations as relationType is null`);
|
||||
eventType = null;
|
||||
}
|
||||
@@ -9425,7 +9465,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
targets.set(userId, Array.from(deviceMessages.keys()));
|
||||
}
|
||||
|
||||
logger.log(`PUT ${path}`, targets);
|
||||
this.logger.debug(`PUT ${path}`, targets);
|
||||
|
||||
return this.http.authedRequest(Method.Put, path, undefined, body);
|
||||
}
|
||||
@@ -9684,7 +9724,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @deprecated use supportsThreads() instead
|
||||
*/
|
||||
public supportsExperimentalThreads(): boolean {
|
||||
logger.warn(`supportsExperimentalThreads() is deprecated, use supportThreads() instead`);
|
||||
this.logger.warn(`supportsExperimentalThreads() is deprecated, use supportThreads() instead`);
|
||||
return this.clientOpts?.experimentalThreadSupport || false;
|
||||
}
|
||||
|
||||
@@ -9882,3 +9922,66 @@ export function fixNotificationCountOnDecryption(cli: MatrixClient, event: Matri
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an event, figure out the thread ID we should use for it in a receipt.
|
||||
*
|
||||
* This will either be "main", or event.threadRootId. For the thread root, or
|
||||
* e.g. reactions to the thread root, this will be main. For events inside the
|
||||
* thread, or e.g. reactions to them, this will be event.threadRootId.
|
||||
*
|
||||
* (Exported for test.)
|
||||
*/
|
||||
export function threadIdForReceipt(event: MatrixEvent): string {
|
||||
return inMainTimelineForReceipt(event) ? MAIN_ROOM_TIMELINE : event.threadRootId!;
|
||||
}
|
||||
|
||||
/**
|
||||
* a) True for non-threaded messages, thread roots and non-thread relations to thread roots.
|
||||
* b) False for messages with thread relations to the thread root.
|
||||
* c) False for messages with any kind of relation to a message from case b.
|
||||
*
|
||||
* Note: true for redactions of messages that are in threads. Redacted messages
|
||||
* are not really in threads (because their relations are gone), so if they look
|
||||
* like they are in threads, that is a sign of a bug elsewhere. (At time of
|
||||
* writing, this bug definitely exists - messages are not moved to another
|
||||
* thread when they are redacted.)
|
||||
*
|
||||
* @returns true if this event is considered to be in the main timeline as far
|
||||
* as receipts are concerned.
|
||||
*/
|
||||
function inMainTimelineForReceipt(event: MatrixEvent): boolean {
|
||||
if (!event.threadRootId) {
|
||||
// Not in a thread: then it is in the main timeline
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.isThreadRoot) {
|
||||
// Thread roots are in the main timeline. Note: the spec is ambiguous (or
|
||||
// wrong) on this - see
|
||||
// https://github.com/matrix-org/matrix-spec-proposals/pull/4037
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!event.isRelation()) {
|
||||
// If it's not related to anything, it can't be related via a chain of
|
||||
// relations to a thread root.
|
||||
//
|
||||
// Note: this is a bug, because how does it have a threadRootId if it is
|
||||
// neither a thread root, nor related to one?
|
||||
logger.warn(`Event is not a relation or a thread root, but still has a threadRootId! id=${event.getId()}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.isRelation(THREAD_RELATION_TYPE.name)) {
|
||||
// It's a message in a thread - definitely not in the main timeline.
|
||||
return false;
|
||||
}
|
||||
|
||||
const isRelatedToRoot = event.relationEventId === event.threadRootId;
|
||||
|
||||
// If it's related to the thread root (and we already know it's not a thread
|
||||
// relation) then it's in the main timeline. If it's related to something
|
||||
// else, then it's in the thread (because it has a thread ID).
|
||||
return isRelatedToRoot;
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
* symmetry with setGlobalBlacklistUnverifiedDevices but there is currently
|
||||
* no room-level equivalent for this setting.
|
||||
*
|
||||
* @remarks this is here, rather than in `CryptoApi`, because I don't think we're
|
||||
* going to support it in the rust crypto implementation.
|
||||
* @remarks This has no effect in Rust Crypto; it exists only for the sake of
|
||||
* the accessors in MatrixClient.
|
||||
*/
|
||||
globalErrorOnUnknownDevices: boolean;
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
Copyright 2023 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base64 encoding and decoding utility for crypo.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Encode a typed array of uint8 as base64.
|
||||
* @param uint8Array - The data to encode.
|
||||
* @returns The base64.
|
||||
*/
|
||||
export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
return Buffer.from(uint8Array).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a typed array of uint8 as unpadded base64.
|
||||
* @param uint8Array - The data to encode.
|
||||
* @returns The unpadded base64.
|
||||
*/
|
||||
export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
return encodeBase64(uint8Array).replace(/={1,2}$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64 string to a typed array of uint8.
|
||||
* @param base64 - The base64 to decode.
|
||||
* @returns The decoded data.
|
||||
*/
|
||||
export function decodeBase64(base64: string): Uint8Array {
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
@@ -39,6 +39,13 @@ export interface CryptoApi {
|
||||
*/
|
||||
globalBlacklistUnverifiedDevices: boolean;
|
||||
|
||||
/**
|
||||
* Return the current version of the crypto module.
|
||||
* For example: `Rust SDK ${versions.matrix_sdk_crypto} (${versions.git_sha}), Vodozemac ${versions.vodozemac}`.
|
||||
* @returns the formatted version
|
||||
*/
|
||||
getVersion(): string;
|
||||
|
||||
/**
|
||||
* Perform any background tasks that can be done before a message is ready to
|
||||
* send, in order to speed up sending of the message.
|
||||
@@ -372,11 +379,25 @@ export interface CryptoApi {
|
||||
* Store the backup decryption key.
|
||||
*
|
||||
* This should be called if the client has received the key from another device via secret sharing (gossiping).
|
||||
* It is the responsability of the caller to check that the decryption key is valid for the current backup version.
|
||||
*
|
||||
* @param key - the backup decryption key
|
||||
*
|
||||
* @deprecated prefer the variant with a `version` parameter.
|
||||
*/
|
||||
storeSessionBackupPrivateKey(key: Uint8Array): Promise<void>;
|
||||
|
||||
/**
|
||||
* Store the backup decryption key.
|
||||
*
|
||||
* This should be called if the client has received the key from another device via secret sharing (gossiping).
|
||||
* It is the responsability of the caller to check that the decryption key is valid for the given backup version.
|
||||
*
|
||||
* @param key - the backup decryption key
|
||||
* @param version - the backup version corresponding to this decryption key
|
||||
*/
|
||||
storeSessionBackupPrivateKey(key: Uint8Array, version: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the current status of key backup.
|
||||
*
|
||||
|
||||
@@ -224,13 +224,25 @@ export enum VerificationPhase {
|
||||
/** An `m.key.verification.ready` event has been sent or received, indicating the verification request is accepted. */
|
||||
Ready,
|
||||
|
||||
/** An `m.key.verification.start` event has been sent or received, choosing a verification method */
|
||||
/**
|
||||
* The verification is in flight.
|
||||
*
|
||||
* This means that an `m.key.verification.start` event has been sent or received, choosing a verification method;
|
||||
* however the verification has not yet completed or been cancelled.
|
||||
*/
|
||||
Started,
|
||||
|
||||
/** An `m.key.verification.cancel` event has been sent or received at any time before the `done` event, cancelling the verification request */
|
||||
/**
|
||||
* An `m.key.verification.cancel` event has been sent or received at any time before the `done` event, cancelling
|
||||
* the verification request
|
||||
*/
|
||||
Cancelled,
|
||||
|
||||
/** An `m.key.verification.done` event has been **sent**, completing the verification request. */
|
||||
/**
|
||||
* The verification request is complete.
|
||||
*
|
||||
* Normally this means that `m.key.verification.done` events have been sent and received.
|
||||
*/
|
||||
Done,
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import type { PkSigning } from "@matrix-org/olm";
|
||||
import { decodeBase64, encodeBase64, IObject, pkSign, pkVerify } from "./olmlib";
|
||||
import { IObject, pkSign, pkVerify } from "./olmlib";
|
||||
import { logger } from "../logger";
|
||||
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
|
||||
import { decryptAES, encryptAES } from "./aes";
|
||||
@@ -31,6 +31,7 @@ import { ISignatures } from "../@types/signed";
|
||||
import { CryptoStore, SecretStorePrivateKeys } from "./store/base";
|
||||
import { ServerSideSecretStorage, SecretStorageKeyDescription } from "../secret-storage";
|
||||
import { DeviceVerificationStatus, UserVerificationStatus as UserTrustLevel } from "../crypto-api";
|
||||
import { decodeBase64, encodeBase64 } from "../base64";
|
||||
|
||||
// backwards-compatibility re-exports
|
||||
export { UserTrustLevel };
|
||||
|
||||
+15
-15
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import { Account, InboundGroupSession, OutboundGroupSession, Session, Utility } from "@matrix-org/olm";
|
||||
|
||||
import { logger, PrefixedLogger } from "../logger";
|
||||
import { logger, Logger } from "../logger";
|
||||
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
|
||||
import * as algorithms from "./algorithms";
|
||||
import { CryptoStore, IProblem, ISessionInfo, IWithheld } from "./store/base";
|
||||
@@ -531,7 +531,7 @@ export class OlmDevice {
|
||||
}
|
||||
});
|
||||
},
|
||||
logger.withPrefix("[createOutboundSession]"),
|
||||
logger.getChild("[createOutboundSession]"),
|
||||
);
|
||||
return newSessionId!;
|
||||
}
|
||||
@@ -588,7 +588,7 @@ export class OlmDevice {
|
||||
}
|
||||
});
|
||||
},
|
||||
logger.withPrefix("[createInboundSession]"),
|
||||
logger.getChild("[createInboundSession]"),
|
||||
);
|
||||
|
||||
return result!;
|
||||
@@ -602,7 +602,7 @@ export class OlmDevice {
|
||||
* @returns a list of known session ids for the device
|
||||
*/
|
||||
public async getSessionIdsForDevice(theirDeviceIdentityKey: string): Promise<string[]> {
|
||||
const log = logger.withPrefix("[getSessionIdsForDevice]");
|
||||
const log = logger.getChild("[getSessionIdsForDevice]");
|
||||
|
||||
if (theirDeviceIdentityKey in this.sessionsInProgress) {
|
||||
log.debug(`Waiting for Olm session for ${theirDeviceIdentityKey} to be created`);
|
||||
@@ -642,7 +642,7 @@ export class OlmDevice {
|
||||
public async getSessionIdForDevice(
|
||||
theirDeviceIdentityKey: string,
|
||||
nowait = false,
|
||||
log?: PrefixedLogger,
|
||||
log?: Logger,
|
||||
): Promise<string | null> {
|
||||
const sessionInfos = await this.getSessionInfoForDevice(theirDeviceIdentityKey, nowait, log);
|
||||
|
||||
@@ -686,9 +686,9 @@ export class OlmDevice {
|
||||
public async getSessionInfoForDevice(
|
||||
deviceIdentityKey: string,
|
||||
nowait = false,
|
||||
log = logger,
|
||||
log: Logger = logger,
|
||||
): Promise<{ sessionId: string; lastReceivedMessageTs: number; hasReceivedMessage: boolean }[]> {
|
||||
log = log.withPrefix("[getSessionInfoForDevice]");
|
||||
log = log.getChild("[getSessionInfoForDevice]");
|
||||
|
||||
if (deviceIdentityKey in this.sessionsInProgress && !nowait) {
|
||||
log.debug(`Waiting for Olm session for ${deviceIdentityKey} to be created`);
|
||||
@@ -764,7 +764,7 @@ export class OlmDevice {
|
||||
this.saveSession(theirDeviceIdentityKey, sessionInfo, txn);
|
||||
});
|
||||
},
|
||||
logger.withPrefix("[encryptMessage]"),
|
||||
logger.getChild("[encryptMessage]"),
|
||||
);
|
||||
return res!;
|
||||
}
|
||||
@@ -806,7 +806,7 @@ export class OlmDevice {
|
||||
this.saveSession(theirDeviceIdentityKey, sessionInfo, txn);
|
||||
});
|
||||
},
|
||||
logger.withPrefix("[decryptMessage]"),
|
||||
logger.getChild("[decryptMessage]"),
|
||||
);
|
||||
return payloadString!;
|
||||
}
|
||||
@@ -842,7 +842,7 @@ export class OlmDevice {
|
||||
matches = sessionInfo.session.matches_inbound(ciphertext);
|
||||
});
|
||||
},
|
||||
logger.withPrefix("[matchesSession]"),
|
||||
logger.getChild("[matchesSession]"),
|
||||
);
|
||||
return matches!;
|
||||
}
|
||||
@@ -1142,7 +1142,7 @@ export class OlmDevice {
|
||||
},
|
||||
);
|
||||
},
|
||||
logger.withPrefix("[addInboundGroupSession]"),
|
||||
logger.getChild("[addInboundGroupSession]"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1282,7 +1282,7 @@ export class OlmDevice {
|
||||
};
|
||||
});
|
||||
},
|
||||
logger.withPrefix("[decryptGroupMessage]"),
|
||||
logger.getChild("[decryptGroupMessage]"),
|
||||
);
|
||||
|
||||
if (error!) {
|
||||
@@ -1328,7 +1328,7 @@ export class OlmDevice {
|
||||
}
|
||||
});
|
||||
},
|
||||
logger.withPrefix("[hasInboundSessionKeys]"),
|
||||
logger.getChild("[hasInboundSessionKeys]"),
|
||||
);
|
||||
|
||||
return result!;
|
||||
@@ -1398,7 +1398,7 @@ export class OlmDevice {
|
||||
};
|
||||
});
|
||||
},
|
||||
logger.withPrefix("[getInboundGroupSessionKey]"),
|
||||
logger.getChild("[getInboundGroupSessionKey]"),
|
||||
);
|
||||
|
||||
return result;
|
||||
@@ -1443,7 +1443,7 @@ export class OlmDevice {
|
||||
(txn) => {
|
||||
result = this.cryptoStore.getSharedHistoryInboundGroupSessions(roomId, txn);
|
||||
},
|
||||
logger.withPrefix("[getSharedHistoryInboundGroupSessionsForRoom]"),
|
||||
logger.getChild("[getSharedHistoryInboundGroupSessionsForRoom]"),
|
||||
);
|
||||
return result!;
|
||||
}
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { decodeBase64, encodeBase64 } from "./olmlib";
|
||||
import { decodeBase64, encodeBase64 } from "../base64";
|
||||
import { subtleCrypto, crypto, TextEncoder } from "./crypto";
|
||||
|
||||
// salt for HKDF, with 8 bytes of zeros
|
||||
|
||||
@@ -21,7 +21,7 @@ limitations under the License.
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import type { IEventDecryptionResult, IMegolmSessionData } from "../../@types/crypto";
|
||||
import { logger, PrefixedLogger } from "../../logger";
|
||||
import { logger, Logger } from "../../logger";
|
||||
import * as olmlib from "../olmlib";
|
||||
import {
|
||||
DecryptionAlgorithm,
|
||||
@@ -246,12 +246,12 @@ export class MegolmEncryption extends EncryptionAlgorithm {
|
||||
};
|
||||
|
||||
protected readonly roomId: string;
|
||||
private readonly prefixedLogger: PrefixedLogger;
|
||||
private readonly prefixedLogger: Logger;
|
||||
|
||||
public constructor(params: IParams & Required<Pick<IParams, "roomId">>) {
|
||||
super(params);
|
||||
this.roomId = params.roomId;
|
||||
this.prefixedLogger = logger.withPrefix(`[${this.roomId} encryption]`);
|
||||
this.prefixedLogger = logger.getChild(`[${this.roomId} encryption]`);
|
||||
|
||||
this.sessionRotationPeriodMsgs = params.config?.rotation_period_msgs ?? 100;
|
||||
this.sessionRotationPeriodMs = params.config?.rotation_period_ms ?? 7 * 24 * 3600 * 1000;
|
||||
@@ -333,7 +333,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
|
||||
|
||||
// need to make a brand new session?
|
||||
if (session?.needsRotation(this.sessionRotationPeriodMsgs, this.sessionRotationPeriodMs)) {
|
||||
this.prefixedLogger.log("Starting new megolm session because we need to rotate.");
|
||||
this.prefixedLogger.debug("Starting new megolm session because we need to rotate.");
|
||||
session = null;
|
||||
}
|
||||
|
||||
@@ -343,9 +343,9 @@ export class MegolmEncryption extends EncryptionAlgorithm {
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
this.prefixedLogger.log("Starting new megolm session");
|
||||
this.prefixedLogger.debug("Starting new megolm session");
|
||||
session = await this.prepareNewSession(sharedHistory);
|
||||
this.prefixedLogger.log(`Started new megolm session ${session.sessionId}`);
|
||||
this.prefixedLogger.debug(`Started new megolm session ${session.sessionId}`);
|
||||
this.outboundSessions[session.sessionId] = session;
|
||||
}
|
||||
|
||||
@@ -968,12 +968,12 @@ export class MegolmEncryption extends EncryptionAlgorithm {
|
||||
for (let i = 0; i < userDeviceMaps.length; i++) {
|
||||
try {
|
||||
await this.sendBlockedNotificationsToDevices(session, userDeviceMaps[i], payload);
|
||||
this.prefixedLogger.log(
|
||||
this.prefixedLogger.debug(
|
||||
`Completed blacklist notification for ${session.sessionId} ` +
|
||||
`(slice ${i + 1}/${userDeviceMaps.length})`,
|
||||
);
|
||||
} catch (e) {
|
||||
this.prefixedLogger.log(
|
||||
this.prefixedLogger.debug(
|
||||
`blacklist notification for ${session.sessionId} ` +
|
||||
`(slice ${i + 1}/${userDeviceMaps.length}) failed`,
|
||||
);
|
||||
@@ -1054,7 +1054,7 @@ export class MegolmEncryption extends EncryptionAlgorithm {
|
||||
* @returns Promise which resolves to the new event body
|
||||
*/
|
||||
public async encryptMessage(room: Room, eventType: string, content: IContent): Promise<IMegolmEncryptedContent> {
|
||||
this.prefixedLogger.log("Starting to encrypt event");
|
||||
this.prefixedLogger.debug("Starting to encrypt event");
|
||||
|
||||
if (this.encryptionPreparation != null) {
|
||||
// If we started sending keys, wait for it to be done.
|
||||
@@ -1291,12 +1291,12 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
private olmlib = olmlib;
|
||||
|
||||
protected readonly roomId: string;
|
||||
private readonly prefixedLogger: PrefixedLogger;
|
||||
private readonly prefixedLogger: Logger;
|
||||
|
||||
public constructor(params: DecryptionClassParams<IParams & Required<Pick<IParams, "roomId">>>) {
|
||||
super(params);
|
||||
this.roomId = params.roomId;
|
||||
this.prefixedLogger = logger.withPrefix(`[${this.roomId} decryption]`);
|
||||
this.prefixedLogger = logger.getChild(`[${this.roomId} decryption]`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1740,7 +1740,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
"readwrite",
|
||||
["parked_shared_history"],
|
||||
(txn) => this.crypto.cryptoStore.addParkedSharedHistory(roomKey.roomId, parkedData, txn),
|
||||
logger.withPrefix("[addParkedSharedHistory]"),
|
||||
logger.getChild("[addParkedSharedHistory]"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1955,7 +1955,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.prefixedLogger.log(
|
||||
this.prefixedLogger.debug(
|
||||
"sharing keys for session " +
|
||||
body.sender_key +
|
||||
"|" +
|
||||
@@ -2051,7 +2051,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
this.crypto.backupManager.backupGroupSession(session.sender_key, session.session_id).catch((e) => {
|
||||
// This throws if the upload failed, but this is fine
|
||||
// since it will have written it to the db and will retry.
|
||||
this.prefixedLogger.log("Failed to back up megolm session", e);
|
||||
this.prefixedLogger.debug("Failed to back up megolm session", e);
|
||||
});
|
||||
}
|
||||
// have another go at decrypting events sent with this session.
|
||||
@@ -2135,7 +2135,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser);
|
||||
|
||||
const sharedHistorySessions = await this.olmDevice.getSharedHistoryInboundGroupSessions(this.roomId);
|
||||
this.prefixedLogger.log(
|
||||
this.prefixedLogger.debug(
|
||||
`Sharing history in with users ${Array.from(devicesByUser.keys())}`,
|
||||
sharedHistorySessions.map(([senderKey, sessionId]) => `${senderKey}|${sessionId}`),
|
||||
);
|
||||
@@ -2178,20 +2178,20 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
for (const [userId, deviceMessages] of contentMap) {
|
||||
for (const [deviceId, content] of deviceMessages) {
|
||||
if (!hasCiphertext(content)) {
|
||||
this.prefixedLogger.log("No ciphertext for device " + userId + ":" + deviceId + ": pruning");
|
||||
this.prefixedLogger.debug("No ciphertext for device " + userId + ":" + deviceId + ": pruning");
|
||||
deviceMessages.delete(deviceId);
|
||||
}
|
||||
}
|
||||
// No devices left for that user? Strip that too.
|
||||
if (deviceMessages.size === 0) {
|
||||
this.prefixedLogger.log("Pruned all devices for user " + userId);
|
||||
this.prefixedLogger.debug("Pruned all devices for user " + userId);
|
||||
contentMap.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Is there anything left?
|
||||
if (contentMap.size === 0) {
|
||||
this.prefixedLogger.log("No users left to send to: aborting");
|
||||
this.prefixedLogger.debug("No users left to send to: aborting");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import anotherjson from "another-json";
|
||||
|
||||
import type { IDeviceKeys, IOneTimeKey } from "../@types/crypto";
|
||||
import { decodeBase64, encodeBase64 } from "./olmlib";
|
||||
import { decodeBase64, encodeBase64 } from "../base64";
|
||||
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
|
||||
import { decryptAES, encryptAES } from "./aes";
|
||||
import { logger } from "../logger";
|
||||
|
||||
+20
-11
@@ -102,6 +102,7 @@ import {
|
||||
import { Device, DeviceMap } from "../models/device";
|
||||
import { deviceInfoToDevice } from "./device-converter";
|
||||
import { ClientPrefix, MatrixError, Method } from "../http-api";
|
||||
import { decodeBase64, encodeBase64 } from "../base64";
|
||||
|
||||
/* re-exports for backwards compatibility */
|
||||
export type {
|
||||
@@ -500,7 +501,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
await this.secretStorage.store("m.megolm_backup.v1", fixedKey, [keys![0]]);
|
||||
}
|
||||
|
||||
return olmlib.decodeBase64(fixedKey || storedKey);
|
||||
return decodeBase64(fixedKey || storedKey);
|
||||
}
|
||||
|
||||
// try to get key from app
|
||||
@@ -609,6 +610,14 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
this.backupManager.checkAndStart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getVersion}.
|
||||
*/
|
||||
public getVersion(): string {
|
||||
const olmVersionTuple = Crypto.getOlmVersion();
|
||||
return `Olm ${olmVersionTuple[0]}.${olmVersionTuple[1]}.${olmVersionTuple[2]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to trust a others users signatures of their devices.
|
||||
* If false, devices will only be considered 'verified' if we have
|
||||
@@ -1050,7 +1059,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
newKeyId = await createSSSS(opts, backupKey);
|
||||
|
||||
// store the backup key in secret storage
|
||||
await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(backupKey!), [newKeyId]);
|
||||
await secretStorage.store("m.megolm_backup.v1", encodeBase64(backupKey!), [newKeyId]);
|
||||
|
||||
// The backup is trusted because the user provided the private key.
|
||||
// Sign the backup with the cross-signing key so the key backup can
|
||||
@@ -1094,7 +1103,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
);
|
||||
// write the key to 4S
|
||||
const privateKey = decodeRecoveryKey(info.recovery_key);
|
||||
await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(privateKey));
|
||||
await secretStorage.store("m.megolm_backup.v1", encodeBase64(privateKey));
|
||||
|
||||
// create keyBackupInfo object to add to builder
|
||||
const data: IKeyBackupInfo = {
|
||||
@@ -1122,7 +1131,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
const keyId = newKeyId || oldKeyId;
|
||||
await secretStorage.store("m.megolm_backup.v1", fixedBackupKey, keyId ? [keyId] : null);
|
||||
}
|
||||
const decodedBackupKey = new Uint8Array(olmlib.decodeBase64(fixedBackupKey || sessionBackupKey));
|
||||
const decodedBackupKey = new Uint8Array(decodeBase64(fixedBackupKey || sessionBackupKey));
|
||||
builder.addSessionBackupPrivateKeyToCache(decodedBackupKey);
|
||||
} else if (this.backupManager.getKeyBackupEnabled()) {
|
||||
// key backup is enabled but we don't have a session backup key in SSSS: see if we have one in
|
||||
@@ -1137,7 +1146,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
return;
|
||||
}
|
||||
logger.info("Got session backup key from cache/user that wasn't in SSSS: saving to SSSS");
|
||||
await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(backupKey));
|
||||
await secretStorage.store("m.megolm_backup.v1", encodeBase64(backupKey));
|
||||
}
|
||||
|
||||
const operation = builder.buildOperation();
|
||||
@@ -1178,7 +1187,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
|
||||
// write the key to 4S
|
||||
const privateKey = info.privateKey;
|
||||
await this.secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(privateKey));
|
||||
await this.secretStorage.store("m.megolm_backup.v1", encodeBase64(privateKey));
|
||||
await this.storeSessionBackupPrivateKey(privateKey);
|
||||
|
||||
await this.backupManager.checkAndStart();
|
||||
@@ -1301,13 +1310,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
|
||||
// make sure we have a Uint8Array, rather than a string
|
||||
if (typeof encodedKey === "string") {
|
||||
key = new Uint8Array(olmlib.decodeBase64(fixBackupKey(encodedKey) || encodedKey));
|
||||
key = new Uint8Array(decodeBase64(fixBackupKey(encodedKey) || encodedKey));
|
||||
await this.storeSessionBackupPrivateKey(key);
|
||||
}
|
||||
if (encodedKey && typeof encodedKey === "object" && "ciphertext" in encodedKey) {
|
||||
const pickleKey = Buffer.from(this.olmDevice.pickleKey);
|
||||
const decrypted = await decryptAES(encodedKey, pickleKey, "m.megolm_backup.v1");
|
||||
key = olmlib.decodeBase64(decrypted);
|
||||
key = decodeBase64(decrypted);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
@@ -1317,13 +1326,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
* @param key - the private key
|
||||
* @returns a promise so you can catch failures
|
||||
*/
|
||||
public async storeSessionBackupPrivateKey(key: ArrayLike<number>): Promise<void> {
|
||||
public async storeSessionBackupPrivateKey(key: ArrayLike<number>, version?: string): Promise<void> {
|
||||
if (!(key instanceof Uint8Array)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
throw new Error(`storeSessionBackupPrivateKey expects Uint8Array, got ${key}`);
|
||||
}
|
||||
const pickleKey = Buffer.from(this.olmDevice.pickleKey);
|
||||
const encryptedKey = await encryptAES(olmlib.encodeBase64(key), pickleKey, "m.megolm_backup.v1");
|
||||
const encryptedKey = await encryptAES(encodeBase64(key), pickleKey, "m.megolm_backup.v1");
|
||||
return this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
|
||||
this.cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", encryptedKey);
|
||||
});
|
||||
@@ -4196,7 +4205,7 @@ export function fixBackupKey(key?: string): string | null {
|
||||
return null;
|
||||
}
|
||||
const fixedKey = Uint8Array.from(key.split(","), (x) => parseInt(x));
|
||||
return olmlib.encodeBase64(fixedKey);
|
||||
return encodeBase64(fixedKey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-30
@@ -24,7 +24,7 @@ import type { PkSigning } from "@matrix-org/olm";
|
||||
import type { IOneTimeKey } from "../@types/crypto";
|
||||
import { OlmDevice } from "./OlmDevice";
|
||||
import { DeviceInfo } from "./deviceinfo";
|
||||
import { logger } from "../logger";
|
||||
import { Logger, logger } from "../logger";
|
||||
import { IClaimOTKsResult, MatrixClient } from "../client";
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
@@ -215,7 +215,7 @@ export async function ensureOlmSessionsForDevices(
|
||||
force = false,
|
||||
otkTimeout?: number,
|
||||
failedServers?: string[],
|
||||
log = logger,
|
||||
log: Logger = logger,
|
||||
): Promise<Map<string, Map<string, IOlmSessionResult>>> {
|
||||
const devicesWithoutSession: [string, string][] = [
|
||||
// [userId, deviceId], ...
|
||||
@@ -319,7 +319,7 @@ export async function ensureOlmSessionsForDevices(
|
||||
for (const resolver of resolveSession.values()) {
|
||||
resolver();
|
||||
}
|
||||
log.log(`Failed to claim ${taskDetail}`, e, devicesWithoutSession);
|
||||
log.debug(`Failed to claim ${taskDetail}`, e, devicesWithoutSession);
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -537,30 +537,3 @@ export function isOlmEncrypted(event: MatrixEvent): boolean {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a typed array of uint8 as base64.
|
||||
* @param uint8Array - The data to encode.
|
||||
* @returns The base64.
|
||||
*/
|
||||
export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
return Buffer.from(uint8Array).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a typed array of uint8 as unpadded base64.
|
||||
* @param uint8Array - The data to encode.
|
||||
* @returns The unpadded base64.
|
||||
*/
|
||||
export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
return encodeBase64(uint8Array).replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64 string to a typed array of uint8.
|
||||
* @param base64 - The base64 to decode.
|
||||
* @returns The decoded data.
|
||||
*/
|
||||
export function decodeBase64(base64: string): Uint8Array {
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { TrackingStatus } from "../DeviceList";
|
||||
import { IRoomEncryption } from "../RoomList";
|
||||
import { IDevice } from "../deviceinfo";
|
||||
import { ICrossSigningInfo } from "../CrossSigning";
|
||||
import { PrefixedLogger } from "../../logger";
|
||||
import { Logger } from "../../logger";
|
||||
import { InboundGroupSessionData } from "../OlmDevice";
|
||||
import { MatrixEvent } from "../../models/event";
|
||||
import { DehydrationManager } from "../dehydration";
|
||||
@@ -144,7 +144,7 @@ export interface CryptoStore {
|
||||
takeParkedSharedHistory(roomId: string, txn?: unknown): Promise<ParkedSharedHistory[]>;
|
||||
|
||||
// Session key backups
|
||||
doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T, log?: PrefixedLogger): Promise<T>;
|
||||
doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T, log?: Logger): Promise<T>;
|
||||
}
|
||||
|
||||
export type Mode = "readonly" | "readwrite";
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger, PrefixedLogger } from "../../logger";
|
||||
import { Logger, logger } from "../../logger";
|
||||
import { deepCompare } from "../../utils";
|
||||
import {
|
||||
CryptoStore,
|
||||
@@ -906,7 +906,7 @@ export class Backend implements CryptoStore {
|
||||
mode: Mode,
|
||||
stores: string | string[],
|
||||
func: (txn: IDBTransaction) => T,
|
||||
log: PrefixedLogger = logger,
|
||||
log: Logger = logger,
|
||||
): Promise<T> {
|
||||
let startTime: number;
|
||||
let description: string;
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger, PrefixedLogger } from "../../logger";
|
||||
import { logger, Logger } from "../../logger";
|
||||
import { LocalStorageCryptoStore } from "./localStorage-crypto-store";
|
||||
import { MemoryCryptoStore } from "./memory-crypto-store";
|
||||
import * as IndexedDBCryptoStoreBackend from "./indexeddb-crypto-store-backend";
|
||||
@@ -697,12 +697,7 @@ export class IndexedDBCryptoStore implements CryptoStore {
|
||||
* reject with that exception. On synchronous backends, the
|
||||
* exception will propagate to the caller of the getFoo method.
|
||||
*/
|
||||
public doTxn<T>(
|
||||
mode: Mode,
|
||||
stores: Iterable<string>,
|
||||
func: (txn: IDBTransaction) => T,
|
||||
log?: PrefixedLogger,
|
||||
): Promise<T> {
|
||||
public doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: IDBTransaction) => T, log?: Logger): Promise<T> {
|
||||
return this.backend!.doTxn<T>(mode, stores, func as (txn: unknown) => T, log);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ limitations under the License.
|
||||
import { crypto } from "../crypto";
|
||||
import { VerificationBase as Base } from "./Base";
|
||||
import { newKeyMismatchError, newUserCancelledError } from "./Error";
|
||||
import { decodeBase64, encodeUnpaddedBase64 } from "../olmlib";
|
||||
import { decodeBase64, encodeUnpaddedBase64 } from "../../base64";
|
||||
import { logger } from "../../logger";
|
||||
import { VerificationRequest } from "./request/VerificationRequest";
|
||||
import { MatrixClient } from "../../client";
|
||||
|
||||
+53
-14
@@ -25,7 +25,6 @@ import { ConnectionError, MatrixError } from "./errors";
|
||||
import { HttpApiEvent, HttpApiEventHandlerMap, IHttpOpts, IRequestOpts, Body } from "./interface";
|
||||
import { anySignal, parseErrorResponse, timeoutSignal } from "./utils";
|
||||
import { QueryDict } from "../utils";
|
||||
import { logger } from "../logger";
|
||||
|
||||
interface TypedResponse<T> extends Response {
|
||||
json(): Promise<T>;
|
||||
@@ -112,8 +111,9 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
*
|
||||
* @param body - The HTTP JSON body.
|
||||
*
|
||||
* @param opts - additional options. If a number is specified,
|
||||
* this is treated as `opts.localTimeoutMs`.
|
||||
* @param opts - additional options.
|
||||
* When `opts.doNotAttemptTokenRefresh` is true, token refresh will not be attempted
|
||||
* when an expired token is encountered. Used to only attempt token refresh once.
|
||||
*
|
||||
* @returns Promise which resolves to
|
||||
* ```
|
||||
@@ -127,15 +127,18 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
* @returns Rejects with an error if a problem occurred.
|
||||
* This includes network problems and Matrix-specific error JSON.
|
||||
*/
|
||||
public authedRequest<T>(
|
||||
public async authedRequest<T>(
|
||||
method: Method,
|
||||
path: string,
|
||||
queryParams?: QueryDict,
|
||||
body?: Body,
|
||||
opts: IRequestOpts = {},
|
||||
paramOpts: IRequestOpts & { doNotAttemptTokenRefresh?: boolean } = {},
|
||||
): Promise<ResponseType<T, O>> {
|
||||
if (!queryParams) queryParams = {};
|
||||
|
||||
// avoid mutating paramOpts so they can be used on retry
|
||||
const opts = { ...paramOpts };
|
||||
|
||||
if (this.opts.accessToken) {
|
||||
if (this.opts.useAuthorizationHeader) {
|
||||
if (!opts.headers) {
|
||||
@@ -152,19 +155,53 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
}
|
||||
}
|
||||
|
||||
const requestPromise = this.request<T>(method, path, queryParams, body, opts);
|
||||
try {
|
||||
const response = await this.request<T>(method, path, queryParams, body, opts);
|
||||
return response;
|
||||
} catch (error) {
|
||||
const err = error as MatrixError;
|
||||
|
||||
requestPromise.catch((err: MatrixError) => {
|
||||
if (err.errcode === "M_UNKNOWN_TOKEN" && !opts.doNotAttemptTokenRefresh) {
|
||||
const shouldRetry = await this.tryRefreshToken();
|
||||
// if we got a new token retry the request
|
||||
if (shouldRetry) {
|
||||
return this.authedRequest(method, path, queryParams, body, {
|
||||
...paramOpts,
|
||||
doNotAttemptTokenRefresh: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
// otherwise continue with error handling
|
||||
if (err.errcode == "M_UNKNOWN_TOKEN" && !opts?.inhibitLogoutEmit) {
|
||||
this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, err);
|
||||
} else if (err.errcode == "M_CONSENT_NOT_GIVEN") {
|
||||
this.eventEmitter.emit(HttpApiEvent.NoConsent, err.message, err.data.consent_uri);
|
||||
}
|
||||
});
|
||||
|
||||
// return the original promise, otherwise tests break due to it having to
|
||||
// go around the event loop one more time to process the result of the request
|
||||
return requestPromise;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh access tokens.
|
||||
* On success, sets new access and refresh tokens in opts.
|
||||
* @returns Promise that resolves to a boolean - true when token was refreshed successfully
|
||||
*/
|
||||
private async tryRefreshToken(): Promise<boolean> {
|
||||
if (!this.opts.refreshToken || !this.opts.tokenRefreshFunction) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const { accessToken, refreshToken } = await this.opts.tokenRefreshFunction(this.opts.refreshToken);
|
||||
this.opts.accessToken = accessToken;
|
||||
this.opts.refreshToken = refreshToken;
|
||||
// successfully got new tokens
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.opts.logger?.warn("Failed to refresh token", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,7 +262,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
opts: Pick<IRequestOpts, "headers" | "json" | "localTimeoutMs" | "keepAlive" | "abortSignal" | "priority"> = {},
|
||||
): Promise<ResponseType<T, O>> {
|
||||
const urlForLogs = this.sanitizeUrlForLogs(url);
|
||||
logger.debug(`FetchHttpApi: --> ${method} ${urlForLogs}`);
|
||||
this.opts.logger?.debug(`FetchHttpApi: --> ${method} ${urlForLogs}`);
|
||||
|
||||
const headers = Object.assign({}, opts.headers || {});
|
||||
const json = opts.json ?? true;
|
||||
@@ -279,9 +316,11 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
priority: opts.priority,
|
||||
});
|
||||
|
||||
logger.debug(`FetchHttpApi: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${res.status}]`);
|
||||
this.opts.logger?.debug(
|
||||
`FetchHttpApi: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${res.status}]`,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.debug(`FetchHttpApi: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${e}]`);
|
||||
this.opts.logger?.debug(`FetchHttpApi: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${e}]`);
|
||||
if ((<Error>e).name === "AbortError") {
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -15,9 +15,26 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixError } from "./errors";
|
||||
import { Logger } from "../logger";
|
||||
|
||||
export type Body = Record<string, any> | BodyInit;
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Unencrypted access and (optional) refresh token
|
||||
*/
|
||||
export type AccessTokens = {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
/**
|
||||
* @experimental
|
||||
* Function that performs token refresh using the given refreshToken.
|
||||
* Returns a promise that resolves to the refreshed access and (optional) refresh tokens.
|
||||
*
|
||||
* Can be passed to HttpApi instance as {@link IHttpOpts.tokenRefreshFunction} during client creation {@link ICreateClientOpts}
|
||||
*/
|
||||
export type TokenRefreshFunction = (refreshToken: string) => Promise<AccessTokens>;
|
||||
export interface IHttpOpts {
|
||||
fetchFn?: typeof global.fetch;
|
||||
|
||||
@@ -27,10 +44,22 @@ export interface IHttpOpts {
|
||||
extraParams?: Record<string, string>;
|
||||
|
||||
accessToken?: string;
|
||||
/**
|
||||
* Used in conjunction with tokenRefreshFunction to attempt token refresh
|
||||
*/
|
||||
refreshToken?: string;
|
||||
/**
|
||||
* Function to attempt token refresh when a possibly expired token is encountered
|
||||
* Optional, only called when a refreshToken is present
|
||||
*/
|
||||
tokenRefreshFunction?: TokenRefreshFunction;
|
||||
useAuthorizationHeader?: boolean; // defaults to true
|
||||
|
||||
onlyData?: boolean;
|
||||
localTimeoutMs?: number;
|
||||
|
||||
/** Optional logger instance. If provided, requests and responses will be logged. */
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
export interface IRequestOpts extends Pick<RequestInit, "priority"> {
|
||||
|
||||
+69
-14
@@ -15,7 +15,53 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import log, { Logger } from "loglevel";
|
||||
import loglevel from "loglevel";
|
||||
|
||||
/** Logger interface used within the js-sdk codebase */
|
||||
export interface Logger {
|
||||
/**
|
||||
* Output trace message to the logger, with stack trace.
|
||||
*
|
||||
* @param msg - Data to log.
|
||||
*/
|
||||
trace(...msg: any[]): void;
|
||||
|
||||
/**
|
||||
* Output debug message to the logger.
|
||||
*
|
||||
* @param msg - Data to log.
|
||||
*/
|
||||
debug(...msg: any[]): void;
|
||||
|
||||
/**
|
||||
* Output info message to the logger.
|
||||
*
|
||||
* @param msg - Data to log.
|
||||
*/
|
||||
info(...msg: any[]): void;
|
||||
|
||||
/**
|
||||
* Output warn message to the logger.
|
||||
*
|
||||
* @param msg - Data to log.
|
||||
*/
|
||||
warn(...msg: any[]): void;
|
||||
|
||||
/**
|
||||
* Output error message to the logger.
|
||||
*
|
||||
* @param msg - Data to log.
|
||||
*/
|
||||
error(...msg: any[]): void;
|
||||
|
||||
/**
|
||||
* Create a child logger.
|
||||
*
|
||||
* @param namespace - name to add to the current logger to generate the child. Some implementations of `Logger`
|
||||
* use this as a prefix; others use a different mechanism.
|
||||
*/
|
||||
getChild(namespace: string): Logger;
|
||||
}
|
||||
|
||||
// This is to demonstrate, that you can use any namespace you want.
|
||||
// Namespaces allow you to turn on/off the logging for specific parts of the
|
||||
@@ -30,7 +76,7 @@ const DEFAULT_NAMESPACE = "matrix";
|
||||
// to avoid the issue, we override the methodFactory of loglevel that binds to the
|
||||
// console methods at initialization time by a factory that looks up the console methods
|
||||
// when logging so we always get the current value of console methods.
|
||||
log.methodFactory = function (methodName, logLevel, loggerName) {
|
||||
loglevel.methodFactory = function (methodName, logLevel, loggerName) {
|
||||
return function (this: PrefixedLogger, ...args): void {
|
||||
/* eslint-disable @typescript-eslint/no-invalid-this */
|
||||
if (this.prefix) {
|
||||
@@ -54,33 +100,42 @@ log.methodFactory = function (methodName, logLevel, loggerName) {
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop-in replacement for `console` using {@link https://www.npmjs.com/package/loglevel|loglevel}.
|
||||
* Can be tailored down to specific use cases if needed.
|
||||
* Implementation of {@link Logger} based on `loglevel`.
|
||||
*
|
||||
* @deprecated this shouldn't be public; prefer {@link Logger}.
|
||||
*/
|
||||
export const logger = log.getLogger(DEFAULT_NAMESPACE) as PrefixedLogger;
|
||||
logger.setLevel(log.levels.DEBUG, false);
|
||||
|
||||
export interface PrefixedLogger extends Logger {
|
||||
export interface PrefixedLogger extends loglevel.Logger, Logger {
|
||||
/** @deprecated prefer {@link Logger.getChild} */
|
||||
withPrefix: (prefix: string) => PrefixedLogger;
|
||||
|
||||
/** @deprecated internal property */
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
function extendLogger(logger: Logger): void {
|
||||
(<PrefixedLogger>logger).withPrefix = function (prefix: string): PrefixedLogger {
|
||||
/** Internal utility function to turn a `loglevel.Logger` into a `PrefixedLogger` */
|
||||
function extendLogger(logger: loglevel.Logger): void {
|
||||
const prefixedLogger = <PrefixedLogger>logger;
|
||||
prefixedLogger.getChild = prefixedLogger.withPrefix = function (prefix: string): PrefixedLogger {
|
||||
const existingPrefix = this.prefix || "";
|
||||
return getPrefixedLogger(existingPrefix + prefix);
|
||||
};
|
||||
}
|
||||
|
||||
extendLogger(logger);
|
||||
|
||||
function getPrefixedLogger(prefix: string): PrefixedLogger {
|
||||
const prefixLogger = log.getLogger(`${DEFAULT_NAMESPACE}-${prefix}`) as PrefixedLogger;
|
||||
const prefixLogger = loglevel.getLogger(`${DEFAULT_NAMESPACE}-${prefix}`) as PrefixedLogger;
|
||||
if (prefixLogger.prefix !== prefix) {
|
||||
// Only do this setup work the first time through, as loggers are saved by name.
|
||||
extendLogger(prefixLogger);
|
||||
prefixLogger.prefix = prefix;
|
||||
prefixLogger.setLevel(log.levels.DEBUG, false);
|
||||
prefixLogger.setLevel(loglevel.levels.DEBUG, false);
|
||||
}
|
||||
return prefixLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for `console` using {@link https://www.npmjs.com/package/loglevel|loglevel}.
|
||||
* Can be tailored down to specific use cases if needed.
|
||||
*/
|
||||
export const logger = loglevel.getLogger(DEFAULT_NAMESPACE) as PrefixedLogger;
|
||||
logger.setLevel(loglevel.levels.DEBUG, false);
|
||||
extendLogger(logger);
|
||||
|
||||
@@ -29,6 +29,7 @@ export * from "./http-api";
|
||||
export * from "./autodiscovery";
|
||||
export * from "./sync-accumulator";
|
||||
export * from "./errors";
|
||||
export * from "./base64";
|
||||
export * from "./models/beacon";
|
||||
export * from "./models/event";
|
||||
export * from "./models/room";
|
||||
@@ -42,6 +43,7 @@ export * from "./models/typed-event-emitter";
|
||||
export * from "./models/user";
|
||||
export * from "./models/device";
|
||||
export * from "./models/search-result";
|
||||
export * from "./oidc";
|
||||
export * from "./scheduler";
|
||||
export * from "./filter";
|
||||
export * from "./timeline-window";
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixEvent, RoomMember } from "../matrix";
|
||||
import { MatrixEvent } from "../matrix";
|
||||
import { deepCompare } from "../utils";
|
||||
import { Focus } from "./focus";
|
||||
|
||||
@@ -42,11 +42,11 @@ export class CallMembership {
|
||||
if (typeof data.device_id !== "string") throw new Error("Malformed membership event: device_id must be string");
|
||||
if (typeof data.call_id !== "string") throw new Error("Malformed membership event: call_id must be string");
|
||||
if (typeof data.scope !== "string") throw new Error("Malformed membership event: scope must be string");
|
||||
if (!parentEvent.sender) throw new Error("Invalid parent event: sender is null");
|
||||
if (!parentEvent.getSender()) throw new Error("Invalid parent event: sender is null");
|
||||
}
|
||||
|
||||
public get member(): RoomMember {
|
||||
return this.parentEvent.sender!;
|
||||
public get sender(): string | undefined {
|
||||
return this.parentEvent.getSender();
|
||||
}
|
||||
|
||||
public get callId(): string {
|
||||
|
||||
@@ -118,10 +118,12 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
}
|
||||
|
||||
callMemberships.sort((a, b) => a.createdTs() - b.createdTs());
|
||||
logger.debug(
|
||||
"Call memberships, in order: ",
|
||||
callMemberships.map((m) => [m.createdTs(), m.member.userId]),
|
||||
);
|
||||
if (callMemberships.length > 1) {
|
||||
logger.debug(
|
||||
`Call memberships in room ${room.roomId}, in order: `,
|
||||
callMemberships.map((m) => [m.createdTs(), m.sender]),
|
||||
);
|
||||
}
|
||||
|
||||
return callMemberships;
|
||||
}
|
||||
@@ -154,8 +156,8 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
/**
|
||||
* Performs cleanup & removes timers for client shutdown
|
||||
*/
|
||||
public stop(): void {
|
||||
this.leaveRoomSession();
|
||||
public async stop(): Promise<void> {
|
||||
await this.leaveRoomSession(1000);
|
||||
if (this.expiryTimeout) {
|
||||
clearTimeout(this.expiryTimeout);
|
||||
this.expiryTimeout = undefined;
|
||||
@@ -195,11 +197,14 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
* and stops scheduled updates.
|
||||
* This will not unsubscribe from updates: remember to call unsubscribe() separately if
|
||||
* desired.
|
||||
* The membership update required to leave the session will retry if it fails.
|
||||
* Without network connection the promise will never resolve.
|
||||
* A timeout can be provided so that there is a guarantee for the promise to resolve.
|
||||
*/
|
||||
public leaveRoomSession(): void {
|
||||
public async leaveRoomSession(timeout: number | undefined = undefined): Promise<boolean> {
|
||||
if (!this.isJoined()) {
|
||||
logger.info(`Not joined to session in room ${this.room.roomId}: ignoring leave call`);
|
||||
return;
|
||||
return new Promise((resolve) => resolve(false));
|
||||
}
|
||||
|
||||
logger.info(`Leaving call session in room ${this.room.roomId}`);
|
||||
@@ -207,7 +212,20 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
this.activeFoci = undefined;
|
||||
this.membershipId = undefined;
|
||||
this.emit(MatrixRTCSessionEvent.JoinStateChanged, false);
|
||||
this.triggerCallMembershipEventUpdate();
|
||||
|
||||
const timeoutPromise = new Promise((r) => {
|
||||
if (timeout) {
|
||||
// will never resolve if timeout is not set
|
||||
setTimeout(r, timeout, "timeout");
|
||||
}
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
Promise.race([this.triggerCallMembershipEventUpdate(), timeoutPromise]).then((value) => {
|
||||
// The timeoutPromise returns the string 'timeout' and the membership update void
|
||||
// A success implies that the membership update was quicker then the timeout.
|
||||
resolve(value != "timeout");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -411,7 +429,6 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
memberships: this.makeNewMemberships(memberships, myCallMemberEvent, myPrevMembership),
|
||||
};
|
||||
|
||||
let resendDelay;
|
||||
try {
|
||||
await this.client.sendStateEvent(
|
||||
this.room.roomId,
|
||||
@@ -422,12 +439,14 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
||||
logger.info(`Sent updated call member event.`);
|
||||
|
||||
// check periodically to see if we need to refresh our member event
|
||||
if (this.isJoined()) resendDelay = MEMBER_EVENT_CHECK_PERIOD;
|
||||
if (this.isJoined()) {
|
||||
this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD);
|
||||
}
|
||||
} catch (e) {
|
||||
resendDelay = CALL_MEMBER_EVENT_RETRY_DELAY_MIN + Math.random() * 2000;
|
||||
const resendDelay = CALL_MEMBER_EVENT_RETRY_DELAY_MIN + Math.random() * 2000;
|
||||
logger.warn(`Failed to send call member event: retrying in ${resendDelay}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, resendDelay));
|
||||
await this.triggerCallMembershipEventUpdate();
|
||||
}
|
||||
|
||||
if (resendDelay) this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, resendDelay);
|
||||
}
|
||||
}
|
||||
|
||||
+15
-6
@@ -414,9 +414,12 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the event as though it would appear unencrypted. If the event is already not
|
||||
* encrypted, it is simply returned as-is.
|
||||
* @returns The event in wire format.
|
||||
* Gets the event as it would appear if it had been sent unencrypted.
|
||||
*
|
||||
* If the event is encrypted, we attempt to mock up an event as it would have looked had the sender not encrypted it.
|
||||
* If the event is not encrypted, a copy of it is simply returned as-is.
|
||||
*
|
||||
* @returns A shallow copy of the event, in wire format, as it would have been had it not been encrypted.
|
||||
*/
|
||||
public getEffectiveEvent(): IEvent {
|
||||
const content = Object.assign({}, this.getContent()); // clone for mutation
|
||||
@@ -1593,15 +1596,21 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarise the event as JSON. This is currently used by React SDK's view
|
||||
* event source feature and Seshat's event indexing, so take care when
|
||||
* adjusting the output here.
|
||||
* Summarise the event as JSON.
|
||||
*
|
||||
* If encrypted, include both the decrypted and encrypted view of the event.
|
||||
*
|
||||
* This is named `toJSON` for use with `JSON.stringify` which checks objects
|
||||
* for functions named `toJSON` and will call them to customise the output
|
||||
* if they are defined.
|
||||
*
|
||||
* **WARNING** Do not log the result of this method; otherwise, it will end up
|
||||
* in rageshakes, leading to a privacy violation.
|
||||
*
|
||||
* @deprecated Prefer to use {@link MatrixEvent#getEffectiveEvent} or similar.
|
||||
* This method will be removed soon; it is too easy to use it accidentally
|
||||
* and cause a privacy violation (cf https://github.com/vector-im/element-web/issues/26380).
|
||||
* In any case, the value it returns is not a faithful serialization of the object.
|
||||
*/
|
||||
public toJSON(): object {
|
||||
const event = this.getEffectiveEvent();
|
||||
|
||||
+82
-6
@@ -90,7 +90,40 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
|
||||
private reEmitter: TypedReEmitter<ThreadEmittedEvents, ThreadEventHandlerMap>;
|
||||
|
||||
/**
|
||||
* The last event in this thread, if we don't yet have this in the timeline.
|
||||
*
|
||||
* When we run {@link processRootEvent} (which I think happens during the
|
||||
* setting-up of the thread), we set this to the event pointed to by the
|
||||
* server in `latest_event` [1] that came through with the thread root.
|
||||
*
|
||||
* [1]: https://spec.matrix.org/v1.8/client-server-api/#server-side-aggregation-of-mthread-relationships
|
||||
*
|
||||
* Later, when we have populated the timeline, this is set to undefined, so
|
||||
* that methods like {@link replyToEvent} fall through to use lastReply,
|
||||
* which looks in the timeline for the latest event that is a "thread reply"
|
||||
* i.e. directly refers to the thread root with an m.thread relation.
|
||||
*
|
||||
* So it looks like this is only really relevant when initialEventsFetched
|
||||
* is false, because as soon as the initial events have been fetched, we
|
||||
* should have a timeline (I think).
|
||||
*
|
||||
* If all replies in this thread are redacted, this is set to the root
|
||||
* event. I'm not clear what the meaning of this is, since usually after the
|
||||
* initial events have been fetched, lastEvent should be undefined.
|
||||
* In fact, the whole usage inside onRedaction looks suspect - it may be
|
||||
* that we were thinking lastEvent always refers to the actual last event,
|
||||
* but it only does so before initialEventsFetched becomes true.
|
||||
*
|
||||
* The usage of lastEvent inside {@link onEcho} looks suspicious, since I'd
|
||||
* think we probably mean {@link replyToEvent} there - we are trying not to
|
||||
* echo a duplicate event, and we probably want that behaviour even after
|
||||
* initialEventsFetched has become true.
|
||||
*
|
||||
* -- andyb
|
||||
*/
|
||||
private lastEvent: MatrixEvent | undefined;
|
||||
|
||||
private replyCount = 0;
|
||||
private lastPendingEvent: MatrixEvent | undefined;
|
||||
private pendingReplyCount = 0;
|
||||
@@ -324,25 +357,34 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
* @param emit - whether to emit the Update event if the thread was updated or not.
|
||||
*/
|
||||
public async addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): Promise<void> {
|
||||
// Modify this event to point at our room's state, and mark its thread
|
||||
// as this.
|
||||
this.setEventMetadata(event);
|
||||
|
||||
// Decide whether this event is going to be added at the end of the timeline.
|
||||
const lastReply = this.lastReply();
|
||||
const isNewestReply = !lastReply || event.localTimestamp >= lastReply!.localTimestamp;
|
||||
|
||||
// Add all incoming events to the thread's timeline set when there's no server support
|
||||
if (!Thread.hasServerSideSupport) {
|
||||
// all the relevant membership info to hydrate events with a sender
|
||||
// is held in the main room timeline
|
||||
// We want to fetch the room state from there and pass it down to this thread
|
||||
// timeline set to let it reconcile an event with its relevant RoomMember
|
||||
// When there's no server-side support, just add it to the end of the timeline.
|
||||
this.addEventToTimeline(event, toStartOfTimeline);
|
||||
|
||||
this.client.decryptEventIfNeeded(event, {});
|
||||
} else if (!toStartOfTimeline && this.initialEventsFetched && isNewestReply) {
|
||||
// When we've asked for the event to be added to the end, and we're
|
||||
// not in the initial state, and this event belongs at the end, add it.
|
||||
this.addEventToTimeline(event, false);
|
||||
this.fetchEditsWhereNeeded(event);
|
||||
} else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) {
|
||||
// If this event is not a direct member of the thread, but is a
|
||||
// reference to something that is, then we have two cases:
|
||||
|
||||
if (!this.initialEventsFetched) {
|
||||
// Case 1: we haven't yet fetched events from the server. In
|
||||
// this case, when we do, the events we get back might only be
|
||||
// the first-order ones, so this event (which is second-order -
|
||||
// a reference to something directly in the thread) needs to be
|
||||
// kept so we can replay it when the first-order ones turn up.
|
||||
|
||||
/**
|
||||
* A thread can be fully discovered via a single sync response
|
||||
* And when that's the case we still ask the server to do an initialisation
|
||||
@@ -354,6 +396,23 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
*/
|
||||
this.replayEvents?.push(event);
|
||||
} else {
|
||||
// Case 2: this is happening later, and we have a timeline. In
|
||||
// this case, these events might be out-of order.
|
||||
//
|
||||
// Specifically, if the server doesn't support recursion, so we
|
||||
// only get these events through sync, they might be coming
|
||||
// later than the first-order ones, so we insert them based on
|
||||
// timestamp (despite the problems with this documented in
|
||||
// #3325).
|
||||
//
|
||||
// If the server does support recursion, we should have got all
|
||||
// the interspersed events from the server when we fetched the
|
||||
// initial events, so if they are coming via sync they should be
|
||||
// the latest ones, so we can add them as normal.
|
||||
//
|
||||
// (Note that both insertEventIntoTimeline and addEventToTimeline
|
||||
// do nothing if we have seen this event before.)
|
||||
|
||||
const recursionSupport =
|
||||
this.client.canSupport.get(Feature.RelationsRecursion) ?? ServerSupport.Unsupported;
|
||||
|
||||
@@ -367,6 +426,23 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
|
||||
this.timelineSet.relations?.aggregateParentEvent(event);
|
||||
this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet);
|
||||
return;
|
||||
} else if (this.initialEventsFetched) {
|
||||
// If initial events have not been fetched, we are OK to throw away
|
||||
// this event, because we are about to fetch all the events for this
|
||||
// thread from the server.
|
||||
|
||||
// Otherwise, we should add it, but we suspect it is out of order.
|
||||
if (toStartOfTimeline) {
|
||||
// If we're adding at the start of the timeline, it doesn't
|
||||
// matter that it's out of order.
|
||||
this.addEventToTimeline(event, toStartOfTimeline);
|
||||
} else {
|
||||
// We think this event might be out of order, because isNewestReply
|
||||
// is false (otherwise we would have gone into the earlier if
|
||||
// clause), so try to insert it in the right place based on
|
||||
// timestamp.
|
||||
this.insertEventIntoTimeline(event);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "../matrix";
|
||||
import { MatrixEvent } from "./event";
|
||||
import { TypedEventEmitter } from "./typed-event-emitter";
|
||||
|
||||
@@ -152,6 +153,7 @@ export class User extends TypedEventEmitter<UserEvent, UserEventHandlerMap> {
|
||||
/**
|
||||
* Construct a new User. A User must have an ID and can optionally have extra information associated with it.
|
||||
* @param userId - Required. The ID of this user.
|
||||
* @deprecated use `User.createUser`
|
||||
*/
|
||||
public constructor(public readonly userId: string) {
|
||||
super();
|
||||
@@ -160,6 +162,25 @@ export class User extends TypedEventEmitter<UserEvent, UserEventHandlerMap> {
|
||||
this.updateModifiedTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a new User whose events will also emit on MatrixClient.
|
||||
* A User must have an ID and can optionally have extra information associated with it.
|
||||
* @param userId - Required. The ID of this user.
|
||||
* @param client - An instance of MatrixClient object
|
||||
* @returns User object with reEmitter setup on client
|
||||
*/
|
||||
public static createUser(userId: string, client: MatrixClient): User {
|
||||
const user = new User(userId);
|
||||
client.reEmitter.reEmit(user, [
|
||||
UserEvent.AvatarUrl,
|
||||
UserEvent.DisplayName,
|
||||
UserEvent.Presence,
|
||||
UserEvent.CurrentlyActive,
|
||||
UserEvent.LastPresenceTs,
|
||||
]);
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update this User with the given presence event. May fire "User.presence",
|
||||
* "User.avatarUrl" and/or "User.displayName" if this event updates this user's
|
||||
|
||||
+18
-7
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Log, OidcClient, SigninResponse, SigninState, WebStorageStateStore } from "oidc-client-ts";
|
||||
import { IdTokenClaims, Log, OidcClient, SigninResponse, SigninState, WebStorageStateStore } from "oidc-client-ts";
|
||||
|
||||
import { IDelegatedAuthConfig } from "../client";
|
||||
import { subtleCrypto, TextEncoder } from "../crypto/crypto";
|
||||
@@ -51,9 +51,9 @@ export type AuthorizationParams = {
|
||||
* Generate the scope used in authorization request with OIDC OP
|
||||
* @returns scope
|
||||
*/
|
||||
const generateScope = (): string => {
|
||||
const deviceId = randomString(10);
|
||||
return `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${deviceId}`;
|
||||
export const generateScope = (deviceId?: string): string => {
|
||||
const safeDeviceId = deviceId ?? randomString(10);
|
||||
return `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${safeDeviceId}`;
|
||||
};
|
||||
|
||||
// https://www.rfc-editor.org/rfc/rfc7636
|
||||
@@ -122,8 +122,13 @@ export const generateAuthorizationUrl = async (
|
||||
* @experimental
|
||||
* Generate a URL to attempt authorization with the OP
|
||||
* See https://openid.net/specs/openid-connect-basic-1_0.html#CodeRequest
|
||||
* @param oidcClientSettings - oidc configuration
|
||||
* @param homeserverName - used as state
|
||||
* @param metadata - validated metadata from OP discovery
|
||||
* @param clientId - this client's id as registered with the OP
|
||||
* @param homeserverUrl - used to establish the session on return from the OP
|
||||
* @param identityServerUrl - used to establish the session on return from the OP
|
||||
* @param nonce - state
|
||||
* @param prompt - indicates to the OP which flow the user should see - eg login or registration
|
||||
* See https://openid.net/specs/openid-connect-prompt-create-1_0.html#name-prompt-parameter
|
||||
* @returns a Promise with the url as a string
|
||||
*/
|
||||
export const generateOidcAuthorizationUrl = async ({
|
||||
@@ -133,6 +138,7 @@ export const generateOidcAuthorizationUrl = async ({
|
||||
homeserverUrl,
|
||||
identityServerUrl,
|
||||
nonce,
|
||||
prompt,
|
||||
}: {
|
||||
clientId: string;
|
||||
metadata: ValidatedIssuerMetadata;
|
||||
@@ -140,6 +146,7 @@ export const generateOidcAuthorizationUrl = async ({
|
||||
identityServerUrl?: string;
|
||||
redirectUri: string;
|
||||
nonce: string;
|
||||
prompt?: string;
|
||||
}): Promise<string> => {
|
||||
const scope = await generateScope();
|
||||
const oidcClient = new OidcClient({
|
||||
@@ -156,6 +163,7 @@ export const generateOidcAuthorizationUrl = async ({
|
||||
const request = await oidcClient.createSigninRequest({
|
||||
state: userState,
|
||||
nonce,
|
||||
prompt,
|
||||
});
|
||||
|
||||
return request.url;
|
||||
@@ -190,7 +198,8 @@ const normalizeBearerTokenResponseTokenType = (response: SigninResponse): Bearer
|
||||
* @param code - authorization code as returned by OP during authorization
|
||||
* @param storedAuthorizationParams - stored params from start of oidc login flow
|
||||
* @returns valid bearer token response
|
||||
* @throws when request fails, or returned token response is invalid
|
||||
* @throws An `Error` with `message` set to an entry in {@link OidcError},
|
||||
* when the request fails, or the returned token response is invalid.
|
||||
*/
|
||||
export const completeAuthorizationCodeGrant = async (
|
||||
code: string,
|
||||
@@ -199,6 +208,7 @@ export const completeAuthorizationCodeGrant = async (
|
||||
oidcClientSettings: IDelegatedAuthConfig & { clientId: string };
|
||||
tokenResponse: BearerTokenResponse;
|
||||
homeserverUrl: string;
|
||||
idTokenClaims: IdTokenClaims;
|
||||
identityServerUrl?: string;
|
||||
}> => {
|
||||
/**
|
||||
@@ -250,6 +260,7 @@ export const completeAuthorizationCodeGrant = async (
|
||||
tokenResponse: normalizedTokenResponse,
|
||||
homeserverUrl: userState.homeserverUrl,
|
||||
identityServerUrl: userState.identityServerUrl,
|
||||
idTokenClaims: signinResponse.profile,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Oidc login failed", error);
|
||||
|
||||
@@ -14,6 +14,10 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Errors expected to be encountered during OIDC discovery, client registration, and authentication.
|
||||
* Not intended to be displayed directly to the user.
|
||||
*/
|
||||
export enum OidcError {
|
||||
NotSupported = "OIDC authentication not supported",
|
||||
Misconfigured = "OIDC is misconfigured",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
Copyright 2023 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.
|
||||
*/
|
||||
|
||||
export * from "./authorize";
|
||||
export * from "./discovery";
|
||||
export * from "./error";
|
||||
export * from "./register";
|
||||
export * from "./tokenRefresher";
|
||||
export * from "./validate";
|
||||
@@ -34,7 +34,8 @@ export type OidcRegistrationClientMetadata = {
|
||||
* @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 when registration request fails, or response is invalid
|
||||
* @throws An `Error` with `message` set to an entry in {@link OidcError},
|
||||
* when the registration request fails, or the response is invalid.
|
||||
*/
|
||||
const doRegistration = async (
|
||||
registrationEndpoint: string,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
Copyright 2023 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 { IdTokenClaims, OidcClient, WebStorageStateStore } from "oidc-client-ts";
|
||||
|
||||
import { AccessTokens } from "../http-api";
|
||||
import { IDelegatedAuthConfig } from "../client";
|
||||
import { generateScope } from "./authorize";
|
||||
import { discoverAndValidateAuthenticationConfig } from "./discovery";
|
||||
import { logger } from "../logger";
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Class responsible for refreshing OIDC access tokens
|
||||
*
|
||||
* Client implementations will likely want to override {@link persistTokens} to persist tokens after successful refresh
|
||||
*
|
||||
*/
|
||||
export class OidcTokenRefresher {
|
||||
/**
|
||||
* Promise which will complete once the OidcClient has been initialised
|
||||
* and is ready to start refreshing tokens.
|
||||
*
|
||||
* Will reject if the client initialisation fails.
|
||||
*/
|
||||
public readonly oidcClientReady!: Promise<void>;
|
||||
private oidcClient!: OidcClient;
|
||||
private inflightRefreshRequest?: Promise<AccessTokens>;
|
||||
|
||||
public constructor(
|
||||
/**
|
||||
* Delegated auth config as found in matrix client .well-known
|
||||
*/
|
||||
authConfig: IDelegatedAuthConfig,
|
||||
/**
|
||||
* id of this client as registered with the OP
|
||||
*/
|
||||
clientId: string,
|
||||
/**
|
||||
* redirectUri as registered with OP
|
||||
*/
|
||||
redirectUri: string,
|
||||
/**
|
||||
* Device ID of current session
|
||||
*/
|
||||
deviceId: string,
|
||||
/**
|
||||
* idTokenClaims as returned from authorization grant
|
||||
* used to validate tokens
|
||||
*/
|
||||
private readonly idTokenClaims: IdTokenClaims,
|
||||
) {
|
||||
this.oidcClientReady = this.initialiseOidcClient(authConfig, clientId, deviceId, redirectUri);
|
||||
}
|
||||
|
||||
private async initialiseOidcClient(
|
||||
authConfig: IDelegatedAuthConfig,
|
||||
clientId: string,
|
||||
deviceId: string,
|
||||
redirectUri: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const config = await discoverAndValidateAuthenticationConfig(authConfig);
|
||||
|
||||
const scope = generateScope(deviceId);
|
||||
|
||||
this.oidcClient = new OidcClient({
|
||||
...config.metadata,
|
||||
client_id: clientId,
|
||||
scope,
|
||||
redirect_uri: redirectUri,
|
||||
authority: config.metadata.issuer,
|
||||
stateStore: new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage }),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to initialise OIDC client.", error);
|
||||
throw new Error("Failed to initialise OIDC client.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt token refresh using given refresh token
|
||||
* @param refreshToken - refresh token to use in request with token issuer
|
||||
* @returns tokens - Promise that resolves with new access and refresh tokens
|
||||
* @throws when token refresh fails
|
||||
*/
|
||||
public async doRefreshAccessToken(refreshToken: string): Promise<AccessTokens> {
|
||||
if (!this.inflightRefreshRequest) {
|
||||
this.inflightRefreshRequest = this.getNewTokens(refreshToken);
|
||||
}
|
||||
try {
|
||||
const tokens = await this.inflightRefreshRequest;
|
||||
return tokens;
|
||||
} finally {
|
||||
this.inflightRefreshRequest = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the new tokens, called after tokens are successfully refreshed.
|
||||
*
|
||||
* This function is intended to be overriden by the consumer when persistence is necessary.
|
||||
*
|
||||
* @param accessToken - new access token
|
||||
* @param refreshToken - OPTIONAL new refresh token
|
||||
*/
|
||||
public async persistTokens(_tokens: { accessToken: string; refreshToken?: string }): Promise<void> {
|
||||
// NOOP
|
||||
}
|
||||
|
||||
private async getNewTokens(refreshToken: string): Promise<AccessTokens> {
|
||||
if (!this.oidcClient) {
|
||||
throw new Error("Cannot get new token before OIDC client is initialised.");
|
||||
}
|
||||
|
||||
const refreshTokenState = {
|
||||
refresh_token: refreshToken,
|
||||
session_state: "test",
|
||||
data: undefined,
|
||||
profile: this.idTokenClaims,
|
||||
};
|
||||
|
||||
const response = await this.oidcClient.useRefreshToken({
|
||||
state: refreshTokenState,
|
||||
timeoutInSeconds: 300,
|
||||
});
|
||||
|
||||
const tokens = {
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
};
|
||||
|
||||
await this.persistTokens(tokens);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
RendezvousTransport,
|
||||
RendezvousFailureReason,
|
||||
} from "..";
|
||||
import { encodeUnpaddedBase64, decodeBase64 } from "../../crypto/olmlib";
|
||||
import { encodeUnpaddedBase64, decodeBase64 } from "../../base64";
|
||||
import { crypto, subtleCrypto, TextEncoder } from "../../crypto/crypto";
|
||||
import { generateDecimalSas } from "../../crypto/verification/SASDecimal";
|
||||
import { UnstableValue } from "../../NamespacedValue";
|
||||
|
||||
@@ -31,6 +31,7 @@ import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
|
||||
import { QueryDict } from "../utils";
|
||||
import { IAuthDict, UIAuthCallback } from "../interactive-auth";
|
||||
import { UIAResponse } from "../@types/uia";
|
||||
import { ToDeviceMessageId } from "../@types/event";
|
||||
|
||||
/**
|
||||
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
|
||||
@@ -60,7 +61,10 @@ export class OutgoingRequestProcessor {
|
||||
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
) {}
|
||||
|
||||
public async makeOutgoingRequest<T>(msg: OutgoingRequest, uiaCallback?: UIAuthCallback<T>): Promise<void> {
|
||||
public async makeOutgoingRequest<T>(
|
||||
msg: OutgoingRequest | SigningKeysUploadRequest,
|
||||
uiaCallback?: UIAuthCallback<T>,
|
||||
): Promise<void> {
|
||||
let resp: string;
|
||||
|
||||
/* refer https://docs.rs/matrix-sdk-crypto/0.6.0/matrix_sdk_crypto/requests/enum.OutgoingRequests.html
|
||||
@@ -82,23 +86,22 @@ export class OutgoingRequestProcessor {
|
||||
msg.body,
|
||||
);
|
||||
} else if (msg instanceof ToDeviceRequest) {
|
||||
const path =
|
||||
`/_matrix/client/v3/sendToDevice/${encodeURIComponent(msg.event_type)}/` +
|
||||
encodeURIComponent(msg.txn_id);
|
||||
resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body);
|
||||
resp = await this.sendToDeviceRequest(msg);
|
||||
} else if (msg instanceof RoomMessageRequest) {
|
||||
const path =
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(msg.room_id)}/send/` +
|
||||
`${encodeURIComponent(msg.event_type)}/${encodeURIComponent(msg.txn_id)}`;
|
||||
resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body);
|
||||
} else if (msg instanceof SigningKeysUploadRequest) {
|
||||
resp = await this.makeRequestWithUIA(
|
||||
await this.makeRequestWithUIA(
|
||||
Method.Post,
|
||||
"/_matrix/client/v3/keys/device_signing/upload",
|
||||
{},
|
||||
msg.body,
|
||||
uiaCallback,
|
||||
);
|
||||
// SigningKeysUploadRequest does not implement OutgoingRequest and does not need to be marked as sent.
|
||||
return;
|
||||
} else {
|
||||
logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
|
||||
resp = "";
|
||||
@@ -122,6 +125,34 @@ export class OutgoingRequestProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the HTTP request for a `ToDeviceRequest`
|
||||
*
|
||||
* @param request - request to send
|
||||
* @returns JSON-serialized body of the response, if successful
|
||||
*/
|
||||
private async sendToDeviceRequest(request: ToDeviceRequest): Promise<string> {
|
||||
// a bit of extra logging, to help trace to-device messages through the system
|
||||
const parsedBody: { messages: Record<string, Record<string, Record<string, any>>> } = JSON.parse(request.body);
|
||||
|
||||
const messageList = [];
|
||||
for (const [userId, perUserMessages] of Object.entries(parsedBody.messages)) {
|
||||
for (const [deviceId, message] of Object.entries(perUserMessages)) {
|
||||
messageList.push(`${userId}/${deviceId} (msgid ${message[ToDeviceMessageId]})`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Sending batch of to-device messages. type=${request.event_type} txnid=${request.txn_id}`,
|
||||
messageList,
|
||||
);
|
||||
|
||||
const path =
|
||||
`/_matrix/client/v3/sendToDevice/${encodeURIComponent(request.event_type)}/` +
|
||||
encodeURIComponent(request.txn_id);
|
||||
return await this.rawJsonRequest(Method.Put, path, {}, request.body);
|
||||
}
|
||||
|
||||
private async makeRequestWithUIA<T>(
|
||||
method: Method,
|
||||
path: string,
|
||||
@@ -164,13 +195,6 @@ export class OutgoingRequestProcessor {
|
||||
prefix: "",
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.http.authedRequest<string>(method, path, queryParams, body, opts);
|
||||
logger.info(`rust-crypto: successfully made HTTP request: ${method} ${path}`);
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.warn(`rust-crypto: error making HTTP request: ${method} ${path}: ${e}`);
|
||||
throw e;
|
||||
}
|
||||
return await this.http.authedRequest<string>(method, path, queryParams, body, opts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,24 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { EncryptionSettings, OlmMachine, RoomId, UserId } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import {
|
||||
EncryptionAlgorithm,
|
||||
EncryptionSettings,
|
||||
OlmMachine,
|
||||
RoomId,
|
||||
UserId,
|
||||
HistoryVisibility as RustHistoryVisibility,
|
||||
ToDeviceRequest,
|
||||
} from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { EventType } from "../@types/event";
|
||||
import { IContent, MatrixEvent } from "../models/event";
|
||||
import { Room } from "../models/room";
|
||||
import { logger, PrefixedLogger } from "../logger";
|
||||
import { Logger, logger } from "../logger";
|
||||
import { KeyClaimManager } from "./KeyClaimManager";
|
||||
import { RoomMember } from "../models/room-member";
|
||||
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
import { HistoryVisibility } from "../@types/partials";
|
||||
|
||||
/**
|
||||
* RoomEncryptor: responsible for encrypting messages to a given room
|
||||
@@ -30,11 +39,12 @@ import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
* @internal
|
||||
*/
|
||||
export class RoomEncryptor {
|
||||
private readonly prefixedLogger: PrefixedLogger;
|
||||
private readonly prefixedLogger: Logger;
|
||||
|
||||
/**
|
||||
* @param olmMachine - The rust-sdk's OlmMachine
|
||||
* @param keyClaimManager - Our KeyClaimManager, which manages the queue of one-time-key claim requests
|
||||
* @param outgoingRequestProcessor - The OutgoingRequestProcessor, which sends outgoing requests
|
||||
* @param room - The room we want to encrypt for
|
||||
* @param encryptionSettings - body of the m.room.encryption event currently in force in this room
|
||||
*/
|
||||
@@ -45,7 +55,7 @@ export class RoomEncryptor {
|
||||
private readonly room: Room,
|
||||
private encryptionSettings: IContent,
|
||||
) {
|
||||
this.prefixedLogger = logger.withPrefix(`[${room.roomId} encryption]`);
|
||||
this.prefixedLogger = logger.getChild(`[${room.roomId} encryption]`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,15 +75,14 @@ export class RoomEncryptor {
|
||||
* @param member - new membership state
|
||||
*/
|
||||
public onRoomMembership(member: RoomMember): void {
|
||||
this.prefixedLogger.debug(`${member.membership} event for ${member.userId}`);
|
||||
|
||||
if (
|
||||
member.membership == "join" ||
|
||||
(member.membership == "invite" && this.room.shouldEncryptForInvitedMembers())
|
||||
) {
|
||||
// make sure we are tracking the deviceList for this user
|
||||
this.prefixedLogger.debug(`starting to track devices for: ${member.userId}`);
|
||||
this.olmMachine.updateTrackedUsers([new UserId(member.userId)]);
|
||||
this.olmMachine.updateTrackedUsers([new UserId(member.userId)]).catch((e) => {
|
||||
this.prefixedLogger.error("Unable to update tracked users", e);
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: handle leaves (including our own)
|
||||
@@ -84,8 +93,10 @@ export class RoomEncryptor {
|
||||
*
|
||||
* This ensures that we have a megolm session ready to use and that we have shared its key with all the devices
|
||||
* in the room.
|
||||
*
|
||||
* @param globalBlacklistUnverifiedDevices - When `true`, it will not send encrypted messages to unverified devices
|
||||
*/
|
||||
public async ensureEncryptionSession(): Promise<void> {
|
||||
public async ensureEncryptionSession(globalBlacklistUnverifiedDevices: boolean): Promise<void> {
|
||||
if (this.encryptionSettings.algorithm !== "m.megolm.v1.aes-sha2") {
|
||||
throw new Error(
|
||||
`Cannot encrypt in ${this.room.roomId} for unsupported algorithm '${this.encryptionSettings.algorithm}'`,
|
||||
@@ -104,9 +115,28 @@ export class RoomEncryptor {
|
||||
this.prefixedLogger.debug("Sessions for users are ready; now sharing room key");
|
||||
|
||||
const rustEncryptionSettings = new EncryptionSettings();
|
||||
/* FIXME historyVisibility, rotation, etc */
|
||||
rustEncryptionSettings.historyVisibility = toRustHistoryVisibility(this.room.getHistoryVisibility());
|
||||
|
||||
const shareMessages = await this.olmMachine.shareRoomKey(
|
||||
// We only support megolm
|
||||
rustEncryptionSettings.algorithm = EncryptionAlgorithm.MegolmV1AesSha2;
|
||||
|
||||
// We need to convert the rotation period from milliseconds to microseconds
|
||||
// See https://spec.matrix.org/v1.8/client-server-api/#mroomencryption and
|
||||
// https://matrix-org.github.io/matrix-rust-sdk-crypto-wasm/classes/EncryptionSettings.html#rotationPeriod
|
||||
if (typeof this.encryptionSettings.rotation_period_ms === "number") {
|
||||
rustEncryptionSettings.rotationPeriod = BigInt(this.encryptionSettings.rotation_period_ms * 1000);
|
||||
}
|
||||
|
||||
if (typeof this.encryptionSettings.rotation_period_msgs === "number") {
|
||||
rustEncryptionSettings.rotationPeriodMessages = BigInt(this.encryptionSettings.rotation_period_msgs);
|
||||
}
|
||||
|
||||
// When this.room.getBlacklistUnverifiedDevices() === null, the global settings should be used
|
||||
// See Room#getBlacklistUnverifiedDevices
|
||||
rustEncryptionSettings.onlyAllowTrustedDevices =
|
||||
this.room.getBlacklistUnverifiedDevices() ?? globalBlacklistUnverifiedDevices;
|
||||
|
||||
const shareMessages: ToDeviceRequest[] = await this.olmMachine.shareRoomKey(
|
||||
new RoomId(this.room.roomId),
|
||||
userList,
|
||||
rustEncryptionSettings,
|
||||
@@ -135,9 +165,10 @@ export class RoomEncryptor {
|
||||
* then encrypt the event using the session.
|
||||
*
|
||||
* @param event - Event to be encrypted.
|
||||
* @param globalBlacklistUnverifiedDevices - When `true`, it will not send encrypted messages to unverified devices
|
||||
*/
|
||||
public async encryptEvent(event: MatrixEvent): Promise<void> {
|
||||
await this.ensureEncryptionSession();
|
||||
public async encryptEvent(event: MatrixEvent, globalBlacklistUnverifiedDevices: boolean): Promise<void> {
|
||||
await this.ensureEncryptionSession(globalBlacklistUnverifiedDevices);
|
||||
|
||||
const encryptedContent = await this.olmMachine.encryptRoomEvent(
|
||||
new RoomId(this.room.roomId),
|
||||
@@ -153,3 +184,21 @@ export class RoomEncryptor {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a HistoryVisibility to a RustHistoryVisibility
|
||||
* @param visibility - HistoryVisibility enum
|
||||
* @returns a RustHistoryVisibility enum
|
||||
*/
|
||||
export function toRustHistoryVisibility(visibility: HistoryVisibility): RustHistoryVisibility {
|
||||
switch (visibility) {
|
||||
case HistoryVisibility.Invited:
|
||||
return RustHistoryVisibility.Invited;
|
||||
case HistoryVisibility.Joined:
|
||||
return RustHistoryVisibility.Joined;
|
||||
case HistoryVisibility.Shared:
|
||||
return RustHistoryVisibility.Shared;
|
||||
case HistoryVisibility.WorldReadable:
|
||||
return RustHistoryVisibility.WorldReadable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import { logger } from "../logger";
|
||||
import { ClientPrefix, IHttpOpts, MatrixError, MatrixHttpApi, Method } from "../http-api";
|
||||
import { CryptoEvent, IMegolmSessionData } from "../crypto";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { encodeUri } from "../utils";
|
||||
import { encodeUri, immediate } from "../utils";
|
||||
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
import { sleep } from "../utils";
|
||||
import { BackupDecryptor } from "../common-crypto/CryptoBackend";
|
||||
@@ -82,7 +82,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
* Get the backup version we are currently backing up to, if any
|
||||
*/
|
||||
public async getActiveBackupVersion(): Promise<string | null> {
|
||||
if (!this.olmMachine.isBackupEnabled()) return null;
|
||||
if (!(await this.olmMachine.isBackupEnabled())) return null;
|
||||
return this.activeBackupVersion;
|
||||
}
|
||||
|
||||
@@ -95,11 +95,9 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
const signatureVerification: SignatureVerification = await this.olmMachine.verifyBackup(info);
|
||||
|
||||
const backupKeys: RustSdkCryptoJs.BackupKeys = await this.olmMachine.getBackupKeys();
|
||||
const pubKeyForSavedPrivateKey = backupKeys?.decryptionKey?.megolmV1PublicKey;
|
||||
const decryptionKey = backupKeys?.decryptionKey;
|
||||
const backupMatchesSavedPrivateKey =
|
||||
info.algorithm === pubKeyForSavedPrivateKey?.algorithm &&
|
||||
(info.auth_data as Curve25519AuthData)?.public_key === pubKeyForSavedPrivateKey.publicKeyBase64;
|
||||
|
||||
!!decryptionKey && backupInfoMatchesBackupDecryptionKey(info, decryptionKey);
|
||||
return {
|
||||
matchesDecryptionKey: backupMatchesSavedPrivateKey,
|
||||
trusted: signatureVerification.trusted(),
|
||||
@@ -124,6 +122,48 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
}
|
||||
return this.keyBackupCheckInProgress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a backup secret received event and store it if it matches the current backup version.
|
||||
*
|
||||
* @param secret - The secret as received from a `m.secert.send` event for secret `m.megolm_backup.v1`.
|
||||
* @returns true if the secret is valid and has been stored, false otherwise.
|
||||
*/
|
||||
public async handleBackupSecretReceived(secret: string): Promise<boolean> {
|
||||
// Currently we only receive the decryption key without any key backup version. It is important to
|
||||
// check that the secret is valid for the current version before storing it.
|
||||
// We force a check to ensure to have the latest version. We also want to check that the backup is trusted
|
||||
// as we don't want to store the secret if the backup is not trusted, and eventually import megolm keys later from an untrusted backup.
|
||||
const backupCheck = await this.checkKeyBackupAndEnable(true);
|
||||
|
||||
if (!backupCheck?.backupInfo?.version || !backupCheck.trustInfo.trusted) {
|
||||
// There is no server-side key backup, or the backup is not signed by a trusted cross-signing key or trusted own device.
|
||||
// This decryption key is useless to us.
|
||||
logger.warn("Received backup decryption key, but there is no trusted server-side key backup");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(secret);
|
||||
const privateKeyMatches = backupInfoMatchesBackupDecryptionKey(backupCheck.backupInfo, backupDecryptionKey);
|
||||
if (!privateKeyMatches) {
|
||||
logger.debug(`onReceiveSecret: backup decryption key does not match current backup version`);
|
||||
// just ignore the secret
|
||||
return false;
|
||||
}
|
||||
logger.info(
|
||||
`handleBackupSecretReceived: A valid backup decryption key has been received and stored in cache.`,
|
||||
);
|
||||
|
||||
await this.olmMachine.saveBackupDecryptionKey(backupDecryptionKey, backupCheck.backupInfo.version);
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.warn("handleBackupSecretReceived: Invalid backup decryption key", e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private keyBackupCheckInProgress: Promise<KeyBackupCheck | null> | null = null;
|
||||
|
||||
/** Helper for `checkKeyBackup` */
|
||||
@@ -379,6 +419,25 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the provided backup info matches the given private key.
|
||||
*
|
||||
* @param info - The backup info to check.
|
||||
* @param backupDecryptionKey - The `BackupDecryptionKey` private key to check against.
|
||||
* @returns `true` if the private key can decrypt the backup, `false` otherwise.
|
||||
*/
|
||||
function backupInfoMatchesBackupDecryptionKey(
|
||||
info: KeyBackupInfo,
|
||||
backupDecryptionKey: RustSdkCryptoJs.BackupDecryptionKey,
|
||||
): boolean {
|
||||
if (info.algorithm !== "m.megolm_backup.v1.curve25519-aes-sha2") {
|
||||
logger.warn("backupMatchesPrivateKey: Unsupported backup algorithm", info.algorithm);
|
||||
return false;
|
||||
}
|
||||
|
||||
return (info.auth_data as Curve25519AuthData)?.public_key === backupDecryptionKey.megolmV1PublicKey.publicKeyBase64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link BackupDecryptor} for the rust crypto backend.
|
||||
*/
|
||||
@@ -401,7 +460,7 @@ export class RustBackupDecryptor implements BackupDecryptor {
|
||||
for (const [sessionId, sessionData] of Object.entries(ciphertexts)) {
|
||||
try {
|
||||
const decrypted = JSON.parse(
|
||||
await this.decryptionKey.decryptV1(
|
||||
this.decryptionKey.decryptV1(
|
||||
sessionData.session_data.ephemeral,
|
||||
sessionData.session_data.mac,
|
||||
sessionData.session_data.ciphertext,
|
||||
@@ -409,6 +468,9 @@ export class RustBackupDecryptor implements BackupDecryptor {
|
||||
);
|
||||
decrypted.session_id = sessionId;
|
||||
keys.push(decrypted);
|
||||
|
||||
// there might be lots of sessions, so don't hog the event loop
|
||||
await immediate();
|
||||
} catch (e) {
|
||||
logger.log("Failed to decrypt megolm session from backup", e, sessionData);
|
||||
}
|
||||
|
||||
@@ -17,14 +17,15 @@ limitations under the License.
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { RustCrypto } from "./rust-crypto";
|
||||
import { logger } from "../logger";
|
||||
import { IHttpOpts, MatrixHttpApi } from "../http-api";
|
||||
import { ServerSideSecretStorage } from "../secret-storage";
|
||||
import { ICryptoCallbacks } from "../crypto";
|
||||
import { Logger } from "../logger";
|
||||
|
||||
/**
|
||||
* Create a new `RustCrypto` implementation
|
||||
*
|
||||
* @param logger - A `Logger` instance that will be used for debug output.
|
||||
* @param http - Low-level HTTP interface: used to make outgoing requests required by the rust SDK.
|
||||
* We expect it to set the access token, etc.
|
||||
* @param userId - The local user's User ID.
|
||||
@@ -40,6 +41,7 @@ import { ICryptoCallbacks } from "../crypto";
|
||||
* @internal
|
||||
*/
|
||||
export async function initRustCrypto(
|
||||
logger: Logger,
|
||||
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
@@ -52,7 +54,7 @@ export async function initRustCrypto(
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
|
||||
// enable tracing in the rust-sdk
|
||||
new RustSdkCryptoJs.Tracing(RustSdkCryptoJs.LoggerLevel.Trace).turnOn();
|
||||
new RustSdkCryptoJs.Tracing(RustSdkCryptoJs.LoggerLevel.Debug).turnOn();
|
||||
|
||||
const u = new RustSdkCryptoJs.UserId(userId);
|
||||
const d = new RustSdkCryptoJs.DeviceId(deviceId);
|
||||
@@ -65,7 +67,7 @@ export async function initRustCrypto(
|
||||
storePrefix ?? undefined,
|
||||
(storePrefix && storePassphrase) ?? undefined,
|
||||
);
|
||||
const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId, secretStorage, cryptoCallbacks);
|
||||
const rustCrypto = new RustCrypto(logger, olmMachine, http, userId, deviceId, secretStorage, cryptoCallbacks);
|
||||
await olmMachine.registerRoomKeyUpdatedCallback((sessions: RustSdkCryptoJs.RoomKeyInfo[]) =>
|
||||
rustCrypto.onRoomKeysUpdated(sessions),
|
||||
);
|
||||
@@ -73,6 +75,17 @@ export async function initRustCrypto(
|
||||
rustCrypto.onUserIdentityUpdated(userId),
|
||||
);
|
||||
|
||||
// Check if there are any key backup secrets pending processing. There may be multiple secrets to process if several devices have gossiped them.
|
||||
// The `registerReceiveSecretCallback` function will only be triggered for new secrets. If the client is restarted before processing them, the secrets will need to be manually handled.
|
||||
rustCrypto.checkSecrets("m.megolm_backup.v1");
|
||||
|
||||
// Register a callback to be notified when a new secret is received, as for now only the key backup secret is supported (the cross signing secrets are handled automatically by the OlmMachine)
|
||||
await olmMachine.registerReceiveSecretCallback((name: string, _value: string) =>
|
||||
// Instead of directly checking the secret value, we poll the inbox to get all values for that secret type.
|
||||
// Once we have all the values, we can safely clear the secret inbox.
|
||||
rustCrypto.checkSecrets(name),
|
||||
);
|
||||
|
||||
// Tell the OlmMachine to think about its outgoing requests before we hand control back to the application.
|
||||
//
|
||||
// This is primarily a fudge to get it to correctly populate the `users_for_key_query` list, so that future
|
||||
|
||||
+208
-58
@@ -24,12 +24,12 @@ import { IContent, MatrixEvent, MatrixEventEvent } from "../models/event";
|
||||
import { Room } from "../models/room";
|
||||
import { RoomMember } from "../models/room-member";
|
||||
import { BackupDecryptor, CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { logger } from "../logger";
|
||||
import { Logger } from "../logger";
|
||||
import { ClientPrefix, IHttpOpts, MatrixHttpApi, Method } from "../http-api";
|
||||
import { RoomEncryptor } from "./RoomEncryptor";
|
||||
import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
import { KeyClaimManager } from "./KeyClaimManager";
|
||||
import { MapWithDefault, encodeUri } from "../utils";
|
||||
import { encodeUri, MapWithDefault } from "../utils";
|
||||
import {
|
||||
BackupTrustInfo,
|
||||
BootstrapCrossSigningOpts,
|
||||
@@ -62,7 +62,7 @@ import { keyFromPassphrase } from "../crypto/key_passphrase";
|
||||
import { encodeRecoveryKey } from "../crypto/recoverykey";
|
||||
import { crypto } from "../crypto/crypto";
|
||||
import { isVerificationEvent, RustVerificationRequest, verificationMethodIdentifierToMethod } from "./verification";
|
||||
import { EventType } from "../@types/event";
|
||||
import { EventType, MsgType } from "../@types/event";
|
||||
import { CryptoEvent } from "../crypto";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { RustBackupCryptoEventMap, RustBackupCryptoEvents, RustBackupDecryptor, RustBackupManager } from "./backup";
|
||||
@@ -70,7 +70,7 @@ import { TypedReEmitter } from "../ReEmitter";
|
||||
import { randomString } from "../randomstring";
|
||||
import { ClientStoppedError } from "../errors";
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { encodeBase64 } from "../common-crypto/base64";
|
||||
import { encodeBase64 } from "../base64";
|
||||
import { DecryptionError } from "../crypto/algorithms";
|
||||
|
||||
const ALL_VERIFICATION_METHODS = ["m.sas.v1", "m.qr_code.scan.v1", "m.qr_code.show.v1", "m.reciprocate.v1"];
|
||||
@@ -88,7 +88,6 @@ const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms
|
||||
* @internal
|
||||
*/
|
||||
export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEventMap> implements CryptoBackend {
|
||||
public globalErrorOnUnknownDevices = false;
|
||||
private _trustCrossSignedDevices = true;
|
||||
|
||||
/** whether {@link stop} has been called */
|
||||
@@ -118,6 +117,8 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
private readonly reemitter = new TypedReEmitter<RustCryptoEvents, RustCryptoEventMap>(this);
|
||||
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
|
||||
/** The `OlmMachine` from the underlying rust crypto sdk. */
|
||||
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
|
||||
|
||||
@@ -143,7 +144,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
super();
|
||||
this.outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, http);
|
||||
this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
|
||||
this.eventDecryptor = new EventDecryptor(olmMachine, this);
|
||||
this.eventDecryptor = new EventDecryptor(this.logger, olmMachine, this);
|
||||
|
||||
this.backupManager = new RustBackupManager(olmMachine, http, this.outgoingRequestProcessor);
|
||||
this.reemitter.reEmit(this.backupManager, [
|
||||
@@ -153,46 +154,78 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
]);
|
||||
|
||||
this.crossSigningIdentity = new CrossSigningIdentity(olmMachine, this.outgoingRequestProcessor, secretStorage);
|
||||
|
||||
// Check and start in background the key backup connection
|
||||
this.checkKeyBackupAndEnable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to retrieve a session from a key backup, if enough time
|
||||
* Starts an attempt to retrieve a session from a key backup, if enough time
|
||||
* has elapsed since the last check for this session id.
|
||||
*
|
||||
* If a backup is found, it is decrypted and imported.
|
||||
*
|
||||
* @param targetRoomId - ID of the room that the session is used in.
|
||||
* @param targetSessionId - ID of the session for which to check backup.
|
||||
*/
|
||||
public async queryKeyBackupRateLimited(targetRoomId: string, targetSessionId: string): Promise<void> {
|
||||
const backupKeys: RustSdkCryptoJs.BackupKeys = await this.olmMachine.getBackupKeys();
|
||||
if (!backupKeys.decryptionKey) return;
|
||||
const version = backupKeys.backupVersion;
|
||||
|
||||
public startQueryKeyBackupRateLimited(targetRoomId: string, targetSessionId: string): void {
|
||||
const now = new Date().getTime();
|
||||
if (
|
||||
!this.sessionLastCheckAttemptedTime[targetSessionId!] ||
|
||||
now - this.sessionLastCheckAttemptedTime[targetSessionId!] > KEY_BACKUP_CHECK_RATE_LIMIT
|
||||
) {
|
||||
const lastCheck = this.sessionLastCheckAttemptedTime[targetSessionId];
|
||||
if (!lastCheck || now - lastCheck > KEY_BACKUP_CHECK_RATE_LIMIT) {
|
||||
this.sessionLastCheckAttemptedTime[targetSessionId!] = now;
|
||||
|
||||
const path = encodeUri("/room_keys/keys/$roomId/$sessionId", {
|
||||
$roomId: targetRoomId,
|
||||
$sessionId: targetSessionId,
|
||||
this.queryKeyBackup(targetRoomId, targetSessionId).catch((e) => {
|
||||
this.logger.error(`Unhandled error while checking key backup for session ${targetSessionId}`, e);
|
||||
});
|
||||
} else {
|
||||
const lastCheckStr = new Date(lastCheck).toISOString();
|
||||
this.logger.debug(
|
||||
`Not checking key backup for session ${targetSessionId} (last checked at ${lastCheckStr})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await this.http.authedRequest<KeyBackupSession>(Method.Get, path, { version }, undefined, {
|
||||
/**
|
||||
* Helper for {@link RustCrypto#startQueryKeyBackupRateLimited}.
|
||||
*
|
||||
* Requests the backup and imports it. Doesn't do any rate-limiting.
|
||||
*
|
||||
* @param targetRoomId - ID of the room that the session is used in.
|
||||
* @param targetSessionId - ID of the session for which to check backup.
|
||||
*/
|
||||
private async queryKeyBackup(targetRoomId: string, targetSessionId: string): Promise<void> {
|
||||
const backupKeys: RustSdkCryptoJs.BackupKeys = await this.olmMachine.getBackupKeys();
|
||||
if (!backupKeys.decryptionKey) {
|
||||
this.logger.debug(`Not checking key backup for session ${targetSessionId} (no decryption key)`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(`Checking key backup for session ${targetSessionId}`);
|
||||
|
||||
const version = backupKeys.backupVersion;
|
||||
const path = encodeUri("/room_keys/keys/$roomId/$sessionId", {
|
||||
$roomId: targetRoomId,
|
||||
$sessionId: targetSessionId,
|
||||
});
|
||||
|
||||
let res: KeyBackupSession;
|
||||
try {
|
||||
res = await this.http.authedRequest<KeyBackupSession>(Method.Get, path, { version }, undefined, {
|
||||
prefix: ClientPrefix.V3,
|
||||
});
|
||||
|
||||
if (this.stopped) return;
|
||||
|
||||
const backupDecryptor = new RustBackupDecryptor(backupKeys.decryptionKey);
|
||||
if (res) {
|
||||
const sessionsToImport: Record<string, KeyBackupSession> = {};
|
||||
sessionsToImport[targetSessionId] = res;
|
||||
const keys = await backupDecryptor.decryptSessions(sessionsToImport);
|
||||
for (const k of keys) {
|
||||
k.room_id = targetRoomId!;
|
||||
}
|
||||
await this.importRoomKeys(keys);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.info(`No luck requesting key backup for session ${targetSessionId}: ${e}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.stopped) return;
|
||||
|
||||
const backupDecryptor = new RustBackupDecryptor(backupKeys.decryptionKey);
|
||||
const sessionsToImport: Record<string, KeyBackupSession> = { [targetSessionId]: res };
|
||||
const keys = await backupDecryptor.decryptSessions(sessionsToImport);
|
||||
for (const k of keys) {
|
||||
k.room_id = targetRoomId;
|
||||
}
|
||||
await this.importRoomKeys(keys);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,6 +248,15 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
//
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
public set globalErrorOnUnknownDevices(_v: boolean) {
|
||||
// Not implemented for rust crypto.
|
||||
}
|
||||
|
||||
public get globalErrorOnUnknownDevices(): boolean {
|
||||
// Not implemented for rust crypto.
|
||||
return false;
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
// stop() may be called multiple times, but attempting to close() the OlmMachine twice
|
||||
// will cause an error.
|
||||
@@ -240,7 +282,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
throw new Error(`Cannot encrypt event in unconfigured room ${roomId}`);
|
||||
}
|
||||
|
||||
await encryptor.encryptEvent(event);
|
||||
await encryptor.encryptEvent(event, this.globalBlacklistUnverifiedDevices);
|
||||
}
|
||||
|
||||
public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> {
|
||||
@@ -262,8 +304,6 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
* @param event - event to inspect
|
||||
*/
|
||||
public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo {
|
||||
// TODO: make this work properly. Or better, replace it.
|
||||
|
||||
const ret: Partial<IEncryptedEventInfo> = {};
|
||||
|
||||
ret.senderKey = event.getSenderKey() ?? undefined;
|
||||
@@ -324,11 +364,19 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
|
||||
public globalBlacklistUnverifiedDevices = false;
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getVersion}.
|
||||
*/
|
||||
public getVersion(): string {
|
||||
const versions = RustSdkCryptoJs.getVersions();
|
||||
return `Rust SDK ${versions.matrix_sdk_crypto} (${versions.git_sha}), Vodozemac ${versions.vodozemac}`;
|
||||
}
|
||||
|
||||
public prepareToEncrypt(room: Room): void {
|
||||
const encryptor = this.roomEncryptors[room.roomId];
|
||||
|
||||
if (encryptor) {
|
||||
encryptor.ensureEncryptionSession();
|
||||
encryptor.ensureEncryptionSession(this.globalBlacklistUnverifiedDevices);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1005,12 +1053,19 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
* Implementation of {@link CryptoApi#storeSessionBackupPrivateKey}.
|
||||
*
|
||||
* @param key - the backup decryption key
|
||||
* @param version - the backup version for this key.
|
||||
*/
|
||||
public async storeSessionBackupPrivateKey(key: Uint8Array): Promise<void> {
|
||||
public async storeSessionBackupPrivateKey(key: Uint8Array, version?: string): Promise<void> {
|
||||
const base64Key = encodeBase64(key);
|
||||
|
||||
// TODO get version from backupManager
|
||||
await this.olmMachine.saveBackupDecryptionKey(RustSdkCryptoJs.BackupDecryptionKey.fromBase64(base64Key), "");
|
||||
if (!version) {
|
||||
throw new Error("storeSessionBackupPrivateKey: version is required");
|
||||
}
|
||||
|
||||
await this.olmMachine.saveBackupDecryptionKey(
|
||||
RustSdkCryptoJs.BackupDecryptionKey.fromBase64(base64Key),
|
||||
version,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1223,10 +1278,6 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
|
||||
// start tracking devices for any users already known to be in this room.
|
||||
const members = await room.getEncryptionTargetMembers();
|
||||
logger.debug(
|
||||
`[${room.roomId} encryption] starting to track devices for: `,
|
||||
members.map((u) => `${u.userId} (${u.membership})`),
|
||||
);
|
||||
await this.olmMachine.updateTrackedUsers(members.map((u) => new RustSdkCryptoJs.UserId(u.userId)));
|
||||
}
|
||||
|
||||
@@ -1306,11 +1357,13 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
|
||||
private onRoomKeyUpdated(key: RustSdkCryptoJs.RoomKeyInfo): void {
|
||||
if (this.stopped) return;
|
||||
logger.debug(`Got update for session ${key.senderKey.toBase64()}|${key.sessionId} in ${key.roomId.toString()}`);
|
||||
this.logger.debug(
|
||||
`Got update for session ${key.senderKey.toBase64()}|${key.sessionId} in ${key.roomId.toString()}`,
|
||||
);
|
||||
const pendingList = this.eventDecryptor.getEventsPendingRoomKey(key);
|
||||
if (pendingList.length === 0) return;
|
||||
|
||||
logger.debug(
|
||||
this.logger.debug(
|
||||
"Retrying decryption on events:",
|
||||
pendingList.map((e) => `${e.getId()}`),
|
||||
);
|
||||
@@ -1323,7 +1376,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
// and deduplicates repeated attempts for the same event.
|
||||
for (const ev of pendingList) {
|
||||
ev.attemptDecryption(this, { isRetry: true }).catch((_e) => {
|
||||
logger.info(`Still unable to decrypt event ${ev.getId()} after receiving key`);
|
||||
this.logger.info(`Still unable to decrypt event ${ev.getId()} after receiving key`);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1339,6 +1392,57 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
public async onUserIdentityUpdated(userId: RustSdkCryptoJs.UserId): Promise<void> {
|
||||
const newVerification = await this.getUserVerificationStatus(userId.toString());
|
||||
this.emit(CryptoEvent.UserTrustStatusChanged, userId.toString(), newVerification);
|
||||
|
||||
// If our own user identity has changed, we may now trust the key backup where we did not before.
|
||||
// So, re-check the key backup status and enable it if available.
|
||||
if (userId.toString() === this.userId) {
|
||||
await this.checkKeyBackupAndEnable();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles secret received from the rust secret inbox.
|
||||
*
|
||||
* The gossipped secrets are received using the `m.secret.send` event type
|
||||
* and are guaranteed to have been received over a 1-to-1 Olm
|
||||
* Session from a verified device.
|
||||
*
|
||||
* The only secret currently handled in this way is `m.megolm_backup.v1`.
|
||||
*
|
||||
* @param name - the secret name
|
||||
* @param value - the secret value
|
||||
*/
|
||||
private async handleSecretReceived(name: string, value: string): Promise<boolean> {
|
||||
this.logger.debug(`onReceiveSecret: Received secret ${name}`);
|
||||
if (name === "m.megolm_backup.v1") {
|
||||
return await this.backupManager.handleBackupSecretReceived(value);
|
||||
// XXX at this point we should probably try to download the backup and import the keys,
|
||||
// or at least retry for the current decryption failures?
|
||||
// Maybe add some signaling when a new secret is received, and let clients handle it?
|
||||
// as it's where the restore from backup APIs are exposed.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a new secret is received in the rust secret inbox.
|
||||
*
|
||||
* Will poll the secret inbox and handle the secrets received.
|
||||
*
|
||||
* @param name - The name of the secret received.
|
||||
*/
|
||||
public async checkSecrets(name: string): Promise<void> {
|
||||
const pendingValues: string[] = await this.olmMachine.getSecretsFromInbox(name);
|
||||
for (const value of pendingValues) {
|
||||
if (await this.handleSecretReceived(name, value)) {
|
||||
// If we have a valid secret for that name there is no point of processing the other secrets values.
|
||||
// It's probably the same secret shared by another device.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Important to call this after handling the secrets as good hygiene.
|
||||
await this.olmMachine.deleteSecretsFromInbox(name);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1393,7 +1497,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
throw new Error("missing roomId in the event");
|
||||
}
|
||||
|
||||
logger.debug(`Incoming verification event ${event.getId()} type ${event.getType()} from ${event.getSender()}`);
|
||||
this.logger.debug(
|
||||
`Incoming verification event ${event.getId()} type ${event.getType()} from ${event.getSender()}`,
|
||||
);
|
||||
|
||||
await this.olmMachine.receiveVerificationEvent(
|
||||
JSON.stringify({
|
||||
@@ -1407,6 +1513,32 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
new RustSdkCryptoJs.RoomId(roomId),
|
||||
);
|
||||
|
||||
if (
|
||||
event.getType() === EventType.RoomMessage &&
|
||||
event.getContent().msgtype === MsgType.KeyVerificationRequest
|
||||
) {
|
||||
const request: RustSdkCryptoJs.VerificationRequest | undefined = this.olmMachine.getVerificationRequest(
|
||||
new RustSdkCryptoJs.UserId(event.getSender()!),
|
||||
event.getId()!,
|
||||
);
|
||||
|
||||
if (!request) {
|
||||
// There are multiple reasons this can happen; probably the most likely is that the event is too old.
|
||||
this.logger.info(
|
||||
`Ignoring just-received verification request ${event.getId()} which did not start a rust-side verification`,
|
||||
);
|
||||
} else {
|
||||
this.emit(
|
||||
CryptoEvent.VerificationRequestReceived,
|
||||
new RustVerificationRequest(
|
||||
request,
|
||||
this.outgoingRequestProcessor,
|
||||
this._supportedVerificationMethods,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// that may have caused us to queue up outgoing requests, so make sure we send them.
|
||||
this.outgoingRequestLoop();
|
||||
}
|
||||
@@ -1432,7 +1564,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
}
|
||||
// fire off the loop in the background
|
||||
this.outgoingRequestLoopInner().catch((e) => {
|
||||
logger.error("Error processing outgoing-message requests from rust crypto-sdk", e);
|
||||
this.logger.error("Error processing outgoing-message requests from rust crypto-sdk", e);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1448,7 +1580,6 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
// if `this.outgoingRequestLoop()` was called while `OlmMachine.outgoingRequests()` was running.
|
||||
this.outgoingRequestLoopOneMoreLoop = false;
|
||||
|
||||
logger.debug("Calling OlmMachine.outgoingRequests()");
|
||||
const outgoingRequests: Object[] = await this.olmMachine.outgoingRequests();
|
||||
|
||||
if (this.stopped) {
|
||||
@@ -1483,10 +1614,17 @@ class EventDecryptor {
|
||||
() => new MapWithDefault<string, Set<MatrixEvent>>(() => new Set()),
|
||||
);
|
||||
|
||||
public constructor(private readonly olmMachine: RustSdkCryptoJs.OlmMachine, private readonly crypto: RustCrypto) {}
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
|
||||
private readonly crypto: RustCrypto,
|
||||
) {}
|
||||
|
||||
public async attemptEventDecryption(event: MatrixEvent): Promise<IEventDecryptionResult> {
|
||||
logger.info("Attempting decryption of event", event);
|
||||
this.logger.info(
|
||||
`Attempting decryption of event ${event.getId()} in ${event.getRoomId()} from ${event.getSender()}`,
|
||||
);
|
||||
|
||||
// add the event to the pending list *before* attempting to decrypt.
|
||||
// then, if the key turns up while decryption is in progress (and
|
||||
// decryption fails), we will schedule a retry.
|
||||
@@ -1524,7 +1662,10 @@ class EventDecryptor {
|
||||
session: content.sender_key + "|" + content.session_id,
|
||||
},
|
||||
);
|
||||
this.crypto.queryKeyBackupRateLimited(event.getRoomId()!, event.getWireContent().session_id!);
|
||||
this.crypto.startQueryKeyBackupRateLimited(
|
||||
event.getRoomId()!,
|
||||
event.getWireContent().session_id!,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case RustSdkCryptoJs.DecryptionErrorCode.UnknownMessageIndex: {
|
||||
@@ -1535,7 +1676,10 @@ class EventDecryptor {
|
||||
session: content.sender_key + "|" + content.session_id,
|
||||
},
|
||||
);
|
||||
this.crypto.queryKeyBackupRateLimited(event.getRoomId()!, event.getWireContent().session_id!);
|
||||
this.crypto.startQueryKeyBackupRateLimited(
|
||||
event.getRoomId()!,
|
||||
event.getWireContent().session_id!,
|
||||
);
|
||||
break;
|
||||
}
|
||||
// We don't map MismatchedIdentityKeys for now, as there is no equivalent in legacy.
|
||||
@@ -1554,17 +1698,22 @@ class EventDecryptor {
|
||||
}
|
||||
|
||||
public async getEncryptionInfoForEvent(event: MatrixEvent): Promise<EventEncryptionInfo | null> {
|
||||
if (!event.getClearContent()) {
|
||||
if (!event.getClearContent() || event.isDecryptionFailure()) {
|
||||
// not successfully decrypted
|
||||
return null;
|
||||
}
|
||||
|
||||
// special-case outgoing events, which the rust crypto-sdk will barf on
|
||||
if (event.status !== null) {
|
||||
return { shieldColour: EventShieldColour.NONE, shieldReason: null };
|
||||
}
|
||||
|
||||
const encryptionInfo = await this.olmMachine.getRoomEventEncryptionInfo(
|
||||
stringifyEvent(event),
|
||||
new RustSdkCryptoJs.RoomId(event.getRoomId()!),
|
||||
);
|
||||
|
||||
return rustEncryptionInfoToJsEncryptionInfo(encryptionInfo);
|
||||
return rustEncryptionInfoToJsEncryptionInfo(this.logger, encryptionInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1636,6 +1785,7 @@ function stringifyEvent(event: MatrixEvent): string {
|
||||
}
|
||||
|
||||
function rustEncryptionInfoToJsEncryptionInfo(
|
||||
logger: Logger,
|
||||
encryptionInfo: RustSdkCryptoJs.EncryptionInfo | undefined,
|
||||
): EventEncryptionInfo | null {
|
||||
if (encryptionInfo === undefined) {
|
||||
@@ -1659,7 +1809,7 @@ function rustEncryptionInfoToJsEncryptionInfo(
|
||||
}
|
||||
|
||||
let shieldReason: EventShieldReason | null;
|
||||
if (shieldState.message === null) {
|
||||
if (shieldState.message === undefined) {
|
||||
shieldReason = null;
|
||||
} else if (shieldState.message === "Encrypted by an unverified user.") {
|
||||
// this case isn't actually used with lax shield semantics.
|
||||
|
||||
@@ -392,7 +392,10 @@ export class RustVerificationRequest
|
||||
* Implementation of {@link Crypto.VerificationRequest#generateQRCode}.
|
||||
*/
|
||||
public async generateQRCode(): Promise<Buffer | undefined> {
|
||||
const innerVerifier: RustSdkCryptoJs.Qr = await this.inner.generateQrCode();
|
||||
const innerVerifier: RustSdkCryptoJs.Qr | undefined = await this.inner.generateQrCode();
|
||||
// If we are unable to generate a QRCode, we return undefined
|
||||
if (!innerVerifier) return;
|
||||
|
||||
return Buffer.from(innerVerifier.toBytes());
|
||||
}
|
||||
|
||||
@@ -566,7 +569,11 @@ export class RustQrCodeVerifier extends BaseRustVerifer<RustSdkCryptoJs.Qr> impl
|
||||
return VerificationPhase.Started;
|
||||
case QrState.Confirmed:
|
||||
// we have confirmed the other side's scan and sent an `m.key.verification.done`.
|
||||
return VerificationPhase.Done;
|
||||
//
|
||||
// However, the verification is not yet "Done", because we have to wait until we have received the
|
||||
// `m.key.verification.done` from the other side (in particular, we don't mark the device/identity as
|
||||
// verified until that happens). If we return "Done" too soon, we risk the user cancelling the flow.
|
||||
return VerificationPhase.Started;
|
||||
case QrState.Reciprocated:
|
||||
// although the rust SDK doesn't immediately send the `m.key.verification.start` on transition into this
|
||||
// state, `RustVerificationRequest.scanQrCode` immediately calls `reciprocate()` and does so, so in practice
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface ISavedSync {
|
||||
accountData: IMinimalEvent[];
|
||||
}
|
||||
|
||||
export type UserCreator = (userId: string) => User;
|
||||
|
||||
/**
|
||||
* A store for most of the data js-sdk needs to store, apart from crypto data
|
||||
*/
|
||||
@@ -62,6 +64,12 @@ export interface IStore {
|
||||
*/
|
||||
storeRoom(room: Room): void;
|
||||
|
||||
/**
|
||||
* Set the user creator which is used for creating User objects
|
||||
* @param creator - A callback that accepts an user-id and returns an User object
|
||||
*/
|
||||
setUserCreator(creator: UserCreator): void;
|
||||
|
||||
/**
|
||||
* Retrieve a room by its' room ID.
|
||||
* @param roomId - The room ID.
|
||||
|
||||
@@ -191,7 +191,6 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
this.db?.close(); // this does not call onclose
|
||||
this.disconnected = true;
|
||||
this.db = undefined;
|
||||
onClose?.();
|
||||
};
|
||||
this.db.onclose = (): void => {
|
||||
this.disconnected = true;
|
||||
|
||||
@@ -19,7 +19,6 @@ limitations under the License.
|
||||
import { MemoryStore, IOpts as IBaseOpts } from "./memory";
|
||||
import { LocalIndexedDBStoreBackend } from "./indexeddb-local-backend";
|
||||
import { RemoteIndexedDBStoreBackend } from "./indexeddb-remote-backend";
|
||||
import { User } from "../models/user";
|
||||
import { IEvent, MatrixEvent } from "../models/event";
|
||||
import { logger } from "../logger";
|
||||
import { ISavedSync } from "./index";
|
||||
@@ -140,7 +139,10 @@ export class IndexedDBStore extends MemoryStore {
|
||||
.then((userPresenceEvents) => {
|
||||
logger.log(`IndexedDBStore.startup: processing presence events`);
|
||||
userPresenceEvents.forEach(([userId, rawEvent]) => {
|
||||
const u = new User(userId);
|
||||
if (!this.createUser) {
|
||||
throw new Error("createUser is undefined, it should be set with setUserCreator()!");
|
||||
}
|
||||
const u = this.createUser(userId);
|
||||
if (rawEvent) {
|
||||
u.setPresenceEvent(new MatrixEvent(rawEvent));
|
||||
}
|
||||
|
||||
+7
-2
@@ -25,7 +25,7 @@ import { IEvent, MatrixEvent } from "../models/event";
|
||||
import { RoomState, RoomStateEvent } from "../models/room-state";
|
||||
import { RoomMember } from "../models/room-member";
|
||||
import { Filter } from "../filter";
|
||||
import { ISavedSync, IStore } from "./index";
|
||||
import { ISavedSync, IStore, UserCreator } from "./index";
|
||||
import { RoomSummary } from "../models/room-summary";
|
||||
import { ISyncResponse } from "../sync-accumulator";
|
||||
import { IStateEventWithRoomId } from "../@types/search";
|
||||
@@ -63,6 +63,7 @@ export class MemoryStore implements IStore {
|
||||
private clientOptions?: IStoredClientOpts;
|
||||
private pendingToDeviceBatches: IndexedToDeviceBatch[] = [];
|
||||
private nextToDeviceBatchId = 0;
|
||||
protected createUser?: UserCreator;
|
||||
|
||||
/**
|
||||
* Construct a new in-memory data store for the Matrix Client.
|
||||
@@ -108,6 +109,10 @@ export class MemoryStore implements IStore {
|
||||
});
|
||||
}
|
||||
|
||||
public setUserCreator(creator: UserCreator): void {
|
||||
this.createUser = creator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a room member in a room being tracked by this store has been
|
||||
* updated.
|
||||
@@ -119,7 +124,7 @@ export class MemoryStore implements IStore {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = this.users[member.userId] || new User(member.userId);
|
||||
const user = this.users[member.userId] || this.createUser?.(member.userId);
|
||||
if (member.name) {
|
||||
user.setDisplayName(member.name);
|
||||
if (member.events.member) {
|
||||
|
||||
+8
-1
@@ -23,7 +23,7 @@ import { Room } from "../models/room";
|
||||
import { User } from "../models/user";
|
||||
import { IEvent, MatrixEvent } from "../models/event";
|
||||
import { Filter } from "../filter";
|
||||
import { ISavedSync, IStore } from "./index";
|
||||
import { ISavedSync, IStore, UserCreator } from "./index";
|
||||
import { RoomSummary } from "../models/room-summary";
|
||||
import { ISyncResponse } from "../sync-accumulator";
|
||||
import { IStateEventWithRoomId } from "../@types/search";
|
||||
@@ -117,6 +117,13 @@ export class StubStore implements IStore {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op.
|
||||
*/
|
||||
public setUserCreator(creator: UserCreator): void {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store events for a room.
|
||||
* @param room - The room to store events for.
|
||||
|
||||
+4
-16
@@ -26,7 +26,7 @@ limitations under the License.
|
||||
import { Optional } from "matrix-events-sdk";
|
||||
|
||||
import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend";
|
||||
import { User, UserEvent } from "./models/user";
|
||||
import { User } from "./models/user";
|
||||
import { NotificationCountType, Room, RoomEvent } from "./models/room";
|
||||
import { deepCopy, defer, IDeferred, noUnsafeEventProps, promiseMapSeries, unsafeProp } from "./utils";
|
||||
import { Filter } from "./filter";
|
||||
@@ -431,7 +431,7 @@ export class SyncApi {
|
||||
if (user) {
|
||||
user.setPresenceEvent(presenceEvent);
|
||||
} else {
|
||||
user = createNewUser(client, presenceEvent.getContent().user_id);
|
||||
user = User.createUser(presenceEvent.getContent().user_id, client);
|
||||
user.setPresenceEvent(presenceEvent);
|
||||
client.store.storeUser(user);
|
||||
}
|
||||
@@ -530,7 +530,7 @@ export class SyncApi {
|
||||
if (user) {
|
||||
user.setPresenceEvent(presenceEvent);
|
||||
} else {
|
||||
user = createNewUser(this.client, presenceEvent.getContent().user_id);
|
||||
user = User.createUser(presenceEvent.getContent().user_id, this.client);
|
||||
user.setPresenceEvent(presenceEvent);
|
||||
this.client.store.storeUser(user);
|
||||
}
|
||||
@@ -1150,7 +1150,7 @@ export class SyncApi {
|
||||
if (user) {
|
||||
user.setPresenceEvent(presenceEvent);
|
||||
} else {
|
||||
user = createNewUser(client, presenceEvent.getSender()!);
|
||||
user = User.createUser(presenceEvent.getSender()!, client);
|
||||
user.setPresenceEvent(presenceEvent);
|
||||
client.store.storeUser(user);
|
||||
}
|
||||
@@ -1893,18 +1893,6 @@ export class SyncApi {
|
||||
};
|
||||
}
|
||||
|
||||
function createNewUser(client: MatrixClient, userId: string): User {
|
||||
const user = new User(userId);
|
||||
client.reEmitter.reEmit(user, [
|
||||
UserEvent.AvatarUrl,
|
||||
UserEvent.DisplayName,
|
||||
UserEvent.Presence,
|
||||
UserEvent.CurrentlyActive,
|
||||
UserEvent.LastPresenceTs,
|
||||
]);
|
||||
return user;
|
||||
}
|
||||
|
||||
// /!\ This function is not intended for public use! It's only exported from
|
||||
// here in order to share some common logic with sliding-sync-sdk.ts.
|
||||
export function _createAndReEmitRoom(client: MatrixClient, roomId: string, opts: Partial<IStoredClientOpts>): Room {
|
||||
|
||||
Reference in New Issue
Block a user