Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14b2ee2da4 | |||
| bb083222d9 | |||
| fa424c44b4 | |||
| fef093747e | |||
| 4b33892d48 | |||
| d7d771fadb | |||
| 4ee3e591bf | |||
| 668183d722 | |||
| 854dae0dc0 | |||
| 9d1aca2232 | |||
| 81569f3461 | |||
| 50783aba76 | |||
| fd01b17236 | |||
| 25e92009b7 | |||
| eb7acfb810 | |||
| ca5655bced | |||
| ef9b13e2a6 | |||
| 159cca0363 | |||
| 3879111850 | |||
| f9a5aa87e3 | |||
| ed58df040c | |||
| 0a3448d4c9 | |||
| 25c1c1ea26 | |||
| a5e67af31f | |||
| b91e80814a | |||
| d096a72605 | |||
| fb547e7b4b | |||
| 815294ca5a | |||
| 6d270b4685 | |||
| 8bc3d96f6b | |||
| b6ea6e105e | |||
| cd4e053fa5 | |||
| 727473af62 | |||
| f17f013f1e | |||
| 9f4ab0b840 | |||
| 6371e4b252 | |||
| b69929e01a | |||
| 9dc12baaa9 | |||
| 159738597d | |||
| d02205652f | |||
| 5e03add29a | |||
| eeafd7fcaa | |||
| 78a3c5372d | |||
| c0c3bc2a8c | |||
| c3ce49cabf | |||
| 5408168dfd | |||
| 61452ddc11 | |||
| d5160a5380 | |||
| 7ff4960a27 | |||
| 00f63db80f | |||
| 9bcb83a20a | |||
| dd8d8e5410 | |||
| 32b8ff8116 | |||
| 3ceadd512d | |||
| 8182180550 | |||
| aed74c5a72 | |||
| 8c259c53a6 | |||
| 4d59291538 | |||
| 80009a1b31 | |||
| 93e6c95953 | |||
| 27a5507cef | |||
| be06f6655e | |||
| 71152f33bf | |||
| bc8f67089c | |||
| acc9aa8939 | |||
| e76f627fe3 | |||
| 45b1e73842 | |||
| f3eefd2f32 | |||
| f7c053216b | |||
| 9c7739f14f | |||
| 897afe153a | |||
| a929391dcd | |||
| 45c5ee9f65 | |||
| e56aaa16c7 | |||
| da0d3d791e | |||
| ed5eb670a1 | |||
| b7fcb6e4c1 | |||
| bd775f6b61 | |||
| c2f9ad28fc | |||
| 7f33e3462e | |||
| d99363d288 | |||
| 3642b99212 | |||
| 6ec0987286 | |||
| 219eb617dc | |||
| c6f9b25046 | |||
| 5d0e2efaf3 | |||
| c7cd5570d3 | |||
| c2f6dd2ce0 | |||
| 393732aaae | |||
| d373fd8540 | |||
| 44a8a9a47a | |||
| 8a0b7ad68b | |||
| 09663302e1 | |||
| 94f83b702c | |||
| 9df27ee672 | |||
| 5739b59faa | |||
| e4425570c7 | |||
| 145cb26054 | |||
| 26d5b1cde2 | |||
| 0666d6b4e1 | |||
| 9002064f10 | |||
| 5ea1554612 | |||
| bd6547c081 | |||
| 8073f27d98 | |||
| 3bb22a9b28 | |||
| ba8bb3228d | |||
| de23c9587b | |||
| 64eb482a49 | |||
| aba7f8a0d4 | |||
| 5495153c63 | |||
| 4f0696e2a4 | |||
| 0e659d294e | |||
| e74eb4928e | |||
| 327d2fa7c8 | |||
| 028357f15f | |||
| 872ec6755e | |||
| 333d6a7bd6 | |||
| 47532de452 | |||
| 87e1049dae | |||
| 6e3efef0c5 | |||
| fb590627bb | |||
| 24cc17c270 | |||
| 68084e8fc3 | |||
| 0c3bb1f246 | |||
| 7f42b67f68 | |||
| 9b871ac969 | |||
| 49f7972a9e | |||
| c5ae4c8c0d | |||
| 2423300acd | |||
| 6cafa175b8 |
+1
-1
@@ -1,7 +1,7 @@
|
||||
* @matrix-org/element-web-reviewers
|
||||
/.github/workflows/** @matrix-org/element-web-team
|
||||
/package.json @matrix-org/element-web-team
|
||||
/yarn.lock @matrix-org/element-web-team
|
||||
/pnpm-lock.yaml @matrix-org/element-web-team
|
||||
/scripts/** @matrix-org/element-web-team
|
||||
/src/webrtc @matrix-org/element-call-reviewers
|
||||
/src/matrixrtc @matrix-org/element-call-reviewers
|
||||
|
||||
@@ -22,7 +22,7 @@ runs:
|
||||
|
||||
- name: Upload tarball signature
|
||||
if: ${{ inputs.upload-url }}
|
||||
uses: shogo82148/actions-upload-release-asset@8f6863c6c894ba46f9e676ef5cccec4752723c1e # v1
|
||||
uses: shogo82148/actions-upload-release-asset@96bc1f0cb850b65efd58a6b5eaa0a69f88d38077 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ env.VERSION }}.tar.gz.asc
|
||||
|
||||
@@ -29,13 +29,13 @@ runs:
|
||||
|
||||
- name: Upload asset signatures
|
||||
if: inputs.gpg-fingerprint
|
||||
uses: shogo82148/actions-upload-release-asset@8f6863c6c894ba46f9e676ef5cccec4752723c1e # v1
|
||||
uses: shogo82148/actions-upload-release-asset@96bc1f0cb850b65efd58a6b5eaa0a69f88d38077 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ inputs.asset-path }}.asc
|
||||
|
||||
- name: Upload assets
|
||||
uses: shogo82148/actions-upload-release-asset@8f6863c6c894ba46f9e676ef5cccec4752723c1e # v1
|
||||
uses: shogo82148/actions-upload-release-asset@96bc1f0cb850b65efd58a6b5eaa0a69f88d38077 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ inputs.asset-path }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Triggers after the "Downstream artifacts" build has finished, to run the
|
||||
# matrix-react-sdk playwright tests (with access to repo secrets)
|
||||
# element-web playwright tests (with access to repo secrets)
|
||||
|
||||
name: matrix-react-sdk End to End Tests
|
||||
name: Element Web End to End Tests
|
||||
on:
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
@@ -21,11 +21,12 @@ 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/build-and-test.yaml@develop # zizmor: ignore[unpinned-uses]
|
||||
permissions:
|
||||
actions: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
contents: read
|
||||
with:
|
||||
matrix-js-sdk-sha: ${{ github.sha }}
|
||||
# We only want to run the playwright tests on merge queue to prevent regressions
|
||||
|
||||
@@ -18,8 +18,8 @@ jobs:
|
||||
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
|
||||
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4
|
||||
- name: Notify element-web repo that a new SDK build is on develop so it can CI against it
|
||||
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
repository: ${{ matrix.repo }}
|
||||
|
||||
@@ -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]
|
||||
@@ -15,7 +18,7 @@ jobs:
|
||||
name: Preview Changelog
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5
|
||||
- uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5
|
||||
if: github.event_name != 'merge_group'
|
||||
with:
|
||||
labels: |
|
||||
@@ -35,7 +38,7 @@ jobs:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Add notice
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
|
||||
with:
|
||||
script: |
|
||||
@@ -60,7 +63,7 @@ jobs:
|
||||
|
||||
- name: Add label
|
||||
if: steps.teams.outputs.isTeamMember == 'false'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.addLabels({
|
||||
@@ -81,7 +84,7 @@ jobs:
|
||||
github.event.pull_request.head.repo.full_name != github.repository
|
||||
steps:
|
||||
- name: Close pull request
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Check for X-Release-Blocker label on any open issues or PRs
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
env:
|
||||
REPO: ${{ inputs.repository }}
|
||||
with:
|
||||
|
||||
@@ -16,18 +16,20 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: staging
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: "yarn"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Deps
|
||||
run: "yarn install --frozen-lockfile"
|
||||
run: "pnpm install --frozen-lockfile"
|
||||
|
||||
- uses: t3chguy/release-drafter@105e541c2c3d857f032bd522c0764694758fabad
|
||||
id: draft-release
|
||||
@@ -37,7 +39,7 @@ jobs:
|
||||
disable-autolabeler: true
|
||||
|
||||
- name: Get actions scripts
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
repository: matrix-org/matrix-js-sdk
|
||||
persist-credentials: false
|
||||
@@ -48,7 +50,7 @@ jobs:
|
||||
|
||||
- name: Ingest upstream changes
|
||||
if: inputs.include-changes
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -12,20 +12,25 @@ on:
|
||||
description: List of dependencies to reset.
|
||||
type: string
|
||||
required: false
|
||||
dir:
|
||||
description: The directory to release
|
||||
type: string
|
||||
default: "."
|
||||
concurrency: ${{ github.workflow }}
|
||||
permissions: {} # Uses ELEMENT_BOT_TOKEN
|
||||
jobs:
|
||||
merge:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
# 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
repository: matrix-org/matrix-js-sdk
|
||||
persist-credentials: false
|
||||
@@ -33,13 +38,14 @@ jobs:
|
||||
sparse-checkout: |
|
||||
scripts/release
|
||||
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
cache: "pnpm"
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Install Deps
|
||||
run: "yarn install --frozen-lockfile"
|
||||
run: "pnpm install --frozen-lockfile"
|
||||
|
||||
- name: Set up git
|
||||
run: |
|
||||
@@ -53,6 +59,7 @@ jobs:
|
||||
|
||||
- name: Reset dependencies
|
||||
if: inputs.dependencies
|
||||
working-directory: ${{ inputs.dir }}
|
||||
run: |
|
||||
while IFS= read -r PACKAGE; do
|
||||
[ -z "$PACKAGE" ] && continue
|
||||
@@ -73,7 +80,7 @@ jobs:
|
||||
fi
|
||||
|
||||
echo "Resetting $PACKAGE to develop branch..."
|
||||
yarn add "github:matrix-org/$PACKAGE#develop"
|
||||
pnpm add "github:matrix-org/$PACKAGE#develop"
|
||||
git add -u
|
||||
git commit -m "Reset $PACKAGE back to develop branch"
|
||||
done <<< "$DEPENDENCIES"
|
||||
|
||||
@@ -26,12 +26,21 @@ on:
|
||||
description: |
|
||||
The path to the asset you want to upload, if any. You can use glob patterns here.
|
||||
Will be GPG signed and an `.asc` file included in the release artifacts if `gpg-fingerprint` is set.
|
||||
Relative to `dir`.
|
||||
type: string
|
||||
required: false
|
||||
expected-asset-count:
|
||||
description: The number of expected assets, including signatures, excluding generated zip & tarball.
|
||||
type: number
|
||||
required: false
|
||||
dist-dir:
|
||||
description: The directory to release
|
||||
type: string
|
||||
default: "."
|
||||
version-dirs:
|
||||
description: Directories in which to update package.json `version` field
|
||||
type: string
|
||||
required: false
|
||||
outputs:
|
||||
npm-id:
|
||||
description: "The npm package@version string we published"
|
||||
@@ -43,7 +52,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
|
||||
@@ -56,7 +65,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 }}
|
||||
@@ -64,22 +73,23 @@ 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:
|
||||
draft: true
|
||||
latest: true
|
||||
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: staging
|
||||
# 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
repository: matrix-org/matrix-js-sdk
|
||||
persist-credentials: false
|
||||
@@ -90,6 +100,7 @@ jobs:
|
||||
|
||||
- name: Prepare variables
|
||||
id: prepare
|
||||
working-directory: ${{ inputs.dist-dir }}
|
||||
run: |
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
|
||||
@@ -104,7 +115,7 @@ jobs:
|
||||
run: echo "VERSION=$(echo $VERSION | cut -d- -f1)" >> $GITHUB_ENV
|
||||
|
||||
- name: Check version number not in use
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
const { VERSION } = process.env;
|
||||
@@ -123,15 +134,17 @@ jobs:
|
||||
git config --global user.email "releases@riot.im"
|
||||
git config --global user.name "RiotRobot"
|
||||
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
cache: "pnpm"
|
||||
node-version-file: ${{ inputs.dist-dir }}/package.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: "yarn install --frozen-lockfile"
|
||||
run: "pnpm install --frozen-lockfile"
|
||||
|
||||
- name: Handle develop dependencies
|
||||
working-directory: ${{ inputs.dist-dir }}
|
||||
run: |
|
||||
ret=0
|
||||
cat package.json | jq -r '.dependencies | to_entries | .[] | "\(.key) \(.value)"' | grep '#develop$' | while read -r dep ; do
|
||||
@@ -140,15 +153,19 @@ jobs:
|
||||
VERSION=${dep[1]}
|
||||
|
||||
echo "::warning title=Develop dependency found::$DEPENDENCY will be kept at $VERSION"
|
||||
yarn upgrade "$PACKAGE@$VERSION" --exact
|
||||
pnpm add "$PACKAGE@$VERSION" --save-exact
|
||||
git add -u
|
||||
git commit -m "Keep $PACKAGE at $VERSION"
|
||||
done
|
||||
|
||||
- name: Bump package.json version
|
||||
- name: Bump package.json versions
|
||||
run: |
|
||||
yarn version --no-git-tag-version --new-version "${VERSION#v}"
|
||||
git add package.json
|
||||
for DIR in $DIRS; do
|
||||
pnpm version -C "$DIR" --no-git-tag-version "${VERSION#v}"
|
||||
git add "$DIR"/package.json
|
||||
done
|
||||
env:
|
||||
DIRS: ${{ inputs.version-dirs || inputs.dist-dir }}
|
||||
|
||||
- name: Add to CHANGELOG.md
|
||||
if: inputs.final
|
||||
@@ -175,7 +192,8 @@ jobs:
|
||||
|
||||
- name: Build assets
|
||||
if: steps.prepare.outputs.has-dist-script == '1'
|
||||
run: DIST_VERSION="$VERSION" yarn dist
|
||||
working-directory: ${{ inputs.dist-dir }}
|
||||
run: DIST_VERSION="$VERSION" pnpm dist
|
||||
|
||||
- name: Upload release assets & signatures
|
||||
if: inputs.asset-path
|
||||
@@ -183,7 +201,7 @@ jobs:
|
||||
with:
|
||||
gpg-fingerprint: ${{ inputs.gpg-fingerprint }}
|
||||
upload-url: ${{ steps.draft-release.outputs.upload_url }}
|
||||
asset-path: ${{ inputs.asset-path }}
|
||||
asset-path: ${{ inputs.dist-dir }}/${{ inputs.asset-path }}
|
||||
|
||||
- name: Create signed tag
|
||||
if: inputs.gpg-fingerprint
|
||||
@@ -216,7 +234,7 @@ jobs:
|
||||
|
||||
- name: Validate release has expected assets
|
||||
if: inputs.expected-asset-count
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
env:
|
||||
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
|
||||
EXPECTED_ASSET_COUNT: ${{ inputs.expected-asset-count }}
|
||||
@@ -244,7 +262,7 @@ jobs:
|
||||
git push origin master
|
||||
|
||||
- name: Publish release
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
env:
|
||||
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
|
||||
FINAL: ${{ inputs.final }}
|
||||
@@ -276,7 +294,9 @@ 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.dist-dir }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
name: Publish to npm
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
dir:
|
||||
description: The directory to release
|
||||
type: string
|
||||
default: "."
|
||||
outputs:
|
||||
id:
|
||||
description: "The npm package@version string we published"
|
||||
@@ -17,26 +22,29 @@ jobs:
|
||||
id: ${{ steps.npm-publish.outputs.id }}
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: staging
|
||||
persist-credentials: false
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
||||
- name: 🔧 pnpm cache
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
cache: "pnpm"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
node-version-file: package.json
|
||||
node-version-file: ${{ inputs.dir }}/package.json
|
||||
|
||||
# Ensure npm 11.5.1 or later is installed
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: 🔨 Install dependencies
|
||||
run: "yarn install --frozen-lockfile"
|
||||
run: "pnpm install --frozen-lockfile"
|
||||
|
||||
- name: 🚀 Publish to npm
|
||||
id: npm-publish
|
||||
working-directory: ${{ inputs.dir }}
|
||||
run: |
|
||||
npm publish --provenance --access public --tag "$TAG"
|
||||
release=$(jq -r '"\(.name)@\(.version)"' package.json)
|
||||
|
||||
@@ -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
|
||||
@@ -41,28 +41,32 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
repo:
|
||||
- element-hq/element-web
|
||||
include:
|
||||
- repo: element-hq/element-web
|
||||
path: apps/web
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
repository: ${{ matrix.repo }}
|
||||
ref: staging
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
persist-credentials: true
|
||||
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
cache: "pnpm"
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Bump dependency
|
||||
env:
|
||||
DEPENDENCY: ${{ needs.release.outputs.npm-id }}
|
||||
DIR: ${{ matrix.path }}
|
||||
run: |
|
||||
git config --global user.email "releases@riot.im"
|
||||
git config --global user.name "RiotRobot"
|
||||
yarn upgrade "$DEPENDENCY" --exact
|
||||
git add package.json yarn.lock
|
||||
pnpm add -C "$DIR" "$DEPENDENCY" --save-exact
|
||||
git add "$DIR"/package.json pnpm-lock.yaml
|
||||
git commit -am"Upgrade dependency to $DEPENDENCY"
|
||||
git push origin staging
|
||||
|
||||
@@ -73,22 +77,25 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
||||
- name: 🔧 pnpm cache
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
cache: "pnpm"
|
||||
node-version-file: package.json
|
||||
|
||||
- name: 🔨 Install dependencies
|
||||
run: "yarn install --frozen-lockfile"
|
||||
run: "pnpm install --frozen-lockfile"
|
||||
|
||||
- name: 📖 Generate docs
|
||||
run: yarn gendoc
|
||||
run: pnpm gendoc
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
|
||||
with:
|
||||
path: _docs
|
||||
|
||||
@@ -106,4 +113,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5
|
||||
|
||||
@@ -13,6 +13,10 @@ on:
|
||||
type: boolean
|
||||
required: false
|
||||
description: "Whether to combine multiple LCOV and sonar-report files in coverage artifact"
|
||||
version-pkg-json-dir:
|
||||
type: string
|
||||
default: "."
|
||||
description: "Relative path of the directory containing package.json with the `version` to use."
|
||||
permissions: {}
|
||||
jobs:
|
||||
sonarqube:
|
||||
@@ -27,7 +31,7 @@ jobs:
|
||||
steps:
|
||||
# We create the status here and then update it to success/failure in the `report` stage
|
||||
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
|
||||
- uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
|
||||
- uses: guibranco/github-status-action-v2@9bfa8773cdbdc6c185747fd43cd7faa9d7c32f09
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: pending
|
||||
@@ -36,14 +40,15 @@ jobs:
|
||||
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
- name: "🧮 Checkout code"
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
if: ${{ !inputs.sharded }}
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -51,14 +56,13 @@ jobs:
|
||||
name: coverage
|
||||
path: coverage
|
||||
- name: 📥 Download sharded artifacts
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
if: inputs.sharded
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
pattern: coverage-*
|
||||
path: coverage
|
||||
merge-multiple: true
|
||||
- name: Check coverage artifact
|
||||
run: |
|
||||
if [ ! -d coverage ]; then
|
||||
@@ -75,7 +79,7 @@ jobs:
|
||||
|
||||
- name: "🩻 SonarCloud Scan"
|
||||
id: sonarcloud
|
||||
uses: matrix-org/sonarcloud-workflow-action@ea0cd9dbd5562e79816685972bc0d03c235a900c
|
||||
uses: matrix-org/sonarcloud-workflow-action@13968a27c924fa19b1dacbce6ca3ff217daa775b
|
||||
# workflow_run fails report against the develop commit always, we don't want that for PRs
|
||||
continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
|
||||
with:
|
||||
@@ -83,12 +87,12 @@ jobs:
|
||||
repository: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
is_pr: ${{ github.event.workflow_run.event == 'pull_request' }}
|
||||
skip_coverage_label: Z-Skip-Coverage
|
||||
version_cmd: "cat package.json | jq -r .version"
|
||||
version_cmd: "cat ${{ inputs.version-pkg-json-dir }}/package.json | jq -r .version"
|
||||
branch: ${{ github.event.workflow_run.head_branch }}
|
||||
revision: ${{ github.event.workflow_run.head_sha }}
|
||||
token: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
- uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
|
||||
- uses: guibranco/github-status-action-v2@9bfa8773cdbdc6c185747fd43cd7faa9d7c32f09
|
||||
if: always()
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -14,58 +14,61 @@ jobs:
|
||||
name: "Typescript Syntax Check"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
cache: "pnpm"
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Install Deps
|
||||
run: "yarn install"
|
||||
run: "pnpm install"
|
||||
|
||||
- name: Typecheck
|
||||
run: "yarn run lint:types"
|
||||
run: "pnpm run lint:types"
|
||||
|
||||
js_lint:
|
||||
name: "ESLint"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
cache: "pnpm"
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Install Deps
|
||||
run: "yarn install"
|
||||
run: "pnpm install"
|
||||
|
||||
- name: Run Linter
|
||||
run: "yarn run lint:js"
|
||||
run: "pnpm run lint:js"
|
||||
|
||||
node_example_lint:
|
||||
name: "Node.js example"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
cache: "pnpm"
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Install Deps
|
||||
run: "yarn install"
|
||||
run: "pnpm install"
|
||||
|
||||
- name: Build Types
|
||||
run: "yarn build:types"
|
||||
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
with:
|
||||
cache: "npm"
|
||||
node-version-file: "examples/node/package.json"
|
||||
# cache-dependency-path: '**/package-lock.json'
|
||||
run: "pnpm build:types"
|
||||
|
||||
- name: Install Example Deps
|
||||
run: "npm install"
|
||||
@@ -82,39 +85,50 @@ jobs:
|
||||
workflow_lint:
|
||||
name: "Workflow Lint"
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
cache: "pnpm"
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Install Deps
|
||||
run: "yarn install --frozen-lockfile"
|
||||
run: "pnpm install --frozen-lockfile"
|
||||
|
||||
- name: Run Linter
|
||||
run: "yarn lint:workflows"
|
||||
run: "pnpm lint:workflows"
|
||||
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
||||
|
||||
docs:
|
||||
name: "JSDoc Checker"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
cache: "pnpm"
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Install Deps
|
||||
run: "yarn install"
|
||||
run: "pnpm install"
|
||||
|
||||
- name: Generate Docs
|
||||
run: "yarn run gendoc --treatWarningsAsErrors --suppressCommentWarningsInDeclarationFiles"
|
||||
run: "pnpm run gendoc --treatWarningsAsErrors --suppressCommentWarningsInDeclarationFiles"
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: docs
|
||||
path: _docs
|
||||
@@ -125,31 +139,36 @@ jobs:
|
||||
name: "Analyse Dead Code"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
cache: "pnpm"
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Install Deps
|
||||
run: "yarn install --frozen-lockfile"
|
||||
run: "pnpm install --frozen-lockfile"
|
||||
|
||||
- name: Run linter
|
||||
run: "yarn run lint:knip"
|
||||
run: "pnpm run lint:knip"
|
||||
|
||||
element-web:
|
||||
name: Downstream tsc element-web
|
||||
if: github.event_name == 'merge_group'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
repository: element-hq/element-web
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
cache: "pnpm"
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install Dependencies
|
||||
@@ -159,15 +178,22 @@ jobs:
|
||||
JS_SDK_GITHUB_BASE_REF: ${{ github.sha }}
|
||||
|
||||
- name: Typecheck
|
||||
run: "yarn run lint:types"
|
||||
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
|
||||
|
||||
@@ -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@7f2f93fb9b52ece7a0998f60e64862aa203c1746
|
||||
with:
|
||||
LABELS: |
|
||||
element-hq/element-meta
|
||||
|
||||
+19
-13
@@ -22,17 +22,20 @@ jobs:
|
||||
node: ["lts/*", 22]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
|
||||
- name: Setup Node
|
||||
id: setupNode
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
cache: "yarn"
|
||||
cache: "pnpm"
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: "yarn install"
|
||||
run: "pnpm install"
|
||||
|
||||
- name: Get number of CPU cores
|
||||
id: cpu-cores
|
||||
@@ -40,20 +43,23 @@ jobs:
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
yarn test \
|
||||
--coverage=${{ env.ENABLE_COVERAGE }} \
|
||||
--maxWorkers ${{ steps.cpu-cores.outputs.count }} \
|
||||
pnpm test \
|
||||
--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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: coverage-${{ matrix.specs }}-${{ matrix.node == 'lts/*' && 'lts' || matrix.node }}
|
||||
path: |
|
||||
@@ -73,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:
|
||||
@@ -83,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: "."
|
||||
|
||||
@@ -112,7 +118,7 @@ jobs:
|
||||
steps:
|
||||
- name: Skip SonarCloud on merge queues
|
||||
if: env.ENABLE_COVERAGE == 'false'
|
||||
uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
|
||||
uses: guibranco/github-status-action-v2@9bfa8773cdbdc6c185747fd43cd7faa9d7c32f09
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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@6339bcda15c71d209303b18a06a9b1c021220bf9
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
|
||||
with:
|
||||
operations-per-run: 250
|
||||
days-before-issue-stale: -1
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
/.npmrc
|
||||
/*.log
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
.lock-wscript
|
||||
build/Release
|
||||
|
||||
@@ -1,3 +1,47 @@
|
||||
Changes in [41.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v41.3.0) (2026-04-07)
|
||||
==================================================================================================
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Rotate the current room key when we see a member leave ([#5231](https://github.com/matrix-org/matrix-js-sdk/pull/5231)). Contributed by @kaylendog.
|
||||
|
||||
|
||||
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
|
||||
|
||||
* Add support for Matrix Spec v1.13 ([#5160](https://github.com/matrix-org/matrix-js-sdk/pull/5160)). Contributed by @t3chguy.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* Download room keys from backup prior to buliding historic room key bundles ([#5171](https://github.com/matrix-org/matrix-js-sdk/pull/5171)). Contributed by @kaylendog.
|
||||
* Add support for Matrix Spec v1.13 ([#5160](https://github.com/matrix-org/matrix-js-sdk/pull/5160)). Contributed by @t3chguy.
|
||||
* Add logging on MSC4108 DELETE request ([#5140](https://github.com/matrix-org/matrix-js-sdk/pull/5140)). Contributed by @reivilibre.
|
||||
* Add `m.invite_permission_config` account data type ([#5183](https://github.com/matrix-org/matrix-js-sdk/pull/5183)). Contributed by @richvdh.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* fix(relations): prevent stale m.replace from overriding newer edits ([#5192](https://github.com/matrix-org/matrix-js-sdk/pull/5192)). Contributed by @basnijholt.
|
||||
* Fix reactive display name disambiguation ([#5135](https://github.com/matrix-org/matrix-js-sdk/pull/5135)). Contributed by @aditya-cherukuru.
|
||||
* Fix empty string to room compatibility trick to only apply to m.call ([#5172](https://github.com/matrix-org/matrix-js-sdk/pull/5172)). Contributed by @toger5.
|
||||
|
||||
|
||||
Changes in [40.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v40.2.0) (2026-02-10)
|
||||
==================================================================================================
|
||||
## 🦖 Deprecations
|
||||
|
||||
@@ -41,10 +41,10 @@ endpoints from before Matrix 1.1, for example.
|
||||
> Servers may require or use authenticated endpoints for media (images, files, avatars, etc). See the
|
||||
> [Authenticated Media](#authenticated-media) section for information on how to enable support for this.
|
||||
|
||||
Using `yarn` instead of `npm` is recommended. Please see the Yarn [install guide](https://classic.yarnpkg.com/en/docs/install)
|
||||
if you do not have it already.
|
||||
Using `pnpm` instead of `npm` is recommended. Please see the pnpm [install
|
||||
guide](https://pnpm.io/installation#using-corepack) if you do not have it already.
|
||||
|
||||
`yarn add matrix-js-sdk`
|
||||
`pnpm add matrix-js-sdk`
|
||||
|
||||
```javascript
|
||||
import * as sdk from "matrix-js-sdk";
|
||||
@@ -310,7 +310,7 @@ This SDK uses [Typedoc](https://typedoc.org/guides/doccomments) doc comments. Yo
|
||||
host the API reference from the source files like this:
|
||||
|
||||
```
|
||||
$ yarn gendoc
|
||||
$ pnpm gendoc
|
||||
$ cd docs
|
||||
$ python -m http.server 8005
|
||||
```
|
||||
@@ -453,7 +453,7 @@ want to use this SDK, skip this section._
|
||||
First, you need to pull in the right build tools:
|
||||
|
||||
```
|
||||
$ yarn install
|
||||
$ pnpm install
|
||||
```
|
||||
|
||||
## Building
|
||||
@@ -461,17 +461,17 @@ First, you need to pull in the right build tools:
|
||||
To build a browser version from scratch when developing:
|
||||
|
||||
```
|
||||
$ yarn build
|
||||
$ pnpm build
|
||||
```
|
||||
|
||||
To run tests:
|
||||
|
||||
```
|
||||
$ yarn test
|
||||
$ pnpm test
|
||||
```
|
||||
|
||||
To run linting:
|
||||
|
||||
```
|
||||
$ yarn lint
|
||||
$ pnpm lint
|
||||
```
|
||||
|
||||
@@ -21,4 +21,4 @@ export PATH="$rootdir/node_modules/.bin:$PATH"
|
||||
|
||||
# now run our checks
|
||||
cd "$tmpdir"
|
||||
yarn lint
|
||||
pnpm lint
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { KnipConfig } from "knip";
|
||||
|
||||
// Specify this as knip loads config files which may conditionally add reporters, e.g. `vitest-sonar-reporter'
|
||||
process.env.GITHUB_ACTIONS = "1";
|
||||
|
||||
export default {
|
||||
entry: [
|
||||
"src/index.ts",
|
||||
@@ -28,10 +31,6 @@ export default {
|
||||
"husky",
|
||||
// Used in script which only runs in environment with `@octokit/rest` installed
|
||||
"@octokit/rest",
|
||||
// Used by `vitest`
|
||||
"vitest-sonar-reporter",
|
||||
// Used by `@babel/plugin-transform-runtime`
|
||||
"@babel/runtime",
|
||||
],
|
||||
ignoreBinaries: [
|
||||
// Used when available by reusable workflow `.github/workflows/release-make.yml`
|
||||
|
||||
+34
-22
@@ -1,27 +1,26 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "40.2.0",
|
||||
"version": "41.3.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "yarn build",
|
||||
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
|
||||
"clean": "rimraf lib",
|
||||
"build": "yarn clean && yarn build:compile && yarn build:types",
|
||||
"prepare": "pnpm build",
|
||||
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel --delete-dir-on-start src -w -s -d lib --verbose --extensions \".ts,.js\"",
|
||||
"build": "pnpm build:compile && pnpm build:types",
|
||||
"build:types": "tsc -p tsconfig-build.json --emitDeclarationOnly",
|
||||
"build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src",
|
||||
"build:compile": "babel --delete-dir-on-start -d lib --verbose --extensions \".ts,.js\" src",
|
||||
"gendoc": "typedoc",
|
||||
"lint": "yarn lint:types && yarn lint:js && yarn lint:workflows",
|
||||
"lint": "pnpm lint:types && pnpm lint:js && pnpm lint:workflows",
|
||||
"lint:js": "eslint --max-warnings 0 src spec && prettier --check .",
|
||||
"lint:js-fix": "prettier --log-level=warn --write . && eslint --fix src spec",
|
||||
"lint:types": "tsc --noEmit",
|
||||
"lint:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) | xargs -I {} sh -c 'echo \"Linting {}\"; action-validator \"{}\"'",
|
||||
"lint:knip": "knip",
|
||||
"test": "vitest",
|
||||
"test:watch": "vitest --watch",
|
||||
"coverage": "yarn test --coverage"
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest watch",
|
||||
"coverage": "pnpm test --coverage"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -49,7 +48,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^17.0.0",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^18.1.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^6.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
@@ -58,10 +57,9 @@
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-widget-api": "^1.16.1",
|
||||
"oidc-client-ts": "^3.0.1",
|
||||
"p-retry": "7",
|
||||
"p-retry": "8",
|
||||
"sdp-transform": "^3.0.0",
|
||||
"unhomoglyph": "^1.0.6",
|
||||
"uuid": "13"
|
||||
"unhomoglyph": "^1.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@action-validator/cli": "^0.6.0",
|
||||
@@ -84,7 +82,7 @@
|
||||
"@stylistic/eslint-plugin": "^5.0.0",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/node": "18",
|
||||
"@types/node": "22",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
@@ -107,20 +105,34 @@
|
||||
"fetch-mock": "^12.6.0",
|
||||
"happy-dom": "^20.1.0",
|
||||
"husky": "^9.0.0",
|
||||
"knip": "^5.0.0",
|
||||
"knip": "^6.0.0",
|
||||
"lint-staged": "^16.0.0",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"prettier": "3.8.0",
|
||||
"rimraf": "^6.0.0",
|
||||
"prettier": "3.8.3",
|
||||
"typedoc": "^0.28.1",
|
||||
"typedoc-plugin-coverage": "^4.0.0",
|
||||
"typedoc-plugin-mdn-links": "^5.0.0",
|
||||
"typedoc-plugin-missing-exports": "^4.0.0",
|
||||
"typescript": "^5.4.2",
|
||||
"typescript": "^6.0.0",
|
||||
"vitest": "^4.0.17",
|
||||
"vitest-sonar-reporter": "^3.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"expect": "30.2.0"
|
||||
}
|
||||
"pnpm": {
|
||||
"peerDependencyRules": {
|
||||
"allowedVersions": {
|
||||
"eslint": "8"
|
||||
}
|
||||
},
|
||||
"allowedDeprecatedVersions": {
|
||||
"eslint": "8"
|
||||
},
|
||||
"overrides": {
|
||||
"expect": "30.3.0",
|
||||
"flatted@<=3.4.1": "^3.4.2",
|
||||
"picomatch@>=4.0.0 <4.0.4": "^4.0.4",
|
||||
"yaml@>=2.0.0 <2.8.3": "^2.8.3",
|
||||
"vite": "8.0.8"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
|
||||
}
|
||||
|
||||
Generated
+8353
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
nodeLinker: hoisted
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
encryptGroupSessionKey,
|
||||
encryptMegolmEvent,
|
||||
encryptMegolmEventRawPlainText,
|
||||
encryptOlmEvent,
|
||||
establishOlmSession,
|
||||
getTestOlmAccountKeys,
|
||||
expectSendRoomKey,
|
||||
@@ -2064,6 +2065,7 @@ describe("crypto", () => {
|
||||
expect(hasCrossSigningKeysForUser).toBe(true);
|
||||
|
||||
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(BOB_TEST_USER_ID);
|
||||
expect(verificationStatus.known).toBe(false); // We haven't actually stashed a copy of Alice's identity
|
||||
expect(verificationStatus.isVerified()).toBe(false);
|
||||
expect(verificationStatus.isCrossSigningVerified()).toBe(false);
|
||||
expect(verificationStatus.wasCrossSigningVerified()).toBe(false);
|
||||
@@ -2078,7 +2080,8 @@ describe("crypto", () => {
|
||||
const hasCrossSigningKeysForUser = await aliceClient.getCrypto()!.userHasCrossSigningKeys(TEST_USER_ID);
|
||||
expect(hasCrossSigningKeysForUser).toBe(true);
|
||||
|
||||
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(BOB_TEST_USER_ID);
|
||||
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(TEST_USER_ID);
|
||||
expect(verificationStatus.known).toBe(true);
|
||||
expect(verificationStatus.isVerified()).toBe(false);
|
||||
expect(verificationStatus.isCrossSigningVerified()).toBe(false);
|
||||
expect(verificationStatus.wasCrossSigningVerified()).toBe(false);
|
||||
@@ -2089,7 +2092,8 @@ describe("crypto", () => {
|
||||
const hasCrossSigningKeysForUser = await aliceClient.getCrypto()!.userHasCrossSigningKeys("@unknown:xyz");
|
||||
expect(hasCrossSigningKeysForUser).toBe(false);
|
||||
|
||||
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(BOB_TEST_USER_ID);
|
||||
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus("@unknown:xyz");
|
||||
expect(verificationStatus.known).toBe(false);
|
||||
expect(verificationStatus.isVerified()).toBe(false);
|
||||
expect(verificationStatus.isCrossSigningVerified()).toBe(false);
|
||||
expect(verificationStatus.wasCrossSigningVerified()).toBe(false);
|
||||
@@ -2119,6 +2123,7 @@ describe("crypto", () => {
|
||||
|
||||
{
|
||||
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(BOB_TEST_USER_ID);
|
||||
expect(verificationStatus.known).toBe(true);
|
||||
expect(verificationStatus.isVerified()).toBe(false);
|
||||
expect(verificationStatus.isCrossSigningVerified()).toBe(false);
|
||||
expect(verificationStatus.wasCrossSigningVerified()).toBe(false);
|
||||
@@ -2311,4 +2316,107 @@ describe("crypto", () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
describe("secret pushing", () => {
|
||||
it("should push a new backup key when a new backup key is set", async () => {
|
||||
// setup: alice has another device, DEVICE_ID, which is verified
|
||||
const crypto = aliceClient.getCrypto()!;
|
||||
expectAliceKeyQuery(getTestKeysQueryResponse("@alice:localhost"));
|
||||
await startClientAndAwaitFirstSync();
|
||||
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo(["@alice:localhost"]);
|
||||
expect(devices.get("@alice:localhost")!.keys()).toContain("DEVICE_ID");
|
||||
await crypto.setDeviceVerified("@alice:localhost", "DEVICE_ID");
|
||||
|
||||
expectAliceKeyClaim(getTestKeysClaimResponse("@alice:localhost"));
|
||||
|
||||
// when we set a new backup key
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
|
||||
});
|
||||
fetchMock.post("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 200,
|
||||
body: { version: "1" },
|
||||
});
|
||||
const secretPushPromise = new Promise<any>((resolve) => {
|
||||
fetchMock.putOnce(new RegExp("/sendToDevice/m.room.encrypted/"), (callLog): RouteResponse => {
|
||||
const content = JSON.parse(callLog.options.body as string);
|
||||
resolve(content);
|
||||
return {};
|
||||
});
|
||||
});
|
||||
|
||||
await crypto.resetKeyBackup();
|
||||
|
||||
// we expect the other device to get a secret push
|
||||
const content = await secretPushPromise;
|
||||
const curve25519key = JSON.parse(testOlmAccount.identity_keys()).curve25519;
|
||||
const ciphertext = content.messages["@alice:localhost"].DEVICE_ID.ciphertext[curve25519key];
|
||||
const olmSession = new Olm.Session();
|
||||
olmSession.create_inbound(testOlmAccount, ciphertext.body);
|
||||
const decrypted = JSON.parse(olmSession.decrypt(0, ciphertext.body));
|
||||
expect(decrypted.type).toBe("io.element.msc4385.secret.push");
|
||||
expect(decrypted.content.name).toBe("m.megolm_backup.v1");
|
||||
});
|
||||
|
||||
it("should receive pushed backup key", async () => {
|
||||
// setup: alice has another device, DEVICE_ID, which is verified,
|
||||
// and has a key backup set up and signed by DEVICE_ID
|
||||
const crypto = aliceClient.getCrypto()!;
|
||||
expectAliceKeyQuery(getTestKeysQueryResponse("@alice:localhost"));
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
await startClientAndAwaitFirstSync();
|
||||
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo(["@alice:localhost"]);
|
||||
expect(devices.get("@alice:localhost")!.keys()).toContain("DEVICE_ID");
|
||||
await crypto.setDeviceVerified("@alice:localhost", "DEVICE_ID");
|
||||
|
||||
expectAliceKeyClaim(getTestKeysClaimResponse("@alice:localhost"));
|
||||
|
||||
// after we push the backup key to alice...
|
||||
|
||||
const senderIdentityKeys = JSON.parse(testOlmAccount.identity_keys());
|
||||
const aliceDeviceKeys = await crypto.getOwnDeviceKeys();
|
||||
const p2pSession = await createOlmSession(testOlmAccount, keyReceiver);
|
||||
const secretPush = encryptOlmEvent({
|
||||
sender: "@alice:localhost",
|
||||
senderKey: senderIdentityKeys.curve25519,
|
||||
senderSigningKey: senderIdentityKeys.ed25519,
|
||||
p2pSession,
|
||||
recipient: "@alice:localhost",
|
||||
recipientCurve25519Key: aliceDeviceKeys.curve25519,
|
||||
recipientEd25519Key: aliceDeviceKeys.ed25519,
|
||||
plaincontent: {
|
||||
secret: testData.BACKUP_DECRYPTION_KEY_BASE64,
|
||||
name: "m.megolm_backup.v1",
|
||||
},
|
||||
plaintype: "io.element.msc4385.secret.push",
|
||||
});
|
||||
|
||||
const syncResponse = {
|
||||
next_batch: 1,
|
||||
to_device: {
|
||||
events: [secretPush],
|
||||
},
|
||||
};
|
||||
|
||||
const backupKeyReceivedPromise = new Promise<string>((resolve) => {
|
||||
aliceClient.on(CryptoEvent.KeyBackupDecryptionKeyCached, resolve);
|
||||
});
|
||||
const keyBackupEnabledPromise = new Promise<void>((resolve) => {
|
||||
aliceClient.on(CryptoEvent.KeyBackupStatus, (enabled) => {
|
||||
if (enabled) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// alice should be using backup now
|
||||
expect(await backupKeyReceivedPromise).toBe(testData.SIGNED_BACKUP_DATA.version);
|
||||
await keyBackupEnabledPromise;
|
||||
expect(await crypto.getActiveSessionBackupVersion()).toBe(testData.SIGNED_BACKUP_DATA.version);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,13 @@ import { advanceTimersUntil, awaitDecryption, syncPromise } from "../../test-uti
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { type KeyBackupInfo, type KeyBackupSession } from "../../../src/crypto-api/keybackup";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import { decodeRecoveryKey, DecryptionFailureCode, CryptoEvent, type CryptoApi } from "../../../src/crypto-api";
|
||||
import {
|
||||
decodeRecoveryKey,
|
||||
DecryptionFailureCode,
|
||||
CryptoEvent,
|
||||
type CryptoApi,
|
||||
DecryptionKeyDoesNotMatchError,
|
||||
} from "../../../src/crypto-api";
|
||||
import { type KeyBackup } from "../../../src/rust-crypto/backup.ts";
|
||||
|
||||
const ROOM_ID = testData.TEST_ROOM_ID;
|
||||
@@ -502,15 +508,10 @@ describe("megolm-keys backup", () => {
|
||||
// @ts-ignore - mock a private method for testing purpose
|
||||
vi.spyOn(aliceCrypto.secretStorage, "get").mockResolvedValue(testData.BACKUP_DECRYPTION_KEY_BASE64);
|
||||
|
||||
const fullBackup = {
|
||||
rooms: {
|
||||
[ROOM_ID]: {
|
||||
sessions: {
|
||||
[testData.MEGOLM_SESSION_DATA.session_id]: testData.CURVE25519_KEY_BACKUP_DATA,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const fullBackup = createFullBackup(
|
||||
testData.MEGOLM_SESSION_DATA.session_id,
|
||||
testData.CURVE25519_KEY_BACKUP_DATA,
|
||||
);
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup);
|
||||
|
||||
await aliceCrypto.loadSessionBackupPrivateKeyFromSecretStorage();
|
||||
@@ -521,9 +522,38 @@ describe("megolm-keys backup", () => {
|
||||
expect(result.imported).toStrictEqual(1);
|
||||
});
|
||||
|
||||
it("Should throw an error if the decryption key does not match the backup", async function () {
|
||||
// Given the stored backup decryption key does not match the public backup info
|
||||
// @ts-ignore - mock a private method for testing purpose
|
||||
vi.spyOn(aliceCrypto.secretStorage, "get").mockResolvedValue(testData.BACKUP_DECRYPTION_KEY_BASE64_ALT);
|
||||
|
||||
const fullBackup = createFullBackup(
|
||||
testData.MEGOLM_SESSION_DATA.session_id,
|
||||
testData.CURVE25519_KEY_BACKUP_DATA,
|
||||
);
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup);
|
||||
|
||||
// When we load that key, we throw because the keys don't match
|
||||
await expect(aliceCrypto.loadSessionBackupPrivateKeyFromSecretStorage()).rejects.toThrow(
|
||||
DecryptionKeyDoesNotMatchError,
|
||||
);
|
||||
});
|
||||
|
||||
it("Should throw an error if the decryption key is not found in cache", async () => {
|
||||
await expect(aliceCrypto.restoreKeyBackup()).rejects.toThrow("No decryption key found in crypto store");
|
||||
});
|
||||
|
||||
function createFullBackup(sessionId: string, data: KeyBackupSession) {
|
||||
return {
|
||||
rooms: {
|
||||
[ROOM_ID]: {
|
||||
sessions: {
|
||||
[sessionId]: data,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
describe("backupLoop", () => {
|
||||
|
||||
@@ -23,7 +23,13 @@ import * as testUtils from "../../test-utils/test-utils";
|
||||
import { getSyncResponse, syncPromise } from "../../test-utils/test-utils";
|
||||
import { TEST_ROOM_ID as ROOM_ID } from "../../test-utils/test-data";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { createClient, PendingEventOrdering, type IStartClientOpts, type MatrixClient } from "../../../src/matrix";
|
||||
import {
|
||||
createClient,
|
||||
HistoryVisibility,
|
||||
PendingEventOrdering,
|
||||
type IStartClientOpts,
|
||||
type MatrixClient,
|
||||
} from "../../../src/matrix";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { type ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
|
||||
@@ -197,7 +203,7 @@ describe("Encrypted State Events", () => {
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
// Alice shares a room with Bob
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"], ROOM_ID, true));
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"], HistoryVisibility.Joined, ROOM_ID, true));
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// ... and claim one of Bob's OTKs ...
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -84,9 +84,9 @@ def main() -> None:
|
||||
* Do not edit by hand! This file is generated by `./generate-test-data.py`
|
||||
*/
|
||||
|
||||
import {{ IDeviceKeys, IMegolmSessionData }} from "../../../src/@types/crypto";
|
||||
import {{ IDownloadKeyResult, IEvent }} from "../../../src";
|
||||
import {{ KeyBackupSession, KeyBackupInfo }} from "../../../src/crypto-api/keybackup";
|
||||
import type {{ IDeviceKeys, IMegolmSessionData }} from "../../../src/@types/crypto";
|
||||
import type {{ IDownloadKeyResult, IEvent }} from "../../../src";
|
||||
import type {{ KeyBackupSession, KeyBackupInfo, KeyBackupRoomSessions }} from "../../../src/crypto-api/keybackup";
|
||||
|
||||
/* eslint-disable comma-dangle */
|
||||
|
||||
@@ -246,15 +246,6 @@ export const {prefix}SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult>
|
||||
/** Signed OTKs, returned by `POST /keys/claim` */
|
||||
export const {prefix}ONE_TIME_KEYS = { json.dumps(otks, indent=4) };
|
||||
|
||||
/** base64-encoded backup decryption (private) key */
|
||||
export const {prefix}BACKUP_DECRYPTION_KEY_BASE64 = "{ user_data['B64_BACKUP_DECRYPTION_KEY'] }";
|
||||
|
||||
/** Backup decryption key in export format */
|
||||
export const {prefix}BACKUP_DECRYPTION_KEY_BASE58 = "{ backup_recovery_key }";
|
||||
|
||||
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{{roomId}}/{{sessionId}}` */
|
||||
export const {prefix}SIGNED_BACKUP_DATA: KeyBackupInfo = { json.dumps(backup_data, indent=4) };
|
||||
|
||||
/** A set of megolm keys that can be imported via CryptoAPI#importRoomKeys */
|
||||
export const {prefix}MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = {
|
||||
json.dumps(set_of_exported_room_keys, indent=4)
|
||||
@@ -278,6 +269,23 @@ export const {prefix}CLEAR_EVENT: Partial<IEvent> = {json.dumps(clear_event, ind
|
||||
|
||||
/** The encrypted CLEAR_EVENT by MEGOLM_SESSION_DATA */
|
||||
export const {prefix}ENCRYPTED_EVENT: Partial<IEvent> = {json.dumps(encrypted_event, indent=4)};
|
||||
|
||||
/** base64-encoded backup decryption (private) key */
|
||||
export const {prefix}BACKUP_DECRYPTION_KEY_BASE64 = "{ user_data['B64_BACKUP_DECRYPTION_KEY'] }";
|
||||
|
||||
/** Backup decryption key in export format */
|
||||
export const {prefix}BACKUP_DECRYPTION_KEY_BASE58 = "{ backup_recovery_key }";
|
||||
|
||||
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{{roomId}}/{{sessionId}}` */
|
||||
export const {prefix}SIGNED_BACKUP_DATA: KeyBackupInfo = { json.dumps(backup_data, indent=4) };
|
||||
|
||||
/**
|
||||
* Per-room backup data, (supposedly) suitable for return from `GET /_matrix/client/v3/room_keys/keys/{{roomId}}`.
|
||||
* Contains the key from {prefix}MEGOLM_SESSION_DATA.
|
||||
*/
|
||||
export const {prefix}PER_ROOM_CURVE25519_KEY_BACKUP_DATA: KeyBackupRoomSessions = {{
|
||||
[{prefix}MEGOLM_SESSION_DATA.session_id]: {prefix}CURVE25519_KEY_BACKUP_DATA
|
||||
}};
|
||||
"""
|
||||
|
||||
alt_master_key = user_data.get("ALT_MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES")
|
||||
@@ -385,7 +393,7 @@ def sign_json(json_object: dict, private_key: ed25519.Ed25519PrivateKey) -> str:
|
||||
def build_exported_megolm_key(device_curve_key: x25519.X25519PrivateKey) -> tuple[dict, ed25519.Ed25519PrivateKey]:
|
||||
"""
|
||||
Creates an exported megolm room key, as per https://gitlab.matrix.org/matrix-org/olm/blob/master/docs/megolm.md#session-export-format
|
||||
that can be imported via importRoomKeys API.
|
||||
that can be imported via importRoomKeys API, or shared via MSC4268 room history sharing.
|
||||
Returns the exported key, the matching privat edKey (needed to encrypt)
|
||||
"""
|
||||
index = 0
|
||||
@@ -409,11 +417,12 @@ def build_exported_megolm_key(device_curve_key: x25519.X25519PrivateKey) -> tupl
|
||||
"session_id": encode_base64(
|
||||
private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
),
|
||||
"session_key": encode_base64(exported_key),
|
||||
"session_key": encode_base64(bytes(exported_key)),
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": encode_base64(ed25519.Ed25519PrivateKey.from_private_bytes(randbytes(32)).public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)),
|
||||
},
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
"m.shared_history": True,
|
||||
}
|
||||
|
||||
return megolm_export, private_key
|
||||
@@ -458,7 +467,7 @@ def symetric_ratchet_step_of_megolm_key(previous: dict , megolm_private_key: ed2
|
||||
"room_id": "!room:id",
|
||||
"sender_key": previous["sender_key"],
|
||||
"session_id": previous["session_id"],
|
||||
"session_key": encode_base64(exported_key),
|
||||
"session_key": encode_base64(bytes(exported_key)),
|
||||
"sender_claimed_keys": previous["sender_claimed_keys"],
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
}
|
||||
@@ -609,7 +618,7 @@ def generate_encrypted_event_content(exported_key: dict, ed_key: ed25519.Ed25519
|
||||
|
||||
message += signature
|
||||
|
||||
cipher_text = encode_base64(message)
|
||||
cipher_text = encode_base64(bytes(message))
|
||||
|
||||
encrypted_payload = {
|
||||
"algorithm" : "m.megolm.v1.aes-sha2",
|
||||
@@ -653,7 +662,7 @@ def export_recovery_key(key_b64: str) -> str:
|
||||
export_bytes += parity_byte.to_bytes(1, 'big')
|
||||
|
||||
# The byte string is encoded using base58
|
||||
recovery_key = base58.b58encode(export_bytes).decode('utf-8')
|
||||
recovery_key = base58.b58encode(bytes(export_bytes)).decode('utf-8')
|
||||
|
||||
split = [recovery_key[i:i + 4] for i in range(0, len(recovery_key), 4)]
|
||||
return ' '.join(split)
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* Do not edit by hand! This file is generated by `./generate-test-data.py`
|
||||
*/
|
||||
|
||||
import { type IDeviceKeys, type IMegolmSessionData } from "../../../src/@types/crypto";
|
||||
import { type IDownloadKeyResult, type IEvent } from "../../../src";
|
||||
import { type KeyBackupSession, type KeyBackupInfo } from "../../../src/crypto-api/keybackup";
|
||||
import type { IDeviceKeys, IMegolmSessionData } from "../../../src/@types/crypto";
|
||||
import type { IDownloadKeyResult, IEvent } from "../../../src";
|
||||
import type { KeyBackupSession, KeyBackupInfo, KeyBackupRoomSessions } from "../../../src/crypto-api/keybackup";
|
||||
|
||||
/* eslint-disable comma-dangle */
|
||||
|
||||
@@ -118,26 +118,6 @@ export const ONE_TIME_KEYS = {
|
||||
}
|
||||
};
|
||||
|
||||
/** base64-encoded backup decryption (private) key */
|
||||
export const BACKUP_DECRYPTION_KEY_BASE64 = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
|
||||
|
||||
/** Backup decryption key in export format */
|
||||
export const BACKUP_DECRYPTION_KEY_BASE58 = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d";
|
||||
|
||||
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
|
||||
export const SIGNED_BACKUP_DATA: KeyBackupInfo = {
|
||||
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
"version": "1",
|
||||
"auth_data": {
|
||||
"public_key": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
"signatures": {
|
||||
"@alice:localhost": {
|
||||
"ed25519:test_device": "KDSNeumirTsd8piI0oVfv/wzg4J4HlEc7rs5XhODFcJ/YAcUdg65ajsZG+rLI0TQOSSGjorJqcrSiSB1HRSCAA"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** A set of megolm keys that can be imported via CryptoAPI#importRoomKeys */
|
||||
export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
|
||||
{
|
||||
@@ -149,7 +129,8 @@ export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
"org.matrix.msc3061.shared_history": true
|
||||
},
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
@@ -160,7 +141,8 @@ export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "IrkbT6H+0urDf6wKDSyVC1fh1t84Vz6T62snni86Cog"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
"org.matrix.msc3061.shared_history": true
|
||||
}
|
||||
];
|
||||
|
||||
@@ -174,7 +156,8 @@ export const MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "Bhbpt6hqMZlSH4sJV7xiEEEiPVeTWz4Vkujl1EMdIPI"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
"org.matrix.msc3061.shared_history": true
|
||||
};
|
||||
|
||||
/** A ratcheted version of MEGOLM_SESSION_DATA */
|
||||
@@ -196,7 +179,7 @@ export const CURVE25519_KEY_BACKUP_DATA: KeyBackupSession = {
|
||||
"forwarded_count": 0,
|
||||
"is_verified": false,
|
||||
"session_data": {
|
||||
"ciphertext": "r6HRk2/Im2yJe5cLP8R81aVjFWjYWPHpw7TVxphiSK1cdIDZTTK57r6MfU+0i/mTPn+/PosT74OvYwCnehy2d1BPGxhDl8AhPcBu3//Kzlq2o5CssPsw+88gRehkAsPg9Zp5G9sL9to6giltvTWTbsaQpmvv3HLmBOYSFIxvyZrOT/Ffqu325f0IEsKcyV2BdIkw8Ob9Xt+VWoe4MYEGG6y1T8W125zeFgKWI4Ow76uput64H9zZjIo+Cc+hCTO9Ea4EnosSjizCotevkNck7C/zGgfhBikiohROb6SbaZgxicSsEDZ+f7brnri9yP3iXS3PMDHHpa1+XzG2VOG/Y9OQZpkPq+pbLrCC+NWJeJPslDAK5i+RURwzjnPmaHKCRHTq86CwhFyiCDf61MGwCY3xjrmBJg44BCdxWqCx0YJvwsvVqqnl4vTieUfrwThNPsQ81aVkDHvlmrgrTt8icDa8jTJhu34jem+pbRSEM5aJikV4B+zYiLz+dH/v6UpYA2eG8ReOvwpPXp6CAcIlplRPpWbMBeLFVcPkT4KAXTp9exFpB4on4pf8OsaDomlt4qAA0rhAZmhPWPKcU/A0Tz4gyMu54OivVtw1SPj+5Iq+YDQ8jB6Po3ApzMf6fwF9x/FjevbboFB05X2Jr0NrbFqXMOUwXHMgDAGiIWX8+gkmmbaiNWqg2etjN94pobQSGZelb18XGN7kuwMk+Zwk7A",
|
||||
"ciphertext": "r6HRk2/Im2yJe5cLP8R81aVjFWjYWPHpw7TVxphiSK1cdIDZTTK57r6MfU+0i/mTPn+/PosT74OvYwCnehy2d/r0NTff1SQt+1GopZkT0nq6jF5Wh/oX+8iwtYjHvTxMpN1UQoXAvRF40O+EVg+Q3efJXh1t45cMco8EWU64VerOir+k7cQ3C9FtcgQw3kmz3s3HeVY10o13X/w6+rc8n6vXqxuIxYHnFxanxX8B6TgTMZNajNfVsmJV0aC1aezim7E2gsftc+6+zW5G+rCFaEsWV/IuSOUz0+Hh0U+7hzSrz9/4qXPEVmPy1f6Ll4hhquPAlXPVDwddqlJDYj7kmvzr1g3bKVpk+TtKDbWlVQDPaJx2DEI2jGkPYjhYb7okpTFKpUny94dZmFIQqCeSGPIniaq8Y+/CanugQ1ZRVQcThuXrTewqWhXcpVvkVHT9i4ImcpBl95HzCBXuiwSUv6FKvO25fp++w555rbn2piFtilrUwnkrZPW32jFuaQcKZF4mZwcLeH7POL5UCuS4TWyaKyArp7bRzXwWuIq1wPET2nAMUmUVL7ge2+tAevk1WOIsjLgSaz/g55wO3Yma7yhXRFKcnzTjS0hUQOZ3GfTNwCM4pjzAtIPzvVd4Fp0b1emWZS5WyOYdXsceEDi3c6WtkoHWOKhPU0zBzn8hA9TdlFFqKzf2QFbN5Zgg0gprDLnLWgpc3/ieI4C7ndEQ7ZeTNMXbT/Y10APFk3qO+IGkLXJ97/qTF41EXFDhlsL0",
|
||||
"ephemeral": "q+P1WdRtEiPIEtNuuGrRcueZxUbLnSKdsuTAkxewXgU",
|
||||
"mac": "OibmACbORhI"
|
||||
}
|
||||
@@ -229,6 +212,37 @@ export const ENCRYPTED_EVENT: Partial<IEvent> = {
|
||||
"origin_server_ts": 1507753886000
|
||||
};
|
||||
|
||||
/** base64-encoded backup decryption (private) key that matches the public key in CURVE25519_KEY_BACKUP_DATA */
|
||||
export const BACKUP_DECRYPTION_KEY_BASE64 = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
|
||||
|
||||
/** base64-encoded backup decryption (private) key that does not match the public key in CURVE25519_KEY_BACKUP_DATA */
|
||||
export const BACKUP_DECRYPTION_KEY_BASE64_ALT = "dh4fP2LITyJusgnb0dEq/SQK253WGObvLxXF5FEX6qc";
|
||||
|
||||
/** Backup decryption key in export format */
|
||||
export const BACKUP_DECRYPTION_KEY_BASE58 = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d";
|
||||
|
||||
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
|
||||
export const SIGNED_BACKUP_DATA: KeyBackupInfo = {
|
||||
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
"version": "1",
|
||||
"auth_data": {
|
||||
"public_key": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
"signatures": {
|
||||
"@alice:localhost": {
|
||||
"ed25519:test_device": "KDSNeumirTsd8piI0oVfv/wzg4J4HlEc7rs5XhODFcJ/YAcUdg65ajsZG+rLI0TQOSSGjorJqcrSiSB1HRSCAA"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-room backup data, (supposedly) suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}`.
|
||||
* Contains the key from MEGOLM_SESSION_DATA.
|
||||
*/
|
||||
export const PER_ROOM_CURVE25519_KEY_BACKUP_DATA: KeyBackupRoomSessions = {
|
||||
[MEGOLM_SESSION_DATA.session_id]: CURVE25519_KEY_BACKUP_DATA
|
||||
};
|
||||
|
||||
// Bob data
|
||||
|
||||
export const BOB_TEST_USER_ID = "@bob:xyz";
|
||||
@@ -338,26 +352,6 @@ export const BOB_ONE_TIME_KEYS = {
|
||||
}
|
||||
};
|
||||
|
||||
/** base64-encoded backup decryption (private) key */
|
||||
export const BOB_BACKUP_DECRYPTION_KEY_BASE64 = "DwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
|
||||
|
||||
/** Backup decryption key in export format */
|
||||
export const BOB_BACKUP_DECRYPTION_KEY_BASE58 = "EsT5 Sd5m mEXs NQYE ibRe 3q9E 4aXW rHih 5f9J 6rU6 AfwY mASR";
|
||||
|
||||
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
|
||||
export const BOB_SIGNED_BACKUP_DATA: KeyBackupInfo = {
|
||||
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
"version": "1",
|
||||
"auth_data": {
|
||||
"public_key": "ZRuVWcWlDuvOwZRygccUCD4Avtnt130800I+WQNwwRY",
|
||||
"signatures": {
|
||||
"@bob:xyz": {
|
||||
"ed25519:bob_device": "lDIMj3VC0WazE2FamGHpmbiqKf9Z4pO4qapZ5TL5BnD3c+dvb+2waOEd6pgay/pmrQ6MW4Eu2KDEpe1fnHc3BA"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** A set of megolm keys that can be imported via CryptoAPI#importRoomKeys */
|
||||
export const BOB_MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
|
||||
{
|
||||
@@ -369,7 +363,8 @@ export const BOB_MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "F4P7f1Z0RjbiZMgHk1xBCG3KC4/Ng9PmxLJ4hQ13sHA"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
"org.matrix.msc3061.shared_history": true
|
||||
},
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
@@ -380,7 +375,8 @@ export const BOB_MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "OsZMdC1gQ5nPr+L9tuT6xXsaFJkVPkgxP2FexHF1/QM"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
"org.matrix.msc3061.shared_history": true
|
||||
}
|
||||
];
|
||||
|
||||
@@ -394,7 +390,8 @@ export const BOB_MEGOLM_SESSION_DATA: IMegolmSessionData = {
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "zBdpQwWYyz1MkZuEUhXqcdMfUNN/B9psLFDDDTJOg64"
|
||||
},
|
||||
"forwarding_curve25519_key_chain": []
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
"org.matrix.msc3061.shared_history": true
|
||||
};
|
||||
|
||||
/** A ratcheted version of BOB_MEGOLM_SESSION_DATA */
|
||||
@@ -416,7 +413,7 @@ export const BOB_CURVE25519_KEY_BACKUP_DATA: KeyBackupSession = {
|
||||
"forwarded_count": 0,
|
||||
"is_verified": false,
|
||||
"session_data": {
|
||||
"ciphertext": "d7UVOK17WEVky/8hK0h3HsTQrFMEbKbfqMcl2KtyTWcI9S5gGFWK9Git5BzVRxRggvxQ0c8PDfqL+dr3zHytAMW+71BJqIPQW910vV7SX3IcGylnoUcS3doVkJZiprXytXMP89AKcgv5Dj7mS2ZdvNGE+Atro74bzZ5yot5BrE0ZE5SjoUBPLaLMMu9HopLIV+qx01Rc3F0wmkocSPo51N0nv6wvO5Cst0FiOGHDK6r1pFlgDEJLmBkOyC4e8oMVbKTJzsSQVbJ8tJ37xuhI+T5P0ZlmiqKDqYRp8uh50w+txLEixYhEUunFgCTt1DAmiS9pLNYhLyl1ggwuQjzZe+AV6timbRxNJy18/AEcPomJw7z/pxYIiNLHRKOC13Wp8kGWx9cOgfMQ5KmBuLS8psGiLTBkfWPLOfNYqjbeqAR+OGZQoS6hUjbBYU7QuFa4FOYBHkNB2UqNsdsMb9qB/qs7QGTSb8Lok5YjW1c81BUpmIyKvuqnKma0MZskrpTYGQD2eJDABFCZwLFm+LgDyUTeSiV5xguYztLrHOk8LHKo9M8dIZgoBjeFVJxyjbcXKsVS3aQkMXKCrRlKLqhZTws/ZJwVfW9DbktZ9dT+tRZQvI7tjJofojcLX61AGJDnqUf5+2Gv1tEnmUI953gIzc8NlcFabPOsDsZEODt7MdOCTPT3w29umyhKbCsslpb64LoS/AB2QRPRCgkJS7snRA",
|
||||
"ciphertext": "d7UVOK17WEVky/8hK0h3HsTQrFMEbKbfqMcl2KtyTWcI9S5gGFWK9Git5BzVRxRggvxQ0c8PDfqL+dr3zHytAA7TEpHlx8Ks23hCqXmVW710VjqK2K9xnWCyJvkHfE8x0w6AYvffDj+tRVP8C8M7t4849rD2itn0uma+YMkvjG/nANUTxG1dBf3oUOZ673vflCPoaz7s7x9ZNhYDVSVH5JTdMgNwwN42R5dqqxnGTu516tJzJh/9BWvyD9oIPWJ8X0rt1sbzEJ3PZeBXcSy8GTlZ1SgSFjeiXlwYxOZCaX2sxprk4N1oI1db6g+wCDBhbCGGucJIlTDJna/h9/C5J4drGd/fkisG3SidUmJXXCyInhs/BhwjGAtTGeQS8j7R8UnJxhMulYBHSckzj0Kas71LElPp8W8M4Jq81APA03n5UfYB+U6jbxjDgf8OJnxGQyrteq9F2+SEvS/TwHe1pE3t6EM2mDYRoYDTpU5pTNYSJkGIQMfWJKRxxuWUGs29o1twewJ6dhHgm+SlCII0M7ESoVdV54vxZCvHZnPcR0NXDzal7ils7zBKJmamHfPQBuaqNPU3KmSo+5R8ngFPaWU5LbWqYp/WxSBfNCoLZ7Jf8Io5uitjXTATR2qy2r6l/RJmk3RlfP51kliQqI2TWqRF96oaB96IGgUGSFCX/2pv0psOBGc1SjfmMB3d7gYis+2iBYVbG3xmnpeXbqvlD0Lw9TiTIPkjhJkTW1+lXyhy1xVH9ZmcFamcL7bX15Jx",
|
||||
"ephemeral": "oO0VX84OUIzm2i/12zAhTWOZT5IFRH5mXaKZ8fXkCgU",
|
||||
"mac": "lEfHlqfJQwU"
|
||||
}
|
||||
@@ -449,6 +446,34 @@ export const BOB_ENCRYPTED_EVENT: Partial<IEvent> = {
|
||||
"origin_server_ts": 1507753886000
|
||||
};
|
||||
|
||||
/** base64-encoded backup decryption (private) key */
|
||||
export const BOB_BACKUP_DECRYPTION_KEY_BASE64 = "DwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
|
||||
|
||||
/** Backup decryption key in export format */
|
||||
export const BOB_BACKUP_DECRYPTION_KEY_BASE58 = "EsT5 Sd5m mEXs NQYE ibRe 3q9E 4aXW rHih 5f9J 6rU6 AfwY mASR";
|
||||
|
||||
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
|
||||
export const BOB_SIGNED_BACKUP_DATA: KeyBackupInfo = {
|
||||
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
"version": "1",
|
||||
"auth_data": {
|
||||
"public_key": "ZRuVWcWlDuvOwZRygccUCD4Avtnt130800I+WQNwwRY",
|
||||
"signatures": {
|
||||
"@bob:xyz": {
|
||||
"ed25519:bob_device": "lDIMj3VC0WazE2FamGHpmbiqKf9Z4pO4qapZ5TL5BnD3c+dvb+2waOEd6pgay/pmrQ6MW4Eu2KDEpe1fnHc3BA"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-room backup data, (supposedly) suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}`.
|
||||
* Contains the key from BOB_MEGOLM_SESSION_DATA.
|
||||
*/
|
||||
export const BOB_PER_ROOM_CURVE25519_KEY_BACKUP_DATA: KeyBackupRoomSessions = {
|
||||
[BOB_MEGOLM_SESSION_DATA.session_id]: BOB_CURVE25519_KEY_BACKUP_DATA
|
||||
};
|
||||
|
||||
/** A second set of signed cross-signing keys data, also suitable for returning from a `/keys/query` call */
|
||||
export const BOB_ALT_SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
|
||||
"master_keys": {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
ClientEvent,
|
||||
EventType,
|
||||
HistoryVisibility,
|
||||
type IJoinedRoom,
|
||||
type IPusher,
|
||||
type ISyncResponse,
|
||||
@@ -57,14 +58,19 @@ export function syncPromise(client: MatrixClient, count = 1): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a sync response which contains a single room (by default TEST_ROOM_ID), with the members given
|
||||
* @param roomMembers
|
||||
* @param roomId
|
||||
* Return a sync response which contains a single room (by default `TEST_ROOM_ID`), with the members given
|
||||
* and history visibility set to `shared`.
|
||||
*
|
||||
* @returns the sync response
|
||||
* @param roomMembers - An array of user IDs representing the members of the room.
|
||||
* @param roomHistoryVisibility - The history visibility setting for the room. Defaults to `shared`.
|
||||
* @param roomId - The ID of the room. Defaults to `TEST_ROOM_ID`.
|
||||
* @param encryptStateEvents - A boolean indicating whether state events should be encrypted. Defaults to `false`.
|
||||
*
|
||||
* @returns The sync response object containing the room data.
|
||||
*/
|
||||
export function getSyncResponse(
|
||||
roomMembers: string[],
|
||||
roomHistoryVisibility: HistoryVisibility = HistoryVisibility.Shared,
|
||||
roomId = TEST_ROOM_ID,
|
||||
encryptStateEvents = false,
|
||||
): ISyncResponse {
|
||||
@@ -85,6 +91,14 @@ export function getSyncResponse(
|
||||
"io.element.msc4362.encrypt_state_events": encryptStateEvents,
|
||||
},
|
||||
}),
|
||||
mkEventCustom({
|
||||
sender: roomMembers[0],
|
||||
type: "m.room.history_visibility",
|
||||
state_key: "",
|
||||
content: {
|
||||
history_visibility: roomHistoryVisibility,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
timeline: {
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("NamespacedValue", () => {
|
||||
});
|
||||
|
||||
it("should have a falsey unstable if needed", () => {
|
||||
const ns = new NamespacedValue("stable");
|
||||
const ns = new NamespacedValue("stable", null);
|
||||
expect(ns.name).toBe(ns.stable);
|
||||
expect(ns.altName).toBeFalsy();
|
||||
expect(ns.names).toEqual([ns.stable]);
|
||||
|
||||
@@ -3940,6 +3940,31 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
expect(httpLookups.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should handle no jwks_uri", async () => {
|
||||
const { jwks_uri: _, ...metadata } = mockOpenIdConfiguration();
|
||||
httpLookups = [
|
||||
{
|
||||
method: "GET",
|
||||
path: `/auth_metadata`,
|
||||
error: new MatrixError({ errcode: "M_UNRECOGNIZED" }, 404),
|
||||
prefix: "/_matrix/client/unstable/org.matrix.msc2965",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: `/auth_issuer`,
|
||||
data: { issuer: metadata.issuer },
|
||||
prefix: "/_matrix/client/unstable/org.matrix.msc2965",
|
||||
},
|
||||
];
|
||||
fetchMock.get("https://auth.org/.well-known/openid-configuration", metadata);
|
||||
|
||||
await expect(client.getAuthMetadata()).resolves.toEqual({
|
||||
...metadata,
|
||||
signingKeys: null,
|
||||
});
|
||||
expect(httpLookups.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("identityHashedLookup", () => {
|
||||
@@ -4001,4 +4026,37 @@ describe("MatrixClient", function () {
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe("getUrlPreview", () => {
|
||||
it("makes a well-formed request to the new endpoint", async () => {
|
||||
client.getVersions = vi.fn().mockResolvedValue({
|
||||
versions: ["v1.11"],
|
||||
});
|
||||
httpLookups = [
|
||||
{
|
||||
method: "GET",
|
||||
path: `/media/preview_url`,
|
||||
expectQueryParams: { url: "https://example.org/", ts: "60000" },
|
||||
data: { "og:title": "Test" },
|
||||
prefix: "/_matrix/client/v1",
|
||||
},
|
||||
];
|
||||
expect(await client.getUrlPreview("https://example.org", 60000)).toEqual({
|
||||
"og:title": "Test",
|
||||
});
|
||||
});
|
||||
it("makes a well-formed request to the old endpoint", async () => {
|
||||
httpLookups = [
|
||||
{
|
||||
method: "GET",
|
||||
path: `/preview_url`,
|
||||
expectQueryParams: { url: "https://example.org/", ts: "60000" },
|
||||
data: { "og:title": "Test" },
|
||||
prefix: "/_matrix/media/v3",
|
||||
},
|
||||
];
|
||||
expect(await client.getUrlPreview("https://example.org", 60000)).toEqual({
|
||||
"og:title": "Test",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2023-2026 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.
|
||||
@@ -14,23 +14,10 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type IContent, type MatrixEvent } from "../../../src";
|
||||
import {
|
||||
CallMembership,
|
||||
type SessionMembershipData,
|
||||
DEFAULT_EXPIRE_DURATION,
|
||||
type RtcMembershipData,
|
||||
} from "../../../src/matrixrtc/CallMembership";
|
||||
import { membershipTemplate } from "./mocks";
|
||||
|
||||
function makeMockEvent(originTs = 0): MatrixEvent {
|
||||
return {
|
||||
getTs: vi.fn().mockReturnValue(originTs),
|
||||
getSender: vi.fn().mockReturnValue("@alice:example.org"),
|
||||
getId: vi.fn().mockReturnValue("$eventid"),
|
||||
getContent: vi.fn().mockReturnValue({}),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
import { type RtcMembershipData, type SessionMembershipData } from "../../../src/matrixrtc/membershipData/index.ts";
|
||||
import { type IContent, type MatrixEvent } from "../../../src/models/event.ts";
|
||||
import { EventType } from "../../../src/@types/event.ts";
|
||||
import { CallMembership, DEFAULT_EXPIRE_DURATION } from "../../../src/matrixrtc/CallMembership.ts";
|
||||
|
||||
function createCallMembership(ev: MatrixEvent, content: IContent): CallMembership {
|
||||
vi.mocked(ev.getContent).mockReturnValue(content);
|
||||
@@ -40,6 +27,15 @@ function createCallMembership(ev: MatrixEvent, content: IContent): CallMembershi
|
||||
|
||||
describe("CallMembership", () => {
|
||||
describe("SessionMembershipData", () => {
|
||||
function makeMockEvent(originTs = 0): MatrixEvent {
|
||||
return {
|
||||
getTs: vi.fn().mockReturnValue(originTs),
|
||||
getSender: vi.fn().mockReturnValue("@alice:example.org"),
|
||||
getId: vi.fn().mockReturnValue("$eventid"),
|
||||
getContent: vi.fn().mockReturnValue({}),
|
||||
getType: vi.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
@@ -158,10 +154,35 @@ describe("CallMembership", () => {
|
||||
expect(membership.eventId).toBe("$eventid");
|
||||
});
|
||||
it("returns correct slot_id", () => {
|
||||
// slot_id is application and call_id dependent. So we create
|
||||
// a membership for each possible combination
|
||||
|
||||
// non call application (should not alter call_id even with empty string)
|
||||
const nonCallMembership = createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
application: "m.not.a.call",
|
||||
call_id: "",
|
||||
});
|
||||
// non "" call id should not be altered
|
||||
const callMembershipCustomId = createCallMembership(makeMockEvent(), {
|
||||
...membershipTemplate,
|
||||
call_id: "customCallId",
|
||||
});
|
||||
|
||||
// for membership (application = m.call and call_id = "") we expect "" -> ROOM
|
||||
// for legacy events we expect the room to be added automagically
|
||||
// See INFO_SLOT_ID_LEGACY_CASE comments
|
||||
expect(membership.slotId).toBe("m.call#ROOM");
|
||||
expect(membership.slotDescription).toStrictEqual({ id: "ROOM", application: "m.call" });
|
||||
|
||||
expect(nonCallMembership.slotId).toBe("m.not.a.call#");
|
||||
expect(nonCallMembership.slotDescription).toStrictEqual({ id: "", application: "m.not.a.call" });
|
||||
|
||||
expect(callMembershipCustomId.slotId).toBe("m.call#customCallId");
|
||||
expect(callMembershipCustomId.slotDescription).toStrictEqual({
|
||||
id: "customCallId",
|
||||
application: "m.call",
|
||||
});
|
||||
});
|
||||
it("returns correct deviceId", () => {
|
||||
expect(membership.deviceId).toBe("AAAAAAA");
|
||||
@@ -187,9 +208,40 @@ describe("CallMembership", () => {
|
||||
expect(membership.isExpired()).toBe(true);
|
||||
});
|
||||
});
|
||||
describe("expiry calculation", () => {
|
||||
let fakeEvent: MatrixEvent;
|
||||
let membership: CallMembership;
|
||||
|
||||
beforeEach(() => {
|
||||
// server origin timestamp for this event is 1000
|
||||
fakeEvent = makeMockEvent(1000);
|
||||
membership = createCallMembership(fakeEvent!, membershipTemplate);
|
||||
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it("calculates time until expiry", () => {
|
||||
vi.setSystemTime(2000);
|
||||
// should be using absolute expiry time
|
||||
expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("RtcMembershipData", () => {
|
||||
function makeMockEvent(originTs = 0, content: IContent = {}): MatrixEvent {
|
||||
return {
|
||||
getTs: vi.fn().mockReturnValue(originTs),
|
||||
getSender: vi.fn().mockReturnValue("@alice:example.org"),
|
||||
getId: vi.fn().mockReturnValue("$eventid"),
|
||||
getContent: vi.fn().mockReturnValue(content),
|
||||
getType: vi.fn().mockReturnValue(EventType.RTCMembership),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
const membershipTemplate: RtcMembershipData = {
|
||||
slot_id: "m.call#",
|
||||
application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" },
|
||||
@@ -209,6 +261,11 @@ describe("CallMembership", () => {
|
||||
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" });
|
||||
}).toThrow();
|
||||
});
|
||||
it("rejects membership with slot_id that contains extra #", () => {
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#mycall#extra" });
|
||||
}).toThrow();
|
||||
});
|
||||
it("accepts membership with valid slot_id", () => {
|
||||
expect(() => {
|
||||
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" });
|
||||
@@ -309,13 +366,9 @@ describe("CallMembership", () => {
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it.skip("considers memberships unexpired if local age low enough", () => {
|
||||
// TODO link prev event
|
||||
});
|
||||
|
||||
it.skip("considers memberships expired if local age large enough", () => {
|
||||
// TODO link prev event
|
||||
});
|
||||
// TODO link prev event
|
||||
it.todo("considers memberships unexpired if local age low enough");
|
||||
it.todo("considers memberships expired if local age large enough");
|
||||
|
||||
describe("getTransport", () => {
|
||||
it("gets the correct active transport with oldest_membership", () => {
|
||||
@@ -372,44 +425,9 @@ describe("CallMembership", () => {
|
||||
expect(membership.isExpired()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("expiry calculation", () => {
|
||||
let fakeEvent: MatrixEvent;
|
||||
let membership: CallMembership;
|
||||
|
||||
beforeEach(() => {
|
||||
// server origin timestamp for this event is 1000
|
||||
fakeEvent = makeMockEvent(1000);
|
||||
membership = createCallMembership(fakeEvent!, membershipTemplate);
|
||||
|
||||
vi.useFakeTimers();
|
||||
it("uses unpadded base64 for RTC backend identities", async () => {
|
||||
const membership = await CallMembership.parseFromEvent(makeMockEvent(0, { ...membershipTemplate }));
|
||||
expect(membership.rtcBackendIdentity).toBe("jUZ0Q1yF5nV3LlAI5xfD1I7BPnAytJaPEAR57EXjJ6s");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("calculates time until expiry", () => {
|
||||
vi.setSystemTime(2000);
|
||||
// should be using absolute expiry time
|
||||
expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses unpadded base64 for RTC backend identities", async () => {
|
||||
expect(
|
||||
await CallMembership.computeRtcBackendIdentity(makeMockEvent(), {
|
||||
kind: "rtc",
|
||||
data: {
|
||||
slot_id: "m.call#",
|
||||
application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" },
|
||||
member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzRANDOMxyz" },
|
||||
rtc_transports: [{ type: "livekit" }],
|
||||
versions: [],
|
||||
msc4354_sticky_key: "abc123",
|
||||
},
|
||||
}),
|
||||
).toBe("2+h2ELE1XY/NsuveToZOekORCoyQMO6V0W7XZUWk5Q4");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,23 +16,50 @@ limitations under the License.
|
||||
|
||||
import { ClientEvent, EventTimeline, MatrixClient, type Room, RoomStateEvent } from "../../../src";
|
||||
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc";
|
||||
import { makeMockRoom, type MembershipData, membershipTemplate, mockRoomState, mockRTCEvent } from "./mocks";
|
||||
import {
|
||||
makeMockRoom,
|
||||
type MembershipData,
|
||||
sessionMembershipTemplate,
|
||||
mockRoomState,
|
||||
mockRTCEvent,
|
||||
rtcMembershipTemplate,
|
||||
} from "./mocks.ts";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import { type RtcMembershipData, type SessionMembershipData } from "../../../src/matrixrtc/membershipData";
|
||||
|
||||
describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
"MatrixRTCSessionManager ($eventKind)",
|
||||
({ eventKind }) => {
|
||||
let client: MatrixClient;
|
||||
|
||||
function generateMembership(opts: { type: string; callId?: string } = { type: "m.call" }): MembershipData {
|
||||
if (eventKind === "sticky") {
|
||||
return {
|
||||
...rtcMembershipTemplate,
|
||||
slot_id: opts.callId ? `${opts.type}#${opts.callId}` : rtcMembershipTemplate.slot_id,
|
||||
application: {
|
||||
...rtcMembershipTemplate.application,
|
||||
type: opts.type,
|
||||
},
|
||||
} satisfies RtcMembershipData & { user_id: string };
|
||||
}
|
||||
|
||||
return {
|
||||
...sessionMembershipTemplate,
|
||||
application: opts.type,
|
||||
call_id: opts.callId ?? sessionMembershipTemplate.call_id, // approximate version.
|
||||
} satisfies SessionMembershipData & { user_id: string };
|
||||
}
|
||||
|
||||
async function sendLeaveMembership(room: Room, membershipData: MembershipData[]): Promise<void> {
|
||||
if (eventKind === "memberState") {
|
||||
mockRoomState(room, [{ user_id: membershipTemplate.user_id }]);
|
||||
mockRoomState(room, [{ user_id: sessionMembershipTemplate.user_id }]);
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
} else {
|
||||
membershipData.splice(0, 1, { user_id: membershipTemplate.user_id });
|
||||
membershipData.splice(0, 1, { user_id: sessionMembershipTemplate.user_id });
|
||||
client.emit(ClientEvent.Event, mockRTCEvent(membershipData[0], room.roomId, 10000));
|
||||
}
|
||||
await flushPromises();
|
||||
@@ -46,22 +73,17 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
client.matrixRTC.stop();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("Fires event when session starts", async () => {
|
||||
const onStarted = vi.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([membershipTemplate], eventKind === "sticky");
|
||||
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
await flushPromises();
|
||||
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
}
|
||||
const room1 = makeMockRoom([generateMembership({ type: "m.call" })], eventKind === "sticky");
|
||||
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
const sessionStartedPromise = new Promise((resolve) =>
|
||||
client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionStarted, resolve),
|
||||
);
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
await expect(sessionStartedPromise).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("Doesn't fire event if unrelated sessions starts", () => {
|
||||
@@ -69,7 +91,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }], eventKind === "sticky");
|
||||
const room1 = makeMockRoom([generateMembership({ type: "m.other" })], eventKind === "sticky");
|
||||
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
@@ -80,17 +102,24 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
});
|
||||
|
||||
it("Fires event when session ends", async () => {
|
||||
const onEnded = vi.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const membershipData: MembershipData[] = [membershipTemplate];
|
||||
const sessionStartedPromise = new Promise((resolve) =>
|
||||
client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionStarted, resolve),
|
||||
);
|
||||
const sessionEndedPromise = new Promise((resolve) =>
|
||||
client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionEnded, (...params) => resolve(params)),
|
||||
);
|
||||
const membershipData: MembershipData[] = [generateMembership()];
|
||||
const room1 = makeMockRoom(membershipData, eventKind === "sticky");
|
||||
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
vi.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
await flushPromises();
|
||||
await sessionStartedPromise;
|
||||
await sendLeaveMembership(room1, membershipData);
|
||||
|
||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
await expect(sessionEndedPromise).resolves.toStrictEqual([
|
||||
room1.roomId,
|
||||
client.matrixRTC.getActiveRoomSession(room1),
|
||||
]);
|
||||
});
|
||||
|
||||
it("Fires correctly with custom sessionDescription", async () => {
|
||||
@@ -106,48 +135,44 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
|
||||
sessionManager.start();
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
const sessionStartedPromise = new Promise((resolve) =>
|
||||
sessionManager.once(MatrixRTCSessionManagerEvents.SessionStarted, resolve),
|
||||
);
|
||||
const sessionEndedPromise = new Promise((resolve) =>
|
||||
sessionManager.once(MatrixRTCSessionManagerEvents.SessionEnded, (...params) => resolve(params)),
|
||||
);
|
||||
|
||||
try {
|
||||
// Create a session for applicaation m.other, we ignore this session ecause it lacks a call_id
|
||||
const room1MembershipData: MembershipData[] = [{ ...membershipTemplate, application: "m.other" }];
|
||||
const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky");
|
||||
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
await flushPromises();
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
// Create a session for applicaation m.other, we ignore this session because it lacks a call_id
|
||||
const room1MembershipData: MembershipData[] = [generateMembership({ type: "m.other" })];
|
||||
const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky");
|
||||
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
await flushPromises();
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
|
||||
// Create a session for applicaation m.notCall. We expect this call to be tracked because it has a call_id
|
||||
const room2MembershipData: MembershipData[] = [
|
||||
{ ...membershipTemplate, application: "m.notCall", call_id: "test" },
|
||||
];
|
||||
const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky");
|
||||
vi.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
|
||||
client.emit(ClientEvent.Room, room2);
|
||||
await flushPromises();
|
||||
expect(onStarted).toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
// Create a session for applicaation m.notCall. We expect this call to be tracked because it has matching call_id
|
||||
const room2MembershipData: MembershipData[] = [generateMembership({ type: "m.notCall", callId: "test" })];
|
||||
const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky");
|
||||
vi.spyOn(client, "getRooms").mockReturnValue([room2]);
|
||||
client.emit(ClientEvent.Room, room2);
|
||||
await flushPromises();
|
||||
await sessionStartedPromise;
|
||||
|
||||
// Stop room1's RTC session. Tracked.
|
||||
vi.spyOn(client, "getRoom").mockReturnValue(room2);
|
||||
await sendLeaveMembership(room2, room2MembershipData);
|
||||
expect(onEnded).toHaveBeenCalled();
|
||||
onEnded.mockClear();
|
||||
// Stop room1's RTC session. Not tracked.
|
||||
vi.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
await sendLeaveMembership(room1, room1MembershipData);
|
||||
expect(onEnded).not.toHaveBeenCalled();
|
||||
|
||||
// Stop room1's RTC session. Not tracked.
|
||||
vi.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
await sendLeaveMembership(room1, room1MembershipData);
|
||||
expect(onEnded).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
}
|
||||
// Stop room2's RTC session. Tracked.
|
||||
vi.spyOn(client, "getRoom").mockReturnValue(room2);
|
||||
await sendLeaveMembership(room2, room2MembershipData);
|
||||
await sessionEndedPromise;
|
||||
});
|
||||
|
||||
it("Doesn't fire event if unrelated sessions ends", async () => {
|
||||
const onEnded = vi.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const membership: MembershipData[] = [{ ...membershipTemplate, application: "m.other_app" }];
|
||||
const membership: MembershipData[] = [generateMembership({ type: "m.other_app" })];
|
||||
const room1 = makeMockRoom(membership, eventKind === "sticky");
|
||||
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
vi.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
Copyright 2026 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 { computeRtcIdentityRaw } from "../../../src/matrixrtc/membershipData/index.ts";
|
||||
|
||||
describe("computeRtcIdentityRaw", () => {
|
||||
it("should compute the correct identity hash", async () => {
|
||||
// Test vector taken from the spec, with the expected output updated to match the unpadded base64 encoding
|
||||
// https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md#appendix-hash-derivation-test-vectors
|
||||
const result = await computeRtcIdentityRaw("@alice:example.com", "DEVICE123", "memberABC");
|
||||
// Add assertions based on expected hash output
|
||||
expect(result).toBe("J+T45tGruxc+HrUOqJJlyQSV33m728Cme4+vt8/SWrU");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2025-2026 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.
|
||||
@@ -25,15 +25,16 @@ import {
|
||||
type Room,
|
||||
MAX_STICKY_DURATION_MS,
|
||||
} from "../../../src";
|
||||
import { MembershipManagerEvent, Status, type Transport, type LivekitFocusSelection } from "../../../src/matrixrtc";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
Status,
|
||||
type Transport,
|
||||
type SessionMembershipData,
|
||||
type LivekitFocusSelection,
|
||||
} from "../../../src/matrixrtc";
|
||||
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
|
||||
makeMockClient,
|
||||
makeMockRoom,
|
||||
sessionMembershipTemplate,
|
||||
mockCallMembership,
|
||||
type MockClient,
|
||||
} from "./mocks.ts";
|
||||
import { MembershipManager, StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
|
||||
import { type SessionMembershipData } from "../../../src/matrixrtc/membershipData/index.ts";
|
||||
|
||||
/**
|
||||
* Create a promise that will resolve once a mocked method is called.
|
||||
@@ -90,7 +91,7 @@ describe("MembershipManager", () => {
|
||||
// Default to fake timers.
|
||||
vi.useFakeTimers();
|
||||
client = makeMockClient("@alice:example.org", "AAAAAAA");
|
||||
room = makeMockRoom([membershipTemplate]);
|
||||
room = makeMockRoom([sessionMembershipTemplate]);
|
||||
// Provide a default mock that is like the default "non error" server behaviour.
|
||||
vi.mocked(client._unstable_sendDelayedStateEvent).mockResolvedValue({ delay_id: "id" });
|
||||
vi.mocked(client._unstable_updateDelayedEvent).mockResolvedValue({});
|
||||
@@ -139,6 +140,7 @@ describe("MembershipManager", () => {
|
||||
"org.matrix.msc3401.call.member",
|
||||
{
|
||||
application: "m.call",
|
||||
// This tests INFO_SLOT_ID_LEGACY_CASE because it is using callSession = { id: "ROOM", application: "m.call" }
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 14400000,
|
||||
@@ -147,6 +149,7 @@ describe("MembershipManager", () => {
|
||||
focus_active: focusActive,
|
||||
scope: "m.room",
|
||||
},
|
||||
// This tests INFO_SLOT_ID_LEGACY_CASE because it is using callSession = { id: "ROOM", application: "m.call" }
|
||||
"_@alice:example.org_AAAAAAA_m.call",
|
||||
);
|
||||
restartScheduledDelayedEventHandle.resolve?.();
|
||||
@@ -160,6 +163,45 @@ describe("MembershipManager", () => {
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends correct call_id and state key when using non empty string. Not using empty string -> ROOM hack. See: INFO_SLOT_ID_LEGACY_CASE", async () => {
|
||||
// Spys/Mocks
|
||||
|
||||
const customCallSession = { id: "custom", application: "m.call" };
|
||||
const restartScheduledDelayedEventHandle = createAsyncHandle<void>(
|
||||
client._unstable_restartScheduledDelayedEvent,
|
||||
);
|
||||
|
||||
// Test
|
||||
const memberManager = new MembershipManager(undefined, room, client, customCallSession);
|
||||
memberManager.join([focus], undefined);
|
||||
// expects
|
||||
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
room.roomId,
|
||||
"org.matrix.msc3401.call.member",
|
||||
{
|
||||
application: "m.call",
|
||||
call_id: "custom",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 14400000,
|
||||
foci_preferred: [focus],
|
||||
membershipID: "@alice:example.org:AAAAAAA",
|
||||
focus_active: focusActive,
|
||||
scope: "m.room",
|
||||
},
|
||||
"_@alice:example.org_AAAAAAA_m.callcustom",
|
||||
);
|
||||
restartScheduledDelayedEventHandle.resolve?.();
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
|
||||
room.roomId,
|
||||
{ delay: 8000 },
|
||||
"org.matrix.msc3401.call.member",
|
||||
{},
|
||||
"_@alice:example.org_AAAAAAA_m.callcustom",
|
||||
);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reschedules delayed leave event if sending state cancels it", async () => {
|
||||
const memberManager = new MembershipManager(undefined, room, client, callSession);
|
||||
const waitForSendState = waitForMockCall(client.sendStateEvent);
|
||||
@@ -359,7 +401,7 @@ describe("MembershipManager", () => {
|
||||
const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
await vi.advanceTimersByTimeAsync(RESTART_DELAY);
|
||||
// first simulate the sync, then resolve sending the delayed event.
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]);
|
||||
resolve({ delay_id: "id" });
|
||||
// Let the scheduler run one iteration so that the new join gets sent
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
@@ -465,7 +507,7 @@ describe("MembershipManager", () => {
|
||||
describe("onRTCSessionMemberUpdate()", () => {
|
||||
it("does nothing if not joined", async () => {
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]);
|
||||
await vi.advanceTimersToNextTimerAsync();
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
|
||||
@@ -488,7 +530,7 @@ describe("MembershipManager", () => {
|
||||
vi.mocked(client._unstable_sendDelayedStateEvent).mockClear();
|
||||
|
||||
await manager.onRTCSessionMemberUpdate([
|
||||
mockCallMembership(membershipTemplate, room.roomId),
|
||||
mockCallMembership(sessionMembershipTemplate, room.roomId),
|
||||
mockCallMembership(
|
||||
{ ...(myMembership as SessionMembershipData), user_id: client.getUserId()! },
|
||||
room.roomId,
|
||||
@@ -514,7 +556,7 @@ describe("MembershipManager", () => {
|
||||
vi.mocked(client._unstable_sendDelayedStateEvent).mockClear();
|
||||
|
||||
// Our own membership is removed:
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled();
|
||||
@@ -537,7 +579,7 @@ describe("MembershipManager", () => {
|
||||
|
||||
const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]);
|
||||
resolve({ delay_id: "id" });
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
@@ -909,7 +951,10 @@ describe("MembershipManager", () => {
|
||||
const manager = new MembershipManager({}, room, client, callSession);
|
||||
manager.join([]);
|
||||
expect(manager.isActivated()).toEqual(true);
|
||||
const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId);
|
||||
const membership = mockCallMembership(
|
||||
{ ...sessionMembershipTemplate, user_id: client.getUserId()! },
|
||||
room.roomId,
|
||||
);
|
||||
await manager.onRTCSessionMemberUpdate([membership]);
|
||||
await manager.updateCallIntent("video");
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(2);
|
||||
@@ -923,7 +968,7 @@ describe("MembershipManager", () => {
|
||||
manager.join([]);
|
||||
expect(manager.isActivated()).toEqual(true);
|
||||
const membership = mockCallMembership(
|
||||
{ ...membershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" },
|
||||
{ ...sessionMembershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" },
|
||||
room.roomId,
|
||||
);
|
||||
await manager.onRTCSessionMemberUpdate([membership]);
|
||||
@@ -998,7 +1043,7 @@ describe("MembershipManager", () => {
|
||||
|
||||
it("Should prefix log with MembershipManager used", () => {
|
||||
const client = makeMockClient("@alice:example.org", "AAAAAAA");
|
||||
const room = makeMockRoom([membershipTemplate]);
|
||||
const room = makeMockRoom([sessionMembershipTemplate]);
|
||||
|
||||
const membershipManager = new MembershipManager(undefined, room, client, callSession);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2025-2026 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.
|
||||
@@ -17,10 +17,10 @@ limitations under the License.
|
||||
import { type Mock, type Mocked } from "vitest";
|
||||
|
||||
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
|
||||
import { type CallMembership, type Statistics } from "../../../src/matrixrtc";
|
||||
import { type CallMembership } from "../../../src/matrixrtc";
|
||||
import { type ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { membershipTemplate, mockCallMembership } from "./mocks.ts";
|
||||
import { sessionMembershipTemplate, mockCallMembership } from "./mocks.ts";
|
||||
import { decodeBase64, TypedEventEmitter } from "../../../src";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
import { getEncryptionKeyMapKey } from "../../../src/matrixrtc/EncryptionManager.ts";
|
||||
@@ -31,20 +31,10 @@ describe("RTCEncryptionManager", () => {
|
||||
let encryptionManager: RTCEncryptionManager;
|
||||
let getMembershipMock: Mock;
|
||||
let mockTransport: Mocked<ToDeviceKeyTransport>;
|
||||
let statistics: Statistics;
|
||||
let onEncryptionKeysChanged: Mock;
|
||||
let rtcIdentifierProvider: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
getMembershipMock = vi.fn().mockReturnValue([]);
|
||||
onEncryptionKeysChanged = vi.fn();
|
||||
mockTransport = {
|
||||
@@ -63,7 +53,6 @@ describe("RTCEncryptionManager", () => {
|
||||
{ userId: "@alice:example.org", deviceId: "DEVICE01", memberId: "@alice:example.org:DEVICE01" },
|
||||
getMembershipMock,
|
||||
mockTransport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
logger,
|
||||
rtcIdentifierProvider,
|
||||
@@ -223,8 +212,6 @@ describe("RTCEncryptionManager", () => {
|
||||
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
|
||||
});
|
||||
|
||||
// Test an edge case where the use key delay is higher than the grace period.
|
||||
@@ -322,8 +309,6 @@ describe("RTCEncryptionManager", () => {
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
|
||||
});
|
||||
|
||||
it("Should not rotate key when several users join within the rotation grace period", async () => {
|
||||
@@ -464,8 +449,6 @@ describe("RTCEncryptionManager", () => {
|
||||
},
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
|
||||
});
|
||||
|
||||
it("Should not distribute keys if encryption is disabled", async () => {
|
||||
@@ -501,7 +484,6 @@ describe("RTCEncryptionManager", () => {
|
||||
{ userId: "@alice:example.org", deviceId: "DEVICE01", memberId: "@alice:example.org:DEVICE01" },
|
||||
getMembershipMock,
|
||||
mockTransport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
);
|
||||
});
|
||||
@@ -525,7 +507,6 @@ describe("RTCEncryptionManager", () => {
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0);
|
||||
});
|
||||
|
||||
it("should accept keys from transport", async () => {
|
||||
@@ -597,8 +578,6 @@ describe("RTCEncryptionManager", () => {
|
||||
},
|
||||
"rtcIDCARL1",
|
||||
);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(3);
|
||||
});
|
||||
|
||||
it("Should support quick re-joiner if keys received out of order", async () => {
|
||||
@@ -914,7 +893,6 @@ describe("RTCEncryptionManager", () => {
|
||||
{ userId: "@alice:example.org", deviceId: "DEVICE01", memberId: "@alice:example.org:DEVICE01" },
|
||||
getMembershipMock,
|
||||
mockTransport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
logger,
|
||||
rtcIdentifierProvider,
|
||||
@@ -983,7 +961,7 @@ describe("RTCEncryptionManager", () => {
|
||||
rtcBackendIdentity: string,
|
||||
): CallMembership {
|
||||
return mockCallMembership(
|
||||
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
|
||||
{ ...sessionMembershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
|
||||
"!room:id",
|
||||
rtcBackendIdentity,
|
||||
);
|
||||
@@ -998,7 +976,7 @@ describe("RTCEncryptionManager", () => {
|
||||
*/
|
||||
function aStateBaseMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
|
||||
return mockCallMembership(
|
||||
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
|
||||
{ ...sessionMembershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
|
||||
"!room:id",
|
||||
`${userId}|${deviceId}`,
|
||||
);
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 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 { type Mocked } from "vitest";
|
||||
|
||||
import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey } from "./mocks";
|
||||
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport";
|
||||
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport";
|
||||
import { EventType, MatrixClient, RoomEvent } from "../../../src";
|
||||
import { type IRoomTimelineData, MatrixEvent, type Room } from "../../../src";
|
||||
import type { Logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("RoomKeyTransport", () => {
|
||||
let client: MatrixClient;
|
||||
let room: Room & {
|
||||
emitTimelineEvent: (event: MatrixEvent) => void;
|
||||
};
|
||||
let transport: RoomKeyTransport;
|
||||
let mockLogger: Mocked<Logger>;
|
||||
|
||||
const onCallEncryptionMock = vi.fn();
|
||||
beforeEach(() => {
|
||||
onCallEncryptionMock.mockReset();
|
||||
mockLogger = {
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
|
||||
const statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
room = makeMockRoom([membershipTemplate]);
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
client.matrixRTC.start();
|
||||
transport = new RoomKeyTransport(room, client, statistics, {
|
||||
getChild: vi.fn().mockReturnValue(mockLogger),
|
||||
} as unknown as Mocked<Logger>);
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, (...p) => {
|
||||
onCallEncryptionMock(...p);
|
||||
});
|
||||
transport.start();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
client.matrixRTC.stop();
|
||||
transport.stop();
|
||||
});
|
||||
|
||||
it("Calls onCallEncryption on encryption keys event", async () => {
|
||||
client.decryptEventIfNeeded = () => Promise.resolve();
|
||||
const timelineEvent = makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
});
|
||||
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
await new Promise(process.nextTick);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("event decryption", () => {
|
||||
it("Retries decryption and processes success", async () => {
|
||||
vi.useFakeTimers();
|
||||
let isDecryptionFailure = true;
|
||||
client.decryptEventIfNeeded = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(Promise.resolve())
|
||||
.mockImplementation(() => {
|
||||
isDecryptionFailure = false;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
const timelineEvent = Object.assign(
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
}),
|
||||
{ isDecryptionFailure: vi.fn().mockImplementation(() => isDecryptionFailure) },
|
||||
);
|
||||
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// should retry after one second:
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("Retries decryption and processes failure", async () => {
|
||||
try {
|
||||
vi.useFakeTimers();
|
||||
const onCallEncryptionMock = vi.fn();
|
||||
client.decryptEventIfNeeded = vi.fn().mockReturnValue(Promise.resolve());
|
||||
|
||||
const timelineEvent = Object.assign(
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
}),
|
||||
{ isDecryptionFailure: vi.fn().mockReturnValue(true) },
|
||||
);
|
||||
|
||||
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// should retry after one second:
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// doesn't retry again:
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("malformed events", () => {
|
||||
const MALFORMED_EVENT = [
|
||||
// empty content
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {},
|
||||
}),
|
||||
// no sender
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
// Call_id not empty string
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "FOO",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
// Various Malformed keys
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: "FOO",
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: [{ index: 0 }],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
key: "BASE64KEY",
|
||||
index: "mcall",
|
||||
},
|
||||
],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
test.each(MALFORMED_EVENT)("should warn on malformed event %j", (event) => {
|
||||
transport.onEncryptionEvent(event);
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import { type Mocked } from "vitest";
|
||||
|
||||
import { makeMockEvent } from "./mocks";
|
||||
import { makeMockEvent } from "./mocks.ts";
|
||||
import { ClientEvent, EventType, type MatrixClient } from "../../../src";
|
||||
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import { getMockClientWithEventEmitter } from "../../test-utils/client.ts";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2023-2026 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.
|
||||
@@ -18,12 +18,12 @@ import { EventEmitter } from "stream";
|
||||
import { type Mocked, type MockedObject } from "vitest";
|
||||
|
||||
import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src";
|
||||
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc";
|
||||
import { CallMembership } from "../../../src/matrixrtc";
|
||||
import { secureRandomString } from "../../../src/randomstring";
|
||||
import { type RtcMembershipData, type SessionMembershipData } from "../../../src/matrixrtc/membershipData";
|
||||
import { type CallMembershipIdentityParts } from "../../../src/matrixrtc/EncryptionManager";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
export type MembershipData = (SessionMembershipData | {}) & { user_id: string };
|
||||
export type MembershipData = (SessionMembershipData | RtcMembershipData | {}) & { user_id: string };
|
||||
|
||||
export const owmMemberIdentity: CallMembershipIdentityParts = {
|
||||
deviceId: "AAAAAAA",
|
||||
@@ -31,7 +31,7 @@ export const owmMemberIdentity: CallMembershipIdentityParts = {
|
||||
userId: "@alice:example.org",
|
||||
};
|
||||
|
||||
export const membershipTemplate: SessionMembershipData & { user_id: string } = {
|
||||
export const sessionMembershipTemplate: SessionMembershipData & { user_id: string } = {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
user_id: "@mock:user.example",
|
||||
@@ -52,6 +52,39 @@ export const membershipTemplate: SessionMembershipData & { user_id: string } = {
|
||||
],
|
||||
};
|
||||
|
||||
export const rtcMembershipTemplate: RtcMembershipData & { user_id: string } = {
|
||||
user_id: "@mock:user.example",
|
||||
application: {
|
||||
type: "m.call",
|
||||
},
|
||||
member: {
|
||||
id: "IDIDID",
|
||||
user_id: "@mock:user.example",
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
slot_id: "m.call#ROOM",
|
||||
versions: [],
|
||||
rtc_transports: [
|
||||
{
|
||||
type: "livekit",
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
|
||||
foci_preferred: [
|
||||
{
|
||||
livekit_alias: "!alias:something.org",
|
||||
livekit_service_url: "https://livekit-jwt.something.io",
|
||||
type: "livekit",
|
||||
},
|
||||
{
|
||||
livekit_alias: "!alias:something.org",
|
||||
livekit_service_url: "https://livekit-jwt.something.dev",
|
||||
type: "livekit",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
msc4354_sticky_key: "m.call#",
|
||||
};
|
||||
|
||||
export type MockClient = MockedObject<
|
||||
Pick<
|
||||
MatrixClient,
|
||||
@@ -198,7 +231,7 @@ export function mockCallMembership(
|
||||
const ev = mockRTCEvent(membershipData, roomId);
|
||||
vi.mocked(ev.getContent).mockReturnValue(membershipData);
|
||||
const data = CallMembership.membershipDataFromMatrixEvent(ev);
|
||||
return new CallMembership(ev, data, rtcBackendIdentity ?? "xx", logger);
|
||||
return new CallMembership(ev, data, rtcBackendIdentity ?? "xx");
|
||||
}
|
||||
|
||||
export function makeKey(id: number, key: string): { key: string; index: number } {
|
||||
|
||||
@@ -175,12 +175,29 @@ describe("oidc authorization", () => {
|
||||
|
||||
expect(authUrl.searchParams.get("login_hint")).toEqual("login1234");
|
||||
});
|
||||
|
||||
it("should generate url with response_mode=fragment", async () => {
|
||||
const nonce = "abc123";
|
||||
|
||||
const authUrl = new URL(
|
||||
await generateOidcAuthorizationUrl({
|
||||
metadata: delegatedAuthConfig,
|
||||
homeserverUrl: baseUrl,
|
||||
clientId,
|
||||
redirectUri: baseUrl,
|
||||
nonce,
|
||||
responseMode: "fragment",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(authUrl.searchParams.get("response_mode")).toEqual("fragment");
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeAuthorizationCodeGrant", () => {
|
||||
const homeserverUrl = "https://server.org/";
|
||||
const identityServerUrl = "https://id.org/";
|
||||
const nonce = "test-nonce";
|
||||
const nonce = "hRpB6pkE06";
|
||||
const redirectUri = baseUrl;
|
||||
const code = "auth_code_xyz";
|
||||
const validBearerTokenResponse = {
|
||||
@@ -290,6 +307,32 @@ describe("oidc authorization", () => {
|
||||
expect(queryParams.get("code")).toEqual(code);
|
||||
});
|
||||
|
||||
it("should make correct request to the token endpoint with response_mode=fragment", async () => {
|
||||
const state = await setupState({ responseMode: "fragment" });
|
||||
const codeVerifier = getValueFromStorage(state, "code_verifier");
|
||||
await completeAuthorizationCodeGrant(code, state, "fragment");
|
||||
|
||||
expect(fetchMock.callHistory.lastCall(metadata.token_endpoint)?.options).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
method: "post",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// check body is correctly formed
|
||||
const queryParams = fetchMock.callHistory.lastCall(metadata.token_endpoint)!.options
|
||||
.body as URLSearchParams;
|
||||
expect(queryParams.get("grant_type")).toEqual("authorization_code");
|
||||
expect(queryParams.get("client_id")).toEqual(clientId);
|
||||
expect(queryParams.get("code_verifier")).toEqual(codeVerifier);
|
||||
expect(queryParams.get("redirect_uri")).toEqual(redirectUri);
|
||||
expect(queryParams.get("code")).toEqual(code);
|
||||
});
|
||||
|
||||
it("should return with valid bearer token", async () => {
|
||||
const state = await setupState();
|
||||
const scope = getValueFromStorage(state, "scope");
|
||||
|
||||
@@ -275,6 +275,108 @@ describe("Relations", function () {
|
||||
expect(badlyEditedTopic.getContent().topic).toBe("topic");
|
||||
});
|
||||
|
||||
describe("m.replace async ordering", () => {
|
||||
const userId = "@bob:example.com";
|
||||
const roomId = "!room:example.com";
|
||||
const targetEventId = "$target";
|
||||
|
||||
function makeEditEvent(eventId: string, ts: number): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
sender: userId,
|
||||
type: "m.room.message",
|
||||
event_id: eventId,
|
||||
room_id: roomId,
|
||||
origin_server_ts: ts,
|
||||
content: {
|
||||
"body": `edited ${eventId}`,
|
||||
"msgtype": "m.text",
|
||||
"m.new_content": {
|
||||
body: `edited ${eventId}`,
|
||||
msgtype: "m.text",
|
||||
},
|
||||
"m.relates_to": {
|
||||
event_id: targetEventId,
|
||||
rel_type: "m.replace",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("should not let a slow-decrypting older edit overwrite a newer one", async () => {
|
||||
const room = new Room(roomId, new TestClient(userId).client, userId);
|
||||
const relations = new Relations("m.replace", "m.room.message", room);
|
||||
|
||||
const targetEvent = new MatrixEvent({
|
||||
sender: userId,
|
||||
type: "m.room.message",
|
||||
event_id: targetEventId,
|
||||
room_id: roomId,
|
||||
origin_server_ts: 1000,
|
||||
content: { body: "original", msgtype: "m.text" },
|
||||
});
|
||||
|
||||
await relations.setTargetEvent(targetEvent);
|
||||
|
||||
// Create two edits: edit1 is older (ts=2000), edit2 is newer (ts=3000).
|
||||
const edit1 = makeEditEvent("$edit1", 2000);
|
||||
const edit2 = makeEditEvent("$edit2", 3000);
|
||||
|
||||
// Simulate edit1 being in the process of decryption: isBeingDecrypted()
|
||||
// returns true and getDecryptionPromise() returns a deferred promise.
|
||||
let resolveEdit1Decryption!: () => void;
|
||||
const edit1DecryptionPromise = new Promise<void>((resolve) => {
|
||||
resolveEdit1Decryption = resolve;
|
||||
});
|
||||
vi.spyOn(edit1, "isBeingDecrypted").mockReturnValue(true);
|
||||
vi.spyOn(edit1, "getDecryptionPromise").mockReturnValue(edit1DecryptionPromise);
|
||||
vi.spyOn(edit1, "shouldAttemptDecryption").mockReturnValue(false);
|
||||
|
||||
// edit2 is already decrypted.
|
||||
vi.spyOn(edit2, "isBeingDecrypted").mockReturnValue(false);
|
||||
vi.spyOn(edit2, "shouldAttemptDecryption").mockReturnValue(false);
|
||||
|
||||
// Add edit1 first (it will block on decryption).
|
||||
const addEdit1Promise = relations.addEvent(edit1);
|
||||
|
||||
// While edit1 is still decrypting, add edit2 (resolves immediately).
|
||||
await relations.addEvent(edit2);
|
||||
|
||||
// edit2 should be applied as the replacement (it's newer).
|
||||
expect(targetEvent.replacingEvent()).toBe(edit2);
|
||||
|
||||
// Now resolve edit1's decryption — the stale result must NOT overwrite edit2.
|
||||
resolveEdit1Decryption();
|
||||
await addEdit1Promise;
|
||||
|
||||
// edit2 must still be the replacing event, not edit1.
|
||||
expect(targetEvent.replacingEvent()).toBe(edit2);
|
||||
});
|
||||
|
||||
it("should apply an edit correctly when there is no concurrency", async () => {
|
||||
const room = new Room(roomId, new TestClient(userId).client, userId);
|
||||
const relations = new Relations("m.replace", "m.room.message", room);
|
||||
|
||||
const targetEvent = new MatrixEvent({
|
||||
sender: userId,
|
||||
type: "m.room.message",
|
||||
event_id: targetEventId,
|
||||
room_id: roomId,
|
||||
origin_server_ts: 1000,
|
||||
content: { body: "original", msgtype: "m.text" },
|
||||
});
|
||||
|
||||
await relations.setTargetEvent(targetEvent);
|
||||
|
||||
const edit = makeEditEvent("$edit1", 2000);
|
||||
vi.spyOn(edit, "isBeingDecrypted").mockReturnValue(false);
|
||||
vi.spyOn(edit, "shouldAttemptDecryption").mockReturnValue(false);
|
||||
|
||||
await relations.addEvent(edit);
|
||||
|
||||
expect(targetEvent.replacingEvent()).toBe(edit);
|
||||
});
|
||||
});
|
||||
|
||||
it("getSortedAnnotationsByKey should return null for non-annotation relations", async () => {
|
||||
const userId = "@user:server";
|
||||
const room = new Room("room123", new TestClient(userId).client, userId);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1308,4 +1308,144 @@ describe("RoomState", function () {
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reactive display name disambiguation", function () {
|
||||
it("should disambiguate existing member when another member changes to the same name", function () {
|
||||
// Create a fresh state
|
||||
const testState = new RoomState(roomId);
|
||||
|
||||
// Alice joins with display name "Alice"
|
||||
const aliceJoinEvent = utils.mkMembership({
|
||||
user: userA,
|
||||
mship: KnownMembership.Join,
|
||||
room: roomId,
|
||||
event: true,
|
||||
name: "Alice",
|
||||
});
|
||||
|
||||
// Bob joins with display name "Bob"
|
||||
const bobJoinEvent = utils.mkMembership({
|
||||
user: userB,
|
||||
mship: KnownMembership.Join,
|
||||
room: roomId,
|
||||
event: true,
|
||||
name: "Bob",
|
||||
});
|
||||
|
||||
testState.setStateEvents([aliceJoinEvent, bobJoinEvent]);
|
||||
|
||||
// Verify no disambiguation needed initially
|
||||
const aliceBefore = testState.getMember(userA);
|
||||
const bobBefore = testState.getMember(userB);
|
||||
expect(aliceBefore?.disambiguate).toBe(false);
|
||||
expect(bobBefore?.disambiguate).toBe(false);
|
||||
expect(aliceBefore?.name).toBe("Alice");
|
||||
expect(bobBefore?.name).toBe("Bob");
|
||||
|
||||
// Bob changes display name to "Alice"
|
||||
const bobRenameEvent = utils.mkMembership({
|
||||
user: userB,
|
||||
mship: KnownMembership.Join,
|
||||
room: roomId,
|
||||
event: true,
|
||||
name: "Alice",
|
||||
});
|
||||
|
||||
testState.setStateEvents([bobRenameEvent]);
|
||||
|
||||
// Now both should be disambiguated
|
||||
const aliceAfter = testState.getMember(userA);
|
||||
const bobAfter = testState.getMember(userB);
|
||||
expect(aliceAfter?.disambiguate).toBe(true);
|
||||
expect(bobAfter?.disambiguate).toBe(true);
|
||||
expect(aliceAfter?.name).toContain(userA);
|
||||
expect(bobAfter?.name).toContain(userB);
|
||||
});
|
||||
|
||||
it("should un-disambiguate member when conflicting member changes to different name", function () {
|
||||
// Create a fresh state
|
||||
const testState = new RoomState(roomId);
|
||||
|
||||
// Both Alice and Bob join with display name "Alice"
|
||||
const aliceJoinEvent = utils.mkMembership({
|
||||
user: userA,
|
||||
mship: KnownMembership.Join,
|
||||
room: roomId,
|
||||
event: true,
|
||||
name: "Alice",
|
||||
});
|
||||
|
||||
const bobJoinEvent = utils.mkMembership({
|
||||
user: userB,
|
||||
mship: KnownMembership.Join,
|
||||
room: roomId,
|
||||
event: true,
|
||||
name: "Alice",
|
||||
});
|
||||
|
||||
testState.setStateEvents([aliceJoinEvent, bobJoinEvent]);
|
||||
|
||||
// Verify both are disambiguated
|
||||
const aliceBefore = testState.getMember(userA);
|
||||
const bobBefore = testState.getMember(userB);
|
||||
expect(aliceBefore?.disambiguate).toBe(true);
|
||||
expect(bobBefore?.disambiguate).toBe(true);
|
||||
|
||||
// Bob changes display name to "Bob"
|
||||
const bobRenameEvent = utils.mkMembership({
|
||||
user: userB,
|
||||
mship: KnownMembership.Join,
|
||||
room: roomId,
|
||||
event: true,
|
||||
name: "Bob",
|
||||
});
|
||||
|
||||
testState.setStateEvents([bobRenameEvent]);
|
||||
|
||||
// Alice should no longer be disambiguated, Bob should not be either
|
||||
const aliceAfter = testState.getMember(userA);
|
||||
const bobAfter = testState.getMember(userB);
|
||||
expect(aliceAfter?.disambiguate).toBe(false);
|
||||
expect(bobAfter?.disambiguate).toBe(false);
|
||||
expect(aliceAfter?.name).toBe("Alice");
|
||||
expect(bobAfter?.name).toBe("Bob");
|
||||
});
|
||||
|
||||
it("should emit RoomState.members for affected members when disambiguation changes", function () {
|
||||
// Create a fresh state
|
||||
const testState = new RoomState(roomId);
|
||||
|
||||
// Alice joins with display name "Alice"
|
||||
const aliceJoinEvent = utils.mkMembership({
|
||||
user: userA,
|
||||
mship: KnownMembership.Join,
|
||||
room: roomId,
|
||||
event: true,
|
||||
name: "Alice",
|
||||
});
|
||||
|
||||
testState.setStateEvents([aliceJoinEvent]);
|
||||
|
||||
// Set up listener for Members event
|
||||
const membersEmitted: string[] = [];
|
||||
testState.on(RoomStateEvent.Members, (_ev, _state, member) => {
|
||||
membersEmitted.push(member.userId);
|
||||
});
|
||||
|
||||
// Bob joins with display name "Alice" - should trigger disambiguation for Alice
|
||||
const bobJoinEvent = utils.mkMembership({
|
||||
user: userB,
|
||||
mship: KnownMembership.Join,
|
||||
room: roomId,
|
||||
event: true,
|
||||
name: "Alice",
|
||||
});
|
||||
|
||||
testState.setStateEvents([bobJoinEvent]);
|
||||
|
||||
// Both Alice and Bob should have emitted Members events
|
||||
expect(membersEmitted).toContain(userA);
|
||||
expect(membersEmitted).toContain(userB);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ exports[`RustCrypto > importing and exporting room keys > should import and expo
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
"org.matrix.msc3061.shared_history": false,
|
||||
"org.matrix.msc3061.shared_history": true,
|
||||
"room_id": "!room:id",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0",
|
||||
@@ -19,7 +19,7 @@ exports[`RustCrypto > importing and exporting room keys > should import and expo
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
"org.matrix.msc3061.shared_history": false,
|
||||
"org.matrix.msc3061.shared_history": true,
|
||||
"room_id": "!room:id",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -590,6 +592,72 @@ describe("RustCrypto", () => {
|
||||
expect(res.length).toEqual(0);
|
||||
});
|
||||
|
||||
it.each(["m.room_key_bundle", "io.element.msc4268.room_key_bundle"])(
|
||||
"should accept key bundles when we find out about them",
|
||||
async (type: string) => {
|
||||
// Given we are faking that the received to-device message is a
|
||||
// decrypted room key bundle.
|
||||
|
||||
// @ts-ignore Overriding a private function
|
||||
rustCrypto.receiveSyncChanges = vi.fn().mockReturnValue([keyBundleEvent(type)]);
|
||||
|
||||
// And that there is a pending key bundle
|
||||
|
||||
// @ts-ignore Overriding a private function
|
||||
rustCrypto.olmMachine.getPendingKeyBundleDetailsForRoom = vi.fn().mockReturnValue({
|
||||
inviteAcceptedAtMillis: Date.now(),
|
||||
inviterId: { toString: vi.fn().mockReturnValue("@inv:s.co") },
|
||||
});
|
||||
|
||||
// When we process to-device messages
|
||||
rustCrypto.maybeAcceptKeyBundle = vi.fn().mockName("maybeAcceptKeyBundle").mockResolvedValue(null);
|
||||
await rustCrypto.preprocessToDeviceMessages([]);
|
||||
|
||||
// Then we accepted the key bundle
|
||||
expect(rustCrypto.maybeAcceptKeyBundle).toHaveBeenCalledWith("!r:s.co", "@inv:s.co");
|
||||
},
|
||||
);
|
||||
|
||||
it("should not accept other to-device messages as key bundles when we receive them", async () => {
|
||||
// Given we are faking that the received to-device message looks
|
||||
// like a room key bundle, except it has the wrong type.
|
||||
|
||||
// @ts-ignore Overriding a private function
|
||||
rustCrypto.receiveSyncChanges = vi.fn().mockReturnValue([keyBundleEvent("foo.some_other_type")]);
|
||||
|
||||
// And that there is a pending key bundle
|
||||
|
||||
// @ts-ignore Overriding a private function
|
||||
rustCrypto.olmMachine.getPendingKeyBundleDetailsForRoom = vi.fn().mockReturnValue({
|
||||
inviteAcceptedAtMillis: Date.now(),
|
||||
inviterId: { toString: vi.fn().mockReturnValue("@inv:s.co") },
|
||||
});
|
||||
|
||||
// When we process to-device messages
|
||||
rustCrypto.maybeAcceptKeyBundle = vi.fn().mockName("maybeAcceptKeyBundle").mockResolvedValue(null);
|
||||
await rustCrypto.preprocessToDeviceMessages([]);
|
||||
|
||||
// Then we do not try to accepted a key bundle
|
||||
expect(rustCrypto.maybeAcceptKeyBundle).not.toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
function keyBundleEvent(type: string): RustSdkCryptoJs.ProcessedToDeviceEvent {
|
||||
return {
|
||||
rawEvent: JSON.stringify({
|
||||
content: { room_id: "!r:s.co" },
|
||||
sender: "",
|
||||
type,
|
||||
}),
|
||||
type: 0,
|
||||
encryptionInfo: {
|
||||
sender: "",
|
||||
senderDevice: null,
|
||||
senderCurve25519Key: "",
|
||||
isSenderVerified: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
} as any as RustSdkCryptoJs.ProcessedToDeviceEvent;
|
||||
}
|
||||
|
||||
it("emits VerificationRequestReceived on incoming m.key.verification.request", async () => {
|
||||
rustCrypto = await makeTestRustCrypto(
|
||||
new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||
@@ -788,6 +856,7 @@ describe("RustCrypto", () => {
|
||||
undefined,
|
||||
secretStorage,
|
||||
);
|
||||
vi.spyOn(rustCrypto, "pushSecretToVerifiedDevices").mockResolvedValue();
|
||||
|
||||
async function createSecretStorageKey() {
|
||||
return {
|
||||
@@ -835,6 +904,7 @@ describe("RustCrypto", () => {
|
||||
{} as CryptoCallbacks,
|
||||
false,
|
||||
);
|
||||
vi.spyOn(rustCrypto, "pushSecretToVerifiedDevices").mockResolvedValue();
|
||||
|
||||
async function createSecretStorageKey() {
|
||||
return {
|
||||
@@ -1524,6 +1594,7 @@ describe("RustCrypto", () => {
|
||||
|
||||
it("returns an unverified UserVerificationStatus when there is no UserIdentity", async () => {
|
||||
const userVerificationStatus = await rustCrypto.getUserVerificationStatus(testData.TEST_USER_ID);
|
||||
expect(userVerificationStatus.known).toBe(false);
|
||||
expect(userVerificationStatus.isVerified()).toBeFalsy();
|
||||
expect(userVerificationStatus.isTofu()).toBeFalsy();
|
||||
expect(userVerificationStatus.isCrossSigningVerified()).toBeFalsy();
|
||||
@@ -1535,9 +1606,10 @@ 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.known).toBe(true);
|
||||
expect(userVerificationStatus.isVerified()).toBeTruthy();
|
||||
expect(userVerificationStatus.isTofu()).toBeFalsy();
|
||||
expect(userVerificationStatus.isCrossSigningVerified()).toBeTruthy();
|
||||
@@ -2320,6 +2392,7 @@ describe("RustCrypto", () => {
|
||||
});
|
||||
|
||||
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), undefined, undefined, secretStorage);
|
||||
vi.spyOn(rustCrypto, "pushSecretToVerifiedDevices").mockResolvedValue();
|
||||
|
||||
// We have a key backup
|
||||
await waitFor(async () => expect(await rustCrypto.getActiveSessionBackupVersion()).not.toBeNull());
|
||||
@@ -2388,6 +2461,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 +2522,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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -79,6 +79,41 @@ describe("IndexedDBStore", () => {
|
||||
expect(await store.getOutOfBandMembers(roomId)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should handle failed queries", async () => {
|
||||
const store = new IndexedDBStore({
|
||||
indexedDB: indexedDB,
|
||||
dbName: "database",
|
||||
localStorage,
|
||||
});
|
||||
await store.startup();
|
||||
|
||||
// Simulate a failed query
|
||||
let txn: IDBRequest;
|
||||
(store.backend as LocalIndexedDBStoreBackend)["db"]!.transaction = (): IDBTransaction => {
|
||||
return {
|
||||
objectStore: (name: string) =>
|
||||
({
|
||||
name,
|
||||
openCursor: (query: unknown) => {
|
||||
return (txn = {
|
||||
error: new DOMException("Expected error"),
|
||||
} as IDBRequest);
|
||||
},
|
||||
}) as IDBObjectStore,
|
||||
} as IDBTransaction;
|
||||
};
|
||||
|
||||
// Call backend directly as otherwise the error is masked.
|
||||
const promise = store.backend.getClientOptions();
|
||||
// The function uses a Promise.then(() => trick to delay execution
|
||||
// so we need to wait before we can call the txn onerror handler.
|
||||
process.nextTick(() => {
|
||||
txn!.onerror!(new Event("we-ignore-this"));
|
||||
});
|
||||
|
||||
await expect(() => promise).rejects.toThrow("selectQuery failed for client_options");
|
||||
});
|
||||
|
||||
it("Should load presence events on startup", async () => {
|
||||
// 1. Create idb database
|
||||
const indexedDB = new IDBFactory();
|
||||
|
||||
@@ -314,7 +314,7 @@ describe("Call", function () {
|
||||
answer: {
|
||||
sdp: DUMMY_SDP,
|
||||
},
|
||||
[SDPStreamMetadataKey]: {
|
||||
[SDPStreamMetadataKey.name]: {
|
||||
remote_stream: {
|
||||
purpose: SDPStreamMetadataPurpose.Usermedia,
|
||||
audio_muted: true,
|
||||
@@ -420,7 +420,7 @@ describe("Call", function () {
|
||||
answer: {
|
||||
sdp: DUMMY_SDP,
|
||||
},
|
||||
[SDPStreamMetadataKey]: {},
|
||||
[SDPStreamMetadataKey.name]: {},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -451,7 +451,7 @@ describe("Call", function () {
|
||||
answer: {
|
||||
sdp: DUMMY_SDP,
|
||||
},
|
||||
[SDPStreamMetadataKey]: {},
|
||||
[SDPStreamMetadataKey.name]: {},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -478,7 +478,7 @@ describe("Call", function () {
|
||||
answer: {
|
||||
sdp: DUMMY_SDP,
|
||||
},
|
||||
[SDPStreamMetadataKey]: {},
|
||||
[SDPStreamMetadataKey.name]: {},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -504,7 +504,7 @@ describe("Call", function () {
|
||||
|
||||
call.onSDPStreamMetadataChangedReceived(
|
||||
makeMockEvent("@test:foo", {
|
||||
[SDPStreamMetadataKey]: {
|
||||
[SDPStreamMetadataKey.name]: {
|
||||
remote_stream: {
|
||||
purpose: SDPStreamMetadataPurpose.Screenshare,
|
||||
audio_muted: true,
|
||||
@@ -849,7 +849,7 @@ describe("Call", function () {
|
||||
answer: {
|
||||
sdp: DUMMY_SDP,
|
||||
},
|
||||
[SDPStreamMetadataKey]: {
|
||||
[SDPStreamMetadataKey.name]: {
|
||||
[STREAM_ID]: {
|
||||
purpose: SDPStreamMetadataPurpose.Usermedia,
|
||||
},
|
||||
@@ -959,8 +959,8 @@ describe("Call", function () {
|
||||
describe("sending sdp_stream_metadata_changed events", () => {
|
||||
it("should send sdp_stream_metadata_changed when muting audio", async () => {
|
||||
await call.setMicrophoneMuted(true);
|
||||
expect(mockSendVoipEvent).toHaveBeenCalledWith(EventType.CallSDPStreamMetadataChangedPrefix, {
|
||||
[SDPStreamMetadataKey]: {
|
||||
expect(mockSendVoipEvent).toHaveBeenCalledWith(EventType.CallSDPStreamMetadataChanged, {
|
||||
[SDPStreamMetadataKey.name]: {
|
||||
mock_stream_from_media_handler: {
|
||||
purpose: SDPStreamMetadataPurpose.Usermedia,
|
||||
audio_muted: true,
|
||||
@@ -972,8 +972,8 @@ describe("Call", function () {
|
||||
|
||||
it("should send sdp_stream_metadata_changed when muting video", async () => {
|
||||
await call.setLocalVideoMuted(true);
|
||||
expect(mockSendVoipEvent).toHaveBeenCalledWith(EventType.CallSDPStreamMetadataChangedPrefix, {
|
||||
[SDPStreamMetadataKey]: {
|
||||
expect(mockSendVoipEvent).toHaveBeenCalledWith(EventType.CallSDPStreamMetadataChanged, {
|
||||
[SDPStreamMetadataKey.name]: {
|
||||
mock_stream_from_media_handler: {
|
||||
purpose: SDPStreamMetadataPurpose.Usermedia,
|
||||
audio_muted: false,
|
||||
@@ -1001,7 +1001,7 @@ describe("Call", function () {
|
||||
);
|
||||
call.onSDPStreamMetadataChangedReceived({
|
||||
getContent: () => ({
|
||||
[SDPStreamMetadataKey]: metadata,
|
||||
[SDPStreamMetadataKey.name]: metadata,
|
||||
}),
|
||||
} as MatrixEvent);
|
||||
return metadata;
|
||||
@@ -1293,9 +1293,9 @@ describe("Call", function () {
|
||||
FAKE_ROOM_ID,
|
||||
EventType.CallNegotiate,
|
||||
expect.objectContaining({
|
||||
"version": "1",
|
||||
"call_id": call.callId,
|
||||
"org.matrix.msc3077.sdp_stream_metadata": expect.objectContaining({
|
||||
version: "1",
|
||||
call_id: call.callId,
|
||||
sdp_stream_metadata: expect.objectContaining({
|
||||
[SCREENSHARE_STREAM_ID]: expect.objectContaining({
|
||||
purpose: SDPStreamMetadataPurpose.Screenshare,
|
||||
}),
|
||||
|
||||
@@ -963,7 +963,7 @@ describe("Group Call", function () {
|
||||
const getMetadataEvent = (audio: boolean, video: boolean): MatrixEvent =>
|
||||
({
|
||||
getContent: () => ({
|
||||
[SDPStreamMetadataKey]: {
|
||||
[SDPStreamMetadataKey.name]: {
|
||||
stream: {
|
||||
purpose: SDPStreamMetadataPurpose.Usermedia,
|
||||
audio_muted: audio,
|
||||
@@ -1330,7 +1330,7 @@ describe("Group Call", function () {
|
||||
call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember;
|
||||
call.onNegotiateReceived({
|
||||
getContent: () => ({
|
||||
[SDPStreamMetadataKey]: {
|
||||
[SDPStreamMetadataKey.name]: {
|
||||
screensharing_stream: {
|
||||
purpose: SDPStreamMetadataPurpose.Screenshare,
|
||||
},
|
||||
|
||||
+47
-6
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2020-2026 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.
|
||||
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type EitherAnd } from "matrix-events-sdk";
|
||||
|
||||
import { NamespacedValue, UnstableValue } from "../NamespacedValue.ts";
|
||||
import {
|
||||
type PolicyRuleEventContent,
|
||||
@@ -50,7 +52,6 @@ import {
|
||||
type MCallReplacesEvent,
|
||||
type MCallSelectAnswer,
|
||||
type SDPStreamMetadata,
|
||||
type SDPStreamMetadataKey,
|
||||
} from "../webrtc/callEventTypes.ts";
|
||||
import {
|
||||
type IRTCNotificationContent,
|
||||
@@ -59,7 +60,7 @@ import {
|
||||
type ICallNotifyContent,
|
||||
} from "../matrixrtc/types.ts";
|
||||
import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts";
|
||||
import { type RtcMembershipData, type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
|
||||
import { type RtcMembershipData, type SessionMembershipData } from "../matrixrtc/membershipData/index.ts";
|
||||
import { type LocalNotificationSettings } from "./local_notifications.ts";
|
||||
import { type IPushRules } from "./PushRules.ts";
|
||||
import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts";
|
||||
@@ -133,11 +134,13 @@ export enum EventType {
|
||||
FullyRead = "m.fully_read",
|
||||
Tag = "m.tag",
|
||||
SpaceOrder = "org.matrix.msc3230.space_order", // MSC3230
|
||||
MarkedUnread = "m.marked_unread",
|
||||
|
||||
// User account_data events
|
||||
PushRules = "m.push_rules",
|
||||
Direct = "m.direct",
|
||||
IgnoredUserList = "m.ignored_user_list",
|
||||
InvitePermissionConfig = "m.invite_permission_config", // MSC4380
|
||||
|
||||
// to_device events
|
||||
RoomKey = "m.room_key",
|
||||
@@ -334,7 +337,16 @@ export interface TimelineEvents {
|
||||
[EventType.CallCandidates]: MCallCandidates;
|
||||
[EventType.CallHangup]: MCallHangupReject;
|
||||
[EventType.CallReject]: MCallHangupReject;
|
||||
[EventType.CallSDPStreamMetadataChangedPrefix]: MCallBase & { [SDPStreamMetadataKey]: SDPStreamMetadata };
|
||||
[EventType.CallSDPStreamMetadataChangedPrefix]: MCallBase &
|
||||
EitherAnd<
|
||||
{ sdp_stream_metadata: SDPStreamMetadata },
|
||||
{ "org.matrix.msc3077.sdp_stream_metadata": SDPStreamMetadata }
|
||||
>;
|
||||
[EventType.CallSDPStreamMetadataChanged]: MCallBase &
|
||||
EitherAnd<
|
||||
{ sdp_stream_metadata: SDPStreamMetadata },
|
||||
{ "org.matrix.msc3077.sdp_stream_metadata": SDPStreamMetadata }
|
||||
>;
|
||||
[EventType.CallEncryptionKeysPrefix]: EncryptionKeysEventContent;
|
||||
[EventType.CallNotify]: ICallNotifyContent;
|
||||
[EventType.RTCNotification]: IRTCNotificationContent;
|
||||
@@ -386,6 +398,16 @@ export interface StateEvents {
|
||||
[M_BEACON_INFO.name]: MBeaconInfoEventContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapped type from event type to content type for all specified room-specific account_data events.
|
||||
*/
|
||||
export interface RoomAccountDataEvents extends SecretStorageAccountDataEvents {
|
||||
[EventType.FullyRead]: { event_id: string };
|
||||
[EventType.Tag]: { tags: { [name: string]: { order?: number } } };
|
||||
[EventType.SpaceOrder]: { order: string };
|
||||
[EventType.MarkedUnread]: { unread: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapped type from event type to content type for all specified global account_data events.
|
||||
*/
|
||||
@@ -394,9 +416,12 @@ export interface AccountDataEvents extends SecretStorageAccountDataEvents {
|
||||
[EventType.Direct]: { [userId: string]: string[] };
|
||||
[EventType.IgnoredUserList]: { ignored_users: { [userId: string]: EmptyObject } };
|
||||
"m.secret_storage.default_key": { key: string };
|
||||
// Flag set by the rust SDK (Element X) and also used by us to mark that the user opted out of backup
|
||||
// (I don't know why it's m.org.matrix...)
|
||||
|
||||
// MSC4287: Sharing key backup preference between clients - used to mark that the user opted out of key storage
|
||||
"m.key_backup": { enabled: boolean };
|
||||
// MSC4287 unstable prefix (note the boolean property has the opposite sense)
|
||||
"m.org.matrix.custom.backup_disabled": { disabled: boolean };
|
||||
|
||||
"m.identity_server": { base_url: string | null };
|
||||
[key: `${typeof LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${string}`]: LocalNotificationSettings;
|
||||
[key: `m.secret_storage.key.${string}`]: SecretStorageKeyDescription;
|
||||
@@ -404,8 +429,24 @@ export interface AccountDataEvents extends SecretStorageAccountDataEvents {
|
||||
// Invites-ignorer events
|
||||
[POLICIES_ACCOUNT_EVENT_TYPE.name]: { [key: string]: any };
|
||||
[POLICIES_ACCOUNT_EVENT_TYPE.altName]: { [key: string]: any };
|
||||
|
||||
[EventType.InvitePermissionConfig]: { default_action?: string };
|
||||
|
||||
// List of recently used reaction emojis
|
||||
// https://spec.matrix.org/v1.18/client-server-api/#mrecent_emoji
|
||||
"m.recent_emoji": {
|
||||
recent_emoji: Array<{
|
||||
emoji: string;
|
||||
total: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of AccountDataEvents, excluding events specified in https://spec.matrix.org/v1.17/client-server-api/#server-behaviour-12
|
||||
*/
|
||||
export type WritableAccountDataEvents = Exclude<AccountDataEvents, "m.fully_read" | "m.push_rules">;
|
||||
|
||||
/**
|
||||
* Mapped type from event type to content type for all specified global events encrypted by secret storage.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ import { type IMessageRendering } from "./extensible_events.ts";
|
||||
/**
|
||||
* The event type for an m.topic event (in content)
|
||||
*/
|
||||
export const M_TOPIC = new NamespacedValue("m.topic");
|
||||
export const M_TOPIC = new NamespacedValue("m.topic", null);
|
||||
|
||||
/**
|
||||
* The event content for an m.topic event (in content)
|
||||
|
||||
@@ -22,11 +22,11 @@ export class NamespacedValue<S extends string, U extends string> {
|
||||
// Stable is optional, but one of the two parameters is required, hence the weird-looking types.
|
||||
// Goal is to to have developers explicitly say there is no stable value (if applicable).
|
||||
public constructor(stable: S, unstable: U);
|
||||
public constructor(stable: S, unstable?: U);
|
||||
public constructor(stable: null | undefined, unstable: U);
|
||||
public constructor(stable: S, unstable: U | null);
|
||||
public constructor(stable: null, unstable: U);
|
||||
public constructor(
|
||||
public readonly stable?: S | null,
|
||||
public readonly unstable?: U,
|
||||
public readonly stable: S | null,
|
||||
public readonly unstable: U | null,
|
||||
) {
|
||||
if (!this.unstable && !this.stable) {
|
||||
throw new Error("One of stable or unstable values must be supplied");
|
||||
@@ -60,8 +60,8 @@ export class NamespacedValue<S extends string, U extends string> {
|
||||
|
||||
// this desperately wants https://github.com/microsoft/TypeScript/pull/26349 at the top level of the class
|
||||
// so we can instantiate `NamespacedValue<string, _, _>` as a default type for that namespace.
|
||||
public findIn<T>(obj: any): T | undefined {
|
||||
let val: T | undefined = undefined;
|
||||
public findIn<V>(obj: Partial<Record<NonNullable<S | U>, V>>): V | undefined {
|
||||
let val: V | undefined = undefined;
|
||||
if (this.name) {
|
||||
val = obj?.[this.name];
|
||||
}
|
||||
|
||||
+28
-14
@@ -101,7 +101,7 @@ import {
|
||||
type RoomNameState,
|
||||
} from "./models/room.ts";
|
||||
import { RoomMemberEvent, type RoomMemberEventHandlerMap } from "./models/room-member.ts";
|
||||
import { type IPowerLevelsContent, type RoomStateEvent, type RoomStateEventHandlerMap } from "./models/room-state.ts";
|
||||
import { RoomStateEvent, type IPowerLevelsContent, type RoomStateEventHandlerMap } from "./models/room-state.ts";
|
||||
import {
|
||||
isSendDelayedEventRequestOpts,
|
||||
UpdateDelayedEventAction,
|
||||
@@ -138,6 +138,7 @@ import {
|
||||
MsgType,
|
||||
PUSHER_ENABLED,
|
||||
RelationType,
|
||||
type RoomAccountDataEvents,
|
||||
RoomCreateTypeField,
|
||||
RoomType,
|
||||
type StateEvents,
|
||||
@@ -145,6 +146,7 @@ import {
|
||||
UNSTABLE_MSC3088_ENABLED,
|
||||
UNSTABLE_MSC3088_PURPOSE,
|
||||
UNSTABLE_MSC3089_TREE_SUBTYPE,
|
||||
type WritableAccountDataEvents,
|
||||
} from "./@types/event.ts";
|
||||
import {
|
||||
GuestAccess,
|
||||
@@ -2025,6 +2027,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
// attach the event listeners needed by RustCrypto
|
||||
this.on(RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto));
|
||||
this.on(RoomStateEvent.Events, rustCrypto.onRoomStateEvent.bind(rustCrypto));
|
||||
this.on(ClientEvent.Event, (event) => {
|
||||
rustCrypto.onLiveEventFromSync(event);
|
||||
});
|
||||
@@ -2221,7 +2224,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param eventType - The event type
|
||||
* @param content - the contents object for the event
|
||||
*/
|
||||
public async setAccountData<K extends keyof AccountDataEvents>(
|
||||
public async setAccountData<K extends keyof WritableAccountDataEvents>(
|
||||
eventType: K,
|
||||
content: AccountDataEvents[K] | Record<string, never>,
|
||||
): Promise<EmptyObject> {
|
||||
@@ -2273,7 +2276,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param eventType - The event type
|
||||
* @param content - the contents object for the event
|
||||
*/
|
||||
public setAccountDataRaw<K extends keyof AccountDataEvents>(
|
||||
public setAccountDataRaw<K extends keyof WritableAccountDataEvents>(
|
||||
eventType: K,
|
||||
content: AccountDataEvents[K] | Record<string, never>,
|
||||
): Promise<EmptyObject> {
|
||||
@@ -2328,7 +2331,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteAccountData(eventType: keyof AccountDataEvents): Promise<void> {
|
||||
public async deleteAccountData(eventType: keyof WritableAccountDataEvents): Promise<void> {
|
||||
const msc3391DeleteAccountDataServerSupport = this.canSupport.get(Feature.AccountDataDeletion);
|
||||
// if deletion is not supported overwrite with empty content
|
||||
if (msc3391DeleteAccountDataServerSupport === ServerSupport.Unsupported) {
|
||||
@@ -2426,12 +2429,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
|
||||
@@ -2584,12 +2585,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param roomId - the ID of the room this event should be stored within
|
||||
* @param eventType - event type to be set
|
||||
* @param content - event content
|
||||
* @returns Promise which resolves: to an empty object `{}`
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public setRoomAccountData(roomId: string, eventType: string, content: Record<string, any>): Promise<EmptyObject> {
|
||||
public setRoomAccountData<K extends keyof RoomAccountDataEvents>(
|
||||
roomId: string,
|
||||
eventType: K,
|
||||
content: RoomAccountDataEvents[K] | Record<string, never>,
|
||||
): Promise<EmptyObject> {
|
||||
const path = utils.encodeUri("/user/$userId/rooms/$roomId/account_data/$type", {
|
||||
$userId: this.credentials.userId!,
|
||||
$roomId: roomId,
|
||||
@@ -3911,7 +3917,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Rejects: with an error response.
|
||||
* May return synthesized attributes if the URL lacked OG meta.
|
||||
*/
|
||||
public getUrlPreview(url: string, ts: number): Promise<IPreviewUrlResponse> {
|
||||
public async getUrlPreview(url: string, ts: number): Promise<IPreviewUrlResponse> {
|
||||
// bucket the timestamp to the nearest minute to prevent excessive spam to the server
|
||||
// Surely 60-second accuracy is enough for anyone.
|
||||
ts = Math.floor(ts / 60000) * 60000;
|
||||
@@ -3927,16 +3933,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.urlPreviewCache[key];
|
||||
}
|
||||
|
||||
const supportsNewEndpoint = await this.isVersionSupported("v1.11");
|
||||
|
||||
const resp = this.http.authedRequest<IPreviewUrlResponse>(
|
||||
Method.Get,
|
||||
"/preview_url",
|
||||
supportsNewEndpoint ? "/media/preview_url" : "/preview_url",
|
||||
{
|
||||
url,
|
||||
ts: ts.toString(),
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
prefix: MediaPrefix.V3,
|
||||
prefix: supportsNewEndpoint ? ClientPrefix.V1 : MediaPrefix.V3,
|
||||
priority: "low",
|
||||
},
|
||||
);
|
||||
@@ -4079,7 +4087,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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import { type MBeaconEventContent, type MBeaconInfoContent, type MBeaconInfoEventContent } from "./@types/beacon.ts";
|
||||
import { MsgType } from "./@types/event.ts";
|
||||
import { M_TEXT, REFERENCE_RELATION } from "./@types/extensible_events.ts";
|
||||
import { type IMessageRendering, M_TEXT, REFERENCE_RELATION } from "./@types/extensible_events.ts";
|
||||
import { isProvided } from "./extensible_events_v1/utilities.ts";
|
||||
import {
|
||||
M_ASSET,
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
type MAssetContent,
|
||||
type LegacyLocationEventContent,
|
||||
} from "./@types/location.ts";
|
||||
import { type MRoomTopicEventContent, type MTopicContent, M_TOPIC } from "./@types/topic.ts";
|
||||
import { type MRoomTopicEventContent, type MTopicContent, M_TOPIC, type MTopicEvent } from "./@types/topic.ts";
|
||||
import { type RoomMessageEventContent } from "./@types/events.ts";
|
||||
|
||||
/**
|
||||
@@ -206,7 +206,7 @@ export type TopicState = {
|
||||
};
|
||||
|
||||
export const parseTopicContent = (content: MRoomTopicEventContent): TopicState => {
|
||||
const mtopicParent = M_TOPIC.findIn<MTopicContent>(content);
|
||||
const mtopicParent = M_TOPIC.findIn<MTopicContent | IMessageRendering[]>(content as MTopicEvent);
|
||||
const mtopic = Array.isArray(mtopicParent) ? mtopicParent : mtopicParent?.["m.text"];
|
||||
// TODO remove support for the old malformed m.topic arrays after a few releases (only allow array in m.text)
|
||||
// https://github.com/matrix-org/matrix-js-sdk/pull/4984#pullrequestreview-3174251065
|
||||
|
||||
@@ -51,6 +51,7 @@ function validateMediaId(mediaId: string): boolean {
|
||||
* for authenticated media will *not* be checked - it is the caller's responsibility
|
||||
* to do so before calling this function. Note also that `useAuthentication`
|
||||
* implies `allowRedirects`. Defaults to false (unauthenticated endpoints).
|
||||
* @param animated - Whether the desired thumbnail should be animated.
|
||||
* @returns The complete URL to the content, may be an empty string if the provided mxc is not valid.
|
||||
*/
|
||||
export function getHttpUriForMxc(
|
||||
@@ -62,6 +63,7 @@ export function getHttpUriForMxc(
|
||||
allowDirectLinks = false,
|
||||
allowRedirects?: boolean,
|
||||
useAuthentication?: boolean,
|
||||
animated?: boolean,
|
||||
): string {
|
||||
if (typeof mxc !== "string" || !mxc) {
|
||||
return "";
|
||||
@@ -107,6 +109,9 @@ export function getHttpUriForMxc(
|
||||
if (resizeMethod) {
|
||||
url.searchParams.set("method", resizeMethod);
|
||||
}
|
||||
if (animated !== undefined) {
|
||||
url.searchParams.set("animated", String(animated));
|
||||
}
|
||||
|
||||
if (typeof allowRedirects === "boolean") {
|
||||
// We add this after, so we don't convert everything to a thumbnail request.
|
||||
|
||||
+53
-21
@@ -188,7 +188,9 @@ export interface CryptoApi {
|
||||
/**
|
||||
* Check if the given user has published cross-signing keys.
|
||||
*
|
||||
* - If the user is tracked, a `/keys/query` request is made to update locally the cross signing keys.
|
||||
* - If the user is this user, a `/keys/query` request is made to update locally the cross signing keys.
|
||||
* - If the user is tracked, any current `/keys/query` requests are awaited (with a timeout) and then
|
||||
* the locally cached information is used.
|
||||
* - If the user is not tracked locally and downloadUncached is set to true,
|
||||
* a `/keys/query` request is made to the server to retrieve the cross signing keys.
|
||||
* - Otherwise, return false
|
||||
@@ -205,7 +207,10 @@ export interface CryptoApi {
|
||||
* Get the device information for the given list of users.
|
||||
*
|
||||
* For any users whose device lists are cached (due to sharing an encrypted room with the user), the
|
||||
* cached device data is returned.
|
||||
* cached device data is returned, unless it is stale.
|
||||
*
|
||||
* If there are users with stale cached entries, wait (with some timeout) for any in-progress
|
||||
* `/keys/query` request to complete.
|
||||
*
|
||||
* If there are uncached users, and the `downloadUncached` parameter is set to `true`,
|
||||
* a `/keys/query` request is made to the server to retrieve these devices.
|
||||
@@ -563,8 +568,11 @@ export interface CryptoApi {
|
||||
* if they match, stores the key in the crypto store by calling {@link storeSessionBackupPrivateKey},
|
||||
* which enables automatic restore of individual keys when an Unable-to-decrypt error is encountered.
|
||||
*
|
||||
* If we are unable to fetch the key from secret storage, there is no backup on the server, or the key
|
||||
* does not match, throws an exception.
|
||||
* If the backup decryption key from secret storage does not match the
|
||||
* latest backup on the server, we throw a {@link DecryptionKeyDoesNotMatchError}.
|
||||
*
|
||||
* If we are unable to fetch the key from secret storage or there is no backup on the server,
|
||||
* we throw an exception.
|
||||
*/
|
||||
loadSessionBackupPrivateKeyFromSecretStorage(): Promise<void>;
|
||||
|
||||
@@ -623,7 +631,6 @@ export interface CryptoApi {
|
||||
* * Disables 4S, deleting the info for the default key, the default key pointer itself and any
|
||||
* known 4S data (cross-signing keys and the megolm key backup key).
|
||||
* * Deletes any dehydrated devices.
|
||||
* * Sets the "m.org.matrix.custom.backup_disabled" account data flag to indicate that the user has disabled backups.
|
||||
*/
|
||||
disableKeyStorage(): Promise<void>;
|
||||
|
||||
@@ -717,20 +724,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. */
|
||||
@@ -805,6 +798,9 @@ export enum DeviceIsolationModeKind {
|
||||
*
|
||||
* Events from all senders are always decrypted (and should be decorated with message shields in case
|
||||
* of authenticity warnings, see {@link EventEncryptionInfo}).
|
||||
*
|
||||
* `AllDevicesIsolationMode` is used in the legacy, non-'exclude insecure devices' mode in Element Web. It is not
|
||||
* recommended (see {@link https://github.com/matrix-org/matrix-spec-proposals/pull/4153 | MSC4153}).
|
||||
*/
|
||||
export class AllDevicesIsolationMode {
|
||||
public readonly kind = DeviceIsolationModeKind.AllDevicesIsolationMode;
|
||||
@@ -831,6 +827,9 @@ export class AllDevicesIsolationMode {
|
||||
*
|
||||
* Events are decrypted only if they come from a cross-signed device. Other events will result in a decryption
|
||||
* failure. (To access the failure reason, see {@link MatrixEvent.decryptionFailureReason}.)
|
||||
*
|
||||
* `OnlySignedDevicesIsolationMode` corresponds to the 'Exclude insecure devices' mode in Element Web, which is
|
||||
* recommended by {@link https://github.com/matrix-org/matrix-spec-proposals/pull/4153 | MSC4153}.
|
||||
*/
|
||||
export class OnlySignedDevicesIsolationMode {
|
||||
public readonly kind = DeviceIsolationModeKind.OnlySignedDevicesIsolationMode;
|
||||
@@ -862,6 +861,25 @@ export interface BootstrapCrossSigningOpts {
|
||||
* Represents the ways in which we trust a user
|
||||
*/
|
||||
export class UserVerificationStatus {
|
||||
/**
|
||||
* Indicates if we have saved a known identity for this user. Typically, this means that we share a
|
||||
* room with them (or have done in the past).
|
||||
*
|
||||
* If this is `false`, then the other flags ({@link isCrossSigningVerified}, {@link wasCrossSigningVerified},
|
||||
* {@link needsUserApproval}) will also be `false`. This means that we haven't seen this user before.
|
||||
*
|
||||
* If this is `true`, then there are further possibilities:
|
||||
*
|
||||
* - If {@link isCrossSigningVerified} returns `true`, then we have cryptographically verified the current
|
||||
* identity of this user: that is the highest form of trust we have.
|
||||
*
|
||||
* - If {@link needsUserApproval} is `true`, that means that the user has changed their identity.
|
||||
*
|
||||
* - Otherwise, the user is "TOFU trusted": we have a record of their identity, and, typically, will share
|
||||
* encrypted content with them as long as they retain that identity.
|
||||
*/
|
||||
public readonly known: boolean;
|
||||
|
||||
/**
|
||||
* Indicates if the identity has changed in a way that needs user approval.
|
||||
*
|
||||
@@ -877,12 +895,14 @@ export class UserVerificationStatus {
|
||||
*/
|
||||
public readonly needsUserApproval: boolean;
|
||||
|
||||
/** @internal */
|
||||
public constructor(
|
||||
private readonly crossSigningVerified: boolean,
|
||||
private readonly crossSigningVerifiedBefore: boolean,
|
||||
private readonly tofu: boolean,
|
||||
known: boolean,
|
||||
needsUserApproval: boolean = false,
|
||||
) {
|
||||
this.known = known;
|
||||
this.needsUserApproval = needsUserApproval;
|
||||
}
|
||||
|
||||
@@ -914,7 +934,7 @@ export class UserVerificationStatus {
|
||||
* @deprecated No longer supported, with the Rust crypto stack.
|
||||
*/
|
||||
public isTofu(): boolean {
|
||||
return this.tofu;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1339,6 +1359,18 @@ export interface OlmEncryptionInfo {
|
||||
senderVerified: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* An error thrown by loadSessionBackupPrivateKeyFromSecretStorage indicating
|
||||
* that the decryption key found in secret storage does not match the public key
|
||||
* of the latest backup.
|
||||
*/
|
||||
export class DecryptionKeyDoesNotMatchError extends Error {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "DecryptionKeyDoesNotMatchError";
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./verification.ts";
|
||||
export type * from "./keybackup.ts";
|
||||
export * from "./recovery-key.ts";
|
||||
|
||||
+1
-1
@@ -806,7 +806,7 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
// Sliding Sync
|
||||
await this.syncApi!.injectRoomEvents(this.room!, [event]);
|
||||
}
|
||||
logger.info(`Updated state entry ${event.getType()} ${event.getStateKey()} to ${event.getId()}`);
|
||||
logger.debug(`Updated state entry ${event.getType()} ${event.getStateKey()} to ${event.getId()}`);
|
||||
} else {
|
||||
const { event_id: eventId, room_id: roomId } = ev.detail.data;
|
||||
logger.info(`Received state entry ${eventId} for a different room ${roomId}; discarding`);
|
||||
|
||||
@@ -67,6 +67,11 @@ export interface IHttpOpts {
|
||||
* Optional, only called when a refreshToken is present
|
||||
*/
|
||||
tokenRefreshFunction?: TokenRefreshFunction;
|
||||
|
||||
/**
|
||||
* Whether to use the HTTP Authorization header over the `access_token` query parameter
|
||||
* @deprecated as of v1.11 in https://spec.matrix.org/v1.17/client-server-api/#using-access-tokens
|
||||
*/
|
||||
useAuthorizationHeader?: boolean; // defaults to true
|
||||
|
||||
/** For historical reasons, must be set to `true`. Will eventually be removed. */
|
||||
|
||||
+5
-5
@@ -52,35 +52,35 @@ export interface BaseLogger {
|
||||
*
|
||||
* @param msg - Data to log.
|
||||
*/
|
||||
trace(...msg: any[]): void;
|
||||
trace(this: void, ...msg: any[]): void;
|
||||
|
||||
/**
|
||||
* Output debug message to the logger.
|
||||
*
|
||||
* @param msg - Data to log.
|
||||
*/
|
||||
debug(...msg: any[]): void;
|
||||
debug(this: void, ...msg: any[]): void;
|
||||
|
||||
/**
|
||||
* Output info message to the logger.
|
||||
*
|
||||
* @param msg - Data to log.
|
||||
*/
|
||||
info(...msg: any[]): void;
|
||||
info(this: void, ...msg: any[]): void;
|
||||
|
||||
/**
|
||||
* Output warn message to the logger.
|
||||
*
|
||||
* @param msg - Data to log.
|
||||
*/
|
||||
warn(...msg: any[]): void;
|
||||
warn(this: void, ...msg: any[]): void;
|
||||
|
||||
/**
|
||||
* Output error message to the logger.
|
||||
*
|
||||
* @param msg - Data to log.
|
||||
*/
|
||||
error(...msg: any[]): void;
|
||||
error(this: void, ...msg: any[]): void;
|
||||
}
|
||||
|
||||
// This is to demonstrate, that you can use any namespace you want.
|
||||
|
||||
@@ -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";
|
||||
|
||||
+203
-392
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2023-2026 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.
|
||||
@@ -14,16 +14,20 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MXID_PATTERN } from "../models/room-member.ts";
|
||||
import { deepCompare } from "../utils.ts";
|
||||
import { type LivekitFocusSelection } from "./LivekitTransport.ts";
|
||||
import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts";
|
||||
import type { RTCCallIntent, Transport } from "./types.ts";
|
||||
import { type MatrixEvent, type IContent } from "../models/event.ts";
|
||||
import { type RelationType } from "../@types/event.ts";
|
||||
import { sha256 } from "../digest.ts";
|
||||
import { encodeUnpaddedBase64 } from "../base64.ts";
|
||||
import { type Logger } from "../logger.ts";
|
||||
import { type RTCCallIntent, type Transport, type SlotDescription } from "./types.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { type Logger, logger } from "../logger.ts";
|
||||
import { computeSlotId, slotIdToDescription } from "./utils.ts";
|
||||
import {
|
||||
checkRtcMembershipData,
|
||||
computeRtcIdentityRaw,
|
||||
type RtcMembershipData,
|
||||
checkSessionsMembershipData,
|
||||
type SessionMembershipData,
|
||||
MatrixRTCMembershipParseError,
|
||||
} from "./membershipData/index.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
|
||||
/**
|
||||
* The default duration in milliseconds that a membership is considered valid for.
|
||||
@@ -32,331 +36,118 @@ import { type Logger } from "../logger.ts";
|
||||
*/
|
||||
export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4;
|
||||
|
||||
type CallScope = "m.room" | "m.user";
|
||||
type Member = {
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
/**
|
||||
* Describes the source event type that provided the membership data.
|
||||
*/
|
||||
enum MembershipKind {
|
||||
/**
|
||||
* The id used on the media backend.
|
||||
* (With livekit this is the participant identity on the LK SFU)
|
||||
* This can be a UUID but right now it is `${this.matrixEventData.sender}:${data.device_id}`.
|
||||
* The modern MSC4143 format event.
|
||||
*/
|
||||
id: string;
|
||||
};
|
||||
|
||||
export interface RtcMembershipData {
|
||||
"slot_id": string;
|
||||
"member": Member;
|
||||
"m.relates_to"?: {
|
||||
event_id: string;
|
||||
rel_type: RelationType.Reference;
|
||||
};
|
||||
"application": {
|
||||
type: string;
|
||||
// other application specific keys
|
||||
[key: string]: unknown;
|
||||
};
|
||||
"rtc_transports": Transport[];
|
||||
"versions": string[];
|
||||
"msc4354_sticky_key"?: string;
|
||||
"sticky_key"?: string;
|
||||
RTC = "rtc",
|
||||
/**
|
||||
* The legacy call event type.
|
||||
*/
|
||||
Session = "session",
|
||||
}
|
||||
|
||||
const checkRtcMembershipData = (
|
||||
data: IContent,
|
||||
errors: string[],
|
||||
referenceUserId: string,
|
||||
): data is RtcMembershipData => {
|
||||
const prefix = " - ";
|
||||
type MembershipData =
|
||||
| { kind: MembershipKind.RTC; data: RtcMembershipData }
|
||||
| { kind: MembershipKind.Session; data: SessionMembershipData };
|
||||
|
||||
// required fields
|
||||
if (typeof data.slot_id !== "string") {
|
||||
errors.push(prefix + "slot_id must be string");
|
||||
} else {
|
||||
if (data.slot_id.split("#").length !== 2) errors.push(prefix + 'slot_id must include exactly one "#"');
|
||||
}
|
||||
if (typeof data.member !== "object" || data.member === null) {
|
||||
errors.push(prefix + "member must be an object");
|
||||
} else {
|
||||
if (typeof data.member.user_id !== "string") errors.push(prefix + "member.user_id must be string");
|
||||
else if (!MXID_PATTERN.test(data.member.user_id)) errors.push(prefix + "member.user_id must be a valid mxid");
|
||||
// This is not what the spec enforces but there currently are no rules what power levels are required to
|
||||
// send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there
|
||||
// is a proper definition when this is allowed.
|
||||
else if (data.member.user_id !== referenceUserId) errors.push(prefix + "member.user_id must match the sender");
|
||||
if (typeof data.member.device_id !== "string") errors.push(prefix + "member.device_id must be string");
|
||||
if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string");
|
||||
}
|
||||
if (typeof data.application !== "object" || data.application === null) {
|
||||
errors.push(prefix + "application must be an object");
|
||||
} else {
|
||||
if (typeof data.application.type !== "string") {
|
||||
errors.push(prefix + "application.type must be a string");
|
||||
} else {
|
||||
if (data.application.type.includes("#")) errors.push(prefix + 'application.type must not include "#"');
|
||||
}
|
||||
}
|
||||
if (data.rtc_transports === undefined || !Array.isArray(data.rtc_transports)) {
|
||||
errors.push(prefix + "rtc_transports must be an array");
|
||||
} else {
|
||||
// validate that each transport has at least a string 'type'
|
||||
for (const t of data.rtc_transports) {
|
||||
if (typeof t !== "object" || t === null || typeof (t as any).type !== "string") {
|
||||
errors.push(prefix + "rtc_transports entries must be objects with a string type");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.versions === undefined || !Array.isArray(data.versions)) {
|
||||
errors.push(prefix + "versions must be an array");
|
||||
} else if (!data.versions.every((v) => typeof v === "string")) {
|
||||
errors.push(prefix + "versions must be an array of strings");
|
||||
}
|
||||
|
||||
// optional fields
|
||||
if ((data.sticky_key ?? data.msc4354_sticky_key) === undefined) {
|
||||
errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined");
|
||||
}
|
||||
if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") {
|
||||
errors.push(prefix + "sticky_key must be a string");
|
||||
}
|
||||
if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "string") {
|
||||
errors.push(prefix + "msc4354_sticky_key must be a string");
|
||||
}
|
||||
if (
|
||||
data.sticky_key !== undefined &&
|
||||
data.msc4354_sticky_key !== undefined &&
|
||||
data.sticky_key !== data.msc4354_sticky_key
|
||||
) {
|
||||
errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined");
|
||||
}
|
||||
if (data["m.relates_to"] !== undefined) {
|
||||
const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"];
|
||||
if (typeof rel !== "object" || rel === null) {
|
||||
errors.push(prefix + "m.relates_to must be an object if provided");
|
||||
} else {
|
||||
if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string");
|
||||
if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference");
|
||||
}
|
||||
}
|
||||
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* MSC4143 (MatrixRTC) session membership data.
|
||||
* Represents the `session` in the memberships section of an m.call.member event as it is on the wire.
|
||||
**/
|
||||
export type SessionMembershipData = {
|
||||
/**
|
||||
* The RTC application defines the type of the RTC session.
|
||||
*/
|
||||
"application": string;
|
||||
|
||||
/**
|
||||
* The id of this session.
|
||||
* A session can never span over multiple rooms so this id is to distinguish between
|
||||
* multiple session in one room. A room wide session that is not associated with a user,
|
||||
* and therefore immune to creation race conflicts, uses the `call_id: ""`.
|
||||
*/
|
||||
"call_id": string;
|
||||
|
||||
/**
|
||||
* The Matrix device ID of this session. A single user can have multiple sessions on different devices.
|
||||
*/
|
||||
"device_id": string;
|
||||
|
||||
/**
|
||||
* The focus selection system this user/membership is using.
|
||||
*/
|
||||
"focus_active": LivekitFocusSelection;
|
||||
|
||||
/**
|
||||
* A list of possible foci this user knows about. One of them might be used based on the focus_active
|
||||
* selection system.
|
||||
*/
|
||||
"foci_preferred": Transport[];
|
||||
|
||||
/**
|
||||
* Optional field that contains the creation of the session. If it is undefined the creation
|
||||
* is the `origin_server_ts` of the event itself. For updates to the event this property tracks
|
||||
* the `origin_server_ts` of the initial join event.
|
||||
* - If it is undefined it can be interpreted as a "Join".
|
||||
* - If it is defined it can be interpreted as an "Update"
|
||||
*/
|
||||
"created_ts"?: number;
|
||||
|
||||
// Application specific data
|
||||
|
||||
/**
|
||||
* If the `application` = `"m.call"` this defines if it is a room or user owned call.
|
||||
* There can always be one room scoped call but multiple user owned calls (breakout sessions)
|
||||
*/
|
||||
"scope"?: CallScope;
|
||||
|
||||
/**
|
||||
* Optionally we allow to define a delta to the `created_ts` that defines when the event is expired/invalid.
|
||||
* This should be set to multiple hours. The only reason it exist is to deal with failed delayed events.
|
||||
* (for example caused by a homeserver crashes)
|
||||
**/
|
||||
"expires"?: number;
|
||||
|
||||
/**
|
||||
* The intent of the call from the perspective of this user. This may be an audio call, video call or
|
||||
* something else.
|
||||
*/
|
||||
"m.call.intent"?: RTCCallIntent;
|
||||
/**
|
||||
* The sticky key in case of a sticky event. This string encodes the application + device_id indicating the used slot + device.
|
||||
*/
|
||||
"msc4354_sticky_key"?: string;
|
||||
|
||||
/**
|
||||
* The id used on the media backend.
|
||||
* (With livekit this is the participant identity on the LK SFU)
|
||||
* This can be a UUID but right now it is `${this.matrixEventData.sender}:${data.device_id}`.
|
||||
*
|
||||
* It is compleatly valid to not set this field. Other clients will treat `undefined` as `${this.matrixEventData.sender}:${data.device_id}`
|
||||
*/
|
||||
"membershipID"?: string;
|
||||
};
|
||||
|
||||
const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => {
|
||||
const prefix = " - ";
|
||||
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
|
||||
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
|
||||
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
|
||||
if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string");
|
||||
if (data.focus_active === undefined) {
|
||||
errors.push(prefix + "focus_active has an invalid type");
|
||||
}
|
||||
if (
|
||||
data.foci_preferred !== undefined &&
|
||||
!(
|
||||
Array.isArray(data.foci_preferred) &&
|
||||
data.foci_preferred.every(
|
||||
(f: Transport) => typeof f === "object" && f !== null && typeof f.type === "string",
|
||||
)
|
||||
)
|
||||
) {
|
||||
errors.push(prefix + "foci_preferred must be an array of transport objects");
|
||||
}
|
||||
// optional parameters
|
||||
if (data.created_ts !== undefined && typeof data.created_ts !== "number") {
|
||||
errors.push(prefix + "created_ts must be number");
|
||||
}
|
||||
|
||||
// application specific data (we first need to check if they exist)
|
||||
if (data.scope !== undefined && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
|
||||
|
||||
if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") {
|
||||
errors.push(prefix + "m.call.intent must be a string");
|
||||
}
|
||||
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "session"; data: SessionMembershipData };
|
||||
// TODO: Rename to RtcMembership once we removed the legacy SessionMembership from this file.
|
||||
type LimitedEvent = Pick<MatrixEvent, "getId" | "getSender" | "getTs" | "getType" | "getContent">;
|
||||
// TODO: Rename to RtcMembership once we removed the legacy SessionMembership is removed, to avoid confusion.
|
||||
export class CallMembership {
|
||||
/**
|
||||
* Parse the membershipdata from a call membership event.
|
||||
* @param matrixEvent The Matrix event to read.
|
||||
* @returns MembershipData in either MembershipKind.RTC or MembershipKind.Session format.
|
||||
* @throws If the content is neither format.
|
||||
*/
|
||||
public static membershipDataFromMatrixEvent(matrixEvent: LimitedEvent): MembershipData {
|
||||
const sender = matrixEvent.getSender();
|
||||
const evType = matrixEvent.getType();
|
||||
const data = matrixEvent.getContent();
|
||||
if (sender === undefined) throw new Error("matrixEvent is missing sender field");
|
||||
try {
|
||||
// Event types are strictly checked here.
|
||||
if (evType === EventType.RTCMembership && checkRtcMembershipData(data, sender)) {
|
||||
return { kind: MembershipKind.RTC, data };
|
||||
} else if (evType === EventType.GroupCallMemberPrefix && checkSessionsMembershipData(data)) {
|
||||
return { kind: MembershipKind.Session, data };
|
||||
} else {
|
||||
throw Error(`'${evType} is not a known call membership type`);
|
||||
}
|
||||
} catch (ex) {
|
||||
if (ex instanceof MatrixRTCMembershipParseError) {
|
||||
logger.debug("CallMembership.MatrixRTCMembershipParseError provided invalid data", data);
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the contents of a MatrixEvent and create a CallMembership instance.
|
||||
* @param matrixEvent The Matrix event to read.
|
||||
*/
|
||||
public static async parseFromEvent(matrixEvent: LimitedEvent): Promise<CallMembership> {
|
||||
const membershipData: MembershipData = this.membershipDataFromMatrixEvent(matrixEvent);
|
||||
const rtcBackendIdentity =
|
||||
membershipData.kind === MembershipKind.RTC
|
||||
? await computeRtcIdentityRaw(
|
||||
membershipData.data.member.user_id,
|
||||
membershipData.data.member.device_id,
|
||||
membershipData.data.member.id,
|
||||
)
|
||||
: `${matrixEvent.getSender()}:${membershipData.data.device_id}`;
|
||||
return new CallMembership(matrixEvent, membershipData, rtcBackendIdentity);
|
||||
}
|
||||
|
||||
public static equal(a?: CallMembership, b?: CallMembership): boolean {
|
||||
return deepCompare(a?.membershipData, b?.membershipData);
|
||||
}
|
||||
|
||||
private logger?: Logger;
|
||||
private logger: Logger;
|
||||
|
||||
/** The parsed data from the Matrix event.
|
||||
* To access checked eventId and sender from the matrixEvent.
|
||||
* Class construction will fail if these values cannot get obtained. */
|
||||
private readonly matrixEventData: { eventId: string; sender: string; ts: number };
|
||||
|
||||
public constructor(
|
||||
/** The required parts of the Matrix event that this membership is based on */
|
||||
matrixEvent: Pick<MatrixEvent, "getId" | "getSender" | "getTs">,
|
||||
|
||||
/**
|
||||
* The type checked membership data {data: (content of the matrix event), kind: (type hint)}
|
||||
*
|
||||
*/
|
||||
private readonly membershipData: MembershipData,
|
||||
|
||||
/**
|
||||
*
|
||||
* Anonymized identity to use with the RTC backend.
|
||||
*
|
||||
* The rtcBackendIdentity is a hashed version of all the identity parts:
|
||||
* `sha256(${this.userId}|${this.deviceId}|${this.memberId})`
|
||||
*
|
||||
* It is used to anonymize the identity of the user in the RTC backend.
|
||||
*/
|
||||
public readonly rtcBackendIdentity: string,
|
||||
/**
|
||||
* The constructor will automatically create a properly tagged child logger instance.
|
||||
*/
|
||||
logger?: Logger,
|
||||
) {
|
||||
const [eventId, sender, ts] = [matrixEvent.getId(), matrixEvent.getSender(), matrixEvent.getTs()];
|
||||
if (eventId === undefined) throw new Error("parentEvent is missing eventId field");
|
||||
if (sender === undefined) throw new Error("parentEvent is missing sender field");
|
||||
|
||||
this.matrixEventData = { eventId, sender, ts };
|
||||
|
||||
this.logger = logger?.getChild(`[CallMembership ${sender}:${this.deviceId}]`);
|
||||
}
|
||||
private readonly matrixEventData: { eventId: string; sender: string };
|
||||
|
||||
/**
|
||||
* sha256(`${this.userId}|${this.deviceId}|${this.memberId}`) for sticky events (kind = rtc)
|
||||
* `${this.userId}:${this.deviceId}` for state events (kind = session)
|
||||
* Use `parseFromEvent`.
|
||||
* Constructor should only be used by tests.
|
||||
* @private
|
||||
* @param matrixEvent
|
||||
* @param membershipData
|
||||
* @param rtcBackendIdentity
|
||||
*/
|
||||
public static async computeRtcBackendIdentity(
|
||||
matrixEvent: Pick<MatrixEvent, "getSender">,
|
||||
membershipData: MembershipData,
|
||||
): Promise<string> {
|
||||
const { kind, data } = membershipData;
|
||||
switch (kind) {
|
||||
case "rtc": {
|
||||
return CallMembership.computeRtcIdentityRaw(data.member.user_id, data.member.device_id, data.member.id);
|
||||
}
|
||||
case "session":
|
||||
return `${matrixEvent.getSender()}:${data.device_id}`;
|
||||
}
|
||||
}
|
||||
|
||||
public static async computeRtcIdentityRaw(userId: string, deviceId: string, memberId: string): Promise<string> {
|
||||
return encodeUnpaddedBase64(await sha256(`${userId}|${deviceId}|${memberId}`));
|
||||
}
|
||||
|
||||
public static membershipDataFromMatrixEvent(matrixEvent: MatrixEvent): MembershipData {
|
||||
const [eventId, sender, content] = [matrixEvent.getId(), matrixEvent.getSender(), matrixEvent.getContent()];
|
||||
public constructor(
|
||||
/** The Matrix event that this membership is based on */
|
||||
private readonly matrixEvent: LimitedEvent,
|
||||
private readonly membershipData: MembershipData,
|
||||
public readonly rtcBackendIdentity: string,
|
||||
) {
|
||||
const eventId = matrixEvent.getId();
|
||||
const sender = matrixEvent.getSender();
|
||||
|
||||
if (eventId === undefined) throw new Error("parentEvent is missing eventId field");
|
||||
if (sender === undefined) throw new Error("parentEvent is missing sender field");
|
||||
|
||||
const sessionErrors: string[] = [];
|
||||
const rtcErrors: string[] = [];
|
||||
if (checkSessionsMembershipData(content, sessionErrors)) {
|
||||
return { kind: "session", data: content };
|
||||
} else if (checkRtcMembershipData(content, rtcErrors, sender)) {
|
||||
return { kind: "rtc", data: content };
|
||||
} else {
|
||||
const details =
|
||||
sessionErrors.length < rtcErrors.length
|
||||
? `Does not match MSC4143 m.call.member:\n${sessionErrors.join("\n")}\n\n`
|
||||
: `Does not match MSC4143 m.rtc.member:\n${rtcErrors.join("\n")}\n\n`;
|
||||
const json = "\nevent:\n" + JSON.stringify(content).replaceAll('"', "'");
|
||||
throw Error(`unknown CallMembership data.\n` + details + json);
|
||||
}
|
||||
this.logger = logger.getChild(`[CallMembership ${sender}:${this.deviceId}]`);
|
||||
this.matrixEventData = { eventId, sender };
|
||||
}
|
||||
|
||||
/** @deprecated use userId instead */
|
||||
public get sender(): string {
|
||||
return this.userId;
|
||||
}
|
||||
|
||||
public get userId(): string {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
case MembershipKind.RTC:
|
||||
return data.member.user_id;
|
||||
case "session":
|
||||
case MembershipKind.Session:
|
||||
default:
|
||||
return this.matrixEventData.sender;
|
||||
}
|
||||
@@ -372,104 +163,122 @@ export class CallMembership {
|
||||
*/
|
||||
public get slotId(): string {
|
||||
const { kind, data } = this.membershipData;
|
||||
if (data.application === "m.call") {
|
||||
switch (kind) {
|
||||
case MembershipKind.RTC:
|
||||
return data.slot_id;
|
||||
case MembershipKind.Session:
|
||||
default: {
|
||||
const [application, id] = [data.application, data.call_id];
|
||||
|
||||
// INFO_SLOT_ID_LEGACY_CASE (search for all occurances of this INFO to get the full picture)
|
||||
// The spec got changed to use `"ROOM"` instead of `""` empyt string for the implicit default call.
|
||||
// State events still are sent with `""` however. To find other events that should end up in the same call,
|
||||
// we use the slotId.
|
||||
// Since the CallMembership is the public representation of a rtc.member event, we just pretend it is a
|
||||
// "ROOM" slotId/call_id.
|
||||
// This makes all the remote members work with just this simple trick.
|
||||
//
|
||||
// We of course now need to be careful when sending legacy events (state events)
|
||||
// They get a slotDescription containing "ROOM" since this is what we use starting at the time this comment
|
||||
// is commited.
|
||||
//
|
||||
// See the Other INFO_SLOT_ID_LEGACY_CASE comments to see where we revert back to "" just before sending the event.
|
||||
let compatibilityAdaptedId: string;
|
||||
if (id === "") {
|
||||
compatibilityAdaptedId = "ROOM";
|
||||
this.logger?.info("use slotId compat hack emptyString -> ROOM");
|
||||
} else {
|
||||
compatibilityAdaptedId = id;
|
||||
}
|
||||
return computeSlotId({
|
||||
application,
|
||||
id: compatibilityAdaptedId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger?.info("NOT using slotId compat hack emptyString -> ROOM");
|
||||
// This is what the function should look like for any other application that did not
|
||||
// go through a `""`=> `"ROOM"` rename
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
case MembershipKind.RTC:
|
||||
return data.slot_id;
|
||||
case "session":
|
||||
case MembershipKind.Session:
|
||||
default:
|
||||
// INFO_SLOT_ID_LEGACY_CASE (search for all occurances of this INFO to get the full picture)
|
||||
// The spec got changed to use `"ROOM"` instead of `""` empyt string for the implicit default call.
|
||||
// State events still are sent with `""` however. To find other events that should end up in the same call,
|
||||
// we use the slotId.
|
||||
// Since the CallMembership is the public representation of a rtc.member event, we just pretend it is a
|
||||
// "ROOM" slotId/call_id.
|
||||
// This makes all the remote members work with just this simple trick.
|
||||
//
|
||||
// We of course now need to be careful when sending legacy events (state events)
|
||||
// They get a slotDescription containing "ROOM" since this is what we use starting at the time this comment
|
||||
// is commited.
|
||||
//
|
||||
// See the Other INFO_SLOT_ID_LEGACY_CASE comments to see where we revert back to "" just before sending the event.
|
||||
return slotDescriptionToId({
|
||||
application: this.application,
|
||||
id: data.call_id === "" ? "ROOM" : data.call_id,
|
||||
});
|
||||
return computeSlotId({ application: data.application, id: data.call_id });
|
||||
}
|
||||
}
|
||||
|
||||
public get deviceId(): string {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
case MembershipKind.RTC:
|
||||
return data.member.device_id;
|
||||
case "session":
|
||||
case MembershipKind.Session:
|
||||
default:
|
||||
return data.device_id;
|
||||
}
|
||||
}
|
||||
|
||||
public get callIntent(): RTCCallIntent | undefined {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc": {
|
||||
const intent = data.application["m.call.intent"];
|
||||
if (typeof intent === "string") {
|
||||
return intent;
|
||||
}
|
||||
this.logger?.warn("RTC membership has invalid m.call.intent");
|
||||
return undefined;
|
||||
}
|
||||
case "session":
|
||||
default:
|
||||
return data["m.call.intent"];
|
||||
const intent = this.applicationData["m.call.intent"];
|
||||
if (typeof intent === "string") {
|
||||
return intent;
|
||||
}
|
||||
this.logger.warn("RTC membership has invalid m.call.intent");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed `slot_id` (format `{application}#{id}`) into its components (application and id).
|
||||
*/
|
||||
public get slotDescription(): SlotDescription {
|
||||
const { kind, data } = this.membershipData;
|
||||
if (kind === MembershipKind.RTC) {
|
||||
const id = data.slot_id.slice(`${data.application.type}#`.length);
|
||||
return { application: data.application.type, id };
|
||||
}
|
||||
return slotIdToDescription(this.slotId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The application `type`.
|
||||
* @deprecated Use @see applicationData
|
||||
*/
|
||||
public get application(): string {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return data.application.type;
|
||||
case "session":
|
||||
default:
|
||||
return data.application;
|
||||
}
|
||||
return this.applicationData.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about the application being used for the RTC session.
|
||||
* May contain extra keys specific to the application.
|
||||
*/
|
||||
public get applicationData(): { type: string; [key: string]: unknown } {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
case MembershipKind.RTC:
|
||||
return data.application;
|
||||
case "session":
|
||||
case MembershipKind.Session:
|
||||
default:
|
||||
// SessionData does not have application data as such. We return specific
|
||||
// properties in use by other getters in this class, for compatibility.
|
||||
return { "type": data.application, "m.call.intent": data["m.call.intent"] };
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated scope is not used and will be removed in future versions. replaced by application specific types.*/
|
||||
public get scope(): CallScope | undefined {
|
||||
public get scope(): SessionMembershipData["scope"] | undefined {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
case MembershipKind.RTC:
|
||||
return undefined;
|
||||
case "session":
|
||||
case MembershipKind.Session:
|
||||
default:
|
||||
return data.scope;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @deprecated renamed to `memberId`
|
||||
*/
|
||||
public get membershipID(): string {
|
||||
return this.memberId;
|
||||
}
|
||||
|
||||
/**
|
||||
* This computes the membership ID for the membership.
|
||||
@@ -494,25 +303,33 @@ export class CallMembership {
|
||||
case "rtc":
|
||||
return data.member.id;
|
||||
case "session":
|
||||
default:
|
||||
return (
|
||||
// best case we have a client already publishing the right custom membershipId
|
||||
data.membershipID ??
|
||||
// alternativly we use the hard coded jwt id defuatl value (used until version 0.16.0)
|
||||
`${this.matrixEventData.sender}:${data.device_id}`
|
||||
);
|
||||
default:
|
||||
throw Error("Not possible to get memberID without knowing the membership event kind");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated renamed to `memberId`
|
||||
*/
|
||||
public get membershipID(): string {
|
||||
return this.memberId;
|
||||
}
|
||||
|
||||
public createdTs(): number {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
case MembershipKind.RTC:
|
||||
// TODO we need to read the referenced (relation) event if available to get the real created_ts
|
||||
return this.matrixEventData.ts;
|
||||
case "session":
|
||||
return this.matrixEvent.getTs();
|
||||
case MembershipKind.Session:
|
||||
default:
|
||||
return data.created_ts ?? this.matrixEventData.ts;
|
||||
return data.created_ts ?? this.matrixEvent.getTs();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,9 +340,9 @@ export class CallMembership {
|
||||
public getAbsoluteExpiry(): number | undefined {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
case MembershipKind.RTC:
|
||||
return undefined;
|
||||
case "session":
|
||||
case MembershipKind.Session:
|
||||
default:
|
||||
// TODO: calculate this from the MatrixRTCSession join configuration directly
|
||||
return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION);
|
||||
@@ -534,19 +351,20 @@ export class CallMembership {
|
||||
|
||||
/**
|
||||
* @returns The number of milliseconds until the membership expires or undefined if applicable
|
||||
* @deprecated Not used by RTC events.
|
||||
*/
|
||||
public getMsUntilExpiry(): number | undefined {
|
||||
const { kind } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
return undefined;
|
||||
case "session":
|
||||
default:
|
||||
if (kind === MembershipKind.Session) {
|
||||
const absExpiry = this.getAbsoluteExpiry();
|
||||
if (absExpiry) {
|
||||
// Assume that local clock is sufficiently in sync with other clocks in the distributed system.
|
||||
// We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate.
|
||||
// The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2
|
||||
return this.getAbsoluteExpiry()! - Date.now();
|
||||
return absExpiry - Date.now();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -555,9 +373,9 @@ export class CallMembership {
|
||||
public isExpired(): boolean {
|
||||
const { kind } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
case MembershipKind.RTC:
|
||||
return false;
|
||||
case "session":
|
||||
case MembershipKind.Session:
|
||||
default:
|
||||
return this.getMsUntilExpiry()! <= 0;
|
||||
}
|
||||
@@ -583,30 +401,26 @@ export class CallMembership {
|
||||
public getTransport(oldestMembership: CallMembership): Transport | undefined {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
case MembershipKind.RTC:
|
||||
return data.rtc_transports[0];
|
||||
case "session":
|
||||
case MembershipKind.Session:
|
||||
switch (data.focus_active.focus_selection) {
|
||||
case "multi_sfu":
|
||||
return data.foci_preferred[0];
|
||||
case "oldest_membership":
|
||||
if (CallMembership.equal(this, oldestMembership)) return data.foci_preferred[0];
|
||||
if (oldestMembership !== undefined) return oldestMembership.getTransport(oldestMembership);
|
||||
break;
|
||||
case "multi_sfu":
|
||||
return data.foci_preferred[0];
|
||||
default:
|
||||
// `focus_selection` not understood.
|
||||
return undefined;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The focus_active filed of the session membership (m.call.member).
|
||||
* @deprecated focus_active is not used and will be removed in future versions.
|
||||
*/
|
||||
public getFocusActive(): LivekitFocusSelection | undefined {
|
||||
const { kind, data } = this.membershipData;
|
||||
if (kind === "session") return data.focus_active;
|
||||
return undefined;
|
||||
}
|
||||
/**
|
||||
* The value of the `rtc_transports` field for RTC memberships (m.rtc.member).
|
||||
* Or the value of the `foci_preferred` field for legacy session memberships (m.call.member).
|
||||
@@ -614,14 +428,11 @@ export class CallMembership {
|
||||
public get transports(): Transport[] {
|
||||
const { kind, data } = this.membershipData;
|
||||
switch (kind) {
|
||||
case "rtc":
|
||||
case MembershipKind.RTC:
|
||||
return data.rtc_transports;
|
||||
case "session":
|
||||
case MembershipKind.Session:
|
||||
default:
|
||||
return data.foci_preferred;
|
||||
}
|
||||
}
|
||||
public get kind(): MembershipData["kind"] {
|
||||
return this.membershipData.kind;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import { type EncryptionConfig } from "./MatrixRTCSession.ts";
|
||||
import { secureRandomBase64Url } from "../randomstring.ts";
|
||||
import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts";
|
||||
import { safeGetRetryAfterMs } from "../http-api/errors.ts";
|
||||
import { type CallMembership } from "./CallMembership.ts";
|
||||
import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts";
|
||||
import { isMyMembership, type EncryptionKeyMapKey, type Statistics } from "./types.ts";
|
||||
import { type EncryptionKeyMapKey } from "./types.ts";
|
||||
|
||||
/**
|
||||
* The string used for the keys in the the encryption key map.
|
||||
@@ -57,414 +52,3 @@ export interface IEncryptionManager {
|
||||
}
|
||||
|
||||
export type CallMembershipIdentityParts = Pick<CallMembership, "userId" | "deviceId" | "memberId">;
|
||||
|
||||
/**
|
||||
* This class implements the IEncryptionManager interface,
|
||||
* and takes care of managing the encryption keys of all rtc members:
|
||||
* - generate new keys for the local user and send them to other participants
|
||||
* - track all keys of all other members and update livekit.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class EncryptionManager implements IEncryptionManager {
|
||||
private manageMediaKeys = false;
|
||||
private keysEventUpdateTimeout?: ReturnType<typeof setTimeout>;
|
||||
private makeNewKeyTimeout?: ReturnType<typeof setTimeout>;
|
||||
private setNewKeyTimeouts = new Set<ReturnType<typeof setTimeout>>();
|
||||
|
||||
private get updateEncryptionKeyThrottle(): number {
|
||||
return this.joinConfig?.updateEncryptionKeyThrottle ?? 3_000;
|
||||
}
|
||||
|
||||
private get makeKeyDelay(): number {
|
||||
return this.joinConfig?.makeKeyDelay ?? 3_000;
|
||||
}
|
||||
|
||||
private get useKeyDelay(): number {
|
||||
return this.joinConfig?.useKeyDelay ?? 5_000;
|
||||
}
|
||||
|
||||
private encryptionKeys = new Map<
|
||||
string,
|
||||
Array<{ key: Uint8Array<ArrayBuffer>; timestamp: number; membership: CallMembershipIdentityParts }>
|
||||
>();
|
||||
private lastEncryptionKeyUpdateRequest?: number;
|
||||
|
||||
// We use this to store the last membership fingerprints we saw, so we can proactively re-send encryption keys
|
||||
// if it looks like a membership has been updated.
|
||||
private lastMembershipFingerprints: Set<string> | undefined;
|
||||
|
||||
private latestGeneratedKeyIndex = -1;
|
||||
private joinConfig: EncryptionConfig | undefined;
|
||||
private logger: Logger;
|
||||
|
||||
public constructor(
|
||||
private membership: CallMembershipIdentityParts,
|
||||
private getMemberships: () => CallMembership[],
|
||||
private transport: IKeyTransport,
|
||||
private statistics: Statistics,
|
||||
private onEncryptionKeysChanged: (
|
||||
keyBin: Uint8Array<ArrayBuffer>,
|
||||
encryptionKeyIndex: number,
|
||||
membership: CallMembershipIdentityParts,
|
||||
rtcBackendIdentity: string,
|
||||
) => void,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
this.logger = (parentLogger ?? rootLogger).getChild(`[EncryptionManager]`);
|
||||
}
|
||||
|
||||
private rtcBackendIdentityFromMembershipParts(membership: CallMembershipIdentityParts): string {
|
||||
// Implement logic to construct rtcBackendIdentity from membership parts
|
||||
return `${membership.userId}:${membership.deviceId}`;
|
||||
}
|
||||
|
||||
public getEncryptionKeys(): ReadonlyMap<
|
||||
EncryptionKeyMapKey,
|
||||
ReadonlyArray<{
|
||||
key: Uint8Array<ArrayBuffer>;
|
||||
keyIndex: number;
|
||||
membership: CallMembershipIdentityParts;
|
||||
rtcBackendIdentity: string;
|
||||
}>
|
||||
> {
|
||||
const keysMap = new Map<
|
||||
EncryptionKeyMapKey,
|
||||
ReadonlyArray<{
|
||||
key: Uint8Array<ArrayBuffer>;
|
||||
keyIndex: number;
|
||||
membership: CallMembershipIdentityParts;
|
||||
rtcBackendIdentity: string;
|
||||
}>
|
||||
>();
|
||||
for (const [userId, userKeyEntry] of this.encryptionKeys) {
|
||||
const keys = userKeyEntry.map((entry, index) => ({
|
||||
key: entry.key,
|
||||
membership: entry.membership,
|
||||
keyIndex: index,
|
||||
rtcBackendIdentity: this.rtcBackendIdentityFromMembershipParts(entry.membership),
|
||||
}));
|
||||
keysMap.set(userId as EncryptionKeyMapKey, keys);
|
||||
}
|
||||
return keysMap;
|
||||
}
|
||||
|
||||
private joined = false;
|
||||
|
||||
public join(joinConfig: EncryptionConfig): void {
|
||||
this.joinConfig = joinConfig;
|
||||
this.joined = true;
|
||||
this.manageMediaKeys = this.joinConfig?.manageMediaKeys ?? this.manageMediaKeys;
|
||||
|
||||
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
|
||||
this.transport.start();
|
||||
if (this.joinConfig?.manageMediaKeys) {
|
||||
this.makeNewSenderKey();
|
||||
this.requestSendCurrentKey();
|
||||
}
|
||||
}
|
||||
|
||||
public leave(): void {
|
||||
// clear our encryption keys as we're done with them now (we'll
|
||||
// make new keys if we rejoin). We leave keys for other participants
|
||||
// as they may still be using the same ones.
|
||||
this.encryptionKeys.set(getEncryptionKeyMapKey(this.membership), []);
|
||||
this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
|
||||
this.transport.stop();
|
||||
|
||||
if (this.makeNewKeyTimeout !== undefined) {
|
||||
clearTimeout(this.makeNewKeyTimeout);
|
||||
this.makeNewKeyTimeout = undefined;
|
||||
}
|
||||
for (const t of this.setNewKeyTimeouts) {
|
||||
clearTimeout(t);
|
||||
}
|
||||
this.setNewKeyTimeouts.clear();
|
||||
|
||||
this.manageMediaKeys = false;
|
||||
this.joined = false;
|
||||
}
|
||||
|
||||
public onMembershipsUpdate(oldMemberships: CallMembership[]): void {
|
||||
if (this.manageMediaKeys && this.joined) {
|
||||
const oldMembershipIds = new Set(
|
||||
oldMemberships
|
||||
.filter((m) => !isMyMembership(m, this.membership.userId, this.membership.deviceId))
|
||||
.map(getEncryptionKeyMapKey),
|
||||
);
|
||||
const newMembershipIds = new Set(
|
||||
this.getMemberships()
|
||||
.filter((m) => !isMyMembership(m, this.membership.userId, this.membership.deviceId))
|
||||
.map(getEncryptionKeyMapKey),
|
||||
);
|
||||
|
||||
// We can use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/symmetricDifference
|
||||
// for this once available
|
||||
const anyLeft = Array.from(oldMembershipIds).some((x) => !newMembershipIds.has(x));
|
||||
const anyJoined = Array.from(newMembershipIds).some((x) => !oldMembershipIds.has(x));
|
||||
|
||||
const oldFingerprints = this.lastMembershipFingerprints;
|
||||
// always store the fingerprints of these latest memberships
|
||||
this.storeLastMembershipFingerprints();
|
||||
|
||||
if (anyLeft) {
|
||||
if (this.makeNewKeyTimeout) {
|
||||
// existing rotation in progress, so let it complete
|
||||
} else {
|
||||
this.logger.debug(`Member(s) have left: queueing sender key rotation`);
|
||||
this.makeNewKeyTimeout = setTimeout(this.onRotateKeyTimeout, this.makeKeyDelay);
|
||||
}
|
||||
} else if (anyJoined) {
|
||||
this.logger.debug(`New member(s) have joined: re-sending keys`);
|
||||
this.requestSendCurrentKey();
|
||||
} else if (oldFingerprints) {
|
||||
// does it look like any of the members have updated their memberships?
|
||||
const newFingerprints = this.lastMembershipFingerprints!;
|
||||
|
||||
// We can use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/symmetricDifference
|
||||
// for this once available
|
||||
const candidateUpdates =
|
||||
Array.from(oldFingerprints).some((x) => !newFingerprints.has(x)) ||
|
||||
Array.from(newFingerprints).some((x) => !oldFingerprints.has(x));
|
||||
if (candidateUpdates) {
|
||||
this.logger.debug(`Member(s) have updated/reconnected: re-sending keys to everyone`);
|
||||
this.requestSendCurrentKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new sender key and add it at the next available index
|
||||
* @param delayBeforeUse - If true, wait for a short period before setting the key for the
|
||||
* media encryptor to use. If false, set the key immediately.
|
||||
* @returns The index of the new key
|
||||
*/
|
||||
private makeNewSenderKey(delayBeforeUse = false): number {
|
||||
const encryptionKey = secureRandomBase64Url(16);
|
||||
const encryptionKeyIndex = this.getNewEncryptionKeyIndex();
|
||||
this.logger.info("Generated new key at index " + encryptionKeyIndex);
|
||||
this.setEncryptionKey(this.membership, encryptionKeyIndex, encryptionKey, Date.now(), delayBeforeUse);
|
||||
return encryptionKeyIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests that we resend our current keys to the room. May send a keys event immediately
|
||||
* or queue for alter if one has already been sent recently.
|
||||
*/
|
||||
private requestSendCurrentKey(): void {
|
||||
if (!this.manageMediaKeys) return;
|
||||
|
||||
if (
|
||||
this.lastEncryptionKeyUpdateRequest &&
|
||||
this.lastEncryptionKeyUpdateRequest + this.updateEncryptionKeyThrottle > Date.now()
|
||||
) {
|
||||
this.logger.info("Last encryption key event sent too recently: postponing");
|
||||
if (this.keysEventUpdateTimeout === undefined) {
|
||||
this.keysEventUpdateTimeout = setTimeout(
|
||||
() => void this.sendEncryptionKeysEvent(),
|
||||
this.updateEncryptionKeyThrottle,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void this.sendEncryptionKeysEvent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the known encryption keys for a given participant device.
|
||||
*
|
||||
* @param membership - The membership identity parts of the participant
|
||||
* @returns The encryption keys for the given participant, or undefined if they are not known.
|
||||
*/
|
||||
private getKeysForParticipant(membership: CallMembershipIdentityParts): Array<Uint8Array<ArrayBuffer>> | undefined {
|
||||
return this.encryptionKeys.get(getEncryptionKeyMapKey(membership))?.map((entry) => entry.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-sends the encryption keys room event
|
||||
*/
|
||||
private sendEncryptionKeysEvent = async (indexToSend?: number): Promise<void> => {
|
||||
if (this.keysEventUpdateTimeout !== undefined) {
|
||||
clearTimeout(this.keysEventUpdateTimeout);
|
||||
this.keysEventUpdateTimeout = undefined;
|
||||
}
|
||||
this.lastEncryptionKeyUpdateRequest = Date.now();
|
||||
|
||||
if (!this.joined) return;
|
||||
|
||||
const myKeys = this.getKeysForParticipant(this.membership);
|
||||
|
||||
if (!myKeys) {
|
||||
this.logger.warn("Tried to send encryption keys event but no keys found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof indexToSend !== "number" && this.latestGeneratedKeyIndex === -1) {
|
||||
this.logger.warn("Tried to send encryption keys event but no current key index found!");
|
||||
return;
|
||||
}
|
||||
|
||||
const keyIndexToSend = indexToSend ?? this.latestGeneratedKeyIndex;
|
||||
|
||||
this.logger.info(
|
||||
`Try sending encryption keys event. keyIndexToSend=${keyIndexToSend} (method parameter: ${indexToSend})`,
|
||||
);
|
||||
const keyToSend = myKeys[keyIndexToSend];
|
||||
|
||||
try {
|
||||
this.statistics.counters.roomEventEncryptionKeysSent += 1;
|
||||
const targets = this.getMemberships()
|
||||
.filter((membership) => {
|
||||
return membership.sender != undefined;
|
||||
})
|
||||
.map((membership) => {
|
||||
return {
|
||||
userId: membership.sender!,
|
||||
deviceId: membership.deviceId,
|
||||
membershipTs: membership.createdTs(),
|
||||
};
|
||||
});
|
||||
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, targets);
|
||||
this.logger.debug(
|
||||
`sendEncryptionKeysEvent participantId=${this.membership.userId}:${this.membership.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (this.keysEventUpdateTimeout === undefined) {
|
||||
const resendDelay = safeGetRetryAfterMs(error, 5000);
|
||||
this.logger.warn(`Failed to send m.call.encryption_key, retrying in ${resendDelay}`, error);
|
||||
this.keysEventUpdateTimeout = setTimeout(() => void this.sendEncryptionKeysEvent(), resendDelay);
|
||||
} else {
|
||||
this.logger.info("Not scheduling key resend as another re-send is already pending");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public onNewKeyReceived: KeyTransportEventListener = (membership, keyBase64Encoded, index, timestamp) => {
|
||||
this.logger.debug(
|
||||
`Received key over key transport ${membership.userId}:${membership.deviceId} at index ${index}`,
|
||||
);
|
||||
this.setEncryptionKey(membership, index, keyBase64Encoded, timestamp);
|
||||
};
|
||||
|
||||
private storeLastMembershipFingerprints(): void {
|
||||
this.lastMembershipFingerprints = new Set(
|
||||
this.getMemberships()
|
||||
.filter((m) => !isMyMembership(m, this.membership.userId, this.membership.deviceId))
|
||||
.map((m) => `${getEncryptionKeyMapKey(m)}:${m.createdTs()}`),
|
||||
);
|
||||
}
|
||||
|
||||
private getNewEncryptionKeyIndex(): number {
|
||||
if (this.latestGeneratedKeyIndex === -1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// maximum key index is 255
|
||||
return (this.latestGeneratedKeyIndex + 1) % 256;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an encryption key at a specified index for a participant.
|
||||
* The encryption keys for the local participant are also stored here under the
|
||||
* user and device ID of the local participant.
|
||||
* If the key is older than the existing key at the index, it will be ignored.
|
||||
* @param userId - The user ID of the participant
|
||||
* @param deviceId - Device ID of the participant
|
||||
* @param encryptionKeyIndex - The index of the key to set
|
||||
* @param encryptionKeyString - The string representation of the key to set in base64
|
||||
* @param timestamp - The timestamp of the key. We assume that these are monotonic for each participant device.
|
||||
* @param delayBeforeUse - If true, delay before emitting a key changed event. Useful when setting
|
||||
* encryption keys for the local participant to allow time for the key to
|
||||
* be distributed.
|
||||
*/
|
||||
private setEncryptionKey(
|
||||
membership: CallMembershipIdentityParts,
|
||||
encryptionKeyIndex: number,
|
||||
encryptionKeyString: string,
|
||||
timestamp: number,
|
||||
delayBeforeUse = false,
|
||||
): void {
|
||||
this.logger.debug(
|
||||
`Setting encryption key for ${membership.userId}:${membership.deviceId} at index ${encryptionKeyIndex}`,
|
||||
);
|
||||
const keyBin = decodeBase64(encryptionKeyString);
|
||||
|
||||
const mapKey = getEncryptionKeyMapKey(membership);
|
||||
if (!this.encryptionKeys.has(mapKey)) {
|
||||
this.encryptionKeys.set(mapKey, []);
|
||||
}
|
||||
const participantKeys = this.encryptionKeys.get(mapKey)!;
|
||||
|
||||
const existingKeyAtIndex = participantKeys[encryptionKeyIndex];
|
||||
|
||||
if (existingKeyAtIndex) {
|
||||
if (existingKeyAtIndex.timestamp > timestamp) {
|
||||
this.logger.info(
|
||||
`Ignoring new key at index ${encryptionKeyIndex} for ${mapKey} as it is older than existing known key`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (keysEqual(existingKeyAtIndex.key, keyBin)) {
|
||||
existingKeyAtIndex.timestamp = timestamp;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (membership.userId === this.membership.userId && membership.deviceId === this.membership.deviceId) {
|
||||
// It is important to already update the latestGeneratedKeyIndex here
|
||||
// NOT IN THE `delayBeforeUse` `setTimeout`.
|
||||
// Even though this is where we call onEncryptionKeysChanged and set the key in EC (and livekit).
|
||||
// It needs to happen here because we will send the key before the timeout has passed and sending
|
||||
// the key will use latestGeneratedKeyIndex as the index. if we update it in the `setTimeout` callback
|
||||
// it will use the wrong index (index - 1)!
|
||||
this.latestGeneratedKeyIndex = encryptionKeyIndex;
|
||||
}
|
||||
participantKeys[encryptionKeyIndex] = {
|
||||
key: keyBin,
|
||||
timestamp,
|
||||
membership: membership,
|
||||
};
|
||||
|
||||
if (delayBeforeUse) {
|
||||
const useKeyTimeout = setTimeout(() => {
|
||||
this.setNewKeyTimeouts.delete(useKeyTimeout);
|
||||
this.logger.info(`Delayed-emitting key changed event for ${mapKey} index ${encryptionKeyIndex}`);
|
||||
|
||||
this.onEncryptionKeysChanged(
|
||||
keyBin,
|
||||
encryptionKeyIndex,
|
||||
membership,
|
||||
this.rtcBackendIdentityFromMembershipParts(membership),
|
||||
);
|
||||
}, this.useKeyDelay);
|
||||
this.setNewKeyTimeouts.add(useKeyTimeout);
|
||||
} else {
|
||||
this.onEncryptionKeysChanged(
|
||||
keyBin,
|
||||
encryptionKeyIndex,
|
||||
membership,
|
||||
this.rtcBackendIdentityFromMembershipParts(membership),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private onRotateKeyTimeout = (): void => {
|
||||
if (!this.manageMediaKeys) return;
|
||||
|
||||
this.makeNewKeyTimeout = undefined;
|
||||
this.logger.info("Making new sender key for key rotation");
|
||||
const newKeyIndex = this.makeNewSenderKey(true);
|
||||
// send immediately: if we're about to start sending with a new key, it's
|
||||
// important we get it out to others as soon as we can.
|
||||
void this.sendEncryptionKeysEvent(newKeyIndex);
|
||||
};
|
||||
}
|
||||
|
||||
function keysEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean {
|
||||
if (a === b) return true;
|
||||
return !!a && !!b && a.length === b.length && a.every((x, i) => x === b[i]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023 - 2024 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2023 - 2026 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.
|
||||
@@ -25,7 +25,7 @@ import { type ISendEventResponse } from "../@types/requests.ts";
|
||||
import { CallMembership } from "./CallMembership.ts";
|
||||
import { RoomStateEvent } from "../models/room-state.ts";
|
||||
import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts";
|
||||
import { type CallMembershipIdentityParts, EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
|
||||
import { type CallMembershipIdentityParts, type IEncryptionManager } from "./EncryptionManager.ts";
|
||||
import { logDurationSync } from "../utils.ts";
|
||||
import type {
|
||||
Statistics,
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
IRTCNotificationContent,
|
||||
RTCCallIntent,
|
||||
Transport,
|
||||
SlotDescription,
|
||||
} from "./types.ts";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
@@ -45,7 +46,7 @@ import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
|
||||
import { TypedReEmitter } from "../ReEmitter.ts";
|
||||
import { type IContent, type MatrixEvent } from "../models/event.ts";
|
||||
import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../models/room-sticky-events.ts";
|
||||
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
|
||||
import { computeSlotId } from "./utils.ts";
|
||||
|
||||
/**
|
||||
* Events emitted by MatrixRTCSession
|
||||
@@ -96,21 +97,6 @@ export interface SessionConfig {
|
||||
callIntent?: RTCCallIntent;
|
||||
}
|
||||
|
||||
/**
|
||||
* The session description is used to identify a session. Used in the state event.
|
||||
*/
|
||||
export interface SlotDescription {
|
||||
id: string;
|
||||
application: string;
|
||||
}
|
||||
export function slotIdToDescription(slotId: string): SlotDescription {
|
||||
const [application, id] = slotId.split("#");
|
||||
return { application, id };
|
||||
}
|
||||
export function slotDescriptionToId(slotDescription: SlotDescription): string {
|
||||
return `${slotDescription.application}#${slotDescription.id}`;
|
||||
}
|
||||
|
||||
// The names follow these principles:
|
||||
// - we use the technical term delay if the option is related to delayed events.
|
||||
// - we use delayedLeaveEvent if the option is related to the delayed leave event.
|
||||
@@ -165,11 +151,6 @@ export interface MembershipConfig {
|
||||
*/
|
||||
networkErrorRetryMs?: number;
|
||||
|
||||
/**
|
||||
* If true, use the new to-device transport for sending encryption keys.
|
||||
*/
|
||||
useExperimentalToDeviceTransport?: boolean;
|
||||
|
||||
/**
|
||||
* The time (in milliseconds) after which a we consider a delayed event restart http request to have failed.
|
||||
* Setting this to a lower value will result in more frequent retries but also a higher chance of failiour.
|
||||
@@ -275,6 +256,16 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
|
||||
public memberships: CallMembership[] = [];
|
||||
|
||||
/**
|
||||
* Resolves when the session has calculated the initial membership of the session.
|
||||
*/
|
||||
public readonly initialMembershipCalculated: Promise<void>;
|
||||
/**
|
||||
* Does membership need to be recalculated? This is set to false upon
|
||||
* recalculation.
|
||||
*/
|
||||
private membershipNeedsRecalculation = false;
|
||||
|
||||
/**
|
||||
* The statistics for this session.
|
||||
*/
|
||||
@@ -315,7 +306,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
* The slotId is the property that, per definition, groups memberships into one call.
|
||||
*/
|
||||
public get slotId(): string | undefined {
|
||||
return slotDescriptionToId(this.slotDescription);
|
||||
return computeSlotId(this.slotDescription);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -326,18 +317,20 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
*/
|
||||
public static async sessionMembershipsForSlot(
|
||||
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "_unstable_getStickyEvents">,
|
||||
slotId: string,
|
||||
slotDescription: SlotDescription,
|
||||
// default both true this implied we combine sticky and state events for the final call state
|
||||
// (prefer sticky events in case of a duplicate)
|
||||
options: SessionMembershipsForSlotOpts = DEFAULT_SESSION_MEMBERSHIPS_FOR_SLOT_OPTS,
|
||||
): Promise<CallMembership[]> {
|
||||
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
|
||||
const logger = rootLogger.getChild(
|
||||
`[MatrixRTCSession ${room.roomId} ${slotDescription.application}#${slotDescription.id}]`,
|
||||
);
|
||||
const callMemberEvents = collectMembersEvents(room, options, logger);
|
||||
|
||||
const callMemberships = await computeBackendIdentityAndVerifyMemberEvents(
|
||||
room,
|
||||
callMemberEvents,
|
||||
slotId,
|
||||
slotDescription,
|
||||
logger,
|
||||
);
|
||||
|
||||
@@ -389,6 +382,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
* @param slotDescription The slot description is a virtual address where participants are allowed to meet.
|
||||
* This session will only manage memberships that match this slot description.Sessions are distinct if any of
|
||||
* those properties are distinct: `roomSubset.roomId`, `slotDescription.application`, `slotDescription.id`.
|
||||
* @param calculateMembershipsOpts - Options to configure how memberships are calculated for this session.
|
||||
*/
|
||||
public constructor(
|
||||
private readonly client: Pick<
|
||||
@@ -412,21 +406,27 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
>,
|
||||
private roomSubset: Pick<
|
||||
Room,
|
||||
"getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "on" | "off"
|
||||
| "getLiveTimeline"
|
||||
| "roomId"
|
||||
| "getVersion"
|
||||
| "hasMembershipState"
|
||||
| "on"
|
||||
| "off"
|
||||
| "_unstable_getStickyEvents"
|
||||
>,
|
||||
|
||||
public readonly slotDescription: SlotDescription,
|
||||
private readonly calculateMembershipsOpts?: SessionMembershipsForSlotOpts,
|
||||
) {
|
||||
super();
|
||||
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
|
||||
this.logger = rootLogger.getChild(
|
||||
`[MatrixRTCSession ${roomSubset.roomId} ${slotDescription.application}#${slotDescription.id}]`,
|
||||
);
|
||||
|
||||
this.roomSubset.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||
this.roomSubset.on(RoomStickyEventsEvent.Update, this.onStickyEventUpdate);
|
||||
|
||||
// We can ignore this promise because `recalculateSessionMembers` will emit
|
||||
// `MatrixRTCSessionEvent.MembershipsChanged` once it has completed.
|
||||
this.ensureRecalculateSessionMembers();
|
||||
this.initialMembershipCalculated = this.ensureRecalculateSessionMembers();
|
||||
this.setExpiryTimer();
|
||||
}
|
||||
/*
|
||||
@@ -500,57 +500,28 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
MembershipManagerEvent.DelayIdChanged,
|
||||
]);
|
||||
// Create Encryption manager
|
||||
let transport;
|
||||
if (joinConfig?.useExperimentalToDeviceTransport) {
|
||||
this.logger.info("Using experimental to-device transport for encryption keys");
|
||||
this.logger.info("Using to-device with room fallback transport for encryption keys");
|
||||
const [room, client, statistics] = [this.roomSubset, this.client, this.statistics];
|
||||
const transport = new ToDeviceKeyTransport(ownMembershipIdentity, room.roomId, client, statistics);
|
||||
this.encryptionManager = new RTCEncryptionManager(
|
||||
ownMembershipIdentity,
|
||||
() => this.memberships,
|
||||
transport,
|
||||
this.statistics,
|
||||
(
|
||||
keyBin: Uint8Array<ArrayBuffer>,
|
||||
encryptionKeyIndex: number,
|
||||
membership: CallMembershipIdentityParts,
|
||||
rtcBackendIdentity: string,
|
||||
) => {
|
||||
this.emit(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
keyBin,
|
||||
encryptionKeyIndex,
|
||||
membership,
|
||||
rtcBackendIdentity,
|
||||
);
|
||||
},
|
||||
this.logger,
|
||||
);
|
||||
} else {
|
||||
// TODO REMOVE ME!
|
||||
transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics);
|
||||
this.encryptionManager = new EncryptionManager(
|
||||
ownMembershipIdentity,
|
||||
() => this.memberships,
|
||||
transport,
|
||||
this.statistics,
|
||||
(
|
||||
keyBin: Uint8Array<ArrayBuffer>,
|
||||
encryptionKeyIndex: number,
|
||||
membership: CallMembershipIdentityParts,
|
||||
rtcBackendIdentity: string,
|
||||
) => {
|
||||
this.emit(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
keyBin,
|
||||
encryptionKeyIndex,
|
||||
membership,
|
||||
rtcBackendIdentity,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
const [room, client, statistics] = [this.roomSubset, this.client, this.statistics];
|
||||
const transport = new ToDeviceKeyTransport(ownMembershipIdentity, room.roomId, client, statistics);
|
||||
this.encryptionManager = new RTCEncryptionManager(
|
||||
ownMembershipIdentity,
|
||||
() => this.memberships,
|
||||
transport,
|
||||
(
|
||||
keyBin: Uint8Array<ArrayBuffer>,
|
||||
encryptionKeyIndex: number,
|
||||
membership: CallMembershipIdentityParts,
|
||||
rtcBackendIdentity: string,
|
||||
) => {
|
||||
this.emit(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
keyBin,
|
||||
encryptionKeyIndex,
|
||||
membership,
|
||||
rtcBackendIdentity,
|
||||
);
|
||||
},
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
|
||||
this.joinConfig = joinConfig;
|
||||
@@ -620,13 +591,6 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
return oldestMembership?.getTransport(oldestMembership);
|
||||
}
|
||||
|
||||
/**
|
||||
* The used focusActive of the oldest membership (to find out the selection type multi-sfu or oldest membership active focus)
|
||||
* @deprecated does not work with m.rtc.member. Do not rely on it.
|
||||
*/
|
||||
public getActiveFocus(): Transport | undefined {
|
||||
return this.getOldestMembership()?.getFocusActive();
|
||||
}
|
||||
public getOldestMembership(): CallMembership | undefined {
|
||||
return this.memberships[0];
|
||||
}
|
||||
@@ -695,7 +659,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
}
|
||||
|
||||
if (soonestExpiry != undefined) {
|
||||
this.expiryTimeout = setTimeout(this.ensureRecalculateSessionMembers.bind(this), soonestExpiry);
|
||||
this.expiryTimeout = setTimeout(() => void this.ensureRecalculateSessionMembers(), soonestExpiry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -747,7 +711,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
* Call this when the Matrix room members have changed.
|
||||
*/
|
||||
private readonly onRoomMemberUpdate = (): void => {
|
||||
this.ensureRecalculateSessionMembers();
|
||||
void this.ensureRecalculateSessionMembers();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -763,7 +727,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
(e) => e.getType() === EventType.RTCMembership,
|
||||
)
|
||||
) {
|
||||
this.ensureRecalculateSessionMembers();
|
||||
void this.ensureRecalculateSessionMembers();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -777,22 +741,24 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
};
|
||||
|
||||
// helper variables to make sure we do not have parallel running recalculations.
|
||||
private recalculateSessionMembersPromise: Promise<void> = Promise.resolve();
|
||||
|
||||
private recalculateSessionMembersDirty = false;
|
||||
private recalculateSessionMembersPromise: Promise<void> | undefined = undefined;
|
||||
|
||||
private ensureRecalculateSessionMembers(): void {
|
||||
if (this.recalculateSessionMembersPromise === undefined) {
|
||||
this.recalculateSessionMembersPromise = this.recalculateSessionMembers().then(() => {
|
||||
this.recalculateSessionMembersPromise = undefined;
|
||||
if (this.recalculateSessionMembersDirty) {
|
||||
this.ensureRecalculateSessionMembers();
|
||||
this.recalculateSessionMembersDirty = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.recalculateSessionMembersDirty = true;
|
||||
/**
|
||||
* Ensures that membership is recalculated when the state of the session may have changed.
|
||||
* Also ensures that only one recalculation is made at a time.
|
||||
* @returns A promise resolving when the state has been recalculated.
|
||||
*/
|
||||
private ensureRecalculateSessionMembers(): Promise<void> {
|
||||
if (this.membershipNeedsRecalculation) {
|
||||
// We have already requested recalcuation, don't attempt a new one.
|
||||
return this.recalculateSessionMembersPromise;
|
||||
}
|
||||
this.membershipNeedsRecalculation = true;
|
||||
// Chain the recalculation.
|
||||
this.recalculateSessionMembersPromise = this.recalculateSessionMembersPromise
|
||||
.finally()
|
||||
.then(() => this.recalculateSessionMembers());
|
||||
return this.recalculateSessionMembersPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -803,11 +769,13 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
* This function should be called when the room members or call memberships might have changed.
|
||||
*/
|
||||
private readonly recalculateSessionMembers = async (): Promise<void> => {
|
||||
// Clear the flag.
|
||||
this.membershipNeedsRecalculation = false;
|
||||
const oldMemberships = this.memberships;
|
||||
|
||||
this.memberships = await MatrixRTCSession.sessionMembershipsForSlot(
|
||||
this.room,
|
||||
slotDescriptionToId(this.slotDescription),
|
||||
this.slotDescription,
|
||||
this.calculateMembershipsOpts,
|
||||
);
|
||||
|
||||
@@ -857,7 +825,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
|
||||
async function computeBackendIdentityAndVerifyMemberEvents(
|
||||
room: Pick<Room, "hasMembershipState">,
|
||||
callMemberEvents: MatrixEvent[],
|
||||
slotId: string,
|
||||
slotDescription: SlotDescription,
|
||||
logger: Logger,
|
||||
): Promise<CallMembership[]> {
|
||||
const callMemberships: CallMembership[] = [];
|
||||
@@ -866,22 +834,14 @@ async function computeBackendIdentityAndVerifyMemberEvents(
|
||||
const content = memberEvent.getContent();
|
||||
|
||||
// Quick filter to avoid unneeded processing of invalid events or left events.
|
||||
// A more thorough validation will be done later with CallMembership.membershipDataFromMatrixEvent.
|
||||
if (!quickFilterNonRelevantContents(content, logger)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const membershipData = CallMembership.membershipDataFromMatrixEvent(memberEvent);
|
||||
const membership = await CallMembership.parseFromEvent(memberEvent);
|
||||
|
||||
const membership = new CallMembership(
|
||||
memberEvent,
|
||||
membershipData,
|
||||
await CallMembership.computeRtcBackendIdentity(memberEvent, membershipData),
|
||||
logger,
|
||||
);
|
||||
|
||||
if (isValidMembership(membership, room, slotId, logger)) {
|
||||
if (isValidMembership(membership, room, slotDescription, logger)) {
|
||||
callMemberships.push(membership);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -903,7 +863,8 @@ function quickFilterNonRelevantContents(content: IContent, logger: Logger): bool
|
||||
// We have a MSC4143 event membership event with a proper joined content
|
||||
return true;
|
||||
} else if (eventKeysCount === 1 && "memberships" in content) {
|
||||
logger.warn(`Legacy event found. Those are ignored, they do not contribute to the MatrixRTC session`);
|
||||
// Events used to have this format in the past, but are now deprecated.
|
||||
// Given that state events ~cannot be deleted, there can be some remaining events in the room, just ignore them.
|
||||
return false;
|
||||
} else {
|
||||
// Invalid or left content
|
||||
@@ -914,12 +875,12 @@ function quickFilterNonRelevantContents(content: IContent, logger: Logger): bool
|
||||
function isValidMembership(
|
||||
membership: CallMembership,
|
||||
room: Pick<Room, "hasMembershipState">,
|
||||
slotId: string,
|
||||
slotDescription: SlotDescription,
|
||||
logger: Logger,
|
||||
): boolean {
|
||||
if (membership.slotId !== slotId) {
|
||||
if (membership.slotDescription.id !== slotDescription.id) {
|
||||
logger.info(
|
||||
`Ignoring membership of user ${membership.userId} for a different slot: user: ${JSON.stringify(membership.slotDescription)}, slotId: ${slotId})`,
|
||||
`Ignoring membership of user ${membership.userId} for a different slot. Theirs: ${JSON.stringify(membership.slotDescription)}, Expected: ${JSON.stringify(slotDescription)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2023-2026 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.
|
||||
@@ -20,8 +20,10 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { RoomStateEvent } from "../models/room-state.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { MatrixRTCSession, type SlotDescription } from "./MatrixRTCSession.ts";
|
||||
import { MatrixRTCSession } from "./MatrixRTCSession.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
import { type SlotDescription } from "./types.ts";
|
||||
import { computeSlotId } from "./utils.ts";
|
||||
|
||||
export enum MatrixRTCSessionManagerEvents {
|
||||
// A member has joined the MatrixRTC session, creating an active session in a room where there wasn't previously
|
||||
@@ -59,7 +61,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
|
||||
private readonly slotDescription: SlotDescription = { application: "m.call", id: "ROOM" }, // Default to the Matrix Call application
|
||||
) {
|
||||
super();
|
||||
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
|
||||
this.logger = rootLogger.getChild(`[MatrixRTCSessionManager ${computeSlotId(slotDescription)}]`);
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2025-2026 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.
|
||||
@@ -22,19 +22,9 @@ import type { MatrixClient } from "../client.ts";
|
||||
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
|
||||
import { type Logger, logger as rootLogger } from "../logger.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import {
|
||||
type CallMembership,
|
||||
DEFAULT_EXPIRE_DURATION,
|
||||
type RtcMembershipData,
|
||||
type SessionMembershipData,
|
||||
} from "./CallMembership.ts";
|
||||
import { type Transport, isMyMembership, type RTCCallIntent, Status } from "./types.ts";
|
||||
import {
|
||||
type SlotDescription,
|
||||
type MembershipConfig,
|
||||
type SessionConfig,
|
||||
slotDescriptionToId,
|
||||
} from "./MatrixRTCSession.ts";
|
||||
import { type CallMembership, DEFAULT_EXPIRE_DURATION } from "./CallMembership.ts";
|
||||
import { type Transport, isMyMembership, type RTCCallIntent, Status, type SlotDescription } from "./types.ts";
|
||||
import { type MembershipConfig, type SessionConfig } from "./MatrixRTCSession.ts";
|
||||
import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
|
||||
@@ -43,6 +33,8 @@ import {
|
||||
type IMembershipManager,
|
||||
type MembershipManagerEventHandlerMap,
|
||||
} from "./IMembershipManager.ts";
|
||||
import { type RtcMembershipData, type SessionMembershipData } from "./membershipData/index.ts";
|
||||
import { computeSlotId } from "./utils.ts";
|
||||
import { isLivekitTransportConfig } from "./LivekitTransport.ts";
|
||||
|
||||
/* MembershipActionTypes:
|
||||
@@ -777,7 +769,8 @@ export class MembershipManager
|
||||
// INFO_SLOT_ID_LEGACY_CASE (search for all occurances of this INFO to get the full picture)
|
||||
// Revert back to "" just for the state key (state keys are always legacy. we use sticky events for non legacy events)
|
||||
const application = this.slotDescription.application;
|
||||
const slotId = this.slotDescription.id === "ROOM" ? "" : this.slotDescription.id;
|
||||
const needsEmptyStringRoomFix = application === "m.call" && this.slotDescription.id === "ROOM";
|
||||
const slotId = needsEmptyStringRoomFix ? "" : this.slotDescription.id;
|
||||
const stateKey = `${localUserId}_${localDeviceId}_${application}${slotId}`;
|
||||
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
|
||||
return stateKey;
|
||||
@@ -788,11 +781,14 @@ export class MembershipManager
|
||||
|
||||
/**
|
||||
* Constructs our own membership
|
||||
* @returns Only returns `SessionMembershipData`
|
||||
*/
|
||||
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
|
||||
const ownMembership = this.ownMembership;
|
||||
const needsEmptyStringRoomFix =
|
||||
this.slotDescription.application === "m.call" && this.slotDescription.id === "ROOM";
|
||||
|
||||
const focusObjects =
|
||||
const focusObjects: Pick<SessionMembershipData, "foci_preferred" | "focus_active"> =
|
||||
this.rtcTransport === undefined
|
||||
? {
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const,
|
||||
@@ -806,7 +802,7 @@ export class MembershipManager
|
||||
"application": this.slotDescription.application,
|
||||
// INFO_SLOT_ID_LEGACY_CASE (search for all occurances of this INFO to get the full picture)
|
||||
// Revert back to "" just for the sending the event.
|
||||
"call_id": this.slotDescription.id === "ROOM" ? "" : this.slotDescription.id,
|
||||
"call_id": needsEmptyStringRoomFix ? "" : this.slotDescription.id,
|
||||
"scope": "m.room",
|
||||
"device_id": this.deviceId,
|
||||
// DO NOT use this.memberId here since that is the state key (using application...)
|
||||
@@ -1096,7 +1092,11 @@ export class StickyEventMembershipManager extends MembershipManager {
|
||||
return super.actionUpdateFromErrors(e, t, StickyEventMembershipManager.nameMap.get(m) ?? "unknown");
|
||||
}
|
||||
|
||||
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
|
||||
/**
|
||||
*
|
||||
* @returns Only returns `RtcMembershipData`
|
||||
*/
|
||||
protected makeMyMembership(): RtcMembershipData {
|
||||
const ownMembership = this.ownMembership;
|
||||
|
||||
const livekitTransport = isLivekitTransportConfig(this.rtcTransport) ? this.rtcTransport : undefined;
|
||||
@@ -1108,7 +1108,7 @@ export class StickyEventMembershipManager extends MembershipManager {
|
||||
type: this.slotDescription.application,
|
||||
...(this.callIntent ? { "m.call.intent": this.callIntent } : {}),
|
||||
},
|
||||
slot_id: slotDescriptionToId(this.slotDescription),
|
||||
slot_id: computeSlotId(this.slotDescription),
|
||||
// Make sure we do not add the alias to the transport.
|
||||
// It is not needed in matrix2.0. The additional session information will be used to find the right alias on the sfu.
|
||||
rtc_transports: livekitTransport
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2025-2026 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.
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type IEncryptionManager,
|
||||
} from "./EncryptionManager.ts";
|
||||
import { type EncryptionConfig, type MembershipConfig } from "./MatrixRTCSession.ts";
|
||||
import { CallMembership } from "./CallMembership.ts";
|
||||
import type { CallMembership } from "./CallMembership.ts";
|
||||
import { decodeBase64, encodeBase64 } from "../base64.ts";
|
||||
import { type IKeyTransport, type KeyTransportEventListener, KeyTransportEvents } from "./IKeyTransport.ts";
|
||||
import { type Logger } from "../logger.ts";
|
||||
@@ -30,9 +30,9 @@ import {
|
||||
type InboundEncryptionSession,
|
||||
type OutboundEncryptionSession,
|
||||
type ParticipantDeviceInfo,
|
||||
type Statistics,
|
||||
} from "./types.ts";
|
||||
import { OutdatedKeyFilter } from "./utils.ts";
|
||||
import { computeRtcIdentityRaw } from "./membershipData/rtc.ts";
|
||||
|
||||
/**
|
||||
* RTCEncryptionManager is used to manage the encryption keys for a call.
|
||||
@@ -58,7 +58,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
* The encryption manager stores the keys because the application layer might not be ready yet to handle the keys.
|
||||
* The keys are stored and can be retrieved later when the application layer is ready {@link RTCEncryptionManager#getEncryptionKeys}.
|
||||
*/
|
||||
private participantKeyRings = new Map<
|
||||
private readonly participantKeyRings = new Map<
|
||||
EncryptionKeyMapKey,
|
||||
Array<{
|
||||
key: Uint8Array<ArrayBuffer>;
|
||||
@@ -111,7 +111,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
|
||||
private logger: Logger | undefined = undefined;
|
||||
|
||||
private rtcIdentityProvider: (userId: string, deviceId: string, memberId: string) => Promise<string>;
|
||||
private readonly rtcIdentityProvider: (userId: string, deviceId: string, memberId: string) => Promise<string>;
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -124,10 +124,9 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
* @param rtcBackendIdProvider - A function to compute the rtc backend identity, exposed for testing purposes
|
||||
*/
|
||||
public constructor(
|
||||
private ownMembership: CallMembershipIdentityParts,
|
||||
private readonly ownMembership: CallMembershipIdentityParts,
|
||||
private getMemberships: () => CallMembership[],
|
||||
private transport: IKeyTransport,
|
||||
private statistics: Statistics,
|
||||
// Callback to notify the media layer of new keys
|
||||
private onEncryptionKeysChanged: (
|
||||
keyBin: Uint8Array<ArrayBuffer>,
|
||||
@@ -139,7 +138,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
rtcBackendIdProvider?: (userId: string, deviceId: string, memberId: string) => Promise<string>,
|
||||
) {
|
||||
this.logger = parentLogger?.getChild(`[EncryptionManager]`);
|
||||
this.rtcIdentityProvider = rtcBackendIdProvider ?? CallMembership.computeRtcIdentityRaw;
|
||||
this.rtcIdentityProvider = rtcBackendIdProvider ?? computeRtcIdentityRaw;
|
||||
}
|
||||
|
||||
private async getOwnRtcBackendIdentity(): Promise<string> {
|
||||
@@ -296,7 +295,6 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
candidateInboundSession.keyIndex,
|
||||
candidateInboundSession.membership,
|
||||
);
|
||||
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
|
||||
} else {
|
||||
this.logger?.info(
|
||||
`Received an out of order key for ${membership.userId}:${membership.deviceId}, dropping it`,
|
||||
@@ -410,7 +408,6 @@ export class RTCEncryptionManager implements IEncryptionManager {
|
||||
try {
|
||||
this.logger?.trace(`Sending key...`);
|
||||
await this.transport.sendKey(encodeBase64(outboundKey.key), outboundKey.keyId, toDistributeTo);
|
||||
this.statistics.counters.roomEventEncryptionKeysSent += 1;
|
||||
outboundKey.sharedWith.push(...toDistributeTo);
|
||||
this.logger?.trace(
|
||||
`key index:${outboundKey.keyId} sent to ${outboundKey.sharedWith.map((m) => `${m.userId}:${m.deviceId}`).join(",")}`,
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 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 type { MatrixClient } from "../client.ts";
|
||||
import { type EncryptionKeysEventContent, type ParticipantDeviceInfo, type Statistics } from "./types.ts";
|
||||
import { EventType } from "../@types/event.ts";
|
||||
import { type MatrixError } from "../http-api/errors.ts";
|
||||
import { logger as rootLogger, type Logger } from "../logger.ts";
|
||||
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
|
||||
import { type MatrixEvent } from "../models/event.ts";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
|
||||
import { type Room, RoomEvent } from "../models/room.ts";
|
||||
|
||||
/**
|
||||
* @deprecated This is depreacted and not used anymore. use the ToDeviceTransport
|
||||
*/
|
||||
export class RoomKeyTransport
|
||||
extends TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>
|
||||
implements IKeyTransport
|
||||
{
|
||||
private logger: Logger = rootLogger;
|
||||
public setParentLogger(parentLogger: Logger): void {
|
||||
this.logger = parentLogger.getChild(`[RoomKeyTransport]`);
|
||||
}
|
||||
public constructor(
|
||||
private room: Pick<Room, "on" | "off" | "roomId">,
|
||||
private client: Pick<
|
||||
MatrixClient,
|
||||
"sendEvent" | "getDeviceId" | "getUserId" | "cancelPendingEvent" | "decryptEventIfNeeded"
|
||||
>,
|
||||
private statistics: Statistics,
|
||||
parentLogger?: Logger,
|
||||
) {
|
||||
super();
|
||||
this.setParentLogger(parentLogger ?? rootLogger);
|
||||
}
|
||||
public start(): void {
|
||||
this.room.on(RoomEvent.Timeline, (ev) => void this.consumeCallEncryptionEvent(ev));
|
||||
}
|
||||
public stop(): void {
|
||||
this.room.off(RoomEvent.Timeline, (ev) => void this.consumeCallEncryptionEvent(ev));
|
||||
}
|
||||
|
||||
private async consumeCallEncryptionEvent(event: MatrixEvent, isRetry = false): Promise<void> {
|
||||
await this.client.decryptEventIfNeeded(event);
|
||||
|
||||
if (event.isDecryptionFailure()) {
|
||||
if (!isRetry) {
|
||||
this.logger.warn(
|
||||
`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason} will retry once only`,
|
||||
);
|
||||
// retry after 1 second. After this we give up.
|
||||
setTimeout(() => void this.consumeCallEncryptionEvent(event, true), 1000);
|
||||
} else {
|
||||
this.logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`);
|
||||
}
|
||||
return;
|
||||
} else if (isRetry) {
|
||||
this.logger.info(`Decryption succeeded for event ${event.getId()} after retry`);
|
||||
}
|
||||
|
||||
if (event.getType() !== EventType.CallEncryptionKeysPrefix) return Promise.resolve();
|
||||
|
||||
if (!this.room) {
|
||||
this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.onEncryptionEvent(event);
|
||||
}
|
||||
|
||||
/** implements {@link IKeyTransport#sendKey} */
|
||||
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
|
||||
// members not used in room transports as the keys are sent to all room members
|
||||
const content: EncryptionKeysEventContent = {
|
||||
keys: [
|
||||
{
|
||||
index: index,
|
||||
key: keyBase64Encoded,
|
||||
},
|
||||
],
|
||||
device_id: this.client.getDeviceId()!,
|
||||
call_id: "",
|
||||
sent_ts: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to send call encryption keys", error);
|
||||
const matrixError = error as MatrixError;
|
||||
if (matrixError.event) {
|
||||
// cancel the pending event: we'll just generate a new one with our latest
|
||||
// keys when we resend
|
||||
this.client.cancelPendingEvent(matrixError.event);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public onEncryptionEvent(event: MatrixEvent): void {
|
||||
const userId = event.getSender();
|
||||
const content = event.getContent<EncryptionKeysEventContent>();
|
||||
|
||||
const deviceId = content["device_id"];
|
||||
const callId = content["call_id"];
|
||||
|
||||
if (!userId) {
|
||||
this.logger.warn(`Received m.call.encryption_keys with no userId: callId=${callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// We currently only handle callId = "" (which is the default for room scoped calls)
|
||||
if (callId !== "") {
|
||||
this.logger.warn(
|
||||
`Received m.call.encryption_keys with unsupported callId: userId=${userId}, deviceId=${deviceId}, callId=${callId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content.keys)) {
|
||||
this.logger.warn(`Received m.call.encryption_keys where keys wasn't an array: callId=${callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
|
||||
// We store our own sender key in the same set along with keys from others, so it's
|
||||
// important we don't allow our own keys to be set by one of these events (apart from
|
||||
// the fact that we don't need it anyway because we already know our own keys).
|
||||
this.logger.info("Ignoring our own keys event");
|
||||
return;
|
||||
}
|
||||
|
||||
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
|
||||
const age = Date.now() - (typeof content.sent_ts === "number" ? content.sent_ts : event.getTs());
|
||||
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
|
||||
|
||||
for (const key of content.keys) {
|
||||
if (!key) {
|
||||
this.logger.info("Ignoring false-y key in keys event");
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptionKey = key.key;
|
||||
const encryptionKeyIndex = key.index;
|
||||
|
||||
if (
|
||||
!encryptionKey ||
|
||||
encryptionKeyIndex === undefined ||
|
||||
encryptionKeyIndex === null ||
|
||||
callId === undefined ||
|
||||
callId === null ||
|
||||
typeof deviceId !== "string" ||
|
||||
typeof callId !== "string" ||
|
||||
typeof encryptionKey !== "string" ||
|
||||
typeof encryptionKeyIndex !== "number"
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Malformed call encryption_key: userId=${userId}, deviceId=${deviceId}, encryptionKeyIndex=${encryptionKeyIndex} callId=${callId}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(
|
||||
`onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`,
|
||||
);
|
||||
this.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
// Using `${userId}:${deviceId}` makes no sense (but works). It does not matter since the RoomKeyTransport is deprecated
|
||||
{ userId, deviceId, memberId: `${userId}:${deviceId}` },
|
||||
encryptionKey,
|
||||
encryptionKeyIndex,
|
||||
event.getTs(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,5 +19,6 @@ export * from "./LivekitTransport.ts";
|
||||
export * from "./MatrixRTCSession.ts";
|
||||
export * from "./MatrixRTCSessionManager.ts";
|
||||
export type * from "./types.ts";
|
||||
export { type SessionMembershipData, type RtcMembershipData } from "./membershipData/index.ts";
|
||||
export { Status, parseCallNotificationContent, isMyMembership } from "./types.ts";
|
||||
export { MembershipManagerEvent } from "./IMembershipManager.ts";
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
Copyright 2026 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Thrown when an event is not valid for use with MatrixRTC.
|
||||
*/
|
||||
export class MatrixRTCMembershipParseError extends AggregateError {
|
||||
public constructor(
|
||||
public readonly type: string,
|
||||
errors: string[],
|
||||
) {
|
||||
super(errors, `Does not match ${type}:\n${errors.join("\n")}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
Copyright 2026 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 { type SessionMembershipData, checkSessionsMembershipData } from "./session.ts";
|
||||
export { type RtcMembershipData, computeRtcIdentityRaw, checkRtcMembershipData } from "./rtc.ts";
|
||||
export { MatrixRTCMembershipParseError } from "./common.ts";
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
Copyright 2026 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 { MXID_PATTERN } from "../../models/room-member.ts";
|
||||
import type { IContent } from "../../models/event.ts";
|
||||
import type { RelationType } from "../../types.ts";
|
||||
import { type RtcSlotEventContent, type Transport } from "../types.ts";
|
||||
import { MatrixRTCMembershipParseError } from "./common.ts";
|
||||
import { sha256 } from "../../digest.ts";
|
||||
import { encodeUnpaddedBase64 } from "../../base64.ts";
|
||||
import { slotIdToDescription } from "../utils.ts";
|
||||
|
||||
/**
|
||||
* Represents the current form of MSC4143, which uses sticky events to store membership.
|
||||
*/
|
||||
export interface RtcMembershipData {
|
||||
"slot_id": string;
|
||||
"member": {
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
id: string;
|
||||
};
|
||||
"m.relates_to"?: {
|
||||
event_id: string;
|
||||
rel_type: RelationType.Reference;
|
||||
};
|
||||
"application": RtcSlotEventContent["application"];
|
||||
"rtc_transports": Transport[];
|
||||
"versions": string[];
|
||||
"msc4354_sticky_key"?: string;
|
||||
"sticky_key"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that `data` matches the format expected by MSC4143.
|
||||
* @param data The event content.
|
||||
* @param sender The sender of the event.
|
||||
* @returns true if `data` is valid RtcMembershipData
|
||||
* @throws {MatrixRTCMembershipParseError} if the content is not valid
|
||||
*/
|
||||
export const checkRtcMembershipData = (data: IContent, sender: string): data is RtcMembershipData => {
|
||||
const errors: string[] = [];
|
||||
const prefix = " - ";
|
||||
const expectedSlotPrefix = `${data?.application?.type}#`;
|
||||
|
||||
// required fields
|
||||
if (typeof data.slot_id !== "string") {
|
||||
errors.push(prefix + "slot_id must be string");
|
||||
} else if (!data.slot_id.startsWith(expectedSlotPrefix)) {
|
||||
errors.push(prefix + `slot_id must start with ${expectedSlotPrefix}`);
|
||||
} else {
|
||||
try {
|
||||
slotIdToDescription(data.slot_id);
|
||||
} catch (ex) {
|
||||
errors.push(prefix + `slot_id was badly formed${ex instanceof Error ? `: ${ex.message}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof data.member !== "object" || data.member === null) {
|
||||
errors.push(prefix + "member must be an object");
|
||||
} else {
|
||||
if (typeof data.member.user_id !== "string") {
|
||||
errors.push(prefix + "member.user_id must be string");
|
||||
} else if (!MXID_PATTERN.test(data.member.user_id)) {
|
||||
errors.push(prefix + "member.user_id must be a valid mxid");
|
||||
}
|
||||
// This is not what the spec enforces but there currently are no rules what power levels are required to
|
||||
// send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there
|
||||
// is a proper definition when this is allowed.
|
||||
else if (data.member.user_id !== sender) {
|
||||
errors.push(prefix + "member.user_id must match the sender");
|
||||
}
|
||||
if (typeof data.member.device_id !== "string") {
|
||||
errors.push(prefix + "member.device_id must be string");
|
||||
}
|
||||
if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string");
|
||||
}
|
||||
if (typeof data.application !== "object" || data.application === null) {
|
||||
errors.push(prefix + "application must be an object");
|
||||
} else {
|
||||
if (typeof data.application.type !== "string") {
|
||||
errors.push(prefix + "application.type must be a string");
|
||||
} else {
|
||||
if (data.application.type.includes("#")) errors.push(prefix + 'application.type must not include "#"');
|
||||
}
|
||||
}
|
||||
if (data.rtc_transports === undefined || !Array.isArray(data.rtc_transports)) {
|
||||
errors.push(prefix + "rtc_transports must be an array");
|
||||
} else {
|
||||
// validate that each transport has at least a string 'type'
|
||||
for (const t of data.rtc_transports) {
|
||||
if (typeof t !== "object" || t === null || typeof (t as any).type !== "string") {
|
||||
errors.push(prefix + "rtc_transports entries must be objects with a string type");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.versions === undefined || !Array.isArray(data.versions)) {
|
||||
errors.push(prefix + "versions must be an array");
|
||||
} else if (!data.versions.every((v) => typeof v === "string")) {
|
||||
errors.push(prefix + "versions must be an array of strings");
|
||||
}
|
||||
|
||||
// optional fields
|
||||
if ((data.sticky_key ?? data.msc4354_sticky_key) === undefined) {
|
||||
errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined");
|
||||
}
|
||||
if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") {
|
||||
errors.push(prefix + "sticky_key must be a string");
|
||||
}
|
||||
if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "string") {
|
||||
errors.push(prefix + "msc4354_sticky_key must be a string");
|
||||
}
|
||||
if (
|
||||
data.sticky_key !== undefined &&
|
||||
data.msc4354_sticky_key !== undefined &&
|
||||
data.sticky_key !== data.msc4354_sticky_key
|
||||
) {
|
||||
errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined");
|
||||
}
|
||||
if (data["m.relates_to"] !== undefined) {
|
||||
const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"];
|
||||
if (typeof rel !== "object" || rel === null) {
|
||||
errors.push(prefix + "m.relates_to must be an object if provided");
|
||||
} else {
|
||||
if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string");
|
||||
if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
throw new MatrixRTCMembershipParseError("RtcMembership", errors);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export async function computeRtcIdentityRaw(userId: string, deviceId: string, memberId: string): Promise<string> {
|
||||
// canonical JSON serialization (Matrix canonical JSON for arrays)
|
||||
const jsonStr = JSON.stringify([userId, deviceId, memberId]);
|
||||
const hashBuffer = await sha256(jsonStr);
|
||||
const hashedString = encodeUnpaddedBase64(hashBuffer);
|
||||
return hashedString;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
Copyright 2026 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 { type IContent } from "../../matrix.ts";
|
||||
import { type RTCCallIntent, type Transport } from "../types.ts";
|
||||
import { MatrixRTCMembershipParseError } from "./common.ts";
|
||||
|
||||
/**
|
||||
* (MatrixRTC) session membership data.
|
||||
* This represents the *OLD* form of MSC4143, which uses state events to store membership.
|
||||
* Represents the `session` in the memberships section of an m.call.member event as it is on the wire.
|
||||
**/
|
||||
export type SessionMembershipData = {
|
||||
/**
|
||||
* The RTC application defines the type of the RTC session.
|
||||
*/
|
||||
"application": string;
|
||||
|
||||
/**
|
||||
* The id of this session.
|
||||
* A session can never span over multiple rooms so this id is to distinguish between
|
||||
* multiple session in one room. A room wide session that is not associated with a user,
|
||||
* and therefore immune to creation race conflicts, uses the `call_id: ""`.
|
||||
*/
|
||||
"call_id": string;
|
||||
|
||||
/**
|
||||
* The Matrix device ID of this session. A single user can have multiple sessions on different devices.
|
||||
*/
|
||||
"device_id": string;
|
||||
|
||||
/**
|
||||
* The focus selection system this user/membership is using.
|
||||
* NOTE: This is still included for legacy reasons, but not consumed by the SDK.
|
||||
*/
|
||||
"focus_active": {
|
||||
type: "livekit" | string;
|
||||
focus_selection: "oldest_membership" | "multi_sfu" | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A list of possible foci this user knows about. One of them might be used based on the focus_active
|
||||
* selection system.
|
||||
*/
|
||||
"foci_preferred": Transport[];
|
||||
|
||||
/**
|
||||
* Optional field that contains the creation of the session. If it is undefined the creation
|
||||
* is the `origin_server_ts` of the event itself. For updates to the event this property tracks
|
||||
* the `origin_server_ts` of the initial join event.
|
||||
* - If it is undefined it can be interpreted as a "Join".
|
||||
* - If it is defined it can be interpreted as an "Update"
|
||||
*/
|
||||
"created_ts"?: number;
|
||||
|
||||
// Application specific data
|
||||
|
||||
/**
|
||||
* If the `application` = `"m.call"` this defines if it is a room or user owned call.
|
||||
* There can always be one room scoped call but multiple user owned calls (breakout sessions)
|
||||
*/
|
||||
"scope"?: "m.room" | "m.user";
|
||||
|
||||
/**
|
||||
* Optionally we allow to define a delta to the `created_ts` that defines when the event is expired/invalid.
|
||||
* This should be set to multiple hours. The only reason it exist is to deal with failed delayed events.
|
||||
* (for example caused by a homeserver crashes)
|
||||
**/
|
||||
"expires"?: number;
|
||||
|
||||
/**
|
||||
* The intent of the call from the perspective of this user. This may be an audio call, video call or
|
||||
* something else.
|
||||
*/
|
||||
"m.call.intent"?: RTCCallIntent;
|
||||
|
||||
/**
|
||||
* The id used on the media backend.
|
||||
* (With livekit this is the participant identity on the LK SFU)
|
||||
* This can be a UUID but right now it is `${this.matrixEventData.sender}:${data.device_id}`.
|
||||
*
|
||||
* It is compleatly valid to not set this field. Other clients will treat `undefined` as `${this.matrixEventData.sender}:${data.device_id}`
|
||||
*/
|
||||
"membershipID"?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that `data` matches the format expected by the legacy form of MSC4143.
|
||||
* @param data The event content.
|
||||
* @returns true if `data` is valid SessionMembershipData
|
||||
* @throws {MatrixRTCMembershipParseError} if the content is not valid
|
||||
*/
|
||||
export const checkSessionsMembershipData = (data: IContent): data is SessionMembershipData => {
|
||||
const prefix = " - ";
|
||||
const errors: string[] = [];
|
||||
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
|
||||
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
|
||||
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
|
||||
if (data.focus_active === undefined) {
|
||||
errors.push(prefix + "focus_active has an invalid type");
|
||||
}
|
||||
if (typeof data.focus_active?.type !== "string") {
|
||||
errors.push(prefix + "focus_active.type must be a string");
|
||||
}
|
||||
if (
|
||||
data.foci_preferred !== undefined &&
|
||||
!(
|
||||
Array.isArray(data.foci_preferred) &&
|
||||
data.foci_preferred.every(
|
||||
(f: Transport) => typeof f === "object" && f !== null && typeof f.type === "string",
|
||||
)
|
||||
)
|
||||
) {
|
||||
errors.push(prefix + "foci_preferred must be an array of transport objects");
|
||||
}
|
||||
// optional parameters
|
||||
if (data.created_ts !== undefined && typeof data.created_ts !== "number") {
|
||||
errors.push(prefix + "created_ts must be number");
|
||||
}
|
||||
|
||||
// application specific data (we first need to check if they exist)
|
||||
if (data.scope !== undefined && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
|
||||
|
||||
if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") {
|
||||
errors.push(prefix + "m.call.intent must be a string");
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
throw new MatrixRTCMembershipParseError("SessionMembership", errors);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
+27
-1
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2023-2026 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.
|
||||
@@ -199,3 +199,29 @@ export interface Transport {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event content for a `m.rtc.slot` state event.
|
||||
*/
|
||||
export interface RtcSlotEventContent<T extends string = string> {
|
||||
application: {
|
||||
type: T;
|
||||
// other application specific keys
|
||||
[key: string]: unknown;
|
||||
};
|
||||
slot_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The session description is used to identify a session. Used in the state event.
|
||||
*/
|
||||
export interface SlotDescription {
|
||||
/**
|
||||
* The application type. e.g. "m.call".
|
||||
*/
|
||||
application: string;
|
||||
/**
|
||||
* The application-specific slot ID. e.g. "ROOM".
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
+24
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2025-2026 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.
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { getEncryptionKeyMapKey, type CallMembershipIdentityParts } from "./EncryptionManager.ts";
|
||||
import { type InboundEncryptionSession, type EncryptionKeyMapKey } from "./types.ts";
|
||||
import type { InboundEncryptionSession, EncryptionKeyMapKey, SlotDescription } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Detects when a key for a given index is outdated.
|
||||
@@ -47,3 +47,25 @@ export class OutdatedKeyFilter {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a slot ID into it's component application and ID portions.
|
||||
* @param slotId e.g. `m.call#call_id`
|
||||
* @throws If the format of `slotId` is invalid.
|
||||
*/
|
||||
export function slotIdToDescription(slotId: string): SlotDescription {
|
||||
const [application, id, ...unexpectedAdditionalValues] = slotId.split("#");
|
||||
if (unexpectedAdditionalValues.length) {
|
||||
throw Error(
|
||||
"MatrixRTC Slot IDs *must* only contain two components seperated by one '#'. Additional '#' characters detected.",
|
||||
);
|
||||
}
|
||||
return { application, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a SlotDescription into it's slot ID format.
|
||||
*/
|
||||
export function computeSlotId(slotDescription: SlotDescription): string {
|
||||
return `${slotDescription.application}#${slotDescription.id}`;
|
||||
}
|
||||
|
||||
+3
-1
@@ -76,6 +76,8 @@ export interface IUnsigned {
|
||||
"m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations
|
||||
"msc4354_sticky_duration_ttl_ms"?: number;
|
||||
[UNSIGNED_THREAD_ID_FIELD.name]?: string;
|
||||
"membership"?: Membership;
|
||||
"io.element.msc4115.membership"?: Membership;
|
||||
}
|
||||
|
||||
export interface IThreadBundledRelationship {
|
||||
@@ -786,7 +788,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
*/
|
||||
public getMembershipAtEvent(): Membership | string | undefined {
|
||||
const unsigned = this.getUnsigned();
|
||||
return UNSIGNED_MEMBERSHIP_FIELD.findIn<Membership | string>(unsigned);
|
||||
return UNSIGNED_MEMBERSHIP_FIELD.findIn<Membership>(unsigned);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+38
-16
@@ -53,6 +53,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
|
||||
private sortedAnnotationsByKey: [string, Set<MatrixEvent>][] = [];
|
||||
private targetEvent: MatrixEvent | null = null;
|
||||
private creationEmitted = false;
|
||||
private replacementUpdateId = 0;
|
||||
private readonly client: MatrixClient;
|
||||
|
||||
/**
|
||||
@@ -106,9 +107,8 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
|
||||
|
||||
if (this.relationType === RelationType.Annotation) {
|
||||
this.addAnnotationToAggregation(event);
|
||||
} else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
|
||||
const lastReplacement = await this.getLastReplacement();
|
||||
this.targetEvent.makeReplaced(lastReplacement!);
|
||||
} else if (this.relationType === RelationType.Replace) {
|
||||
await this.updateTargetEventReplacement();
|
||||
}
|
||||
|
||||
event.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
|
||||
@@ -132,9 +132,8 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
|
||||
|
||||
if (this.relationType === RelationType.Annotation) {
|
||||
this.removeAnnotationFromAggregation(event);
|
||||
} else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
|
||||
const lastReplacement = await this.getLastReplacement();
|
||||
this.targetEvent.makeReplaced(lastReplacement!);
|
||||
} else if (this.relationType === RelationType.Replace) {
|
||||
await this.updateTargetEventReplacement();
|
||||
}
|
||||
|
||||
this.emit(RelationsEvent.Remove, event);
|
||||
@@ -243,9 +242,8 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
|
||||
if (this.relationType === RelationType.Annotation) {
|
||||
// Remove the redacted annotation from aggregation by key
|
||||
this.removeAnnotationFromAggregation(redactedEvent);
|
||||
} else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
|
||||
const lastReplacement = await this.getLastReplacement();
|
||||
this.targetEvent.makeReplaced(lastReplacement!);
|
||||
} else if (this.relationType === RelationType.Replace) {
|
||||
await this.updateTargetEventReplacement();
|
||||
}
|
||||
|
||||
redactedEvent.removeListener(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
|
||||
@@ -343,18 +341,42 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
|
||||
}
|
||||
this.targetEvent = event;
|
||||
|
||||
if (this.relationType === RelationType.Replace && !this.targetEvent.isState()) {
|
||||
const replacement = await this.getLastReplacement();
|
||||
// this is the initial update, so only call it if we already have something
|
||||
// to not emit Event.replaced needlessly
|
||||
if (replacement) {
|
||||
this.targetEvent.makeReplaced(replacement);
|
||||
}
|
||||
if (this.relationType === RelationType.Replace) {
|
||||
await this.updateTargetEventReplacement();
|
||||
}
|
||||
|
||||
this.maybeEmitCreated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the target event with the latest replacement.
|
||||
*
|
||||
* Multiple replacement updates can be triggered concurrently (for example
|
||||
* while edits are still being decrypted). A monotonic update counter guards
|
||||
* against older async resolutions overriding newer replacement selections.
|
||||
*/
|
||||
private async updateTargetEventReplacement(): Promise<void> {
|
||||
if (!this.targetEvent || this.targetEvent.isState()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetEvent = this.targetEvent;
|
||||
const updateId = ++this.replacementUpdateId;
|
||||
const lastReplacement = await this.getLastReplacement();
|
||||
|
||||
// If a newer update started while we were awaiting, discard this stale result.
|
||||
if (updateId !== this.replacementUpdateId || this.targetEvent !== targetEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid emitting Event.replaced when there is no replacement and none currently set.
|
||||
if (!lastReplacement && !targetEvent.replacingEvent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetEvent.makeReplaced(lastReplacement ?? undefined);
|
||||
}
|
||||
|
||||
private maybeEmitCreated(): void {
|
||||
if (this.creationEmitted) {
|
||||
return;
|
||||
|
||||
@@ -227,6 +227,42 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate the disambiguation flag for this member based on current room state.
|
||||
* This should be called when another member's display name changes and may affect
|
||||
* whether this member needs disambiguation.
|
||||
*
|
||||
* @param roomState - The current room state to use for disambiguation check
|
||||
* @returns true if the member's name changed as a result of the disambiguation update
|
||||
*
|
||||
* @remarks
|
||||
* Fires {@link RoomMemberEvent.Name}
|
||||
*/
|
||||
public recalculateDisambiguatedName(roomState: RoomState): boolean {
|
||||
if (!this.events.member) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const displayName = this.events.member.getDirectionalContent().displayname ?? "";
|
||||
const newDisambiguate = shouldDisambiguate(this.userId, displayName, roomState);
|
||||
|
||||
if (newDisambiguate === this.disambiguate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.disambiguate = newDisambiguate;
|
||||
const oldName = this.name;
|
||||
this.name = calculateDisplayName(this.userId, displayName, this.disambiguate);
|
||||
|
||||
if (oldName !== this.name) {
|
||||
this.updateModifiedTime();
|
||||
this.emit(RoomMemberEvent.Name, this.events.member, this, oldName);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update this room member's power level event. Will fire
|
||||
* "RoomMember.powerLevel" if the new power level is different
|
||||
|
||||
@@ -438,6 +438,11 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
this.updateModifiedTime();
|
||||
|
||||
// update the core event dict
|
||||
// Track display names that change so we can recalculate disambiguation
|
||||
const affectedDisplayNames = new Set<string>();
|
||||
// Track userIds whose membership events we process so we don't emit duplicate events
|
||||
const processedMemberUserIds = new Set<string>();
|
||||
|
||||
stateEvents.forEach((event) => {
|
||||
if (event.getRoomId() !== this.roomId || !event.isState()) return;
|
||||
|
||||
@@ -448,7 +453,22 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
const lastStateEvent = this.getStateEventMatching(event);
|
||||
this.setStateEvent(event);
|
||||
if (event.getType() === EventType.RoomMember) {
|
||||
this.updateDisplayNameCache(event.getStateKey()!, event.getContent().displayname ?? "");
|
||||
const userId = event.getStateKey()!;
|
||||
processedMemberUserIds.add(userId);
|
||||
const newDisplayName = event.getContent().displayname ?? "";
|
||||
const oldDisplayName = this.userIdsToDisplayNames[userId];
|
||||
|
||||
// Track both old and new display names for disambiguation recalculation
|
||||
if (oldDisplayName) {
|
||||
const strippedOld = removeHiddenChars(oldDisplayName);
|
||||
if (strippedOld) affectedDisplayNames.add(strippedOld);
|
||||
}
|
||||
if (newDisplayName) {
|
||||
const strippedNew = removeHiddenChars(newDisplayName);
|
||||
if (strippedNew) affectedDisplayNames.add(strippedNew);
|
||||
}
|
||||
|
||||
this.updateDisplayNameCache(userId, newDisplayName);
|
||||
this.updateThirdPartyTokenCache(event);
|
||||
}
|
||||
this.emit(RoomStateEvent.Events, event, this, lastStateEvent);
|
||||
@@ -514,6 +534,33 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
}
|
||||
});
|
||||
|
||||
// Recalculate disambiguation for all members whose display names were affected.
|
||||
// This ensures that when a user changes their name to match (or stop matching)
|
||||
// another user, all affected users' disambiguation flags are updated correctly.
|
||||
if (affectedDisplayNames.size > 0) {
|
||||
// Collect all affected user IDs first to avoid duplicate processing
|
||||
const affectedUserIds = new Set<string>();
|
||||
for (const displayName of affectedDisplayNames) {
|
||||
const userIds = this.displayNameToUserIds.get(displayName) ?? [];
|
||||
userIds.forEach((id) => affectedUserIds.add(id));
|
||||
}
|
||||
|
||||
// Process each affected member once, excluding those whose membership
|
||||
// events were already processed (they already got their events emitted)
|
||||
for (const userId of affectedUserIds) {
|
||||
if (processedMemberUserIds.has(userId)) {
|
||||
continue;
|
||||
}
|
||||
const member = this.members[userId];
|
||||
if (member?.events.member) {
|
||||
const nameChanged = member.recalculateDisambiguatedName(this);
|
||||
if (nameChanged) {
|
||||
this.emit(RoomStateEvent.Members, member.events.member, this, member);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.emit(RoomStateEvent.Update, this);
|
||||
}
|
||||
|
||||
@@ -1110,6 +1157,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
|
||||
|
||||
private updateDisplayNameCache(userId: string, displayName: string): void {
|
||||
const oldName = this.userIdsToDisplayNames[userId];
|
||||
|
||||
delete this.userIdsToDisplayNames[userId];
|
||||
if (oldName) {
|
||||
// Remove the old name from the cache.
|
||||
|
||||
+25
-6
@@ -14,7 +14,15 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type IdTokenClaims, Log, OidcClient, SigninResponse, SigninState, WebStorageStateStore } from "oidc-client-ts";
|
||||
import {
|
||||
type IdTokenClaims,
|
||||
Log,
|
||||
OidcClient,
|
||||
type SigninRequestCreateArgs,
|
||||
SigninResponse,
|
||||
SigninState,
|
||||
WebStorageStateStore,
|
||||
} from "oidc-client-ts";
|
||||
|
||||
import { logger } from "../logger.ts";
|
||||
import { secureRandomString } from "../randomstring.ts";
|
||||
@@ -127,6 +135,8 @@ export const generateAuthorizationUrl = async (
|
||||
* @param urlState - value to append to the opaque state identifier to uniquely identify the callback
|
||||
* @param loginHint - value to send as the `login_hint` to the OP, giving a hint about the login identifier the user might use to log in.
|
||||
* See {@link https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest OIDC core 3.1.2.1}.
|
||||
* @param responseMode - value to send as the `response_mode` to the OP, selecting how auth is passed back during redirect.
|
||||
* See {@link https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest OIDC core 3.1.2.1}.
|
||||
* @returns a Promise with the url as a string
|
||||
*/
|
||||
export const generateOidcAuthorizationUrl = async ({
|
||||
@@ -139,6 +149,7 @@ export const generateOidcAuthorizationUrl = async ({
|
||||
prompt,
|
||||
urlState,
|
||||
loginHint,
|
||||
responseMode = "query",
|
||||
}: {
|
||||
clientId: string;
|
||||
metadata: ValidatedAuthMetadata;
|
||||
@@ -149,6 +160,7 @@ export const generateOidcAuthorizationUrl = async ({
|
||||
prompt?: string;
|
||||
urlState?: string;
|
||||
loginHint?: string;
|
||||
responseMode?: SigninRequestCreateArgs["response_mode"];
|
||||
}): Promise<string> => {
|
||||
const scope = generateScope();
|
||||
const oidcClient = new OidcClient({
|
||||
@@ -156,7 +168,7 @@ export const generateOidcAuthorizationUrl = async ({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
authority: metadata.issuer,
|
||||
response_mode: "query",
|
||||
response_mode: responseMode,
|
||||
response_type: "code",
|
||||
scope,
|
||||
stateStore: new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage }),
|
||||
@@ -200,7 +212,8 @@ const normalizeBearerTokenResponseTokenType = (response: SigninResponse): Bearer
|
||||
* request to the Token Endpoint, to obtain the access token, refresh token, etc.
|
||||
*
|
||||
* @param code - authorization code as returned by OP during authorization
|
||||
* @param storedAuthorizationParams - stored params from start of oidc login flow
|
||||
* @param state - authorization state param as returned by OP during authorization
|
||||
* @param responseMode - the response mode used for authentication
|
||||
* @returns valid bearer token response
|
||||
* @throws An `Error` with `message` set to an entry in {@link OidcError},
|
||||
* when the request fails, or the returned token response is invalid.
|
||||
@@ -208,6 +221,7 @@ const normalizeBearerTokenResponseTokenType = (response: SigninResponse): Bearer
|
||||
export const completeAuthorizationCodeGrant = async (
|
||||
code: string,
|
||||
state: string,
|
||||
responseMode: SigninRequestCreateArgs["response_mode"] = "query",
|
||||
): Promise<{
|
||||
oidcClientSettings: { clientId: string; issuer: string };
|
||||
tokenResponse: BearerTokenResponse;
|
||||
@@ -221,13 +235,18 @@ export const completeAuthorizationCodeGrant = async (
|
||||
* so that oidc-client can parse it
|
||||
*/
|
||||
const reconstructedUrl = new URL(window.location.origin);
|
||||
reconstructedUrl.searchParams.append("code", code);
|
||||
reconstructedUrl.searchParams.append("state", state);
|
||||
|
||||
const params = new URLSearchParams({ code, state });
|
||||
if (responseMode === "query") {
|
||||
reconstructedUrl.search = params.toString();
|
||||
} else {
|
||||
reconstructedUrl.hash = `#${params.toString()}`;
|
||||
}
|
||||
|
||||
// set oidc-client to use our logger
|
||||
Log.setLogger(logger);
|
||||
try {
|
||||
const response = new SigninResponse(reconstructedUrl.searchParams);
|
||||
const response = new SigninResponse(params);
|
||||
|
||||
const stateStore = new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage });
|
||||
|
||||
|
||||
@@ -62,6 +62,6 @@ export const validateAuthMetadataAndKeys = async (authMetadata: unknown): Promis
|
||||
|
||||
return {
|
||||
...validatedIssuerConfig,
|
||||
signingKeys: await metadataService.getSigningKeys(),
|
||||
signingKeys: validatedIssuerConfig.jwks_uri ? await metadataService.getSigningKeys() : null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -262,7 +262,9 @@ export class MSC4108RendezvousSession {
|
||||
|
||||
if (!this.url) return;
|
||||
try {
|
||||
await this.fetch(this.url, { method: Method.Delete });
|
||||
const method = Method.Delete;
|
||||
logger.info(`=> ${method} ${this.url}`);
|
||||
await this.fetch(this.url, { method });
|
||||
} catch (e) {
|
||||
logger.warn(e);
|
||||
}
|
||||
|
||||
+64
-10
@@ -161,7 +161,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
/**
|
||||
* 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.secret.send` event for secret `m.megolm_backup.v1`.
|
||||
* @param secret - The secret as received from a `m.secret.send` or `io.element.msc4385.secret.push` 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> {
|
||||
@@ -180,28 +180,44 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
// There is no server-side key backup.
|
||||
// This decryption key is useless to us.
|
||||
this.logger.warn(
|
||||
"handleBackupSecretReceived: Received a backup decryption key, but there is no trusted server-side key backup",
|
||||
"handleBackupSecretReceived: Received a backup decryption key, but there is no server-side key backup",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let backupDecryptionKey: RustSdkCryptoJs.BackupDecryptionKey;
|
||||
try {
|
||||
backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(secret);
|
||||
} catch (e) {
|
||||
this.logger.warn("handleBackupSecretReceived: Invalid backup decryption key", e);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(secret);
|
||||
const privateKeyMatches = this.backupInfoMatchesBackupDecryptionKey(latestBackupInfo, backupDecryptionKey);
|
||||
if (!privateKeyMatches) {
|
||||
this.logger.warn(
|
||||
`handleBackupSecretReceived: Private decryption key does not match the public key of the current remote backup.`,
|
||||
`handleBackupSecretReceived: Private decryption key does not match the public key of the current server-side backup version (${latestBackupInfo.version})`,
|
||||
);
|
||||
// just ignore the secret
|
||||
return false;
|
||||
}
|
||||
this.logger.info(
|
||||
`handleBackupSecretReceived: A valid backup decryption key has been received and stored in cache.`,
|
||||
`handleBackupSecretReceived: Valid decryption key for the current server-side backup version (${latestBackupInfo.version}) received`,
|
||||
);
|
||||
await this.saveBackupDecryptionKey(backupDecryptionKey, latestBackupInfo.version);
|
||||
// Check if the backup should be enabled (e.g. if it's properly
|
||||
// signed), and enable it if it should
|
||||
if (this.keyBackupCheckInProgress) {
|
||||
await this.keyBackupCheckInProgress;
|
||||
}
|
||||
this.keyBackupCheckInProgress = this.doCheckKeyBackup(latestBackupInfo).finally(() => {
|
||||
this.keyBackupCheckInProgress = null;
|
||||
});
|
||||
await this.keyBackupCheckInProgress;
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.warn("handleBackupSecretReceived: Invalid backup decryption key", e);
|
||||
this.logger.warn("handleBackupSecretReceived: Unable to validate backup decryption key", e);
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -281,12 +297,17 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
|
||||
private keyBackupCheckInProgress: Promise<KeyBackupCheck | null> | null = null;
|
||||
|
||||
/** Helper for `checkKeyBackup` */
|
||||
private async doCheckKeyBackup(): Promise<KeyBackupCheck | null> {
|
||||
/** Helper to check the key backup status, and enable/disable it as appropriate
|
||||
*
|
||||
* A KeyBackupInfo can be passed if it was fetched recently, to avoid trying to
|
||||
* re-fetch it from the server.
|
||||
*/
|
||||
private async doCheckKeyBackup(backupInfo?: KeyBackupInfo | null | undefined): Promise<KeyBackupCheck | null> {
|
||||
this.logger.debug("Checking key backup status...");
|
||||
let backupInfo: KeyBackupInfo | null | undefined;
|
||||
try {
|
||||
backupInfo = await this.requestKeyBackupVersion();
|
||||
if (!backupInfo) {
|
||||
backupInfo = await this.requestKeyBackupVersion();
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn("Error checking for active key backup", e);
|
||||
this.serverBackupInfo = undefined;
|
||||
@@ -631,6 +652,24 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
return this.importKeyBackup(keyBackup, backupVersion, backupDecryptor, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and import the keys for a given room from the current backup version.
|
||||
*
|
||||
* @param roomId - The room in question.
|
||||
*/
|
||||
public async downloadLatestRoomKeyBackup(roomId: string): Promise<void> {
|
||||
const { backupVersion, decryptionKey } = await this.olmMachine.getBackupKeys();
|
||||
if (!backupVersion || !decryptionKey) {
|
||||
this.logger.warn(
|
||||
`downloadLatestRoomKeyBackup: Could not download backup (backupVersion=${backupVersion}, hasDecryptionKey=${!!decryptionKey})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const sessions = await this.downloadRoomKeyBackup(backupVersion, roomId);
|
||||
const backupDecryptor = this.createBackupDecryptor(decryptionKey);
|
||||
this.importKeyBackup({ rooms: { [roomId]: { sessions } } }, backupVersion, backupDecryptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call `/room_keys/keys` to download the key backup (room keys) for the given backup version.
|
||||
* https://spec.matrix.org/v1.12/client-server-api/#get_matrixclientv3room_keyskeys
|
||||
@@ -650,6 +689,21 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call `/room/keys/keys/{roomId}` to download the key backup (room keys) for a given backup version and room ID.
|
||||
* @param backupVersion - The version to download.
|
||||
* @param roomId - The ID of the room.
|
||||
* @returns The key backup response.
|
||||
*/
|
||||
private downloadRoomKeyBackup(backupVersion: string, roomId: string): Promise<KeyBackupRoomSessions> {
|
||||
const path = encodeUri("/room_keys/keys/$roomId", {
|
||||
$roomId: roomId,
|
||||
});
|
||||
return this.http.authedRequest<KeyBackupRoomSessions>(Method.Get, path, { version: backupVersion }, undefined, {
|
||||
prefix: ClientPrefix.V3,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the room keys from a `/room_keys/keys` call.
|
||||
* Calls `opts.progressCallback` with the progress of the import.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+148
-59
@@ -50,6 +50,7 @@ import {
|
||||
CryptoEvent,
|
||||
type CryptoEventHandlerMap,
|
||||
DecryptionFailureCode,
|
||||
DecryptionKeyDoesNotMatchError,
|
||||
deriveRecoveryKeyFromPassphrase,
|
||||
type DeviceIsolationMode,
|
||||
DeviceIsolationModeKind,
|
||||
@@ -97,6 +98,7 @@ import { VerificationMethod } from "../types.ts";
|
||||
import { keyFromAuthData } from "../common-crypto/key-passphrase.ts";
|
||||
import { type UIAuthCallback } from "../interactive-auth.ts";
|
||||
import { getHttpUriForMxc } from "../content-repo.ts";
|
||||
import { type RoomState } from "../matrix.ts";
|
||||
|
||||
const ALL_VERIFICATION_METHODS = [
|
||||
VerificationMethod.Sas,
|
||||
@@ -110,6 +112,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.
|
||||
*
|
||||
@@ -130,9 +135,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;
|
||||
@@ -369,10 +371,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,
|
||||
{},
|
||||
@@ -382,17 +384,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;
|
||||
@@ -401,8 +410,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),
|
||||
);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -724,7 +736,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
? userIdentity.identityNeedsUserApproval()
|
||||
: false;
|
||||
userIdentity.free();
|
||||
return new UserVerificationStatus(verified, wasVerified, false, needsUserApproval);
|
||||
return new UserVerificationStatus(verified, wasVerified, true, needsUserApproval);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -780,9 +792,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;
|
||||
@@ -1011,9 +1021,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) &&
|
||||
@@ -1127,9 +1135,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}`);
|
||||
|
||||
@@ -1213,9 +1221,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");
|
||||
}
|
||||
@@ -1315,7 +1321,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
|
||||
const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(backupKey);
|
||||
if (!decryptionKeyMatchesKeyBackupInfo(backupDecryptionKey, keyBackupInfo)) {
|
||||
throw new Error("loadSessionBackupPrivateKeyFromSecretStorage: decryption key does not match backup info");
|
||||
throw new DecryptionKeyDoesNotMatchError(
|
||||
"loadSessionBackupPrivateKeyFromSecretStorage: decryption key does not match backup info",
|
||||
);
|
||||
}
|
||||
|
||||
await this.backupManager.saveBackupDecryptionKey(backupDecryptionKey, keyBackupInfo.version);
|
||||
@@ -1368,6 +1376,8 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
public async resetKeyBackup(): Promise<void> {
|
||||
const backupInfo = await this.backupManager.setupKeyBackup((o) => this.signObject(o));
|
||||
|
||||
await this.pushSecretToVerifiedDevices("m.megolm_backup.v1");
|
||||
|
||||
// we want to store the private key in 4S
|
||||
// need to check if 4S is set up?
|
||||
if (await this.secretStorageHasAESKey()) {
|
||||
@@ -1616,25 +1626,31 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
|
||||
logger.info("Sharing message history");
|
||||
|
||||
// 1. Construct the key bundle
|
||||
// 1. Download keys from backup.
|
||||
if (!(await this.getOlmMachineOrThrow().hasDownloadedAllRoomKeys(new RustSdkCryptoJs.RoomId(roomId)))) {
|
||||
await this.backupManager.downloadLatestRoomKeyBackup(roomId);
|
||||
await this.getOlmMachineOrThrow().setHasDownloadedAllRoomKeys(new RustSdkCryptoJs.RoomId(roomId));
|
||||
}
|
||||
|
||||
// 2. Construct the key bundle
|
||||
const bundle = await this.getOlmMachineOrThrow().buildRoomKeyBundle(new RustSdkCryptoJs.RoomId(roomId));
|
||||
if (!bundle) {
|
||||
logger.info("No keys to share");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Upload the encrypted bundle to the server
|
||||
// 3. Upload the encrypted bundle to the server
|
||||
const uploadResponse = await this.http.uploadContent(bundle.encryptedData as Uint8Array<ArrayBuffer>);
|
||||
logger.info(`Uploaded encrypted key blob: ${JSON.stringify(uploadResponse)}`);
|
||||
|
||||
// 3. We may not share a room with the user, so get a fresh list of devices for the invited user.
|
||||
// 4. We may not share a room with the user, so get a fresh list of devices for the invited user.
|
||||
const req = this.getOlmMachineOrThrow().queryKeysForUsers([new RustSdkCryptoJs.UserId(userId)]);
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(req);
|
||||
|
||||
// 4. Establish Olm sessions with all of the recipient's devices.
|
||||
// 5. Establish Olm sessions with all of the recipient's devices.
|
||||
await this.keyClaimManager.ensureSessionsForUsers(logger, [new RustSdkCryptoJs.UserId(userId)]);
|
||||
|
||||
// 5. Send to-device messages to the recipient to share the keys.
|
||||
// 6. Send to-device messages to the recipient to share the keys.
|
||||
const requests = await this.getOlmMachineOrThrow().shareRoomKeyBundleData(
|
||||
new RustSdkCryptoJs.UserId(userId),
|
||||
new RustSdkCryptoJs.RoomId(roomId),
|
||||
@@ -1717,34 +1733,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;
|
||||
@@ -1917,7 +1936,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;
|
||||
@@ -1925,6 +1958,44 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
enc.onRoomMembership(member);
|
||||
}
|
||||
|
||||
/**
|
||||
* Previously, it was sufficient to check if we need to rotate the room key
|
||||
* prior to sending a message. However, the history sharing feature
|
||||
* (MSC4268) breaks this logic:
|
||||
*
|
||||
* 1. Alice sends a message M1 in room X;
|
||||
* 2. Bob invites Charlie, who joins and immediately leaves the room;
|
||||
* 3. Alice sends another message M2 in room X.
|
||||
*
|
||||
* Under the old logic, Alice would not rotate her key after Charlie
|
||||
* leaves, resulting in M2 being encrypted with the same session as M1.
|
||||
* This would allow Charlie to decrypt M2 if he ever gains access to
|
||||
* the event.
|
||||
*
|
||||
* To counter this, we proactively discard any active outgoing Megolm
|
||||
* session when we see an event indicating the user left.
|
||||
*
|
||||
* Note that we have to do this in `onRoomStateEvent` rather than
|
||||
* `onRoomMembership`, because `onRoomMembership` is only called when we see
|
||||
* a *change* in membership. In the case of a gappy sync, we might miss
|
||||
* Charlie's invite and join, and only see the final `leave` event (so his
|
||||
* membership goes from `leave` to `leave`).
|
||||
*/
|
||||
public onRoomStateEvent(event: MatrixEvent, _state: RoomState, _prevEvent: MatrixEvent | null): void {
|
||||
if (event.getType() != EventType.RoomMember) {
|
||||
// Ignore all events that aren't member updates.
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.getStateKey()! !== this.olmMachine.userId.toString() &&
|
||||
event.getContent().membership !== KnownMembership.Join
|
||||
) {
|
||||
this.logger.info(`Rotating session for room ${event.getRoomId()} due to member leaving the room`);
|
||||
this.forceDiscardSession(event.getRoomId()!);
|
||||
}
|
||||
}
|
||||
|
||||
/** Callback for OlmMachine.registerRoomKeyUpdatedCallback
|
||||
*
|
||||
* Called by the rust-sdk whenever there is an update to (megolm) room keys. We
|
||||
@@ -2035,9 +2106,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
||||
/**
|
||||
* 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 gossipped secrets are received using the `m.secret.send` or
|
||||
* `io.element.msc4385.secret.push` event types 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`.
|
||||
*
|
||||
@@ -2180,7 +2251,22 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a secret to all of the current user's verified devices.
|
||||
*/
|
||||
public async pushSecretToVerifiedDevices(name: string): Promise<void> {
|
||||
const logger = new LogSpan(this.logger, "pushSecretToVerifiedDevices");
|
||||
await this.keyClaimManager.ensureSessionsForUsers(logger, [new RustSdkCryptoJs.UserId(this.userId)]);
|
||||
await this.olmMachine.pushSecretToVerifiedDevices(name);
|
||||
this.outgoingRequestsManager.doProcessOutgoingRequests().catch((e) => {
|
||||
logger.warn("pushSecretToVerifiedDevices: Error processing outgoing requests", e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2510,7 +2596,7 @@ function rustEncryptionInfoToJsEncryptionInfo(
|
||||
}
|
||||
|
||||
interface RoomKeyBundleMessage {
|
||||
type: "io.element.msc4268.room_key_bundle";
|
||||
type: "m.room_key_bundle" | "io.element.msc4268.room_key_bundle";
|
||||
content: {
|
||||
room_id: string;
|
||||
};
|
||||
@@ -2520,13 +2606,16 @@ interface RoomKeyBundleMessage {
|
||||
* Determines if the given payload is a RoomKeyBundleMessage.
|
||||
*
|
||||
* A RoomKeyBundleMessage is identified by having a specific message type
|
||||
* ("io.element.msc4268.room_key_bundle") and a valid room_id in its content.
|
||||
* ("m.room_key_bundle") and a valid room_id in its content.
|
||||
*
|
||||
* @param message - The received to-device message to check.
|
||||
* @returns True if the payload matches the RoomKeyBundleMessage structure, false otherwise.
|
||||
*/
|
||||
function isRoomKeyBundleMessage(message: IToDeviceEvent): message is IToDeviceEvent & RoomKeyBundleMessage {
|
||||
return message.type === "io.element.msc4268.room_key_bundle" && typeof message.content.room_id === "string";
|
||||
return (
|
||||
(message.type === "io.element.msc4268.room_key_bundle" || message.type === "m.room_key_bundle") &&
|
||||
typeof message.content.room_id === "string"
|
||||
);
|
||||
}
|
||||
|
||||
type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent];
|
||||
|
||||
@@ -61,6 +61,7 @@ const VERSION = DB_MIGRATIONS.length;
|
||||
* Return the data you want to keep.
|
||||
* @returns Promise which resolves to an array of whatever you returned from
|
||||
* resultMapper.
|
||||
* @throws If there was an error completing the query.
|
||||
*/
|
||||
function selectQuery<T>(
|
||||
store: IDBObjectStore,
|
||||
@@ -71,7 +72,7 @@ function selectQuery<T>(
|
||||
return new Promise((resolve, reject) => {
|
||||
const results: T[] = [];
|
||||
query.onerror = (): void => {
|
||||
reject(new Error("Query failed: " + query.error?.name));
|
||||
reject(new Error(`selectQuery failed for ${store.name}`, { cause: query.error }));
|
||||
};
|
||||
// collect results
|
||||
query.onsuccess = (): void => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user