Compare commits

...

48 Commits

Author SHA1 Message Date
Michael Telatynski 14b2ee2da4 Remove dependency on uuid (#5297)
Notify Downstream Projects / notify-downstream (element-web-notify, element-hq/element-web) (push) Has been skipped
Static Analysis / Typescript Syntax Check (push) Failing after 1m36s
Static Analysis / ESLint (push) Failing after 31s
Static Analysis / Node.js example (push) Failing after 42s
Static Analysis / Workflow Lint (push) Failing after 9m59s
Static Analysis / JSDoc Checker (push) Failing after 50s
Static Analysis / Analyse Dead Code (push) Failing after 36s
Static Analysis / Downstream tsc element-web (push) Has been skipped
Tests / Vitest [integ] (Node 22) (push) Failing after 4s
Tests / Vitest [unit] (Node 22) (push) Failing after 39s
Tests / Vitest [integ] (Node lts/*) (push) Failing after 39s
Tests / Vitest [unit] (Node lts/*) (push) Failing after 35s
Tests / Downstream test element-web (push) Has been skipped
Tests / Run Complement Crypto tests (push) Has been skipped
Static Analysis / Static Analysis (push) Successful in 1s
Tests / Tests (push) Failing after 1s
Tests / Downstream Complement Crypto tests (push) Successful in 1s
Tests / Downstream tests (push) Successful in 4s
2026-04-23 09:53:18 +00:00
renovate[bot] bb083222d9 Update dependency vite to v8 (#5295)
* Update dependency vite to v8

* Update lockfile

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2026-04-22 16:16:29 +00:00
Andy Balaam fa424c44b4 Support stable identifiers for MSC4268 (#5290)
* Support stable identifier m.room_key_bundle

* Support stable identifier m.shared_history

* Test that checks isRoomKeyBundleMessage works for stable and unstable identifiers

* Replace similar tests with use of it.each
2026-04-22 09:10:39 +00:00
renovate[bot] fef093747e Update dependency @typescript-eslint/eslint-plugin to v8.59.0 (#5292)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 14:38:05 +00:00
renovate[bot] 4b33892d48 Update npm non-major dependencies (#5293)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 13:58:22 +00:00
renovate[bot] d7d771fadb Update vite to v4.1.5 (#5291)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 12:37:38 +00:00
Hubert Chathi 4ee3e591bf Handle secret pushing for key backups (#5189)
* push backup key to other verified devices when we reset backup

* handle receiving pushed backup keys

- make sure that backup gets enabled after we receive a pushed key that
  matches the current, valid backup

* apply requested changes from review
2026-04-20 21:48:51 +00:00
renovate[bot] 668183d722 Update vite to v4.1.4 (#5289)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 11:25:37 +00:00
renovate[bot] 854dae0dc0 Update typescript (#5288)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 10:01:43 +00:00
renovate[bot] 9d1aca2232 Update eslint-plugins (#5286)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 09:11:28 +00:00
renovate[bot] 81569f3461 Update actions/setup-node digest to 48b55a0 (#5285)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 08:50:10 +00:00
renovate[bot] 50783aba76 Update npm non-major dependencies (#5287)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 08:17:20 +00:00
Michael Telatynski fd01b17236 Tidy knip config (#5284) 2026-04-17 13:00:21 +00:00
Will Hunt 25e92009b7 Throw more helpful errors from indexedb-local-backend (#5282)
* Throw more helpful errors from indexedb-local-backend

* Add a test

* Do not tempt fate
2026-04-17 08:33:56 +00:00
Michael Telatynski eb7acfb810 Add support for m.recent_emoji account data event (#5280) 2026-04-16 21:59:30 +00:00
Andy Balaam ca5655bced Rotate the room key when anyone leaves a room for any reason (#5279) 2026-04-16 12:09:42 +00:00
Michael Telatynski ef9b13e2a6 Fix coverage reports clobbering each other (#5267)
merge-multiple would silently drop files with clashing names - it ultimately isn't necessary given the `find` command will happily find them in nested subdirs
2026-04-16 11:48:51 +00:00
fkwp 159cca0363 Adapt LiveKit Identity hash calculation to latest MSC4195 update (#5268)
* adapt hash calculation to latest MSC4195 update.

Stop using '|' delimiters in hashes; use JSON arrays + canonical JSON instead

Signed-off-by: fkwp <github-fkwp@w4ve.de>

* Update the test for RTC backend identities and add tests for calculating the identity hash.

Signed-off-by: fkwp <github-fkwp@w4ve.de>

* linting

Signed-off-by: fkwp <github-fkwp@w4ve.de>

* add copyright header

---------

Signed-off-by: fkwp <github-fkwp@w4ve.de>
2026-04-16 09:48:11 +00:00
renovate[bot] 3879111850 Update typescript (#5274)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 11:37:50 +00:00
Andy Balaam f9a5aa87e3 Support the stable prefix for MSC4287 (key backup preference) (#5258)
* Support the stable prefix for MSC4287 (key backup preference)

* Remove incorrect doc coment on disableKeyStorage
2026-04-15 11:07:48 +00:00
renovate[bot] ed58df040c Update eslint-plugins (#5276)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 09:51:25 +00:00
Michael Telatynski 0a3448d4c9 Fix references to matrix-react-sdk in CI (#5269) 2026-04-15 09:25:54 +00:00
renovate[bot] 25c1c1ea26 Update actions/github-script action to v9 (#5277)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 09:11:09 +00:00
renovate[bot] a5e67af31f Update actions/upload-pages-artifact action to v5 (#5278)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 08:37:38 +00:00
renovate[bot] b91e80814a Update zizmorcore/zizmor-action action to v0.5.3 (#5275)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 08:37:07 +00:00
renovate[bot] d096a72605 Update npm non-major dependencies (#5273)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 08:36:56 +00:00
renovate[bot] fb547e7b4b Update actions/upload-artifact digest to 043fb46 (#5272)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 08:36:46 +00:00
Michael Telatynski 815294ca5a Allow oidc jwks_uri to be omitted (#5271)
* Allow oidc jwks_uri to be null

As signing keys are seemingly not (yet?) in the spec

* Add test coverage
2026-04-15 08:15:29 +00:00
Michael Telatynski 6d270b4685 Handle response_mode=fragment in completeAuthorizationCodeGrant (#5266)
* Handle response_mode=fragment in completeAuthorizationCodeGrant

* Add test

* Fix docs
2026-04-14 14:59:10 +00:00
Michael Telatynski 8bc3d96f6b Allow generating OIDC URIs with response_mode=fragment (#5265) 2026-04-13 22:53:05 +00:00
Richard van der Hoff b6ea6e105e Log clarifications for handleBackupSecretReceived (#5233)
Some changes to make this a bit easier to understand.
2026-04-10 12:33:30 +00:00
Valere Fedronic cd4e053fa5 Suppress and Reduce noisy logs for rtc (#5260) 2026-04-09 14:24:36 +00:00
Andy Balaam 727473af62 Expand the comment on CryptoApi.getUserDeviceInfo saying we request info from the server (#5256)
* Expand the comment on CryptoApi.getUserDeviceInfo saying we request info from the server

* Update comment to reflect waiting for in-progress requests, not making new ones

* Update the comment for userHasCrossSigningKeys too
2026-04-09 14:19:22 +00:00
Michael Telatynski f17f013f1e Satisfy pnpm audit (#5262) 2026-04-09 11:52:19 +00:00
renovate[bot] 9f4ab0b840 Update matrix-org/sonarcloud-workflow-action digest to 13968a2 (#5263)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 13:28:46 +00:00
Michael Telatynski 6371e4b252 Update pnpm command for version bumping 2026-04-08 08:23:52 +01:00
Michael Telatynski b69929e01a Allow release-make to bump multiple package.json versions (#5261) 2026-04-07 14:44:15 +00:00
RiotRobot 9dc12baaa9 Merge branch 'master' into develop 2026-04-07 13:17:46 +00:00
Andy Balaam d02205652f Re-enable Complement Crypto tests (#5257)
Since https://github.com/matrix-org/complement-crypto/pull/235 these
should be more reliable.

This reverts commit 4d59291538.
2026-04-01 12:38:11 +00:00
Richard van der Hoff 5e03add29a Expose UserVerificationStatus.known flag (#5255)
Indicate whether we have a record of this user's identity.
2026-04-01 09:58:50 +00:00
renovate[bot] eeafd7fcaa Update npm non-major dependencies (#5251)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 16:36:53 +00:00
renovate[bot] 78a3c5372d Update vite (#5253)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 13:58:28 +00:00
renovate[bot] c0c3bc2a8c Update dependency p-retry to v8 (#5254)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 13:28:47 +00:00
renovate[bot] c3ce49cabf Update pnpm to v10.33.0 (#5252)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 13:19:11 +00:00
renovate[bot] 5408168dfd Update dependency eslint-plugin-jsdoc to v62.8.1 (#5250)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 13:17:51 +00:00
renovate[bot] 61452ddc11 Update pnpm/action-setup action to v5 (#5239)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 13:11:15 +00:00
Michael Telatynski d5160a5380 Add permissions for contents in workflow 2026-03-31 14:36:05 +01:00
Michael Telatynski 7ff4960a27 Update workflow to use build-and-test.yaml from EW
Moved in https://github.com/element-hq/element-web/pull/32929
2026-03-31 14:34:06 +01:00
35 changed files with 1601 additions and 1127 deletions
@@ -1,7 +1,7 @@
# Triggers after the "Downstream artifacts" build has finished, to run the # 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: on:
merge_group: merge_group:
types: [checks_requested] types: [checks_requested]
@@ -21,11 +21,12 @@ concurrency:
jobs: jobs:
playwright: playwright:
name: Playwright name: Playwright
uses: element-hq/element-web/.github/workflows/end-to-end-tests.yaml@develop # zizmor: ignore[unpinned-uses] uses: element-hq/element-web/.github/workflows/build-and-test.yaml@develop # zizmor: ignore[unpinned-uses]
permissions: permissions:
actions: read actions: read
issues: read issues: read
pull-requests: read pull-requests: read
contents: read
with: with:
matrix-js-sdk-sha: ${{ github.sha }} matrix-js-sdk-sha: ${{ github.sha }}
# We only want to run the playwright tests on merge queue to prevent regressions # We only want to run the playwright tests on merge queue to prevent regressions
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it - 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 uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4
with: with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }} token: ${{ secrets.ELEMENT_BOT_TOKEN }}
+3 -3
View File
@@ -38,7 +38,7 @@ jobs:
pull-requests: read pull-requests: read
steps: steps:
- name: Add notice - 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') if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
with: with:
script: | script: |
@@ -63,7 +63,7 @@ jobs:
- name: Add label - name: Add label
if: steps.teams.outputs.isTeamMember == 'false' if: steps.teams.outputs.isTeamMember == 'false'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with: with:
script: | script: |
github.rest.issues.addLabels({ github.rest.issues.addLabels({
@@ -84,7 +84,7 @@ jobs:
github.event.pull_request.head.repo.full_name != github.repository github.event.pull_request.head.repo.full_name != github.repository
steps: steps:
- name: Close pull request - name: Close pull request
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with: with:
script: | script: |
github.rest.issues.createComment({ github.rest.issues.createComment({
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Check for X-Release-Blocker label on any open issues or PRs - 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: env:
REPO: ${{ inputs.repository }} REPO: ${{ inputs.repository }}
with: with:
@@ -22,8 +22,8 @@ jobs:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
node-version-file: package.json node-version-file: package.json
cache: "pnpm" cache: "pnpm"
@@ -50,7 +50,7 @@ jobs:
- name: Ingest upstream changes - name: Ingest upstream changes
if: inputs.include-changes if: inputs.include-changes
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_ID: ${{ steps.draft-release.outputs.id }} RELEASE_ID: ${{ steps.draft-release.outputs.id }}
+2 -2
View File
@@ -38,8 +38,8 @@ jobs:
sparse-checkout: | sparse-checkout: |
scripts/release scripts/release
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
cache: "pnpm" cache: "pnpm"
node-version-file: package.json node-version-file: package.json
+23 -16
View File
@@ -33,10 +33,14 @@ on:
description: The number of expected assets, including signatures, excluding generated zip & tarball. description: The number of expected assets, including signatures, excluding generated zip & tarball.
type: number type: number
required: false required: false
dir: dist-dir:
description: The directory to release description: The directory to release
type: string type: string
default: "." default: "."
version-dirs:
description: Directories in which to update package.json `version` field
type: string
required: false
outputs: outputs:
npm-id: npm-id:
description: "The npm package@version string we published" description: "The npm package@version string we published"
@@ -96,7 +100,7 @@ jobs:
- name: Prepare variables - name: Prepare variables
id: prepare id: prepare
working-directory: ${{ inputs.dir }} working-directory: ${{ inputs.dist-dir }}
run: | run: |
echo "VERSION=$VERSION" >> $GITHUB_ENV echo "VERSION=$VERSION" >> $GITHUB_ENV
@@ -111,7 +115,7 @@ jobs:
run: echo "VERSION=$(echo $VERSION | cut -d- -f1)" >> $GITHUB_ENV run: echo "VERSION=$(echo $VERSION | cut -d- -f1)" >> $GITHUB_ENV
- name: Check version number not in use - name: Check version number not in use
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with: with:
script: | script: |
const { VERSION } = process.env; const { VERSION } = process.env;
@@ -130,17 +134,17 @@ jobs:
git config --global user.email "releases@riot.im" git config --global user.email "releases@riot.im"
git config --global user.name "RiotRobot" git config --global user.name "RiotRobot"
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
cache: "pnpm" cache: "pnpm"
node-version-file: ${{ inputs.dir }}/package.json node-version-file: ${{ inputs.dist-dir }}/package.json
- name: Install dependencies - name: Install dependencies
run: "pnpm install --frozen-lockfile" run: "pnpm install --frozen-lockfile"
- name: Handle develop dependencies - name: Handle develop dependencies
working-directory: ${{ inputs.dir }} working-directory: ${{ inputs.dist-dir }}
run: | run: |
ret=0 ret=0
cat package.json | jq -r '.dependencies | to_entries | .[] | "\(.key) \(.value)"' | grep '#develop$' | while read -r dep ; do cat package.json | jq -r '.dependencies | to_entries | .[] | "\(.key) \(.value)"' | grep '#develop$' | while read -r dep ; do
@@ -154,11 +158,14 @@ jobs:
git commit -m "Keep $PACKAGE at $VERSION" git commit -m "Keep $PACKAGE at $VERSION"
done done
- name: Bump package.json version - name: Bump package.json versions
working-directory: ${{ inputs.dir }}
run: | run: |
pnpm version --no-git-tag-version "${VERSION#v}" for DIR in $DIRS; do
git add package.json 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 - name: Add to CHANGELOG.md
if: inputs.final if: inputs.final
@@ -185,7 +192,7 @@ jobs:
- name: Build assets - name: Build assets
if: steps.prepare.outputs.has-dist-script == '1' if: steps.prepare.outputs.has-dist-script == '1'
working-directory: ${{ inputs.dir }} working-directory: ${{ inputs.dist-dir }}
run: DIST_VERSION="$VERSION" pnpm dist run: DIST_VERSION="$VERSION" pnpm dist
- name: Upload release assets & signatures - name: Upload release assets & signatures
@@ -194,7 +201,7 @@ jobs:
with: with:
gpg-fingerprint: ${{ inputs.gpg-fingerprint }} gpg-fingerprint: ${{ inputs.gpg-fingerprint }}
upload-url: ${{ steps.draft-release.outputs.upload_url }} upload-url: ${{ steps.draft-release.outputs.upload_url }}
asset-path: ${{ inputs.dir }}/${{ inputs.asset-path }} asset-path: ${{ inputs.dist-dir }}/${{ inputs.asset-path }}
- name: Create signed tag - name: Create signed tag
if: inputs.gpg-fingerprint if: inputs.gpg-fingerprint
@@ -227,7 +234,7 @@ jobs:
- name: Validate release has expected assets - name: Validate release has expected assets
if: inputs.expected-asset-count if: inputs.expected-asset-count
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env: env:
RELEASE_ID: ${{ steps.draft-release.outputs.id }} RELEASE_ID: ${{ steps.draft-release.outputs.id }}
EXPECTED_ASSET_COUNT: ${{ inputs.expected-asset-count }} EXPECTED_ASSET_COUNT: ${{ inputs.expected-asset-count }}
@@ -255,7 +262,7 @@ jobs:
git push origin master git push origin master
- name: Publish release - name: Publish release
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env: env:
RELEASE_ID: ${{ steps.draft-release.outputs.id }} RELEASE_ID: ${{ steps.draft-release.outputs.id }}
FINAL: ${{ inputs.final }} FINAL: ${{ inputs.final }}
@@ -289,7 +296,7 @@ jobs:
if: inputs.npm if: inputs.npm
uses: matrix-org/matrix-js-sdk/.github/workflows/release-npm.yml@develop # zizmor: ignore[unpinned-uses] uses: matrix-org/matrix-js-sdk/.github/workflows/release-npm.yml@develop # zizmor: ignore[unpinned-uses]
with: with:
dir: ${{ inputs.dir }} dir: ${{ inputs.dist-dir }}
permissions: permissions:
contents: read contents: read
id-token: write id-token: write
+2 -2
View File
@@ -27,9 +27,9 @@ jobs:
ref: staging ref: staging
persist-credentials: false persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: 🔧 pnpm cache - name: 🔧 pnpm cache
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
cache: "pnpm" cache: "pnpm"
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
+5 -5
View File
@@ -52,8 +52,8 @@ jobs:
token: ${{ secrets.ELEMENT_BOT_TOKEN }} token: ${{ secrets.ELEMENT_BOT_TOKEN }}
persist-credentials: true persist-credentials: true
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
cache: "pnpm" cache: "pnpm"
node-version: "lts/*" node-version: "lts/*"
@@ -81,9 +81,9 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: 🔧 pnpm cache - name: 🔧 pnpm cache
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
cache: "pnpm" cache: "pnpm"
node-version-file: package.json node-version-file: package.json
@@ -95,7 +95,7 @@ jobs:
run: pnpm gendoc run: pnpm gendoc
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with: with:
path: _docs path: _docs
+1 -2
View File
@@ -63,7 +63,6 @@ jobs:
run-id: ${{ github.event.workflow_run.id }} run-id: ${{ github.event.workflow_run.id }}
pattern: coverage-* pattern: coverage-*
path: coverage path: coverage
merge-multiple: true
- name: Check coverage artifact - name: Check coverage artifact
run: | run: |
if [ ! -d coverage ]; then if [ ! -d coverage ]; then
@@ -80,7 +79,7 @@ jobs:
- name: "🩻 SonarCloud Scan" - name: "🩻 SonarCloud Scan"
id: sonarcloud 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 # 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' }} continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
with: with:
+16 -16
View File
@@ -18,8 +18,8 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
cache: "pnpm" cache: "pnpm"
node-version-file: package.json node-version-file: package.json
@@ -38,8 +38,8 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
cache: "pnpm" cache: "pnpm"
node-version-file: package.json node-version-file: package.json
@@ -58,8 +58,8 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
cache: "pnpm" cache: "pnpm"
node-version-file: package.json node-version-file: package.json
@@ -92,8 +92,8 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
cache: "pnpm" cache: "pnpm"
node-version-file: package.json node-version-file: package.json
@@ -105,7 +105,7 @@ jobs:
run: "pnpm lint:workflows" run: "pnpm lint:workflows"
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
docs: docs:
name: "JSDoc Checker" name: "JSDoc Checker"
@@ -115,8 +115,8 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
cache: "pnpm" cache: "pnpm"
node-version-file: package.json node-version-file: package.json
@@ -128,7 +128,7 @@ jobs:
run: "pnpm run gendoc --treatWarningsAsErrors --suppressCommentWarningsInDeclarationFiles" run: "pnpm run gendoc --treatWarningsAsErrors --suppressCommentWarningsInDeclarationFiles"
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with: with:
name: docs name: docs
path: _docs path: _docs
@@ -143,8 +143,8 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
cache: "pnpm" cache: "pnpm"
node-version-file: package.json node-version-file: package.json
@@ -165,8 +165,8 @@ jobs:
repository: element-hq/element-web repository: element-hq/element-web
persist-credentials: false persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
cache: "pnpm" cache: "pnpm"
node-version: "lts/*" node-version: "lts/*"
+4 -4
View File
@@ -26,10 +26,10 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: Setup Node - name: Setup Node
id: setupNode id: setupNode
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
cache: "pnpm" cache: "pnpm"
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
@@ -59,7 +59,7 @@ jobs:
- name: Upload Artifact - name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true' if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with: with:
name: coverage-${{ matrix.specs }}-${{ matrix.node == 'lts/*' && 'lts' || matrix.node }} name: coverage-${{ matrix.specs }}-${{ matrix.node == 'lts/*' && 'lts' || matrix.node }}
path: | path: |
@@ -88,7 +88,7 @@ jobs:
complement-crypto: complement-crypto:
name: "Run Complement Crypto tests" name: "Run Complement Crypto tests"
if: false # disabled due to flakiness if: github.event_name == 'merge_group'
permissions: read-all # zizmor: ignore[excessive-permissions] permissions: read-all # zizmor: ignore[excessive-permissions]
uses: matrix-org/complement-crypto/.github/workflows/single_sdk_tests.yml@main # zizmor: ignore[unpinned-uses] uses: matrix-org/complement-crypto/.github/workflows/single_sdk_tests.yml@main # zizmor: ignore[unpinned-uses]
with: with:
+3 -4
View File
@@ -1,5 +1,8 @@
import { KnipConfig } from "knip"; 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 { export default {
entry: [ entry: [
"src/index.ts", "src/index.ts",
@@ -28,10 +31,6 @@ export default {
"husky", "husky",
// Used in script which only runs in environment with `@octokit/rest` installed // Used in script which only runs in environment with `@octokit/rest` installed
"@octokit/rest", "@octokit/rest",
// Used by `vitest`
"vitest-sonar-reporter",
// Used by `@babel/plugin-transform-runtime`
"@babel/runtime",
], ],
ignoreBinaries: [ ignoreBinaries: [
// Used when available by reusable workflow `.github/workflows/release-make.yml` // Used when available by reusable workflow `.github/workflows/release-make.yml`
+14 -11
View File
@@ -18,8 +18,8 @@
"lint:types": "tsc --noEmit", "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:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) | xargs -I {} sh -c 'echo \"Linting {}\"; action-validator \"{}\"'",
"lint:knip": "knip", "lint:knip": "knip",
"test": "vitest", "test": "vitest run",
"test:watch": "vitest --watch", "test:watch": "vitest watch",
"coverage": "pnpm test --coverage" "coverage": "pnpm test --coverage"
}, },
"repository": { "repository": {
@@ -48,7 +48,7 @@
], ],
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^18.0.0", "@matrix-org/matrix-sdk-crypto-wasm": "^18.1.0",
"another-json": "^0.2.0", "another-json": "^0.2.0",
"bs58": "^6.0.0", "bs58": "^6.0.0",
"content-type": "^1.0.4", "content-type": "^1.0.4",
@@ -57,10 +57,9 @@
"matrix-events-sdk": "0.0.1", "matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.16.1", "matrix-widget-api": "^1.16.1",
"oidc-client-ts": "^3.0.1", "oidc-client-ts": "^3.0.1",
"p-retry": "7", "p-retry": "8",
"sdp-transform": "^3.0.0", "sdp-transform": "^3.0.0",
"unhomoglyph": "^1.0.6", "unhomoglyph": "^1.0.6"
"uuid": "13"
}, },
"devDependencies": { "devDependencies": {
"@action-validator/cli": "^0.6.0", "@action-validator/cli": "^0.6.0",
@@ -109,7 +108,7 @@
"knip": "^6.0.0", "knip": "^6.0.0",
"lint-staged": "^16.0.0", "lint-staged": "^16.0.0",
"matrix-mock-request": "^2.5.0", "matrix-mock-request": "^2.5.0",
"prettier": "3.8.1", "prettier": "3.8.3",
"typedoc": "^0.28.1", "typedoc": "^0.28.1",
"typedoc-plugin-coverage": "^4.0.0", "typedoc-plugin-coverage": "^4.0.0",
"typedoc-plugin-mdn-links": "^5.0.0", "typedoc-plugin-mdn-links": "^5.0.0",
@@ -118,9 +117,6 @@
"vitest": "^4.0.17", "vitest": "^4.0.17",
"vitest-sonar-reporter": "^3.0.0" "vitest-sonar-reporter": "^3.0.0"
}, },
"resolutions": {
"expect": "30.3.0"
},
"pnpm": { "pnpm": {
"peerDependencyRules": { "peerDependencyRules": {
"allowedVersions": { "allowedVersions": {
@@ -129,7 +125,14 @@
}, },
"allowedDeprecatedVersions": { "allowedDeprecatedVersions": {
"eslint": "8" "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.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be" "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
} }
+1027 -991
View File
File diff suppressed because it is too large Load Diff
+110 -2
View File
@@ -86,6 +86,7 @@ import {
encryptGroupSessionKey, encryptGroupSessionKey,
encryptMegolmEvent, encryptMegolmEvent,
encryptMegolmEventRawPlainText, encryptMegolmEventRawPlainText,
encryptOlmEvent,
establishOlmSession, establishOlmSession,
getTestOlmAccountKeys, getTestOlmAccountKeys,
expectSendRoomKey, expectSendRoomKey,
@@ -2064,6 +2065,7 @@ describe("crypto", () => {
expect(hasCrossSigningKeysForUser).toBe(true); expect(hasCrossSigningKeysForUser).toBe(true);
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(BOB_TEST_USER_ID); 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.isVerified()).toBe(false);
expect(verificationStatus.isCrossSigningVerified()).toBe(false); expect(verificationStatus.isCrossSigningVerified()).toBe(false);
expect(verificationStatus.wasCrossSigningVerified()).toBe(false); expect(verificationStatus.wasCrossSigningVerified()).toBe(false);
@@ -2078,7 +2080,8 @@ describe("crypto", () => {
const hasCrossSigningKeysForUser = await aliceClient.getCrypto()!.userHasCrossSigningKeys(TEST_USER_ID); const hasCrossSigningKeysForUser = await aliceClient.getCrypto()!.userHasCrossSigningKeys(TEST_USER_ID);
expect(hasCrossSigningKeysForUser).toBe(true); 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.isVerified()).toBe(false);
expect(verificationStatus.isCrossSigningVerified()).toBe(false); expect(verificationStatus.isCrossSigningVerified()).toBe(false);
expect(verificationStatus.wasCrossSigningVerified()).toBe(false); expect(verificationStatus.wasCrossSigningVerified()).toBe(false);
@@ -2089,7 +2092,8 @@ describe("crypto", () => {
const hasCrossSigningKeysForUser = await aliceClient.getCrypto()!.userHasCrossSigningKeys("@unknown:xyz"); const hasCrossSigningKeysForUser = await aliceClient.getCrypto()!.userHasCrossSigningKeys("@unknown:xyz");
expect(hasCrossSigningKeysForUser).toBe(false); 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.isVerified()).toBe(false);
expect(verificationStatus.isCrossSigningVerified()).toBe(false); expect(verificationStatus.isCrossSigningVerified()).toBe(false);
expect(verificationStatus.wasCrossSigningVerified()).toBe(false); expect(verificationStatus.wasCrossSigningVerified()).toBe(false);
@@ -2119,6 +2123,7 @@ describe("crypto", () => {
{ {
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(BOB_TEST_USER_ID); const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(BOB_TEST_USER_ID);
expect(verificationStatus.known).toBe(true);
expect(verificationStatus.isVerified()).toBe(false); expect(verificationStatus.isVerified()).toBe(false);
expect(verificationStatus.isCrossSigningVerified()).toBe(false); expect(verificationStatus.isCrossSigningVerified()).toBe(false);
expect(verificationStatus.wasCrossSigningVerified()).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);
});
});
}); });
+33 -10
View File
@@ -742,9 +742,18 @@ describe("History Sharing", () => {
).toBeFalsy(); ).toBeFalsy();
}); });
test.each([false, true])( test.each([
"Room key is rotated after a member joins and leaves the room (gappy sync = %s)", { gappySync: false, leftState: KnownMembership.Ban },
async (gappySync) => { { gappySync: false, leftState: KnownMembership.Invite },
{ gappySync: false, leftState: KnownMembership.Knock },
{ gappySync: false, leftState: KnownMembership.Leave },
{ gappySync: true, leftState: KnownMembership.Ban },
{ gappySync: true, leftState: KnownMembership.Invite },
{ gappySync: true, leftState: KnownMembership.Knock },
{ gappySync: true, leftState: KnownMembership.Leave },
])(
"Room key is rotated after a member joins and leaves the room (%s)",
async (config) => {
const [alice, bob, charlie] = await setupClients(3); const [alice, bob, charlie] = await setupClients(3);
const { homeserverUrl: aliceHomeserverUrl, client: aliceClient, syncResponder: aliceSyncResponder } = alice; const { homeserverUrl: aliceHomeserverUrl, client: aliceClient, syncResponder: aliceSyncResponder } = alice;
const { homeserverUrl: bobHomeserverUrl, client: bobClient, syncResponder: bobSyncResponder } = bob; const { homeserverUrl: bobHomeserverUrl, client: bobClient, syncResponder: bobSyncResponder } = bob;
@@ -890,7 +899,7 @@ describe("History Sharing", () => {
timeline: { timeline: {
events: [ events: [
mkEventCustom({ mkEventCustom({
content: { membership: KnownMembership.Leave }, content: { membership: config.leftState },
type: EventType.RoomMember, type: EventType.RoomMember,
sender: charlieClient.getSafeUserId(), sender: charlieClient.getSafeUserId(),
state_key: charlieClient.getSafeUserId(), state_key: charlieClient.getSafeUserId(),
@@ -922,13 +931,13 @@ describe("History Sharing", () => {
}, },
}, },
} as any; } as any;
if (gappySync) { if (config.gappySync) {
// In case of a gappy sync, the timeline is limited and we only see the leave event. // In case of a gappy sync, the timeline is limited and we only see the leave event.
syncResponse.rooms.join[ROOM_ID].timeline.limited = true; syncResponse.rooms.join[ROOM_ID].timeline.limited = true;
syncResponse.rooms.join[ROOM_ID].state = { syncResponse.rooms.join[ROOM_ID].state = {
events: [ events: [
mkEventCustom({ mkEventCustom({
content: { membership: KnownMembership.Leave }, content: { membership: config.leftState },
type: EventType.RoomMember, type: EventType.RoomMember,
sender: charlieClient.getSafeUserId(), sender: charlieClient.getSafeUserId(),
state_key: charlieClient.getSafeUserId(), state_key: charlieClient.getSafeUserId(),
@@ -944,7 +953,7 @@ describe("History Sharing", () => {
state_key: charlieClient.getSafeUserId(), state_key: charlieClient.getSafeUserId(),
}) as any, }) as any,
mkEventCustom({ mkEventCustom({
content: { membership: KnownMembership.Leave }, content: { membership: config.leftState },
type: EventType.RoomMember, type: EventType.RoomMember,
sender: charlieClient.getSafeUserId(), sender: charlieClient.getSafeUserId(),
state_key: charlieClient.getSafeUserId(), state_key: charlieClient.getSafeUserId(),
@@ -967,8 +976,10 @@ describe("History Sharing", () => {
expect(sentToDeviceRequest).toBeDefined(); expect(sentToDeviceRequest).toBeDefined();
aliceToDeviceMessage = sentToDeviceRequest[aliceClient.getSafeUserId()][aliceClient.deviceId!]; aliceToDeviceMessage = sentToDeviceRequest[aliceClient.getSafeUserId()][aliceClient.deviceId!];
// Charlie should not receive the room key // Charlie should not receive the room key, but may receive a
expect(sentToDeviceRequest[charlieClient.getSafeUserId()]).toBeUndefined(); // different to-device message if he is invited.
const charliesToDevice = sentToDeviceRequest[charlieClient.getSafeUserId()];
expect(charliesToDevice === undefined || config.leftState === KnownMembership.Invite).toBeTruthy();
debug(`Bob sent encrypted room event: ${JSON.stringify(bobEventM2Content)}`); debug(`Bob sent encrypted room event: ${JSON.stringify(bobEventM2Content)}`);
@@ -1009,7 +1020,10 @@ describe("History Sharing", () => {
expect(aliceEventM2.getType()).toEqual("m.room.message"); expect(aliceEventM2.getType()).toEqual("m.room.message");
expect(aliceEventM2.getContent().body).toEqual("Charlie should not be able to read"); expect(aliceEventM2.getContent().body).toEqual("Charlie should not be able to read");
// Charlie rejoins the room by ID, receives M2, which he should not be able to decrypt. // Charlie rejoins the room by ID, receives any supplied to-device
// messages, and receives M2, which he should not be able to
// decrypt. This proves that any to-device message he received was
// not the room key.
fetchMock.postOnce(`${charlieHomeserverUrl}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, { fetchMock.postOnce(`${charlieHomeserverUrl}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
room_id: ROOM_ID, room_id: ROOM_ID,
}); });
@@ -1032,6 +1046,15 @@ describe("History Sharing", () => {
}, },
}, },
}, },
to_device: {
events: [
{
type: "m.room.encrypted",
sender: bobClient.getSafeUserId(),
content: charliesToDevice,
},
],
},
} as any; } as any;
charlieSyncResponder.sendOrQueueSyncResponse(syncResponse); charlieSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(charlieClient); await syncPromise(charlieClient);
@@ -279,7 +279,7 @@ 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}}` */ /** 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) }; 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}}`. * 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. * Contains the key from {prefix}MEGOLM_SESSION_DATA.
*/ */
@@ -422,7 +422,7 @@ def build_exported_megolm_key(device_curve_key: x25519.X25519PrivateKey) -> tupl
"ed25519": encode_base64(ed25519.Ed25519PrivateKey.from_private_bytes(randbytes(32)).public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)), "ed25519": encode_base64(ed25519.Ed25519PrivateKey.from_private_bytes(randbytes(32)).public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)),
}, },
"forwarding_curve25519_key_chain": [], "forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": True, "m.shared_history": True,
} }
return megolm_export, private_key return megolm_export, private_key
+25
View File
@@ -3940,6 +3940,31 @@ describe("MatrixClient", function () {
}); });
expect(httpLookups.length).toEqual(0); 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", () => { describe("identityHashedLookup", () => {
+1 -1
View File
@@ -427,7 +427,7 @@ describe("CallMembership", () => {
}); });
it("uses unpadded base64 for RTC backend identities", async () => { it("uses unpadded base64 for RTC backend identities", async () => {
const membership = await CallMembership.parseFromEvent(makeMockEvent(0, { ...membershipTemplate })); const membership = await CallMembership.parseFromEvent(makeMockEvent(0, { ...membershipTemplate }));
expect(membership.rtcBackendIdentity).toBe("j9N1u04ZbvI9qKf3cxrf2NauD-fIGJ4uAcYkfI9V7SY"); expect(membership.rtcBackendIdentity).toBe("jUZ0Q1yF5nV3LlAI5xfD1I7BPnAytJaPEAR57EXjJ6s");
}); });
}); });
}); });
@@ -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");
});
});
+43
View File
@@ -175,6 +175,23 @@ describe("oidc authorization", () => {
expect(authUrl.searchParams.get("login_hint")).toEqual("login1234"); 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", () => { describe("completeAuthorizationCodeGrant", () => {
@@ -290,6 +307,32 @@ describe("oidc authorization", () => {
expect(queryParams.get("code")).toEqual(code); 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 () => { it("should return with valid bearer token", async () => {
const state = await setupState(); const state = await setupState();
const scope = getValueFromStorage(state, "scope"); const scope = getValueFromStorage(state, "scope");
+71
View File
@@ -592,6 +592,72 @@ describe("RustCrypto", () => {
expect(res.length).toEqual(0); 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 () => { it("emits VerificationRequestReceived on incoming m.key.verification.request", async () => {
rustCrypto = await makeTestRustCrypto( rustCrypto = await makeTestRustCrypto(
new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), { new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
@@ -790,6 +856,7 @@ describe("RustCrypto", () => {
undefined, undefined,
secretStorage, secretStorage,
); );
vi.spyOn(rustCrypto, "pushSecretToVerifiedDevices").mockResolvedValue();
async function createSecretStorageKey() { async function createSecretStorageKey() {
return { return {
@@ -837,6 +904,7 @@ describe("RustCrypto", () => {
{} as CryptoCallbacks, {} as CryptoCallbacks,
false, false,
); );
vi.spyOn(rustCrypto, "pushSecretToVerifiedDevices").mockResolvedValue();
async function createSecretStorageKey() { async function createSecretStorageKey() {
return { return {
@@ -1526,6 +1594,7 @@ describe("RustCrypto", () => {
it("returns an unverified UserVerificationStatus when there is no UserIdentity", async () => { it("returns an unverified UserVerificationStatus when there is no UserIdentity", async () => {
const userVerificationStatus = await rustCrypto.getUserVerificationStatus(testData.TEST_USER_ID); const userVerificationStatus = await rustCrypto.getUserVerificationStatus(testData.TEST_USER_ID);
expect(userVerificationStatus.known).toBe(false);
expect(userVerificationStatus.isVerified()).toBeFalsy(); expect(userVerificationStatus.isVerified()).toBeFalsy();
expect(userVerificationStatus.isTofu()).toBeFalsy(); expect(userVerificationStatus.isTofu()).toBeFalsy();
expect(userVerificationStatus.isCrossSigningVerified()).toBeFalsy(); expect(userVerificationStatus.isCrossSigningVerified()).toBeFalsy();
@@ -1540,6 +1609,7 @@ describe("RustCrypto", () => {
} as unknown as OtherUserIdentity); } as unknown as OtherUserIdentity);
const userVerificationStatus = await rustCrypto.getUserVerificationStatus(testData.TEST_USER_ID); const userVerificationStatus = await rustCrypto.getUserVerificationStatus(testData.TEST_USER_ID);
expect(userVerificationStatus.known).toBe(true);
expect(userVerificationStatus.isVerified()).toBeTruthy(); expect(userVerificationStatus.isVerified()).toBeTruthy();
expect(userVerificationStatus.isTofu()).toBeFalsy(); expect(userVerificationStatus.isTofu()).toBeFalsy();
expect(userVerificationStatus.isCrossSigningVerified()).toBeTruthy(); expect(userVerificationStatus.isCrossSigningVerified()).toBeTruthy();
@@ -2322,6 +2392,7 @@ describe("RustCrypto", () => {
}); });
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), undefined, undefined, secretStorage); const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), undefined, undefined, secretStorage);
vi.spyOn(rustCrypto, "pushSecretToVerifiedDevices").mockResolvedValue();
// We have a key backup // We have a key backup
await waitFor(async () => expect(await rustCrypto.getActiveSessionBackupVersion()).not.toBeNull()); await waitFor(async () => expect(await rustCrypto.getActiveSessionBackupVersion()).not.toBeNull());
+35
View File
@@ -79,6 +79,41 @@ describe("IndexedDBStore", () => {
expect(await store.getOutOfBandMembers(roomId)).toHaveLength(2); 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 () => { it("Should load presence events on startup", async () => {
// 1. Create idb database // 1. Create idb database
const indexedDB = new IDBFactory(); const indexedDB = new IDBFactory();
+14 -2
View File
@@ -416,9 +416,12 @@ export interface AccountDataEvents extends SecretStorageAccountDataEvents {
[EventType.Direct]: { [userId: string]: string[] }; [EventType.Direct]: { [userId: string]: string[] };
[EventType.IgnoredUserList]: { ignored_users: { [userId: string]: EmptyObject } }; [EventType.IgnoredUserList]: { ignored_users: { [userId: string]: EmptyObject } };
"m.secret_storage.default_key": { key: string }; "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.org.matrix.custom.backup_disabled": { disabled: boolean };
"m.identity_server": { base_url: string | null }; "m.identity_server": { base_url: string | null };
[key: `${typeof LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${string}`]: LocalNotificationSettings; [key: `${typeof LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${string}`]: LocalNotificationSettings;
[key: `m.secret_storage.key.${string}`]: SecretStorageKeyDescription; [key: `m.secret_storage.key.${string}`]: SecretStorageKeyDescription;
@@ -428,6 +431,15 @@ export interface AccountDataEvents extends SecretStorageAccountDataEvents {
[POLICIES_ACCOUNT_EVENT_TYPE.altName]: { [key: string]: any }; [POLICIES_ACCOUNT_EVENT_TYPE.altName]: { [key: string]: any };
[EventType.InvitePermissionConfig]: { default_action?: string }; [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;
}>;
};
} }
/** /**
+30 -5
View File
@@ -188,7 +188,9 @@ export interface CryptoApi {
/** /**
* Check if the given user has published cross-signing keys. * 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, * - 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. * a `/keys/query` request is made to the server to retrieve the cross signing keys.
* - Otherwise, return false * - Otherwise, return false
@@ -205,7 +207,10 @@ export interface CryptoApi {
* Get the device information for the given list of users. * 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 * 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`, * 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. * a `/keys/query` request is made to the server to retrieve these devices.
@@ -626,7 +631,6 @@ export interface CryptoApi {
* * Disables 4S, deleting the info for the default key, the default key pointer itself and any * * 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). * known 4S data (cross-signing keys and the megolm key backup key).
* * Deletes any dehydrated devices. * * 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>; disableKeyStorage(): Promise<void>;
@@ -857,6 +861,25 @@ export interface BootstrapCrossSigningOpts {
* Represents the ways in which we trust a user * Represents the ways in which we trust a user
*/ */
export class UserVerificationStatus { 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. * Indicates if the identity has changed in a way that needs user approval.
* *
@@ -872,12 +895,14 @@ export class UserVerificationStatus {
*/ */
public readonly needsUserApproval: boolean; public readonly needsUserApproval: boolean;
/** @internal */
public constructor( public constructor(
private readonly crossSigningVerified: boolean, private readonly crossSigningVerified: boolean,
private readonly crossSigningVerifiedBefore: boolean, private readonly crossSigningVerifiedBefore: boolean,
private readonly tofu: boolean, known: boolean,
needsUserApproval: boolean = false, needsUserApproval: boolean = false,
) { ) {
this.known = known;
this.needsUserApproval = needsUserApproval; this.needsUserApproval = needsUserApproval;
} }
@@ -909,7 +934,7 @@ export class UserVerificationStatus {
* @deprecated No longer supported, with the Rust crypto stack. * @deprecated No longer supported, with the Rust crypto stack.
*/ */
public isTofu(): boolean { public isTofu(): boolean {
return this.tofu; return false;
} }
} }
+1 -1
View File
@@ -806,7 +806,7 @@ export class RoomWidgetClient extends MatrixClient {
// Sliding Sync // Sliding Sync
await this.syncApi!.injectRoomEvents(this.room!, [event]); 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 { } else {
const { event_id: eventId, room_id: roomId } = ev.detail.data; const { event_id: eventId, room_id: roomId } = ev.detail.data;
logger.info(`Received state entry ${eventId} for a different room ${roomId}; discarding`); logger.info(`Received state entry ${eventId} for a different room ${roomId}; discarding`);
+2 -1
View File
@@ -863,7 +863,8 @@ function quickFilterNonRelevantContents(content: IContent, logger: Logger): bool
// We have a MSC4143 event membership event with a proper joined content // We have a MSC4143 event membership event with a proper joined content
return true; return true;
} else if (eventKeysCount === 1 && "memberships" in content) { } 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; return false;
} else { } else {
// Invalid or left content // Invalid or left content
+5 -4
View File
@@ -20,7 +20,7 @@ import type { RelationType } from "../../types.ts";
import { type RtcSlotEventContent, type Transport } from "../types.ts"; import { type RtcSlotEventContent, type Transport } from "../types.ts";
import { MatrixRTCMembershipParseError } from "./common.ts"; import { MatrixRTCMembershipParseError } from "./common.ts";
import { sha256 } from "../../digest.ts"; import { sha256 } from "../../digest.ts";
import { encodeUnpaddedBase64Url } from "../../base64.ts"; import { encodeUnpaddedBase64 } from "../../base64.ts";
import { slotIdToDescription } from "../utils.ts"; import { slotIdToDescription } from "../utils.ts";
/** /**
@@ -149,8 +149,9 @@ export const checkRtcMembershipData = (data: IContent, sender: string): data is
}; };
export async function computeRtcIdentityRaw(userId: string, deviceId: string, memberId: string): Promise<string> { export async function computeRtcIdentityRaw(userId: string, deviceId: string, memberId: string): Promise<string> {
const hashInput = `${userId}|${deviceId}|${memberId}`; // canonical JSON serialization (Matrix canonical JSON for arrays)
const hashBuffer = await sha256(hashInput); const jsonStr = JSON.stringify([userId, deviceId, memberId]);
const hashedString = encodeUnpaddedBase64Url(hashBuffer); const hashBuffer = await sha256(jsonStr);
const hashedString = encodeUnpaddedBase64(hashBuffer);
return hashedString; return hashedString;
} }
+25 -6
View File
@@ -14,7 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License. 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 { logger } from "../logger.ts";
import { secureRandomString } from "../randomstring.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 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. * @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}. * 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 * @returns a Promise with the url as a string
*/ */
export const generateOidcAuthorizationUrl = async ({ export const generateOidcAuthorizationUrl = async ({
@@ -139,6 +149,7 @@ export const generateOidcAuthorizationUrl = async ({
prompt, prompt,
urlState, urlState,
loginHint, loginHint,
responseMode = "query",
}: { }: {
clientId: string; clientId: string;
metadata: ValidatedAuthMetadata; metadata: ValidatedAuthMetadata;
@@ -149,6 +160,7 @@ export const generateOidcAuthorizationUrl = async ({
prompt?: string; prompt?: string;
urlState?: string; urlState?: string;
loginHint?: string; loginHint?: string;
responseMode?: SigninRequestCreateArgs["response_mode"];
}): Promise<string> => { }): Promise<string> => {
const scope = generateScope(); const scope = generateScope();
const oidcClient = new OidcClient({ const oidcClient = new OidcClient({
@@ -156,7 +168,7 @@ export const generateOidcAuthorizationUrl = async ({
client_id: clientId, client_id: clientId,
redirect_uri: redirectUri, redirect_uri: redirectUri,
authority: metadata.issuer, authority: metadata.issuer,
response_mode: "query", response_mode: responseMode,
response_type: "code", response_type: "code",
scope, scope,
stateStore: new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage }), 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. * request to the Token Endpoint, to obtain the access token, refresh token, etc.
* *
* @param code - authorization code as returned by OP during authorization * @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 * @returns valid bearer token response
* @throws An `Error` with `message` set to an entry in {@link OidcError}, * @throws An `Error` with `message` set to an entry in {@link OidcError},
* when the request fails, or the returned token response is invalid. * when the request fails, or the returned token response is invalid.
@@ -208,6 +221,7 @@ const normalizeBearerTokenResponseTokenType = (response: SigninResponse): Bearer
export const completeAuthorizationCodeGrant = async ( export const completeAuthorizationCodeGrant = async (
code: string, code: string,
state: string, state: string,
responseMode: SigninRequestCreateArgs["response_mode"] = "query",
): Promise<{ ): Promise<{
oidcClientSettings: { clientId: string; issuer: string }; oidcClientSettings: { clientId: string; issuer: string };
tokenResponse: BearerTokenResponse; tokenResponse: BearerTokenResponse;
@@ -221,13 +235,18 @@ export const completeAuthorizationCodeGrant = async (
* so that oidc-client can parse it * so that oidc-client can parse it
*/ */
const reconstructedUrl = new URL(window.location.origin); 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 // set oidc-client to use our logger
Log.setLogger(logger); Log.setLogger(logger);
try { try {
const response = new SigninResponse(reconstructedUrl.searchParams); const response = new SigninResponse(params);
const stateStore = new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage }); const stateStore = new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage });
+1 -1
View File
@@ -62,6 +62,6 @@ export const validateAuthMetadataAndKeys = async (authMetadata: unknown): Promis
return { return {
...validatedIssuerConfig, ...validatedIssuerConfig,
signingKeys: await metadataService.getSigningKeys(), signingKeys: validatedIssuerConfig.jwks_uri ? await metadataService.getSigningKeys() : null,
}; };
}; };
+31 -10
View File
@@ -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. * 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. * @returns true if the secret is valid and has been stored, false otherwise.
*/ */
public async handleBackupSecretReceived(secret: string): Promise<boolean> { public async handleBackupSecretReceived(secret: string): Promise<boolean> {
@@ -180,28 +180,44 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
// There is no server-side key backup. // There is no server-side key backup.
// This decryption key is useless to us. // This decryption key is useless to us.
this.logger.warn( 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; 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 { try {
const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(secret);
const privateKeyMatches = this.backupInfoMatchesBackupDecryptionKey(latestBackupInfo, backupDecryptionKey); const privateKeyMatches = this.backupInfoMatchesBackupDecryptionKey(latestBackupInfo, backupDecryptionKey);
if (!privateKeyMatches) { if (!privateKeyMatches) {
this.logger.warn( 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 // just ignore the secret
return false; return false;
} }
this.logger.info( 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); 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; return true;
} catch (e) { } catch (e) {
this.logger.warn("handleBackupSecretReceived: Invalid backup decryption key", e); this.logger.warn("handleBackupSecretReceived: Unable to validate backup decryption key", e);
} }
return false; return false;
@@ -281,12 +297,17 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
private keyBackupCheckInProgress: Promise<KeyBackupCheck | null> | null = null; private keyBackupCheckInProgress: Promise<KeyBackupCheck | null> | null = null;
/** Helper for `checkKeyBackup` */ /** Helper to check the key backup status, and enable/disable it as appropriate
private async doCheckKeyBackup(): Promise<KeyBackupCheck | null> { *
* 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..."); this.logger.debug("Checking key backup status...");
let backupInfo: KeyBackupInfo | null | undefined;
try { try {
backupInfo = await this.requestKeyBackupVersion(); if (!backupInfo) {
backupInfo = await this.requestKeyBackupVersion();
}
} catch (e) { } catch (e) {
this.logger.warn("Error checking for active key backup", e); this.logger.warn("Error checking for active key backup", e);
this.serverBackupInfo = undefined; this.serverBackupInfo = undefined;
+33 -15
View File
@@ -736,7 +736,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
? userIdentity.identityNeedsUserApproval() ? userIdentity.identityNeedsUserApproval()
: false; : false;
userIdentity.free(); userIdentity.free();
return new UserVerificationStatus(verified, wasVerified, false, needsUserApproval); return new UserVerificationStatus(verified, wasVerified, true, needsUserApproval);
} }
/** /**
@@ -1376,6 +1376,8 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
public async resetKeyBackup(): Promise<void> { public async resetKeyBackup(): Promise<void> {
const backupInfo = await this.backupManager.setupKeyBackup((o) => this.signObject(o)); 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 // we want to store the private key in 4S
// need to check if 4S is set up? // need to check if 4S is set up?
if (await this.secretStorageHasAESKey()) { if (await this.secretStorageHasAESKey()) {
@@ -1971,14 +1973,15 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
* the event. * the event.
* *
* To counter this, we proactively discard any active outgoing Megolm * To counter this, we proactively discard any active outgoing Megolm
* session when we see a `leave` event. Note that we have to do this in * session when we see an event indicating the user left.
* `onRoomStateEvent` rather than `onRoomMembership`, because *
* `onRoomMembership` is only called when we see a *change* in membership. * Note that we have to do this in `onRoomStateEvent` rather than
* In the case of a gappy sync, we might miss Charlie's invite and join, * `onRoomMembership`, because `onRoomMembership` is only called when we see
* and only see the final `leave` event (sohis membership goes from `leave` * a *change* in membership. In the case of a gappy sync, we might miss
* to `leave`). * 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 { public onRoomStateEvent(event: MatrixEvent, _state: RoomState, _prevEvent: MatrixEvent | null): void {
if (event.getType() != EventType.RoomMember) { if (event.getType() != EventType.RoomMember) {
// Ignore all events that aren't member updates. // Ignore all events that aren't member updates.
return; return;
@@ -1986,7 +1989,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
if ( if (
event.getStateKey()! !== this.olmMachine.userId.toString() && event.getStateKey()! !== this.olmMachine.userId.toString() &&
event.getContent().membership === KnownMembership.Leave event.getContent().membership !== KnownMembership.Join
) { ) {
this.logger.info(`Rotating session for room ${event.getRoomId()} due to member leaving the room`); this.logger.info(`Rotating session for room ${event.getRoomId()} due to member leaving the room`);
this.forceDiscardSession(event.getRoomId()!); this.forceDiscardSession(event.getRoomId()!);
@@ -2103,9 +2106,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
/** /**
* Handles secret received from the rust secret inbox. * Handles secret received from the rust secret inbox.
* *
* The gossipped secrets are received using the `m.secret.send` event type * The gossipped secrets are received using the `m.secret.send` or
* and are guaranteed to have been received over a 1-to-1 Olm * `io.element.msc4385.secret.push` event types and are guaranteed to have
* Session from a verified device. * 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`. * The only secret currently handled in this way is `m.megolm_backup.v1`.
* *
@@ -2253,6 +2256,18 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
| undefined; | undefined;
return identity; 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);
});
}
} }
class EventDecryptor { class EventDecryptor {
@@ -2581,7 +2596,7 @@ function rustEncryptionInfoToJsEncryptionInfo(
} }
interface RoomKeyBundleMessage { interface RoomKeyBundleMessage {
type: "io.element.msc4268.room_key_bundle"; type: "m.room_key_bundle" | "io.element.msc4268.room_key_bundle";
content: { content: {
room_id: string; room_id: string;
}; };
@@ -2591,13 +2606,16 @@ interface RoomKeyBundleMessage {
* Determines if the given payload is a RoomKeyBundleMessage. * Determines if the given payload is a RoomKeyBundleMessage.
* *
* A RoomKeyBundleMessage is identified by having a specific message type * 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. * @param message - The received to-device message to check.
* @returns True if the payload matches the RoomKeyBundleMessage structure, false otherwise. * @returns True if the payload matches the RoomKeyBundleMessage structure, false otherwise.
*/ */
function isRoomKeyBundleMessage(message: IToDeviceEvent): message is IToDeviceEvent & RoomKeyBundleMessage { 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]; type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent];
+2 -1
View File
@@ -61,6 +61,7 @@ const VERSION = DB_MIGRATIONS.length;
* Return the data you want to keep. * Return the data you want to keep.
* @returns Promise which resolves to an array of whatever you returned from * @returns Promise which resolves to an array of whatever you returned from
* resultMapper. * resultMapper.
* @throws If there was an error completing the query.
*/ */
function selectQuery<T>( function selectQuery<T>(
store: IDBObjectStore, store: IDBObjectStore,
@@ -71,7 +72,7 @@ function selectQuery<T>(
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const results: T[] = []; const results: T[] = [];
query.onerror = (): void => { query.onerror = (): void => {
reject(new Error("Query failed: " + query.error?.name)); reject(new Error(`selectQuery failed for ${store.name}`, { cause: query.error }));
}; };
// collect results // collect results
query.onsuccess = (): void => { query.onsuccess = (): void => {
+1 -2
View File
@@ -21,7 +21,6 @@ limitations under the License.
* This is an internal module. See {@link createNewMatrixCall} for the public API. * This is an internal module. See {@link createNewMatrixCall} for the public API.
*/ */
import { v4 as uuidv4 } from "uuid";
import { parse as parseSdp, write as writeSdp } from "sdp-transform"; import { parse as parseSdp, write as writeSdp } from "sdp-transform";
import { logger } from "../logger.ts"; import { logger } from "../logger.ts";
@@ -2490,7 +2489,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
sender_session_id: this.client.getSessionId(), sender_session_id: this.client.getSessionId(),
dest_session_id: this.opponentSessionId, dest_session_id: this.opponentSessionId,
seq: toDeviceSeq, seq: toDeviceSeq,
[ToDeviceMessageId]: uuidv4(), [ToDeviceMessageId]: globalThis.crypto.randomUUID(),
}; };
this.emit( this.emit(