Compare commits

..

15 Commits

Author SHA1 Message Date
RiotRobot acc9aa8939 v41.2.0 2026-03-24 11:21:57 +00:00
RiotRobot bd775f6b61 v41.2.0-rc.0 2026-03-17 11:39:31 +00:00
Skye Elliot c2f9ad28fc Only share history if room history visibility is shared (#5216)
* feat: Only share history if room history visibility is shared

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

* docs: Update documentation for `InviteOpts.shareEncryptedHistory`

* tests: Ensure shared history respects current history visibility

This commit additionally modifies `expectSendRoomEvent` to remove
the matcher on success, since fetchmock takes a while to do this
automatically.

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

---------

Signed-off-by: Skye Elliot <actuallyori@gmail.com>
2026-03-16 13:03:41 +00:00
Richard van der Hoff 7f33e3462e History sharing: resume key-bundle import on restart (#5214)
* Store rooms pending key bundles in the CryptoStore

Replace the in-memory storage of which rooms are waiting for a key bundle with
permanent storage in the crypto store.

* Clear pending-key-bundle flag on malformed bundles

If we cannot import the key bundle, there is no point trying again another
time: we may as well clear the flag either way.

* Factor out some helpers in history sharing integ test

* Do not accept key bundles for rooms we joined more than 24h ago

Per discussion in crypto-internal.

* Clear pending key bundle data when we leave a room

* Resume key-bundle import on restart

* Clear pending-key-bundle flag on rooms that we joined ages ago

* fixup! Clear pending-key-bundle flag on malformed bundles
2026-03-16 11:58:36 +00:00
Richard van der Hoff d99363d288 Move shareRoomHistoryWithUser to CryptoBackend (#5218)
There is no need for this method to be exposed to the application, and it's a
footgun waiting to trap the unwary user.

It's marked `@experimental` so we're allowed to move it without a major version
bump.
2026-03-12 21:21:31 +00:00
renovate[bot] 3642b99212 Update dependency @matrix-org/matrix-sdk-crypto-wasm to v18 (#5217)
* Update dependency @matrix-org/matrix-sdk-crypto-wasm to v18

* Adapt to breaking changes in rust-sdk wasm bindings

* more types fixes

* types fixes for tests

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Richard van der Hoff <richard@matrix.org>
2026-03-12 18:16:44 +00:00
Valere Fedronic 6ec0987286 re export sticky event types (#5213)
Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2026-03-12 17:27:11 +00:00
RiotRobot 219eb617dc Merge branch 'master' into develop 2026-03-10 13:46:12 +00:00
RiotRobot c6f9b25046 v41.1.0 2026-03-10 13:45:36 +00:00
Michael Telatynski 5d0e2efaf3 Add zizmor CI & make it happy (#5212)
* Add zizmor CI & make it happy

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix additional zizmor warning

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-03-04 09:27:47 +00:00
renovate[bot] c7cd5570d3 Update eslint-plugins (#5207)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 14:01:28 +00:00
renovate[bot] c2f6dd2ce0 Update crazy-max/ghaction-import-gpg action to v7 (#5210)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 13:24:07 +00:00
renovate[bot] 393732aaae Update npm non-major dependencies (#5208)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 13:23:47 +00:00
renovate[bot] d373fd8540 Update GitHub Artifact Actions (#5211)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 13:22:31 +00:00
renovate[bot] 44a8a9a47a Update pnpm to v10.30.3 (#5209)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 13:22:19 +00:00
34 changed files with 992 additions and 630 deletions
+3 -1
View File
@@ -1,6 +1,8 @@
name: Backport
on:
pull_request_target:
# Privilege escalation necessary to enable backporting PRs from forks
# 🚨 We must not execute any checked out code here.
pull_request_target: # zizmor: ignore[dangerous-triggers]
types:
- closed
- labeled
+4 -2
View File
@@ -1,7 +1,9 @@
name: Deploy documentation PR preview
on:
workflow_run:
# Privilege escalation necessary to publish to Netlify
# 🚨 We must not execute any checked out code here.
workflow_run: # zizmor: ignore[dangerous-triggers]
workflows: ["Static Analysis"]
types:
- completed
@@ -15,7 +17,7 @@ jobs:
deployments: write
steps:
- name: 📥 Download artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
@@ -21,7 +21,7 @@ concurrency:
jobs:
playwright:
name: Playwright
uses: element-hq/element-web/.github/workflows/end-to-end-tests.yaml@develop
uses: element-hq/element-web/.github/workflows/end-to-end-tests.yaml@develop # zizmor: ignore[unpinned-uses]
permissions:
actions: read
issues: read
+4 -1
View File
@@ -1,6 +1,9 @@
name: Pull Request
on:
pull_request_target:
# Privilege escalation necessary access members of the review teams
# 🚨 We must not execute any checked out code here, and be careful around use of user-controlled inputs.
# FIXME: only `community-prs` job needs this privilege, so it should be in its own workflow file.
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited, labeled, unlabeled, synchronize]
merge_group:
types: [checks_requested]
@@ -20,6 +20,7 @@ jobs:
with:
ref: staging
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
+1 -1
View File
@@ -13,4 +13,4 @@ jobs:
draft:
permissions:
contents: write
uses: matrix-org/matrix-js-sdk/.github/workflows/release-drafter-workflow.yml@develop
uses: matrix-org/matrix-js-sdk/.github/workflows/release-drafter-workflow.yml@develop # zizmor: ignore[unpinned-uses]
+1
View File
@@ -27,6 +27,7 @@ jobs:
# We will be pushing to this branch and want the CI to run after we do so we cannot use the GITHUB_TOKEN
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
fetch-depth: 0
persist-credentials: true
- name: Get actions scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+5 -4
View File
@@ -48,7 +48,7 @@ jobs:
permissions:
issues: read
pull-requests: read
uses: matrix-org/matrix-js-sdk/.github/workflows/release-checks.yml@develop
uses: matrix-org/matrix-js-sdk/.github/workflows/release-checks.yml@develop # zizmor: ignore[unpinned-uses]
release:
name: Release
@@ -61,7 +61,7 @@ jobs:
- name: Load GPG key
id: gpg
if: inputs.gpg-fingerprint
uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
@@ -69,7 +69,7 @@ jobs:
- name: Get draft release
id: draft-release
uses: cardinalby/git-get-release-action@5172c3a026600b1d459b117738c605fabc9e4e44 # v1
uses: cardinalby/git-get-release-action@5172c3a026600b1d459b117738c605fabc9e4e44 # 1.2.5
env:
GITHUB_TOKEN: ${{ github.token }}
with:
@@ -82,6 +82,7 @@ jobs:
# We will be pushing to this branch and want the CI to run after we do so we cannot use the GITHUB_TOKEN
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
fetch-depth: 0
persist-credentials: true
- name: Get actions scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
@@ -286,7 +287,7 @@ jobs:
name: Publish to npm
needs: release
if: inputs.npm
uses: matrix-org/matrix-js-sdk/.github/workflows/release-npm.yml@develop
uses: matrix-org/matrix-js-sdk/.github/workflows/release-npm.yml@develop # zizmor: ignore[unpinned-uses]
with:
dir: ${{ inputs.dir }}
permissions:
+1
View File
@@ -25,6 +25,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: staging
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- name: 🔧 pnpm cache
+4 -1
View File
@@ -24,7 +24,7 @@ concurrency: ${{ github.workflow }}
permissions: {} # No permissions required
jobs:
release:
uses: matrix-org/matrix-js-sdk/.github/workflows/release-make.yml@develop
uses: matrix-org/matrix-js-sdk/.github/workflows/release-make.yml@develop # zizmor: ignore[unpinned-uses,secrets-inherit]
permissions:
contents: write
issues: write
@@ -50,6 +50,7 @@ jobs:
repository: ${{ matrix.repo }}
ref: staging
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
persist-credentials: true
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
@@ -77,6 +78,8 @@ jobs:
steps:
- name: 🧮 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- name: 🔧 pnpm cache
+3 -2
View File
@@ -41,9 +41,10 @@ jobs:
repository: ${{ github.event.workflow_run.head_repository.full_name }}
ref: ${{ github.event.workflow_run.head_branch }} # checkout commit that triggered this workflow
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
persist-credentials: false
- name: 📥 Download artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
if: ${{ !inputs.sharded }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -51,7 +52,7 @@ jobs:
name: coverage
path: coverage
- name: 📥 Download sharded artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
if: inputs.sharded
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+4 -2
View File
@@ -1,6 +1,8 @@
name: SonarQube
on:
workflow_run:
# Privilege escalation necessary to call upon SonarCloud
# 🚨 We must not execute any checked out code here.
workflow_run: # zizmor: ignore[dangerous-triggers]
workflows: ["Tests"]
types:
- completed
@@ -16,7 +18,7 @@ jobs:
actions: read
statuses: write
id-token: write # sonar
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop # zizmor: ignore[unpinned-uses]
secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
+31 -7
View File
@@ -15,6 +15,8 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
@@ -33,6 +35,8 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
@@ -51,6 +55,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
@@ -79,8 +85,12 @@ jobs:
workflow_lint:
name: "Workflow Lint"
runs-on: ubuntu-24.04
permissions:
security-events: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
@@ -94,11 +104,16 @@ jobs:
- name: Run Linter
run: "pnpm lint:workflows"
- name: Run zizmor
uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0
docs:
name: "JSDoc Checker"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
@@ -113,7 +128,7 @@ jobs:
run: "pnpm run gendoc --treatWarningsAsErrors --suppressCommentWarningsInDeclarationFiles"
- name: Upload Artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: docs
path: _docs
@@ -125,6 +140,8 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
@@ -146,6 +163,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: element-hq/element-web
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
@@ -163,13 +181,19 @@ jobs:
working-directory: apps/web
run: "pnpm run lint:types"
# Hook for branch protection to skip downstream typechecking outside of merge queues
downstream:
name: Downstream Typescript Syntax Check
# Workflow consolidation job
done:
needs:
- ts_lint
- js_lint
- node_example_lint
- workflow_lint
- docs
- analyse_dead_code
- element-web
name: Static Analysis
runs-on: ubuntu-24.04
if: always()
needs:
- element-web
steps:
- if: needs.element-web.result != 'skipped' && needs.element-web.result != 'success'
- if: contains(needs.*.result , 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1
+1 -1
View File
@@ -11,7 +11,7 @@ on:
permissions: {} # We use ELEMENT_BOT_TOKEN instead
jobs:
sync-labels:
uses: element-hq/element-meta/.github/workflows/sync-labels.yml@develop
uses: element-hq/element-meta/.github/workflows/sync-labels.yml@dac99c67f08f8f2a079e885ffb682a2f39cd3960
with:
LABELS: |
element-hq/element-meta
+12 -7
View File
@@ -23,6 +23,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- name: Setup Node
@@ -42,19 +44,22 @@ jobs:
- name: Run tests
run: |
pnpm test \
--coverage=${{ env.ENABLE_COVERAGE }} \
--maxWorkers ${{ steps.cpu-cores.outputs.count }} \
--coverage=${ENABLE_COVERAGE} \
--maxWorkers ${NUM_WORKERS} \
./spec/${{ matrix.specs }}
env:
SHARD: ${{ matrix.specs }}
NUM_WORKERS: ${{ steps.cpu-cores.outputs.count }}
- name: Move coverage files into place
if: env.ENABLE_COVERAGE == 'true'
run: mv coverage/lcov.info coverage/${{ steps.setupNode.outputs.node-version }}-${{ matrix.specs }}.lcov.info
run: mv coverage/lcov.info coverage/${NODE_VERSION}-${{ matrix.specs }}.lcov.info
env:
NODE_VERSION: ${{ steps.setupNode.outputs.node-version }}
- name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: coverage-${{ matrix.specs }}-${{ matrix.node == 'lts/*' && 'lts' || matrix.node }}
path: |
@@ -74,7 +79,7 @@ jobs:
element-web:
name: Downstream test element-web
if: github.event_name == 'merge_group'
uses: element-hq/element-web/.github/workflows/tests.yml@develop
uses: element-hq/element-web/.github/workflows/tests.yml@develop # zizmor: ignore[unpinned-uses]
permissions:
statuses: write
with:
@@ -84,8 +89,8 @@ jobs:
complement-crypto:
name: "Run Complement Crypto tests"
if: github.event_name == 'merge_group'
permissions: read-all
uses: matrix-org/complement-crypto/.github/workflows/single_sdk_tests.yml@main
permissions: read-all # zizmor: ignore[excessive-permissions]
uses: matrix-org/complement-crypto/.github/workflows/single_sdk_tests.yml@main # zizmor: ignore[unpinned-uses]
with:
use_js_sdk: "."
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
automate-project-columns-next:
runs-on: ubuntu-24.04
steps:
- uses: actions/add-to-project@main
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
with:
project-url: https://github.com/orgs/element-hq/projects/120
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
+1 -1
View File
@@ -6,6 +6,6 @@ on:
permissions: {} # We use ELEMENT_BOT_TOKEN instead
jobs:
call-triage-labelled:
uses: element-hq/element-web/.github/workflows/triage-labelled.yml@develop
uses: element-hq/element-web/.github/workflows/triage-labelled.yml@2c9f55cbea90702aa61b9304afa9cbf940efa14f
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
+17
View File
@@ -1,3 +1,20 @@
Changes in [41.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v41.2.0) (2026-03-24)
==================================================================================================
## ✨ Features
* Only share history if room history visibility is shared ([#5216](https://github.com/matrix-org/matrix-js-sdk/pull/5216)). Contributed by @kaylendog.
* History sharing: resume key-bundle import on restart ([#5214](https://github.com/matrix-org/matrix-js-sdk/pull/5214)). Contributed by @richvdh.
* Move `CryptoApi.shareRoomHistoryWithUser` to `CryptoBackend` ([#5218](https://github.com/matrix-org/matrix-js-sdk/pull/5218)). Contributed by @richvdh.
Changes in [41.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v41.1.0) (2026-03-10)
==================================================================================================
## ✨ Features
* Throw a specific error when the backup decryption key does not match the public backup ([#5202](https://github.com/matrix-org/matrix-js-sdk/pull/5202)). Contributed by @andybalaam.
* Update getUrlPreview to use /\_matrix/client/v1/media/preview\_url ([#5191](https://github.com/matrix-org/matrix-js-sdk/pull/5191)). Contributed by @Half-Shot.
Changes in [41.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v41.0.0) (2026-02-24)
==================================================================================================
## 🚨 BREAKING CHANGES
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "41.1.0-rc.0",
"version": "41.2.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=22.0.0"
@@ -48,7 +48,7 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^17.1.0",
"@matrix-org/matrix-sdk-crypto-wasm": "^18.0.0",
"another-json": "^0.2.0",
"bs58": "^6.0.0",
"content-type": "^1.0.4",
@@ -131,5 +131,5 @@
"eslint": "8"
}
},
"packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc"
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017"
}
+284 -434
View File
File diff suppressed because it is too large Load Diff
+460 -69
View File
@@ -24,6 +24,7 @@ import {
EventType,
HistoryVisibility,
type IContent,
type ILeftRoom,
type IRoomEvent,
KnownMembership,
type MatrixClient,
@@ -36,7 +37,7 @@ import { getSyncResponse, mkEventCustom, syncPromise, waitFor } from "../../test
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder.ts";
import { flushPromises } from "../../test-utils/flushPromises.ts";
import { E2EOTKClaimResponder } from "../../test-utils/E2EOTKClaimResponder.ts";
import { escapeRegExp } from "../../../src/utils.ts";
import { escapeRegExp, sleep } from "../../../src/utils.ts";
import { EventShieldColour, EventShieldReason } from "../../../src/crypto-api";
import {
BACKUP_DECRYPTION_KEY_BASE64,
@@ -68,7 +69,7 @@ const ROOM_ID = "!room:example.com";
const ALICE_HOMESERVER_URL = "https://alice-server.com";
const BOB_HOMESERVER_URL = "https://bob-server.com";
async function createAndInitClient(homeserverUrl: string, userId: string) {
async function createAndInitClient(homeserverUrl: string, userId: string, setupNewCrossSigning = true) {
mockInitialApiRequests(homeserverUrl, userId);
const client = createClient({
@@ -81,7 +82,7 @@ async function createAndInitClient(homeserverUrl: string, userId: string) {
await client.initRustCrypto({ cryptoDatabasePrefix: userId });
await client.startClient();
await client.getCrypto()!.bootstrapCrossSigning({ setupNewCrossSigning: true });
await client.getCrypto()!.bootstrapCrossSigning({ setupNewCrossSigning });
return client;
}
@@ -142,24 +143,18 @@ describe("History Sharing", () => {
await assertInviteAndShareHistory(ROOM_ID);
// Bob receives, should be able to decrypt, the megolm message
const bobSyncResponse = getSyncResponse(
[aliceClient.getSafeUserId(), bobClient.getSafeUserId()],
HistoryVisibility.Shared,
ROOM_ID,
);
bobSyncResponse.rooms.join[ROOM_ID].timeline.events.push(
const event = await bobReceivesEvent(
aliceClient,
bobClient,
mkEventCustom({
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: sentMessage,
event_id: "$event_id",
}) as any,
room_id: ROOM_ID,
}),
bobSyncResponder,
);
bobSyncResponder.sendOrQueueSyncResponse(bobSyncResponse);
await syncPromise(bobClient);
const bobRoom = bobClient.getRoom(ROOM_ID);
const event = bobRoom!.getLastLiveEvent()!;
expect(event.getId()).toEqual("$event_id");
await event.getDecryptionPromise();
expect(event.getType()).toEqual("m.room.message");
@@ -183,18 +178,12 @@ describe("History Sharing", () => {
debug(`Alice sent encrypted room event: ${JSON.stringify(sentMessage)}`);
// Now, Alice invites Bob
const uploadProm = new Promise<Uint8Array>((resolve) => {
fetchMock.postOnce(new URL("/_matrix/media/v3/upload", ALICE_HOMESERVER_URL).toString(), (callLog) => {
const body = callLog.options.body as Uint8Array;
debug(`Alice uploaded blob of length ${body.length}`);
resolve(body);
return { content_uri: "mxc://alice-server/here" };
});
});
const uploadProm = expectUploadRequest();
const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
// POST https://alice-server.com/_matrix/client/v3/rooms/!room%3Aexample.com/invite
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
await aliceClient.invite(ROOM_ID, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
const uploadedBlob = await uploadProm;
const sentToDeviceRequest = await toDeviceMessageProm;
debug(`Alice sent encrypted to-device events: ${JSON.stringify(sentToDeviceRequest)}`);
@@ -202,45 +191,34 @@ describe("History Sharing", () => {
expect(bobToDeviceMessage).toBeDefined();
// Bob receives the room invite, but not the room key bundle
const inviteEvent = mkEventCustom({
type: "m.room.member",
sender: aliceClient.getSafeUserId(),
state_key: bobClient.getSafeUserId(),
content: { membership: KnownMembership.Invite },
});
const inviteEvent = mkInviteEvent(aliceClient, bobClient);
bobSyncResponder.sendOrQueueSyncResponse({
rooms: { invite: { [ROOM_ID]: { invite_state: { events: [inviteEvent] } } } },
});
await syncPromise(bobClient);
// Bob joins the room
const room = bobClient.getRoom(ROOM_ID);
expect(room).toBeTruthy();
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
fetchMock.postOnce(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
room_id: ROOM_ID,
});
await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
// Bob receives and attempts to decrypt the megolm message, but should not be able to (yet).
const bobSyncResponse = getSyncResponse(
[aliceClient.getSafeUserId(), bobClient.getSafeUserId()],
HistoryVisibility.Shared,
ROOM_ID,
);
bobSyncResponse.rooms.join[ROOM_ID].timeline.events.push(
const event = await bobReceivesEvent(
aliceClient,
bobClient,
mkEventCustom({
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: sentMessage,
event_id: "$event_id",
}) as any,
room_id: ROOM_ID,
}),
bobSyncResponder,
);
bobSyncResponder.sendOrQueueSyncResponse(bobSyncResponse);
await syncPromise(bobClient);
const bobRoom = bobClient.getRoom(ROOM_ID);
const event = bobRoom!.getLastLiveEvent()!;
expect(event.getId()).toEqual("$event_id");
await event.getDecryptionPromise();
expect(event.isDecryptionFailure()).toBeTruthy();
@@ -274,6 +252,206 @@ describe("History Sharing", () => {
expect(encryptionInfo?.shieldReason).toEqual(EventShieldReason.AUTHENTICITY_NOT_GUARANTEED);
});
test("Room keys are not imported if the bundle arrives more than 24H after the invite is accepted", async () => {
vitest.useFakeTimers();
// Alice is in an encrypted room
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], HistoryVisibility.Shared, ROOM_ID);
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
// ... and she sends an event
const msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted");
await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, { msgtype: MsgType.Text, body: "Hello!" });
const sentMessage = await msgProm;
debug(`Alice sent encrypted room event: ${JSON.stringify(sentMessage)}`);
// Now, Alice invites Bob
const uploadProm = expectUploadRequest();
const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
// POST https://alice-server.com/_matrix/client/v3/rooms/!room%3Aexample.com/invite
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
await aliceClient.invite(ROOM_ID, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
const uploadedBlob = await uploadProm;
const sentToDeviceRequest = await toDeviceMessageProm;
debug(`Alice sent encrypted to-device events: ${JSON.stringify(sentToDeviceRequest)}`);
const bobToDeviceMessage = sentToDeviceRequest[bobClient.getSafeUserId()][bobClient.deviceId!];
expect(bobToDeviceMessage).toBeDefined();
// Bob receives the room invite, but not the room key bundle
const inviteEvent = mkInviteEvent(aliceClient, bobClient);
bobSyncResponder.sendOrQueueSyncResponse({
rooms: { invite: { [ROOM_ID]: { invite_state: { events: [inviteEvent] } } } },
});
await syncPromise(bobClient);
// Bob joins the room
const room = bobClient.getRoom(ROOM_ID);
expect(room).toBeTruthy();
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
fetchMock.postOnce(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
room_id: ROOM_ID,
});
await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
// Bob receives and attempts to decrypt the megolm message, but should not be able to (yet).
const event = await bobReceivesEvent(
aliceClient,
bobClient,
mkEventCustom({
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: sentMessage,
event_id: "$event_id",
room_id: ROOM_ID,
}),
bobSyncResponder,
);
await event.getDecryptionPromise();
expect(event.isDecryptionFailure()).toBeTruthy();
// 24 hours elapse before the room key bundle message arrives.
vitest.advanceTimersByTime(24 * 60 * 60 * 1000 + 1);
fetchMock.getOnce(`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`, {
body: uploadedBlob,
});
bobSyncResponder.sendOrQueueSyncResponse({
to_device: {
events: [
{
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: bobToDeviceMessage,
},
],
},
});
await syncPromise(bobClient);
// Wait a bit to ensure the event is not decrypted.
for (let i = 0; i < 10; i++) {
await vitest.advanceTimersByTimeAsync(10);
}
expect(event.isDecryptionFailure()).toBeTruthy();
});
test("Room keys are not imported if we left and rejoined the room after accepting the invite", async () => {
// Alice is in an encrypted room
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], HistoryVisibility.Shared, ROOM_ID);
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
// ... and she sends an event
const msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted");
await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, { msgtype: MsgType.Text, body: "Hello!" });
const sentMessage = await msgProm;
debug(`Alice sent encrypted room event: ${JSON.stringify(sentMessage)}`);
// Now, Alice invites Bob
const uploadProm = expectUploadRequest();
const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
// POST https://alice-server.com/_matrix/client/v3/rooms/!room%3Aexample.com/invite
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
await aliceClient.invite(ROOM_ID, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
const uploadedBlob = await uploadProm;
const sentToDeviceRequest = await toDeviceMessageProm;
debug(`Alice sent encrypted to-device events: ${JSON.stringify(sentToDeviceRequest)}`);
const bobToDeviceMessage = sentToDeviceRequest[bobClient.getSafeUserId()][bobClient.deviceId!];
expect(bobToDeviceMessage).toBeDefined();
// Bob receives the room invite, but not the room key bundle
const inviteEvent = mkInviteEvent(aliceClient, bobClient);
bobSyncResponder.sendOrQueueSyncResponse({
rooms: { invite: { [ROOM_ID]: { invite_state: { events: [inviteEvent] } } } },
});
await syncPromise(bobClient);
// Bob joins the room
const room = bobClient.getRoom(ROOM_ID);
expect(room).toBeTruthy();
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
fetchMock.post(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
room_id: ROOM_ID,
});
await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
// Bob receives and attempts to decrypt the megolm message, but should not be able to (yet).
const event = await bobReceivesEvent(
aliceClient,
bobClient,
mkEventCustom({
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: sentMessage,
event_id: "$event_id",
room_id: ROOM_ID,
}),
bobSyncResponder,
);
await event.getDecryptionPromise();
expect(event.isDecryptionFailure()).toBeTruthy();
// Bob is kicked from the room, and rejoins without an invite.
const roomResponse: ILeftRoom = {
state: { events: [] },
timeline: {
events: [
mkEventCustom({
content: { membership: KnownMembership.Leave },
type: EventType.RoomMember,
sender: aliceClient.getSafeUserId(),
state_key: bobClient.getSafeUserId(),
}),
],
prev_batch: "",
},
account_data: { events: [] },
};
const bobSyncResponse = {
next_batch: "1",
rooms: {
leave: { [ROOM_ID]: roomResponse },
invite: {},
join: {},
knock: {},
},
account_data: { events: [] },
};
bobSyncResponder.sendOrQueueSyncResponse(bobSyncResponse);
await syncPromise(bobClient);
expect(room?.getMyMembership()).toEqual(KnownMembership.Leave);
// Bob rejoins
await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
// Now the bundle arrives
fetchMock.getOnce(`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`, {
body: uploadedBlob,
});
bobSyncResponder.sendOrQueueSyncResponse({
to_device: {
events: [
{
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: bobToDeviceMessage,
},
],
},
});
await syncPromise(bobClient);
// Wait a bit to ensure the event is not decrypted.
await sleep(200);
expect(event.isDecryptionFailure()).toBeTruthy();
});
test("Room keys are downloaded from key backup before inviting", async () => {
// Set up backup, and ignore requests to send room key requests
fetchMock.get("path:/_matrix/client/v3/room_keys/version", SIGNED_BACKUP_DATA);
@@ -300,18 +478,7 @@ describe("History Sharing", () => {
await assertInviteAndShareHistory(TEST_ROOM_ID);
// Bob receives, and should be able to decrypt, the historical message
const bobSyncResponse = getSyncResponse(
[aliceClient.getSafeUserId(), bobClient.getSafeUserId()],
HistoryVisibility.Shared,
TEST_ROOM_ID,
);
bobSyncResponse.rooms.join[TEST_ROOM_ID].timeline.events.push(ENCRYPTED_EVENT as IRoomEvent);
bobSyncResponder.sendOrQueueSyncResponse(bobSyncResponse);
await syncPromise(bobClient);
const bobRoom = bobClient.getRoom(TEST_ROOM_ID);
const event = bobRoom!.getLastLiveEvent()!;
expect(event.getId()).toEqual(ENCRYPTED_EVENT.event_id);
const event = await bobReceivesEvent(aliceClient, bobClient, ENCRYPTED_EVENT as any, bobSyncResponder);
await event.getDecryptionPromise();
expect(event.getType()).toEqual("m.room.message");
expect(event.getContent().body).toEqual(CLEAR_EVENT.content!.body);
@@ -321,7 +488,196 @@ describe("History Sharing", () => {
expect(encryptionInfo?.shieldReason).toEqual(EventShieldReason.AUTHENTICITY_NOT_GUARANTEED);
});
test("Room keys are successfully imported, if the app is shut down while the import is in progress", async () => {
// Alice is in an encrypted room
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], HistoryVisibility.Shared, ROOM_ID);
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
// ... and she sends an event
const msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted");
await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, { msgtype: MsgType.Text, body: "Hi!" });
const sentMessage = await msgProm;
debug(`Alice sent encrypted room event: ${JSON.stringify(sentMessage)}`);
// Alice invites Bob, and shares the room history with him.
const uploadProm = expectUploadRequest();
const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
await aliceClient.invite(ROOM_ID, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
const uploadedBlob = await uploadProm;
const sentToDeviceRequest = await toDeviceMessageProm;
debug(`Alice sent encrypted to-device events: ${JSON.stringify(sentToDeviceRequest)}`);
const bobToDeviceMessage = sentToDeviceRequest[bobClient.getSafeUserId()][bobClient.deviceId!];
expect(bobToDeviceMessage).toBeDefined();
const inviteEvent = mkInviteEvent(aliceClient, bobClient);
bobSyncResponder.sendOrQueueSyncResponse({
rooms: { invite: { [ROOM_ID]: { invite_state: { events: [inviteEvent] } } } },
to_device: {
events: [
{
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: bobToDeviceMessage,
},
],
},
});
await syncPromise(bobClient);
const room = bobClient.getRoom(ROOM_ID);
expect(room).toBeTruthy();
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
fetchMock.postOnce(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
room_id: ROOM_ID,
});
// Have the /download request block indefinitely
const downloadStarted = Promise.withResolvers<void>();
fetchMock.getOnce(`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`, () => {
downloadStarted.resolve();
return new Promise(() => {});
});
// meaning the /join request will block
bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
await downloadStarted.promise;
// Tear down the client, and start again
bobClient.stopClient();
bobSyncResponder.sendOrQueueSyncResponse({});
await flushPromises();
fetchMock.getOnce(`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`, {
body: uploadedBlob,
});
bobClient = await createAndInitClient(BOB_HOMESERVER_URL, bobClient.getSafeUserId(), false);
// Now, Bob receives the megolm message, and can decrypt it
const event = await bobReceivesEvent(
aliceClient,
bobClient,
mkEventCustom({
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: sentMessage,
event_id: "$event_id",
room_id: ROOM_ID,
}),
bobSyncResponder,
);
expect(event.getId()).toEqual("$event_id");
await event.getDecryptionPromise();
expect(event.getType()).toEqual("m.room.message");
expect(event.getContent().body).toEqual("Hi!");
expect(event.getKeyForwardingUser()).toEqual(aliceClient.getUserId());
const encryptionInfo = await bobClient.getCrypto()!.getEncryptionInfoForEvent(event);
expect(encryptionInfo?.shieldColour).toEqual(EventShieldColour.GREY);
expect(encryptionInfo?.shieldReason).toEqual(EventShieldReason.AUTHENTICITY_NOT_GUARANTEED);
});
test("Room keys are not shared if the current history visibility is unshared", async () => {
// Alice is in an encrypted room
let syncResponse = getSyncResponse([aliceClient.getSafeUserId()], HistoryVisibility.Shared, ROOM_ID);
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
// ... and she sends an event
let msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted");
await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, {
msgtype: MsgType.Text,
body: "Sent when shared",
});
const firstMessage = await msgProm;
debug(`Alice sent encrypted room event: ${JSON.stringify(firstMessage)}`);
// She then sets the history visibility to `invited` ...
syncResponse = getSyncResponse([aliceClient.getSafeUserId()], HistoryVisibility.Invited, ROOM_ID);
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
/// ... and sends a second message.
msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted");
await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, {
msgtype: MsgType.Text,
body: "Sent when invited",
});
const secondMessage = await msgProm;
debug(`Alice sent encrypted room event: ${JSON.stringify(secondMessage)}`);
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
await aliceClient.invite(ROOM_ID, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
const inviteEvent = mkEventCustom({
type: "m.room.member",
sender: aliceClient.getSafeUserId(),
state_key: bobClient.getSafeUserId(),
content: { membership: KnownMembership.Invite },
});
bobSyncResponder.sendOrQueueSyncResponse({
rooms: { invite: { [ROOM_ID]: { invite_state: { events: [inviteEvent] } } } },
to_device: {
events: [],
},
});
await syncPromise(bobClient);
const room = bobClient.getRoom(ROOM_ID);
expect(room).toBeTruthy();
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
fetchMock.postOnce(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
room_id: ROOM_ID,
});
await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
// Bob receives only the first message.
const bobSyncResponse = getSyncResponse(
[aliceClient.getSafeUserId(), bobClient.getSafeUserId()],
HistoryVisibility.Shared,
ROOM_ID,
);
bobSyncResponse.rooms.join[ROOM_ID].timeline.events.push(
mkEventCustom({
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: firstMessage,
event_id: "$event_id",
}) as any,
);
bobSyncResponder.sendOrQueueSyncResponse(bobSyncResponse);
await syncPromise(bobClient);
const bobRoom = bobClient.getRoom(ROOM_ID);
const event = bobRoom!.getLastLiveEvent()!;
expect(event.getId()).toEqual("$event_id");
await event.getDecryptionPromise();
expect(event.isDecryptionFailure()).toBeTruthy();
// Assert alice never uploaded the key bundle ...
expect(
fetchMock.callHistory.called(new URL("/_matrix/media/v3/upload", ALICE_HOMESERVER_URL).toString()),
).toBeFalsy();
// ... didn't send Bob the key bundle info ...
expect(
fetchMock.callHistory.called(
new RegExp(
`^${escapeRegExp(ALICE_HOMESERVER_URL)}/_matrix/client/v3/sendToDevice/${escapeRegExp("m.room.encrypted")}/`,
),
),
).toBeFalsy();
// ... and Bob didn't try to download the key bundle.
expect(
fetchMock.callHistory.called(
`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`,
),
).toBeFalsy();
});
afterEach(async () => {
vitest.useRealTimers();
bobClient.stopClient();
aliceClient.stopClient();
await flushPromises();
@@ -342,14 +698,7 @@ describe("History Sharing", () => {
* @param roomId The ID of the room where the invite and history sharing will be tested.
*/
async function assertInviteAndShareHistory(roomId: string): Promise<void> {
const uploadProm = new Promise<Uint8Array>((resolve) => {
fetchMock.postOnce(new URL("/_matrix/media/v3/upload", ALICE_HOMESERVER_URL).toString(), (callLog) => {
const body = callLog.options.body as Uint8Array;
debug(`Alice uploaded blob of length ${body.length}`);
resolve(body);
return { content_uri: "mxc://alice-server/here" };
});
});
const uploadProm = expectUploadRequest();
const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`, {});
await aliceClient.invite(roomId, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
@@ -359,12 +708,7 @@ describe("History Sharing", () => {
const bobToDeviceMessage = sentToDeviceRequest[bobClient.getSafeUserId()][bobClient.deviceId!];
expect(bobToDeviceMessage).toBeDefined();
const inviteEvent = mkEventCustom({
type: "m.room.member",
sender: aliceClient.getSafeUserId(),
state_key: bobClient.getSafeUserId(),
content: { membership: KnownMembership.Invite },
});
const inviteEvent = mkInviteEvent(aliceClient, bobClient);
bobSyncResponder.sendOrQueueSyncResponse({
rooms: { invite: { [roomId]: { invite_state: { events: [inviteEvent] } } } },
to_device: {
@@ -393,20 +737,43 @@ describe("History Sharing", () => {
}
});
function mkInviteEvent(inviter: MatrixClient, recipient: MatrixClient): Partial<IRoomEvent> {
return mkEventCustom({
type: "m.room.member",
sender: inviter.getSafeUserId(),
state_key: recipient.getSafeUserId(),
content: { membership: KnownMembership.Invite },
});
}
function expectSendRoomEvent(homeserverUrl: string, msgtype: string): Promise<IContent> {
const name = `sendRoomEvent-${homeserverUrl}-${msgtype}`;
return new Promise<IContent>((resolve) => {
fetchMock.putOnce(
new RegExp(`^${escapeRegExp(homeserverUrl)}/_matrix/client/v3/rooms/[^/]*/send/${escapeRegExp(msgtype)}/`),
(callLog) => {
const content = JSON.parse(callLog.options.body as string);
resolve(content);
fetchMock.removeRoute(name);
return { event_id: "$event_id" };
},
{ name: "sendRoomEvent" },
{ name },
);
});
}
/** Expect an upload to Alice's server. Returns a Promise that resolves when the upload is complete. */
function expectUploadRequest(): Promise<Uint8Array> {
return new Promise<Uint8Array>((resolve) => {
fetchMock.postOnce(new URL("/_matrix/media/v3/upload", ALICE_HOMESERVER_URL).toString(), (callLog) => {
const body = callLog.options.body as Uint8Array;
debug(`Alice uploaded blob of length ${body.length}`);
resolve(body);
return { content_uri: "mxc://alice-server/here" };
});
});
}
function expectSendToDeviceMessage(
homeserverUrl: string,
msgtype: string,
@@ -422,3 +789,27 @@ function expectSendToDeviceMessage(
);
});
}
/**
* Bob receives the encrypted room event from Alice.
*/
async function bobReceivesEvent(
aliceClient: MatrixClient,
bobClient: MatrixClient,
event: IRoomEvent & { room_id: string },
bobSyncResponder: SyncResponder,
) {
const roomId = event.room_id;
const bobSyncResponse = getSyncResponse(
[aliceClient.getSafeUserId(), bobClient.getSafeUserId()],
HistoryVisibility.Shared,
roomId,
);
bobSyncResponse.rooms.join[roomId].timeline.events.push(event);
bobSyncResponder.sendOrQueueSyncResponse(bobSyncResponse);
await syncPromise(bobClient);
const bobRoom = bobClient.getRoom(roomId);
const received = bobRoom!.getLastLiveEvent()!;
expect(received.getId()).toEqual(event.event_id);
return received;
}
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { QrCodeData, QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm";
import { QrCodeData, QrCodeIntent } from "@matrix-org/matrix-sdk-crypto-wasm";
import fetchMock from "@fetch-mock/vitest";
import {
@@ -146,7 +146,7 @@ describe("MSC4108SignInWithQR", () => {
const ourChannel = new MSC4108SecureChannel(ourMockSession);
const qrCodeData = QrCodeData.fromBytes(
await ourChannel.generateCode(QrCodeMode.Reciprocate, client.getDomain()!),
await ourChannel.generateCode(QrCodeIntent.Reciprocate, client.getDomain()!),
);
const opponentChannel = new MSC4108SecureChannel(opponentMockSession, qrCodeData.publicKey);
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type EstablishedEcies, QrCodeData, QrCodeMode, Ecies } from "@matrix-org/matrix-sdk-crypto-wasm";
import { type EstablishedEcies, QrCodeData, QrCodeIntent, Ecies } from "@matrix-org/matrix-sdk-crypto-wasm";
import { MSC4108RendezvousSession, MSC4108SecureChannel, PayloadType } from "../../../../src/rendezvous";
@@ -28,7 +28,7 @@ describe("MSC4108SecureChannel", () => {
});
const channel = new MSC4108SecureChannel(session);
const code = await channel.generateCode(QrCodeMode.Login);
const code = await channel.generateCode(QrCodeIntent.Login);
expect(code).toHaveLength(71);
const text = new TextDecoder().decode(code);
expect(text.startsWith("MATRIX")).toBeTruthy();
@@ -43,7 +43,7 @@ describe("MSC4108SecureChannel", () => {
} as unknown as MSC4108RendezvousSession;
const channel = new MSC4108SecureChannel(mockSession);
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeIntent.Reciprocate, baseUrl));
const { initial_message: ciphertext } = new Ecies().establish_outbound_channel(
qrCodeData.publicKey,
"MATRIX_QR_CODE_LOGIN_INITIATE",
@@ -64,7 +64,7 @@ describe("MSC4108SecureChannel", () => {
vi.mocked(mockSession.receive).mockResolvedValue("");
await expect(channel.connect()).rejects.toThrow("No response from other device");
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeIntent.Reciprocate, baseUrl));
const { initial_message: ciphertext } = new Ecies().establish_outbound_channel(
qrCodeData.publicKey,
"NOT_REAL_MATRIX_QR_CODE_LOGIN_INITIATE",
@@ -87,7 +87,7 @@ describe("MSC4108SecureChannel", () => {
} as unknown as MSC4108RendezvousSession;
channel = new MSC4108SecureChannel(mockSession);
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeIntent.Reciprocate, baseUrl));
const { channel: _opponentChannel, initial_message: ciphertext } = new Ecies().establish_outbound_channel(
qrCodeData.publicKey,
"MATRIX_QR_CODE_LOGIN_INITIATE",
+8 -1
View File
@@ -21,6 +21,7 @@ import {
KeysQueryRequest,
Migration,
OlmMachine,
type OtherUserIdentity,
type PickledInboundGroupSession,
type PickledSession,
StoreHandle,
@@ -109,6 +110,7 @@ describe("initRustCrypto", () => {
getBackupKeys: vi.fn(),
getIdentity: vi.fn().mockResolvedValue(null),
trackedUsers: vi.fn(),
getAllRoomsPendingKeyBundles: vi.fn().mockResolvedValue([]),
} as unknown as Mocked<OlmMachine>;
}
@@ -1535,7 +1537,7 @@ describe("RustCrypto", () => {
free: vi.fn(),
isVerified: vi.fn().mockReturnValue(true),
wasPreviouslyVerified: vi.fn().mockReturnValue(true),
});
} as unknown as OtherUserIdentity);
const userVerificationStatus = await rustCrypto.getUserVerificationStatus(testData.TEST_USER_ID);
expect(userVerificationStatus.isVerified()).toBeTruthy();
@@ -2388,6 +2390,7 @@ describe("RustCrypto", () => {
queryKeysForUsers: vi.fn().mockReturnValue({}),
getReceivedRoomKeyBundleData: vi.fn(),
receiveRoomKeyBundle: vi.fn(),
clearRoomPendingKeyBundle: vi.fn(),
} as unknown as Mocked<OlmMachine>;
const http = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
@@ -2448,6 +2451,10 @@ describe("RustCrypto", () => {
expect(mockOlmMachine.receiveRoomKeyBundle).toHaveBeenCalledTimes(1);
expect(mockOlmMachine.receiveRoomKeyBundle.mock.calls[0][0]).toBe(bundleData);
expect(mockOlmMachine.receiveRoomKeyBundle.mock.calls[0][1]).toEqual(new TextEncoder().encode("asdfghjkl"));
// It should also flag the room as not waiting for a key bundle
expect(mockOlmMachine.clearRoomPendingKeyBundle).toHaveBeenCalledTimes(1);
expect(mockOlmMachine.clearRoomPendingKeyBundle.mock.calls[0][0].toString()).toEqual("!room_id");
});
});
+10 -4
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { type OutgoingRequest } from "@matrix-org/matrix-sdk-crypto-wasm";
import { type OtherUserIdentity, type OutgoingRequest } from "@matrix-org/matrix-sdk-crypto-wasm";
import { type Mocked } from "vitest";
import {
@@ -138,7 +138,9 @@ describe("VerificationRequest", () => {
await bobOlmMachine.updateTrackedUsers([new RustSdkCryptoJs.UserId(aliceUserId)]);
// Alice requests verification
const bobUserIdentity = await aliceOlmMachine.getIdentity(new RustSdkCryptoJs.UserId(bobUserId));
const bobUserIdentity = (await aliceOlmMachine.getIdentity(
new RustSdkCryptoJs.UserId(bobUserId),
)) as OtherUserIdentity;
const roomId = new RustSdkCryptoJs.RoomId("!roomId:example.org");
const methods = [verificationMethodIdentifierToMethod("m.sas.v1")];
@@ -276,7 +278,9 @@ describe("VerificationRequest", () => {
await bobOlmMachine.updateTrackedUsers([new RustSdkCryptoJs.UserId(aliceUserId)]);
// Alice requests verification
const bobUserIdentity = await aliceOlmMachine.getIdentity(new RustSdkCryptoJs.UserId(bobUserId));
const bobUserIdentity = (await aliceOlmMachine.getIdentity(
new RustSdkCryptoJs.UserId(bobUserId),
)) as OtherUserIdentity;
const roomId = new RustSdkCryptoJs.RoomId("!roomId:example.org");
const methods = [verificationMethodIdentifierToMethod("m.sas.v1")];
@@ -394,7 +398,9 @@ describe("VerificationRequest", () => {
await bobOlmMachine.updateTrackedUsers([new RustSdkCryptoJs.UserId(aliceUserId)]);
// Alice requests verification
const bobUserIdentity = await aliceOlmMachine.getIdentity(new RustSdkCryptoJs.UserId(bobUserId));
const bobUserIdentity = (await aliceOlmMachine.getIdentity(
new RustSdkCryptoJs.UserId(bobUserId),
)) as OtherUserIdentity;
const roomId = new RustSdkCryptoJs.RoomId("!roomId:example.org");
const methods = [
+3 -1
View File
@@ -58,7 +58,9 @@ export interface InviteOpts {
/**
* Before sending the invite, if the room is encrypted, share the keys for any messages sent while the history
* visibility was `shared`, via the experimental
* support for [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268).
* support for [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268). If the room's current
* history visibility setting is neither `shared` nor `world_readable`, history sharing will be disabled to prevent
* exposing keys for messages sent prior to the visibility restriction.
*
* @experimental
*/
+10 -6
View File
@@ -2428,12 +2428,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const roomId = res.room_id;
if (opts.acceptSharedHistory && inviter && this.cryptoBackend) {
// Flag upfront that we are waiting for a key bundle, so that if we crash mid-import, we can try again.
await this.cryptoBackend.markRoomAsPendingKeyBundle(roomId, inviter);
// Try to accept the room key bundle specified in a `m.room_key_bundle` to-device message we (might have) already received.
const bundleDownloaded = await this.cryptoBackend.maybeAcceptKeyBundle(roomId, inviter);
// If this fails, i.e. we haven't received this message yet, we need to wait until the to-device message arrives.
if (!bundleDownloaded) {
this.cryptoBackend.markRoomAsPendingKeyBundle(roomId, inviter);
}
await this.cryptoBackend.maybeAcceptKeyBundle(roomId, inviter);
}
// In case we were originally given an alias, check the room cache again
@@ -4088,7 +4086,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
if (opts.shareEncryptedHistory) {
await this.cryptoBackend?.shareRoomHistoryWithUser(roomId, userId);
const historyVisibility = this.getRoom(roomId)?.getHistoryVisibility() ?? HistoryVisibility.Shared;
// We should only share room history if the *current* visibility allows it.
if ([HistoryVisibility.Invited, HistoryVisibility.Joined].includes(historyVisibility)) {
this.logger.debug("Not sharing message history as the room history visibility is currently unshared");
} else {
await this.cryptoBackend?.shareRoomHistoryWithUser(roomId, userId);
}
}
return await this.membershipChange(roomId, userId, KnownMembership.Invite, opts.reason);
+13 -1
View File
@@ -80,6 +80,18 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
*/
importBackedUpRoomKeys(keys: IMegolmSessionData[], backupVersion: string, opts?: ImportRoomKeysOpts): Promise<void>;
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Room key history sharing (MSC4268)
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Share any shareable E2EE history in the given room with the given recipient,
* as per [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268)
*/
shareRoomHistoryWithUser(roomId: string, userId: string): Promise<void>;
/**
* Having accepted an invite for the given room from the given user, attempt to
* find information about a room key bundle and, if found, download the
@@ -103,7 +115,7 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
* @param roomId - The room we were invited to, for which we did not receive a key bundle before accepting the invite.
* @param inviterId - The user who invited us to the room and is expected to send the room key bundle.
*/
markRoomAsPendingKeyBundle(roomId: string, inviterId: string): void;
markRoomAsPendingKeyBundle(roomId: string, inviterId: string): Promise<void>;
}
/** The methods which crypto implementations should expose to the Sync api
-14
View File
@@ -720,20 +720,6 @@ export interface CryptoApi {
* @param secrets - The secrets bundle received from the other device
*/
importSecretsBundle?(secrets: Awaited<ReturnType<SecretsBundle["to_json"]>>): Promise<void>;
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Room key history sharing (MSC4268)
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Share any shareable E2EE history in the given room with the given recipient,
* as per [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268)
*
* @experimental
*/
shareRoomHistoryWithUser(roomId: string, userId: string): Promise<void>;
}
/** A reason code for a failure to decrypt an event. */
+1
View File
@@ -82,6 +82,7 @@ export * from "./models/room-summary.ts";
export * from "./models/event-status.ts";
export * from "./models/profile-keys.ts";
export * from "./models/related-relations.ts";
export { type StickyMatrixEvent, RoomStickyEventsEvent } from "./models/room-sticky-events.ts";
export type { RoomSummary } from "./client.ts";
export * as ContentHelpers from "./content-helpers.ts";
export * as SecretStorage from "./secret-storage.ts";
+6 -6
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm";
import { QrCodeIntent } from "@matrix-org/matrix-sdk-crypto-wasm";
import {
ClientRendezvousFailureReason,
@@ -108,7 +108,7 @@ interface SecretsPayload extends MSC4108Payload, Awaited<ReturnType<NonNullable<
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
*/
export class MSC4108SignInWithQR {
private readonly ourIntent: QrCodeMode;
private readonly ourIntent: QrCodeIntent;
private _code?: Uint8Array;
private expectingNewDeviceId?: string;
@@ -131,7 +131,7 @@ export class MSC4108SignInWithQR {
private readonly client?: MatrixClient,
public onFailure?: RendezvousFailureListener,
) {
this.ourIntent = client ? QrCodeMode.Reciprocate : QrCodeMode.Login;
this.ourIntent = client ? QrCodeIntent.Reciprocate : QrCodeIntent.Login;
}
/**
@@ -149,9 +149,9 @@ export class MSC4108SignInWithQR {
return;
}
if (this.ourIntent === QrCodeMode.Reciprocate && this.client) {
if (this.ourIntent === QrCodeIntent.Reciprocate && this.client) {
this._code = await this.channel.generateCode(this.ourIntent, this.client.getDomain()!);
} else if (this.ourIntent === QrCodeMode.Login) {
} else if (this.ourIntent === QrCodeIntent.Login) {
this._code = await this.channel.generateCode(this.ourIntent);
}
}
@@ -160,7 +160,7 @@ export class MSC4108SignInWithQR {
* Returns true if the device is the already logged in device reciprocating a new login on the other side of the channel.
*/
public get isExistingDevice(): boolean {
return this.ourIntent === QrCodeMode.Reciprocate;
return this.ourIntent === QrCodeIntent.Reciprocate;
}
/**
@@ -19,7 +19,7 @@ import {
Ecies,
type EstablishedEcies,
QrCodeData,
QrCodeMode,
QrCodeIntent,
} from "@matrix-org/matrix-sdk-crypto-wasm";
import {
@@ -56,9 +56,9 @@ export class MSC4108SecureChannel {
* @param mode the mode to generate the QR code in, either `Login` or `Reciprocate`.
* @param serverName the name of the homeserver to connect to, as defined by server discovery in the spec, required for `Reciprocate` mode.
*/
public async generateCode(mode: QrCodeMode.Login): Promise<Uint8Array>;
public async generateCode(mode: QrCodeMode.Reciprocate, serverName: string): Promise<Uint8Array>;
public async generateCode(mode: QrCodeMode, serverName?: string): Promise<Uint8Array> {
public async generateCode(mode: QrCodeIntent.Login): Promise<Uint8Array>;
public async generateCode(mode: QrCodeIntent.Reciprocate, serverName: string): Promise<Uint8Array>;
public async generateCode(mode: QrCodeIntent, serverName?: string): Promise<Uint8Array> {
const { url } = this.rendezvousSession;
if (!url) {
@@ -68,7 +68,7 @@ export class MSC4108SecureChannel {
return new QrCodeData(
this.secureChannel.public_key(),
url,
mode === QrCodeMode.Reciprocate ? serverName : undefined,
mode === QrCodeIntent.Reciprocate ? serverName : undefined,
).toBytes();
}
+17 -1
View File
@@ -17,7 +17,7 @@ limitations under the License.
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { StoreHandle } from "@matrix-org/matrix-sdk-crypto-wasm";
import { RustCrypto } from "./rust-crypto.ts";
import { MAX_INVITE_ACCEPTANCE_MS_FOR_KEY_BUNDLE, RustCrypto } from "./rust-crypto.ts";
import { type IHttpOpts, type MatrixHttpApi } from "../http-api/index.ts";
import { type ServerSideSecretStorage } from "../secret-storage.ts";
import { type Logger } from "../logger.ts";
@@ -247,5 +247,21 @@ async function initOlmMachine(
}
}
// If we have any recently-joined rooms, see if we have a pending key bundle for them.
for (const pendingDetails of await olmMachine.getAllRoomsPendingKeyBundles()) {
const roomId = pendingDetails.roomId.toString();
if (Date.now() - pendingDetails.inviteAcceptedAtMillis <= MAX_INVITE_ACCEPTANCE_MS_FOR_KEY_BUNDLE) {
logger.info(
`Checking for pending key bundle for recently-joined room ${roomId} (joined ${new Date(pendingDetails.inviteAcceptedAtMillis).toISOString()})`,
);
await rustCrypto.maybeAcceptKeyBundle(roomId, pendingDetails.inviterId.toString());
} else {
logger.info(
`Clearing pending-key-bundle flag for room ${roomId} (too old: joined ${new Date(pendingDetails.inviteAcceptedAtMillis).toISOString()})`,
);
await olmMachine.clearRoomPendingKeyBundle(new RustSdkCryptoJs.RoomId(roomId));
}
}
return rustCrypto;
}
+70 -46
View File
@@ -111,6 +111,9 @@ interface ISignableObject {
unsigned?: object;
}
/** The maximum time, in milliseconds, since we accepted an invite, that we should accept a key bundle. */
export const MAX_INVITE_ACCEPTANCE_MS_FOR_KEY_BUNDLE = 24 * 60 * 60 * 1000; // 24 hours
/**
* An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto.
*
@@ -131,9 +134,6 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
/** mapping of roomId → encryptor class */
private roomEncryptors: Record<string, RoomEncryptor> = {};
/** mapping of room ID -> inviter ID for rooms pending MSC4268 key bundles */
private readonly roomsPendingKeyBundles: Map<string, string> = new Map();
private eventDecryptor: EventDecryptor;
private keyClaimManager: KeyClaimManager;
private outgoingRequestProcessor: OutgoingRequestProcessor;
@@ -370,10 +370,10 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
/* allowRedirects */ true,
/* useAuthentication */ true,
);
let encryptedBundle: Blob;
let encryptedBundle: Uint8Array;
try {
const bundleUrl = new URL(url);
encryptedBundle = await this.http.authedRequest<Blob>(
const encryptedBundleBlob = await this.http.authedRequest<Blob>(
Method.Get,
bundleUrl.pathname + bundleUrl.search,
{},
@@ -383,17 +383,24 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
prefix: "",
},
);
logger.info(`Received blob of length ${encryptedBundleBlob.size}`);
encryptedBundle = new Uint8Array(await encryptedBundleBlob.arrayBuffer());
} catch (err) {
logger.warn(`Error downloading encrypted bundle from ${url}:`, err);
throw err;
}
logger.info(`Received blob of length ${encryptedBundle.size}`);
try {
await this.olmMachine.receiveRoomKeyBundle(bundleData, new Uint8Array(await encryptedBundle.arrayBuffer()));
await this.olmMachine.receiveRoomKeyBundle(bundleData, encryptedBundle);
} catch (err) {
logger.warn(`Error receiving encrypted bundle:`, err);
throw err;
} finally {
// Even if we were unable to import the bundle, we still clear the flag that indicates that we
// are waiting for the bundle to be received. The only reason this can happen is that the bundle was
// malformed somehow, so we don't want to keep retrying it.
await this.olmMachine.clearRoomPendingKeyBundle(new RustSdkCryptoJs.RoomId(roomId));
}
return true;
@@ -402,8 +409,11 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
/**
* Implementation of {@link CryptoBackend.markRoomAsPendingKeyBundle}.
*/
public markRoomAsPendingKeyBundle(roomId: string, inviter: string): void {
this.roomsPendingKeyBundles.set(roomId, inviter);
public async markRoomAsPendingKeyBundle(roomId: string, inviter: string): Promise<void> {
await this.olmMachine.storeRoomPendingKeyBundle(
new RustSdkCryptoJs.RoomId(roomId),
new RustSdkCryptoJs.UserId(inviter),
);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@@ -781,9 +791,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
* Implementation of {@link CryptoApi#getCrossSigningKeyId}
*/
public async getCrossSigningKeyId(type: CrossSigningKey = CrossSigningKey.Master): Promise<string | null> {
const userIdentity: RustSdkCryptoJs.OwnUserIdentity | undefined = await this.olmMachine.getIdentity(
new RustSdkCryptoJs.UserId(this.userId),
);
const userIdentity = await this.getOwnIdentity();
if (!userIdentity) {
// The public keys are not available on this device
return null;
@@ -1012,9 +1020,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
* Implementation of {@link CryptoApi#getCrossSigningStatus}
*/
public async getCrossSigningStatus(): Promise<CrossSigningStatus> {
const userIdentity: RustSdkCryptoJs.OwnUserIdentity | null = await this.getOlmMachineOrThrow().getIdentity(
new RustSdkCryptoJs.UserId(this.userId),
);
const userIdentity = await this.getOwnIdentity();
const publicKeysOnDevice =
Boolean(userIdentity?.masterKey) &&
@@ -1128,9 +1134,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
* Implementation of {@link CryptoApi#requestVerificationDM}
*/
public async requestVerificationDM(userId: string, roomId: string): Promise<VerificationRequest> {
const userIdentity: RustSdkCryptoJs.OtherUserIdentity | undefined = await this.olmMachine.getIdentity(
new RustSdkCryptoJs.UserId(userId),
);
const userIdentity = (await this.olmMachine.getIdentity(new RustSdkCryptoJs.UserId(userId))) as
| RustSdkCryptoJs.OtherUserIdentity
| undefined;
if (!userIdentity) throw new Error(`unknown userId ${userId}`);
@@ -1214,9 +1220,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
* @returns a VerificationRequest when the request has been sent to the other party.
*/
public async requestOwnUserVerification(): Promise<VerificationRequest> {
const userIdentity: RustSdkCryptoJs.OwnUserIdentity | undefined = await this.olmMachine.getIdentity(
new RustSdkCryptoJs.UserId(this.userId),
);
const userIdentity = await this.getOwnIdentity();
if (userIdentity === undefined) {
throw new Error("cannot request verification for this device when there is no existing cross-signing key");
}
@@ -1726,34 +1730,37 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
},
});
// If we have received a room key bundle message, and have previously marked the room
// IDs it references as pending key bundles, tell the Rust SDK to try and accept it,
// just in case it was received after invite.
// If we have received a room key bundle message, and have recently joined the room in question,
// tell the Rust SDK to try and accept the key bundle.
//
// We don't actually need to validate the contents of the bundle message, or do
// anything with its contents at all. We simply want to inform the Rust SDK we have
// received a new room key bundle that we might be able to download.
if (
isRoomKeyBundleMessage(parsedMessage) &&
this.roomsPendingKeyBundles.has(parsedMessage.content.room_id)
) {
// No `await`-ing here, as this is called from inside the `/sync` loop.
this.maybeAcceptKeyBundle(
parsedMessage.content.room_id,
this.roomsPendingKeyBundles.get(parsedMessage.content.room_id)!,
).then(
(success) => {
if (success) {
this.roomsPendingKeyBundles.delete(parsedMessage.content.room_id);
}
},
(err) => {
this.logger.error(
`Error attempting to download key bundle for room ${parsedMessage.content.room_id}`,
);
this.logger.error(err);
},
if (isRoomKeyBundleMessage(parsedMessage)) {
const roomId = parsedMessage.content.room_id;
const pendingDetails = await this.olmMachine.getPendingKeyBundleDetailsForRoom(
new RustSdkCryptoJs.RoomId(roomId),
);
// Only accept the key bundle if we joined the room less than 24 hours ago.
if (!pendingDetails) {
this.logger.debug(
`Not yet accepting key bundle for room where we are not awaiting a bundle: ${roomId}`,
);
} else if (
Date.now() - pendingDetails.inviteAcceptedAtMillis >
MAX_INVITE_ACCEPTANCE_MS_FOR_KEY_BUNDLE
) {
this.logger.info(
`Ignoring key bundle for room we joined too long ago: ${roomId}, joining time: ${new Date(pendingDetails.inviteAcceptedAtMillis).toISOString()}`,
);
} else {
this.logger.info(`Considering key bundle for recently-joined room ${roomId}`);
// Don't block for the import to happen, here, as this is called from inside the `/sync` loop.
this.maybeAcceptKeyBundle(roomId, pendingDetails.inviterId.toString()).catch((err) => {
this.logger.error(`Error attempting to download key bundle for room ${roomId}`);
this.logger.error(err);
});
}
}
break;
@@ -1926,7 +1933,21 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
* @param oldMembership - The previous membership state. Null if it's a new member.
*/
public onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void {
const enc = this.roomEncryptors[event.getRoomId()!];
const roomId = event.getRoomId()!;
// If it's our own membership, and we are no longer joined, clear any indication that we are waiting for a key
// bundle.
if (
oldMembership === KnownMembership.Join &&
member.membership !== KnownMembership.Join &&
member.userId === this.olmMachine.userId.toString()
) {
this.olmMachine.clearRoomPendingKeyBundle(new RustSdkCryptoJs.RoomId(roomId)).catch((e) => {
this.logger.error(`Error clearing room pending key bundle indicator for ${roomId}: ${e}`);
});
}
const enc = this.roomEncryptors[roomId];
if (!enc) {
// not encrypting in this room
return;
@@ -2189,7 +2210,10 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
* Used during migration from legacy js-crypto to update local trust if needed.
*/
public async getOwnIdentity(): Promise<RustSdkCryptoJs.OwnUserIdentity | undefined> {
return await this.olmMachine.getIdentity(new RustSdkCryptoJs.UserId(this.userId));
const identity = (await this.getOlmMachineOrThrow().getIdentity(new RustSdkCryptoJs.UserId(this.userId))) as
| RustSdkCryptoJs.OwnUserIdentity
| undefined;
return identity;
}
}