diff --git a/.github/workflows/sdk-release.js.yml b/.github/workflows/sdk-release.js.yml new file mode 100644 index 0000000000..e11ae692a1 --- /dev/null +++ b/.github/workflows/sdk-release.js.yml @@ -0,0 +1,55 @@ +name: Publish to npm +on: + push: + tags: + - "sdk-v**" +jobs: + npm: + name: Publish to npm + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.1.0] + + steps: + - name: 🧮 Checkout code + uses: actions/checkout@v3 + + - name: Install tools + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + + - name: 🔧 Yarn cache + uses: actions/setup-node@v3 + with: + cache: "yarn" + registry-url: "https://registry.npmjs.org" + + - name: 🔨 Install dependencies + run: "yarn install --prefer-offline --frozen-lockfile" + + - name: Run Unit tests + run: "yarn test" + + - name: Run Lint Checks + run: "yarn run lint-ci" + + - name: Run Typescript Checks + run: "yarn run tsc" + + - name: Run SDK tests + run: "yarn test:sdk" + + - name: Build SDK + run: "yarn build:sdk" + + - name: 🚀 Publish to npm + id: npm-publish + uses: JS-DevTools/npm-publish@v2 + with: + token: ${{ secrets.NPM_TOKEN }} + access: public + package: ./target + dry-run: true diff --git a/doc/SDK.md b/doc/SDK.md index ba021ad149..7782625397 100644 --- a/doc/SDK.md +++ b/doc/SDK.md @@ -15,6 +15,7 @@ yarn create vite cd yarn yarn add hydrogen-view-sdk +yarn add https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz ``` You should see a `index.html` in the project root directory, containing an element with `id="app"`. Add the attribute `class="hydrogen"` to this element, as the CSS we'll include from the SDK assumes for now that the app is rendered in an element with this classname. @@ -32,7 +33,8 @@ import { createRouter, RoomViewModel, TimelineView, - viewClassForTile + viewClassForTile, + FeatureSet } from "hydrogen-view-sdk"; import downloadSandboxPath from 'hydrogen-view-sdk/download-sandbox.html?url'; import workerPath from 'hydrogen-view-sdk/main.js?url'; @@ -81,12 +83,14 @@ async function main() { const {session} = client; // looks for room corresponding to #element-dev:matrix.org, assuming it is already joined const room = session.rooms.get("!bEWtlqtDwCLFIAKAcv:matrix.org"); + const features = await FeatureSet.load(platform.settingsStorage); const vm = new RoomViewModel({ room, ownUserId: session.userId, platform, urlRouter: urlRouter, navigation, + features, }); await vm.load(); const view = new TimelineView(vm.timelineViewModel, viewClassForTile); diff --git a/package.json b/package.json index e367bc09c1..3451dfa98e 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "xxhashjs": "^0.2.2" }, "dependencies": { - "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz", + "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz", "another-json": "^0.2.0", "base64-arraybuffer": "^0.2.0", "dompurify": "^2.3.0", diff --git a/scripts/sdk/base-manifest.json b/scripts/sdk/base-manifest.json index bfead0efce..a8a6267441 100644 --- a/scripts/sdk/base-manifest.json +++ b/scripts/sdk/base-manifest.json @@ -1,7 +1,7 @@ { "name": "@thirdroom/hydrogen-view-sdk", "description": "Embeddable matrix client library, including view components", - "version": "0.1.1", + "version": "0.1.2", "main": "./lib-build/hydrogen.cjs.js", "exports": { ".": { diff --git a/src/domain/AccountSetupViewModel.js b/src/domain/AccountSetupViewModel.js index e7c1301f4f..3e6435823f 100644 --- a/src/domain/AccountSetupViewModel.js +++ b/src/domain/AccountSetupViewModel.js @@ -16,7 +16,7 @@ limitations under the License. import {ViewModel} from "./ViewModel"; import {KeyType} from "../matrix/ssss/index"; -import {Status} from "./session/settings/KeyBackupViewModel.js"; +import {Status} from "./session/settings/KeyBackupViewModel"; export class AccountSetupViewModel extends ViewModel { constructor(options) { diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js index 7c885bf442..9fbc992079 100644 --- a/src/domain/SessionLoadViewModel.js +++ b/src/domain/SessionLoadViewModel.js @@ -78,7 +78,7 @@ export class SessionLoadViewModel extends ViewModel { this._ready(client); } if (loadError) { - console.error("session load error", loadError); + console.error("session load error", loadError.stack); } } catch (err) { this._error = err; diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 7b656a0467..d6302ffa0b 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -34,6 +34,8 @@ export type SegmentType = { "details": true; "members": true; "member": string; + "device-verification": string | boolean; + "join-room": true; "oidc": { state: string, } & @@ -63,7 +65,7 @@ function allowsChild(parent: Segment | undefined, child: Segment { + this._updateVerification(txnId); + })); + this._updateVerification(verification.get()); + } + const lightbox = this.navigation.observe("lightbox"); this.track(lightbox.subscribe(eventId => { this._updateLightbox(eventId); @@ -143,7 +153,8 @@ export class SessionViewModel extends ViewModel { this._gridViewModel || this._settingsViewModel || this._createRoomViewModel || - this._joinRoomViewModel + this._joinRoomViewModel || + this._verificationViewModel ); } @@ -179,6 +190,10 @@ export class SessionViewModel extends ViewModel { return this._joinRoomViewModel; } + get verificationViewModel() { + return this._verificationViewModel; + } + get toastCollectionViewModel() { return this._toastCollectionViewModel; } @@ -327,6 +342,17 @@ export class SessionViewModel extends ViewModel { this.emitChange("activeMiddleViewModel"); } + _updateVerification(txnId) { + if (this._verificationViewModel) { + this._verificationViewModel = this.disposeTracked(this._verificationViewModel); + } + if (txnId) { + const request = this._client.session.crossSigning.get()?.receivedSASVerifications.get(txnId); + this._verificationViewModel = this.track(new DeviceVerificationViewModel(this.childOptions({ session: this._client.session, request }))); + } + this.emitChange("activeMiddleViewModel"); + } + _updateLightbox(eventId) { if (this._lightboxViewModel) { this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel); diff --git a/src/domain/session/rightpanel/MemberDetailsViewModel.js b/src/domain/session/rightpanel/MemberDetailsViewModel.js index 4cbd1a4faf..67467331f5 100644 --- a/src/domain/session/rightpanel/MemberDetailsViewModel.js +++ b/src/domain/session/rightpanel/MemberDetailsViewModel.js @@ -17,6 +17,7 @@ limitations under the License. import {ViewModel} from "../../ViewModel"; import {RoomVisibility} from "../../../matrix/room/common"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; +import {UserTrust} from "../../../matrix/verification/CrossSigning"; export class MemberDetailsViewModel extends ViewModel { constructor(options) { @@ -29,13 +30,56 @@ export class MemberDetailsViewModel extends ViewModel { this._session = options.session; this.track(this._powerLevelsObservable.subscribe(() => this._onPowerLevelsChange())); this.track(this._observableMember.subscribe( () => this._onMemberChange())); + this._userTrust = undefined; + this._userTrustSubscription = undefined; + if (this.features.crossSigning) { + this.track(this._session.crossSigning.subscribe(() => { + this._onCrossSigningChange(); + })); + } + this._onCrossSigningChange(); } get name() { return this._member.name; } + get userId() { return this._member.userId; } + + get trustDescription() { + switch (this._userTrust?.get()) { + case UserTrust.Trusted: return this.i18n`You have verified this user. This user has verified all of their sessions.`; + case UserTrust.UserNotSigned: return this.i18n`You have not verified this user.`; + case UserTrust.UserSignatureMismatch: return this.i18n`You appear to have signed this user, but the signature is invalid.`; + case UserTrust.UserDeviceNotSigned: return this.i18n`You have verified this user, but they have one or more unverified sessions.`; + case UserTrust.UserDeviceSignatureMismatch: return this.i18n`This user has a session signature that is invalid.`; + case UserTrust.UserSetupError: return this.i18n`This user hasn't set up cross-signing correctly`; + case UserTrust.OwnSetupError: return this.i18n`Cross-signing wasn't set up correctly on your side.`; + case undefined: + default: // adding default as well because jslint can't check for switch exhaustiveness + return this.i18n`Please wait…`; + } + } + + get trustShieldColor() { + if (!this._isEncrypted) { + return ""; + } + switch (this._userTrust?.get()) { + case undefined: + case UserTrust.OwnSetupError: + return ""; + case UserTrust.Trusted: + return "green"; + case UserTrust.UserNotSigned: + return "black"; + default: + return "red"; + } + } get type() { return "member-details"; } + get shouldShowBackButton() { return true; } + get previousSegmentName() { return "members"; } get role() { @@ -54,6 +98,15 @@ export class MemberDetailsViewModel extends ViewModel { this.emitChange("role"); } + async signUser() { + const crossSigning = this._session.crossSigning.get(); + if (crossSigning) { + await this.logger.run("MemberDetailsViewModel.signUser", async log => { + await crossSigning.signUser(this.userId, log); + }); + } + } + get avatarLetter() { return avatarInitials(this.name); } @@ -94,4 +147,19 @@ export class MemberDetailsViewModel extends ViewModel { } this.navigation.push("room", roomId); } + + _onCrossSigningChange() { + const crossSigning = this._session.crossSigning.get(); + this._userTrustSubscription = this.disposeTracked(this._userTrustSubscription); + this._userTrust = undefined; + if (crossSigning) { + this.logger.run("MemberDetailsViewModel.observeUserTrust", log => { + this._userTrust = crossSigning.observeUserTrust(this.userId, log); + this._userTrustSubscription = this.track(this._userTrust.subscribe(() => { + this.emitChange("trustShieldColor"); + })); + }); + } + this.emitChange("trustShieldColor"); + } } diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index da5304ed16..90da4bffa3 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -18,6 +18,7 @@ import {SimpleTile} from "./SimpleTile.js"; import {ReactionsViewModel} from "../ReactionsViewModel.js"; import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../../../avatar"; + export class BaseMessageTile extends SimpleTile { constructor(entry, options) { super(entry, options); @@ -44,6 +45,10 @@ export class BaseMessageTile extends SimpleTile { return `https://matrix.to/#/${encodeURIComponent(this._room.id)}/${encodeURIComponent(this._entry.id)}`; } + copyPermalink() { + this.platform.copyPlaintext(this.permaLink); + } + get senderProfileLink() { return `https://matrix.to/#/${encodeURIComponent(this.sender)}`; } diff --git a/src/domain/session/settings/KeyBackupViewModel.js b/src/domain/session/settings/KeyBackupViewModel.js deleted file mode 100644 index 22135e414e..0000000000 --- a/src/domain/session/settings/KeyBackupViewModel.js +++ /dev/null @@ -1,223 +0,0 @@ -/* -Copyright 2020 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 {ViewModel} from "../../ViewModel"; -import {KeyType} from "../../../matrix/ssss/index"; -import {createEnum} from "../../../utils/enum"; -import {FlatMapObservableValue} from "../../../observable/value"; - -export const Status = createEnum("Enabled", "SetupKey", "SetupPhrase", "Pending", "NewVersionAvailable"); -export const BackupWriteStatus = createEnum("Writing", "Stopped", "Done", "Pending"); - -export class KeyBackupViewModel extends ViewModel { - constructor(options) { - super(options); - this._session = options.session; - this._error = null; - this._isBusy = false; - this._dehydratedDeviceId = undefined; - this._status = undefined; - this._backupOperation = new FlatMapObservableValue(this._session.keyBackup, keyBackup => keyBackup.operationInProgress); - this._progress = new FlatMapObservableValue(this._backupOperation, op => op.progress); - this.track(this._backupOperation.subscribe(() => { - // see if needsNewKey might be set - this._reevaluateStatus(); - this.emitChange("isBackingUp"); - })); - this.track(this._progress.subscribe(() => this.emitChange("backupPercentage"))); - this._reevaluateStatus(); - this.track(this._session.keyBackup.subscribe(() => { - if (this._reevaluateStatus()) { - this.emitChange("status"); - } - })); - } - - _reevaluateStatus() { - if (this._isBusy) { - return false; - } - let status; - const keyBackup = this._session.keyBackup.get(); - if (keyBackup) { - status = keyBackup.needsNewKey ? Status.NewVersionAvailable : Status.Enabled; - } else if (keyBackup === null) { - status = this.showPhraseSetup() ? Status.SetupPhrase : Status.SetupKey; - } else { - status = Status.Pending; - } - const changed = status !== this._status; - this._status = status; - return changed; - } - - get decryptAction() { - return this.i18n`Set up`; - } - - get purpose() { - return this.i18n`set up key backup`; - } - - offerDehydratedDeviceSetup() { - return true; - } - - get dehydratedDeviceId() { - return this._dehydratedDeviceId; - } - - get isBusy() { - return this._isBusy; - } - - get backupVersion() { - return this._session.keyBackup.get()?.version; - } - - get isMasterKeyTrusted() { - return this._session.crossSigning?.isMasterKeyTrusted ?? false; - } - - get canSignOwnDevice() { - return !!this._session.crossSigning; - } - - async signOwnDevice() { - if (this._session.crossSigning) { - await this.logger.run("KeyBackupViewModel.signOwnDevice", async log => { - await this._session.crossSigning.signOwnDevice(log); - }); - } - } - - get backupWriteStatus() { - const keyBackup = this._session.keyBackup.get(); - if (!keyBackup) { - return BackupWriteStatus.Pending; - } else if (keyBackup.hasStopped) { - return BackupWriteStatus.Stopped; - } - const operation = keyBackup.operationInProgress.get(); - if (operation) { - return BackupWriteStatus.Writing; - } else if (keyBackup.hasBackedUpAllKeys) { - return BackupWriteStatus.Done; - } else { - return BackupWriteStatus.Pending; - } - } - - get backupError() { - return this._session.keyBackup.get()?.error?.message; - } - - get status() { - return this._status; - } - - get error() { - return this._error?.message; - } - - showPhraseSetup() { - if (this._status === Status.SetupKey) { - this._status = Status.SetupPhrase; - this.emitChange("status"); - } - } - - showKeySetup() { - if (this._status === Status.SetupPhrase) { - this._status = Status.SetupKey; - this.emitChange("status"); - } - } - - async _enterCredentials(keyType, credential, setupDehydratedDevice) { - if (credential) { - try { - this._isBusy = true; - this.emitChange("isBusy"); - const key = await this._session.enableSecretStorage(keyType, credential); - if (setupDehydratedDevice) { - this._dehydratedDeviceId = await this._session.setupDehydratedDevice(key); - } - } catch (err) { - console.error(err); - this._error = err; - this.emitChange("error"); - } finally { - this._isBusy = false; - this._reevaluateStatus(); - this.emitChange(""); - } - } - } - - enterSecurityPhrase(passphrase, setupDehydratedDevice) { - this._enterCredentials(KeyType.Passphrase, passphrase, setupDehydratedDevice); - } - - enterSecurityKey(securityKey, setupDehydratedDevice) { - this._enterCredentials(KeyType.RecoveryKey, securityKey, setupDehydratedDevice); - } - - async disable() { - try { - this._isBusy = true; - this.emitChange("isBusy"); - await this._session.disableSecretStorage(); - } catch (err) { - console.error(err); - this._error = err; - this.emitChange("error"); - } finally { - this._isBusy = false; - this._reevaluateStatus(); - this.emitChange(""); - } - } - - get isBackingUp() { - return !!this._backupOperation.get(); - } - - get backupPercentage() { - const progress = this._progress.get(); - if (progress) { - return Math.round((progress.finished / progress.total) * 100); - } - return 0; - } - - get backupInProgressLabel() { - const progress = this._progress.get(); - if (progress) { - return this.i18n`${progress.finished} of ${progress.total}`; - } - return this.i18n`…`; - } - - cancelBackup() { - this._backupOperation.get()?.abort(); - } - - startBackup() { - this._session.keyBackup.get()?.flush(); - } -} - diff --git a/src/domain/session/settings/KeyBackupViewModel.ts b/src/domain/session/settings/KeyBackupViewModel.ts new file mode 100644 index 0000000000..43681a29b2 --- /dev/null +++ b/src/domain/session/settings/KeyBackupViewModel.ts @@ -0,0 +1,270 @@ +/* +Copyright 2020 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 {ViewModel} from "../../ViewModel"; +import {SegmentType} from "../../navigation/index"; +import {KeyType} from "../../../matrix/ssss/index"; + +import type {Options as BaseOptions} from "../../ViewModel"; +import type {Session} from "../../../matrix/Session"; +import type {Disposable} from "../../../utils/Disposables"; +import type {KeyBackup, Progress} from "../../../matrix/e2ee/megolm/keybackup/KeyBackup"; +import type {CrossSigning} from "../../../matrix/verification/CrossSigning"; + +export enum Status { + Enabled, + SetupWithPassphrase, + SetupWithRecoveryKey, + Pending, + NewVersionAvailable +}; + +export enum BackupWriteStatus { + Writing, + Stopped, + Done, + Pending +}; + +type Options = { + session: Session, +} & BaseOptions; + +export class KeyBackupViewModel extends ViewModel { + private _error?: Error = undefined; + private _isBusy = false; + private _dehydratedDeviceId?: string = undefined; + private _status = Status.Pending; + private _backupOperationSubscription?: Disposable = undefined; + private _keyBackupSubscription?: Disposable = undefined; + private _progress?: Progress = undefined; + private _setupKeyType = KeyType.RecoveryKey; + + constructor(options) { + super(options); + const onKeyBackupSet = (keyBackup: KeyBackup | undefined) => { + if (keyBackup && !this._keyBackupSubscription) { + this._keyBackupSubscription = this.track(this._session.keyBackup.get().disposableOn("change", () => { + this._onKeyBackupChange(); + })); + } else if (!keyBackup && this._keyBackupSubscription) { + this._keyBackupSubscription = this.disposeTracked(this._keyBackupSubscription); + } + this._onKeyBackupChange(); // update status + }; + this.track(this._session.keyBackup.subscribe(onKeyBackupSet)); + onKeyBackupSet(this._keyBackup); + } + + private get _session(): Session { + return this.getOption("session"); + } + + private get _keyBackup(): KeyBackup | undefined { + return this._session.keyBackup.get(); + } + + private get _crossSigning(): CrossSigning | undefined { + return this._session.crossSigning.get(); + } + + private _onKeyBackupChange() { + const keyBackup = this._keyBackup; + if (keyBackup) { + const {operationInProgress} = keyBackup; + if (operationInProgress && !this._backupOperationSubscription) { + this._backupOperationSubscription = this.track(operationInProgress.disposableOn("change", () => { + this._progress = operationInProgress.progress; + this.emitChange("backupPercentage"); + })); + } else if (this._backupOperationSubscription && !operationInProgress) { + this._backupOperationSubscription = this.disposeTracked(this._backupOperationSubscription); + this._progress = undefined; + } + } + this.emitChange("status"); + } + + get status(): Status { + const keyBackup = this._keyBackup; + if (keyBackup) { + if (keyBackup.needsNewKey) { + return Status.NewVersionAvailable; + } else if (keyBackup.version === undefined) { + return Status.Pending; + } else { + return keyBackup.needsNewKey ? Status.NewVersionAvailable : Status.Enabled; + } + } else { + switch (this._setupKeyType) { + case KeyType.RecoveryKey: return Status.SetupWithRecoveryKey; + case KeyType.Passphrase: return Status.SetupWithPassphrase; + } + } + } + + get decryptAction(): string { + return this.i18n`Set up`; + } + + get purpose(): string { + return this.i18n`set up key backup`; + } + + offerDehydratedDeviceSetup(): boolean { + return true; + } + + get dehydratedDeviceId(): string | undefined { + return this._dehydratedDeviceId; + } + + get isBusy(): boolean { + return this._isBusy; + } + + get backupVersion(): string { + return this._keyBackup?.version ?? ""; + } + + get isMasterKeyTrusted(): boolean { + return this._crossSigning?.isMasterKeyTrusted ?? false; + } + + get canSignOwnDevice(): boolean { + return !!this._crossSigning; + } + + async signOwnDevice(): Promise { + const crossSigning = this._crossSigning; + if (crossSigning) { + await this.logger.run("KeyBackupViewModel.signOwnDevice", async log => { + await crossSigning.signOwnDevice(log); + }); + } + } + + navigateToVerification(): void { + this.navigation.push("device-verification", true); + } + + get backupWriteStatus(): BackupWriteStatus { + const keyBackup = this._keyBackup; + if (!keyBackup || keyBackup.version === undefined) { + return BackupWriteStatus.Pending; + } else if (keyBackup.hasStopped) { + return BackupWriteStatus.Stopped; + } + const operation = keyBackup.operationInProgress; + if (operation) { + return BackupWriteStatus.Writing; + } else if (keyBackup.hasBackedUpAllKeys) { + return BackupWriteStatus.Done; + } else { + return BackupWriteStatus.Pending; + } + } + + get backupError(): string | undefined { + return this._keyBackup?.error?.message; + } + + get error(): string | undefined { + return this._error?.message; + } + + showPhraseSetup(): void { + this._setupKeyType = KeyType.Passphrase; + this.emitChange("status"); + } + + showKeySetup(): void { + this._setupKeyType = KeyType.RecoveryKey; + this.emitChange("status"); + } + + private async _enterCredentials(keyType, credential, setupDehydratedDevice): Promise { + if (credential) { + try { + this._isBusy = true; + this.emitChange("isBusy"); + const key = await this._session.enableSecretStorage(keyType, credential); + if (setupDehydratedDevice) { + this._dehydratedDeviceId = await this._session.setupDehydratedDevice(key); + } + } catch (err) { + console.error(err); + this._error = err; + this.emitChange("error"); + } finally { + this._isBusy = false; + this.emitChange(); + } + } + } + + enterSecurityPhrase(passphrase, setupDehydratedDevice): Promise { + return this._enterCredentials(KeyType.Passphrase, passphrase, setupDehydratedDevice); + } + + enterSecurityKey(securityKey, setupDehydratedDevice): Promise { + return this._enterCredentials(KeyType.RecoveryKey, securityKey, setupDehydratedDevice); + } + + async disable(): Promise { + try { + this._isBusy = true; + this.emitChange("isBusy"); + await this._session.disableSecretStorage(); + } catch (err) { + console.error(err); + this._error = err; + this.emitChange("error"); + } finally { + this._isBusy = false; + this.emitChange(); + } + } + + get isBackingUp(): boolean { + return this._keyBackup?.operationInProgress !== undefined; + } + + get backupPercentage(): number { + if (this._progress) { + return Math.round((this._progress.finished / this._progress.total) * 100); + } + return 0; + } + + get backupInProgressLabel(): string { + if (this._progress) { + return this.i18n`${this._progress.finished} of ${this._progress.total}`; + } + return this.i18n`…`; + } + + cancelBackup(): void { + this._keyBackup?.operationInProgress?.abort(); + } + + startBackup(): void { + this.logger.run("KeyBackupViewModel.startBackup", log => { + this._keyBackup?.flush(log); + }); + } +} + diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index cf8bce3e0a..34bbb38bd3 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -15,7 +15,7 @@ limitations under the License. */ import {ViewModel} from "../../ViewModel"; -import {KeyBackupViewModel} from "./KeyBackupViewModel.js"; +import {KeyBackupViewModel} from "./KeyBackupViewModel"; import {FeaturesViewModel} from "./FeaturesViewModel"; import {submitLogsFromSessionToDefaultServer} from "../../../domain/rageshake"; diff --git a/src/domain/session/toast/BaseToastNotificationViewModel.ts b/src/domain/session/toast/BaseToastNotificationViewModel.ts index 41e20e42d3..133a8d367c 100644 --- a/src/domain/session/toast/BaseToastNotificationViewModel.ts +++ b/src/domain/session/toast/BaseToastNotificationViewModel.ts @@ -32,4 +32,6 @@ export abstract class BaseToastNotificationViewModel; +} diff --git a/src/domain/session/toast/ToastCollectionViewModel.ts b/src/domain/session/toast/ToastCollectionViewModel.ts index df4da88fc9..0d31b6ee24 100644 --- a/src/domain/session/toast/ToastCollectionViewModel.ts +++ b/src/domain/session/toast/ToastCollectionViewModel.ts @@ -14,83 +14,35 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {CallToastNotificationViewModel} from "./CallToastNotificationViewModel"; -import {ObservableArray} from "../../../observable"; +import {ConcatList} from "../../../observable"; import {ViewModel, Options as BaseOptions} from "../../ViewModel"; -import type {GroupCall} from "../../../matrix/calls/group/GroupCall"; -import type {Room} from "../../../matrix/room/Room.js"; +import {CallToastCollectionViewModel} from "./calls/CallsToastCollectionViewModel"; +import {VerificationToastCollectionViewModel} from "./verification/VerificationToastCollectionViewModel"; import type {Session} from "../../../matrix/Session.js"; import type {SegmentType} from "../../navigation"; -import { RoomStatus } from "../../../lib"; +import type {BaseToastNotificationViewModel} from "./BaseToastNotificationViewModel"; +import type {IToastCollection} from "./IToastCollection"; type Options = { session: Session; } & BaseOptions; export class ToastCollectionViewModel extends ViewModel { - public readonly toastViewModels: ObservableArray = new ObservableArray(); + public readonly toastViewModels: ConcatList; constructor(options: Options) { super(options); const session = this.getOption("session"); + const collectionVms: IToastCollection[] = []; if (this.features.calls) { - const callsObservableMap = session.callHandler.calls; - this.track(callsObservableMap.subscribe(this)); + collectionVms.push(this.track(new CallToastCollectionViewModel(this.childOptions({ session })))); } - } - - async onAdd(_, call: GroupCall) { - if (this._shouldShowNotification(call)) { - const room = await this._findRoomForCall(call); - const dismiss = () => { - const idx = this.toastViewModels.array.findIndex(vm => vm.call === call); - if (idx !== -1) { - this.toastViewModels.remove(idx); - } - }; - this.toastViewModels.append( - new CallToastNotificationViewModel(this.childOptions({ call, room, dismiss })) - ); - } - } - - onRemove(_, call: GroupCall) { - const idx = this.toastViewModels.array.findIndex(vm => vm.call === call); - if (idx !== -1) { - this.toastViewModels.remove(idx); - } - } - - onUpdate(_, call: GroupCall) { - const idx = this.toastViewModels.array.findIndex(vm => vm.call === call); - if (idx !== -1) { - this.toastViewModels.update(idx, this.toastViewModels.at(idx)!); + if (this.features.crossSigning) { + collectionVms.push(this.track(new VerificationToastCollectionViewModel(this.childOptions({ session })))); } - } - - onReset() { - for (let i = 0; i < this.toastViewModels.length; ++i) { - this.toastViewModels.remove(i); - } - } - - private async _findRoomForCall(call: GroupCall): Promise { - const id = call.roomId; - const session = this.getOption("session"); - const rooms = session.rooms; - // Make sure that we know of this room, - // otherwise wait for it to come through sync - const observable = await session.observeRoomStatus(id); - await observable.waitFor(s => s === RoomStatus.Joined).promise; - const room = rooms.get(id); - return room; - } - - private _shouldShowNotification(call: GroupCall): boolean { - const currentlyOpenedRoomId = this.navigation.path.get("room")?.value; - if (!call.isLoadedFromStorage && call.roomId !== currentlyOpenedRoomId && !call.usesFoci) { - return true; + const vms: IToastCollection["toastViewModels"][] = collectionVms.map(vm => vm.toastViewModels); + if (vms.length !== 0) { + this.toastViewModels = new ConcatList(...vms); } - return false; } } diff --git a/src/domain/session/toast/CallToastNotificationViewModel.ts b/src/domain/session/toast/calls/CallToastNotificationViewModel.ts similarity index 88% rename from src/domain/session/toast/CallToastNotificationViewModel.ts rename to src/domain/session/toast/calls/CallToastNotificationViewModel.ts index 5c7883347c..bbcfed9630 100644 --- a/src/domain/session/toast/CallToastNotificationViewModel.ts +++ b/src/domain/session/toast/calls/CallToastNotificationViewModel.ts @@ -13,12 +13,12 @@ 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 {GroupCall} from "../../../matrix/calls/group/GroupCall"; -import type {Room} from "../../../matrix/room/Room.js"; -import {IAvatarContract, avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; -import {LocalMedia} from "../../../matrix/calls/LocalMedia"; -import {BaseClassOptions, BaseToastNotificationViewModel} from "./BaseToastNotificationViewModel"; -import {SegmentType} from "../../navigation"; +import type {GroupCall} from "../../../../matrix/calls/group/GroupCall"; +import type {Room} from "../../../../matrix/room/Room.js"; +import {IAvatarContract, avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../../avatar"; +import {LocalMedia} from "../../../../matrix/calls/LocalMedia"; +import {BaseClassOptions, BaseToastNotificationViewModel} from ".././BaseToastNotificationViewModel"; +import {SegmentType} from "../../../navigation"; type Options = { call: GroupCall; @@ -46,6 +46,10 @@ export class CallToastNotificationViewModel { await this.logAndCatch("CallToastNotificationViewModel.join", async (log) => { const stream = await this.platform.mediaDevices.getMediaTracks(false, true); diff --git a/src/domain/session/toast/calls/CallsToastCollectionViewModel.ts b/src/domain/session/toast/calls/CallsToastCollectionViewModel.ts new file mode 100644 index 0000000000..4dd9a73a36 --- /dev/null +++ b/src/domain/session/toast/calls/CallsToastCollectionViewModel.ts @@ -0,0 +1,99 @@ + +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {CallToastNotificationViewModel} from "./CallToastNotificationViewModel"; +import {ObservableArray} from "../../../../observable"; +import {ViewModel, Options as BaseOptions} from "../../../ViewModel"; +import {RoomStatus} from "../../../../matrix/room/common"; +import type {GroupCall} from "../../../../matrix/calls/group/GroupCall"; +import type {Room} from "../../../../matrix/room/Room.js"; +import type {Session} from "../../../../matrix/Session.js"; +import type {SegmentType} from "../../../navigation"; +import type {IToastCollection} from "../IToastCollection"; + +type Options = { + session: Session; +} & BaseOptions; + + +export class CallToastCollectionViewModel extends ViewModel implements IToastCollection { + public readonly toastViewModels: ObservableArray = new ObservableArray(); + + constructor(options: Options) { + super(options); + const session = this.getOption("session"); + if (this.features.calls) { + const callsObservableMap = session.callHandler.calls; + this.track(callsObservableMap.subscribe(this)); + } + } + + async onAdd(_, call: GroupCall) { + if (this._shouldShowNotification(call)) { + const room = await this._findRoomForCall(call); + const dismiss = () => { + const idx = this.toastViewModels.array.findIndex(vm => vm.call === call); + if (idx !== -1) { + this.toastViewModels.remove(idx); + } + }; + this.toastViewModels.append( + new CallToastNotificationViewModel(this.childOptions({ call, room, dismiss })) + ); + } + } + + onRemove(_, call: GroupCall) { + const idx = this.toastViewModels.array.findIndex(vm => vm.call === call); + if (idx !== -1) { + this.toastViewModels.remove(idx); + } + } + + onUpdate(_, call: GroupCall) { + const idx = this.toastViewModels.array.findIndex(vm => vm.call === call); + if (idx !== -1) { + this.toastViewModels.update(idx, this.toastViewModels.at(idx)!); + } + } + + onReset() { + for (let i = 0; i < this.toastViewModels.length; ++i) { + this.toastViewModels.remove(i); + } + } + + private async _findRoomForCall(call: GroupCall): Promise { + const id = call.roomId; + const session = this.getOption("session"); + const rooms = session.rooms; + // Make sure that we know of this room, + // otherwise wait for it to come through sync + const observable = await session.observeRoomStatus(id); + await observable.waitFor(s => s === RoomStatus.Joined).promise; + const room = rooms.get(id); + return room; + } + + private _shouldShowNotification(call: GroupCall): boolean { + const currentlyOpenedRoomId = this.navigation.path.get("room")?.value; + if (!call.isLoadedFromStorage && call.roomId !== currentlyOpenedRoomId && !call.usesFoci) { + return true; + } + return false; + } +} diff --git a/src/domain/session/toast/verification/VerificationToastCollectionViewModel.ts b/src/domain/session/toast/verification/VerificationToastCollectionViewModel.ts new file mode 100644 index 0000000000..2cd3da0188 --- /dev/null +++ b/src/domain/session/toast/verification/VerificationToastCollectionViewModel.ts @@ -0,0 +1,76 @@ + +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {VerificationToastNotificationViewModel} from "./VerificationToastNotificationViewModel"; +import {ObservableArray} from "../../../../observable"; +import {ViewModel, Options as BaseOptions} from "../../../ViewModel"; +import type {Session} from "../../../../matrix/Session.js"; +import type {SegmentType} from "../../../navigation"; +import type {IToastCollection} from "../IToastCollection"; +import type {SASRequest} from "../../../../matrix/verification/SAS/SASRequest"; + +type Options = { + session: Session; +} & BaseOptions; + +export class VerificationToastCollectionViewModel extends ViewModel implements IToastCollection { + public readonly toastViewModels: ObservableArray = new ObservableArray(); + + constructor(options: Options) { + super(options); + this.subscribeToSASRequests(); + } + + private async subscribeToSASRequests() { + await this.getOption("session").crossSigning.waitFor(v => !!v).promise; + const crossSigning = this.getOption("session").crossSigning.get(); + this.track(crossSigning.receivedSASVerifications.subscribe(this)); + } + + + async onAdd(_, request: SASRequest) { + const dismiss = () => { + const idx = this.toastViewModels.array.findIndex(vm => vm.request.id === request.id); + if (idx !== -1) { + this.toastViewModels.remove(idx); + } + }; + this.toastViewModels.append( + this.track(new VerificationToastNotificationViewModel(this.childOptions({ request, dismiss }))) + ); + } + + onRemove(_, request: SASRequest) { + const idx = this.toastViewModels.array.findIndex(vm => vm.request.id === request.id); + if (idx !== -1) { + this.toastViewModels.remove(idx); + } + } + + onUpdate(_, request: SASRequest) { + const idx = this.toastViewModels.array.findIndex(vm => vm.request.id === request.id); + if (idx !== -1) { + this.toastViewModels.update(idx, this.toastViewModels.at(idx)!); + } + } + + onReset() { + for (let i = 0; i < this.toastViewModels.length; ++i) { + this.toastViewModels.remove(i); + } + } +} diff --git a/src/domain/session/toast/verification/VerificationToastNotificationViewModel.ts b/src/domain/session/toast/verification/VerificationToastNotificationViewModel.ts new file mode 100644 index 0000000000..9bf1e2f7c1 --- /dev/null +++ b/src/domain/session/toast/verification/VerificationToastNotificationViewModel.ts @@ -0,0 +1,53 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import {BaseClassOptions, BaseToastNotificationViewModel} from ".././BaseToastNotificationViewModel"; +import {SegmentType} from "../../../navigation"; +import {SASRequest} from "../../../../matrix/verification/SAS/SASRequest"; + +type Options = { + request: SASRequest; +} & BaseClassOptions; + +type MinimumNeededSegmentType = { + "device-verification": string | boolean; +}; + +export class VerificationToastNotificationViewModel = Options> extends BaseToastNotificationViewModel { + constructor(options: O) { + super(options); + } + + get kind(): "verification" { + return "verification"; + } + + get request(): SASRequest { + return this.getOption("request"); + } + + get otherDeviceId(): string { + return this.request.deviceId; + } + + accept() { + // @ts-ignore + this.navigation.push("device-verification", this.request.id); + this.dismiss(); + } + +} + + diff --git a/src/domain/session/verification/DeviceVerificationViewModel.ts b/src/domain/session/verification/DeviceVerificationViewModel.ts new file mode 100644 index 0000000000..3257c78482 --- /dev/null +++ b/src/domain/session/verification/DeviceVerificationViewModel.ts @@ -0,0 +1,103 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {Options as BaseOptions} from "../../ViewModel"; +import {SegmentType} from "../../navigation/index"; +import {ErrorReportViewModel} from "../../ErrorReportViewModel"; +import {WaitingForOtherUserViewModel} from "./stages/WaitingForOtherUserViewModel"; +import {VerificationCancelledViewModel} from "./stages/VerificationCancelledViewModel"; +import {SelectMethodViewModel} from "./stages/SelectMethodViewModel"; +import {VerifyEmojisViewModel} from "./stages/VerifyEmojisViewModel"; +import {VerificationCompleteViewModel} from "./stages/VerificationCompleteViewModel"; +import type {Session} from "../../../matrix/Session.js"; +import type {SASVerification} from "../../../matrix/verification/SAS/SASVerification"; +import type {SASRequest} from "../../../matrix/verification/SAS/SASRequest"; + +type Options = BaseOptions & { + session: Session; + request: SASRequest; +}; + +export class DeviceVerificationViewModel extends ErrorReportViewModel { + private sas: SASVerification; + private _currentStageViewModel: any; + + constructor(options: Readonly) { + super(options); + const sasRequest = options.request; + if (options.request) { + this.start(sasRequest); + } + else { + // We are about to send the request + this.start(this.getOption("session").userId); + } + } + + private async start(requestOrUserId: SASRequest | string) { + await this.logAndCatch("DeviceVerificationViewModel.start", (log) => { + const crossSigning = this.getOption("session").crossSigning.get(); + this.sas = crossSigning.startVerification(requestOrUserId, log); + this.addEventListeners(); + if (typeof requestOrUserId === "string") { + this.updateCurrentStageViewModel(new WaitingForOtherUserViewModel(this.childOptions({ sas: this.sas }))); + } + return this.sas.start(); + }); + } + + private addEventListeners() { + this.track(this.sas.disposableOn("SelectVerificationStage", (stage) => { + this.updateCurrentStageViewModel( + new SelectMethodViewModel(this.childOptions({ sas: this.sas, stage: stage!, })) + ); + })); + this.track(this.sas.disposableOn("EmojiGenerated", (stage) => { + this.updateCurrentStageViewModel( + new VerifyEmojisViewModel(this.childOptions({ stage: stage!, })) + ); + })); + this.track(this.sas.disposableOn("VerificationCancelled", (cancellation) => { + this.updateCurrentStageViewModel( + new VerificationCancelledViewModel( + this.childOptions({ cancellation: cancellation! }) + ) + ); + })); + this.track(this.sas.disposableOn("VerificationCompleted", (deviceId) => { + this.updateCurrentStageViewModel( + new VerificationCompleteViewModel(this.childOptions({ deviceId: deviceId! })) + ); + })); + } + + private updateCurrentStageViewModel(vm) { + this._currentStageViewModel = this.disposeTracked(this._currentStageViewModel); + this._currentStageViewModel = this.track(vm); + this.emitChange("currentStageViewModel"); + } + + dispose(): void { + if (!this.sas.finished) { + this.sas.abort().catch(() => {/** ignore */}); + } + super.dispose(); + } + + get currentStageViewModel() { + return this._currentStageViewModel; + } +} diff --git a/src/domain/session/verification/stages/SelectMethodViewModel.ts b/src/domain/session/verification/stages/SelectMethodViewModel.ts new file mode 100644 index 0000000000..e88d0f83c0 --- /dev/null +++ b/src/domain/session/verification/stages/SelectMethodViewModel.ts @@ -0,0 +1,54 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {SegmentType} from "../../../navigation/index"; +import {ErrorReportViewModel} from "../../../ErrorReportViewModel"; +import type {Options as BaseOptions} from "../../../ViewModel"; +import type {Session} from "../../../../matrix/Session.js"; +import type {SASVerification} from "../../../../matrix/verification/SAS/SASVerification"; +import type {SelectVerificationMethodStage} from "../../../../matrix/verification/SAS/stages/SelectVerificationMethodStage"; + +type Options = BaseOptions & { + sas: SASVerification; + stage: SelectVerificationMethodStage; + session: Session; +}; + +export class SelectMethodViewModel extends ErrorReportViewModel { + public hasProceeded: boolean = false; + + async proceed() { + await this.logAndCatch("SelectMethodViewModel.proceed", async (log) => { + await this.options.stage.selectEmojiMethod(log); + this.hasProceeded = true; + this.emitChange("hasProceeded"); + }); + } + + async cancel() { + await this.logAndCatch("SelectMethodViewModel.cancel", async () => { + await this.options.sas.abort(); + }); + } + + get deviceName() { + return this.options.stage.otherDeviceName; + } + + get kind(): string { + return "select-method"; + } +} diff --git a/src/domain/session/verification/stages/VerificationCancelledViewModel.ts b/src/domain/session/verification/stages/VerificationCancelledViewModel.ts new file mode 100644 index 0000000000..75cc0e5d70 --- /dev/null +++ b/src/domain/session/verification/stages/VerificationCancelledViewModel.ts @@ -0,0 +1,42 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {ViewModel, Options as BaseOptions} from "../../../ViewModel"; +import {SegmentType} from "../../../navigation/index"; +import type {CancelReason} from "../../../../matrix/verification/SAS/channel/types"; +import type {IChannel} from "../../../../matrix/verification/SAS/channel/Channel"; + +type Options = BaseOptions & { + cancellation: IChannel["cancellation"]; +}; + +export class VerificationCancelledViewModel extends ViewModel { + get cancelCode(): CancelReason { + return this.options.cancellation!.code; + } + + get isCancelledByUs(): boolean { + return this.options.cancellation!.cancelledByUs; + } + + gotoSettings() { + this.navigation.push("settings", true); + } + + get kind(): string { + return "verification-cancelled"; + } +} diff --git a/src/domain/session/verification/stages/VerificationCompleteViewModel.ts b/src/domain/session/verification/stages/VerificationCompleteViewModel.ts new file mode 100644 index 0000000000..c7ffd82019 --- /dev/null +++ b/src/domain/session/verification/stages/VerificationCompleteViewModel.ts @@ -0,0 +1,39 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {SegmentType} from "../../../navigation/index"; +import {ErrorReportViewModel} from "../../../ErrorReportViewModel"; +import type {Options as BaseOptions} from "../../../ViewModel"; +import type {Session} from "../../../../matrix/Session.js"; + +type Options = BaseOptions & { + deviceId: string; + session: Session; +}; + +export class VerificationCompleteViewModel extends ErrorReportViewModel { + get otherDeviceId(): string { + return this.options.deviceId; + } + + gotoSettings() { + this.navigation.push("settings", true); + } + + get kind(): string { + return "verification-completed"; + } +} diff --git a/src/domain/session/verification/stages/VerifyEmojisViewModel.ts b/src/domain/session/verification/stages/VerifyEmojisViewModel.ts new file mode 100644 index 0000000000..061a8e08ab --- /dev/null +++ b/src/domain/session/verification/stages/VerifyEmojisViewModel.ts @@ -0,0 +1,50 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {SegmentType} from "../../../navigation/index"; +import {ErrorReportViewModel} from "../../../ErrorReportViewModel"; +import type {Options as BaseOptions} from "../../../ViewModel"; +import type {Session} from "../../../../matrix/Session.js"; +import type {CalculateSASStage} from "../../../../matrix/verification/SAS/stages/CalculateSASStage"; + +type Options = BaseOptions & { + stage: CalculateSASStage; + session: Session; +}; + +export class VerifyEmojisViewModel extends ErrorReportViewModel { + private _isWaiting: boolean = false; + + async setEmojiMatch(match: boolean) { + await this.logAndCatch("VerifyEmojisViewModel.setEmojiMatch", async () => { + await this.options.stage.setEmojiMatch(match); + this._isWaiting = true; + this.emitChange("isWaiting"); + }); + } + + get emojis() { + return this.options.stage.emoji; + } + + get kind(): string { + return "verify-emojis"; + } + + get isWaiting(): boolean { + return this._isWaiting; + } +} diff --git a/src/domain/session/verification/stages/WaitingForOtherUserViewModel.ts b/src/domain/session/verification/stages/WaitingForOtherUserViewModel.ts new file mode 100644 index 0000000000..ca68c941c4 --- /dev/null +++ b/src/domain/session/verification/stages/WaitingForOtherUserViewModel.ts @@ -0,0 +1,33 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {ViewModel, Options as BaseOptions} from "../../../ViewModel"; +import {SegmentType} from "../../../navigation/index"; +import type {SASVerification} from "../../../../matrix/verification/SAS/SASVerification"; + +type Options = BaseOptions & { + sas: SASVerification; +}; + +export class WaitingForOtherUserViewModel extends ViewModel { + async cancel() { + await this.options.sas.abort(); + } + + get kind(): string { + return "waiting-for-user"; + } +} diff --git a/src/fixtures/matrix/sas/events.ts b/src/fixtures/matrix/sas/events.ts new file mode 100644 index 0000000000..b3c8228f8a --- /dev/null +++ b/src/fixtures/matrix/sas/events.ts @@ -0,0 +1,174 @@ +/** + POSSIBLE STATES: + (following are messages received, not messages sent) + ready -> accept -> key -> mac -> done + ready -> start -> key -> mac -> done + ready -> start -> accept -> key -> mac -> done (when start resolved to use yours) + element does not send you request! + start -> key -> mac -> done + start -> accept -> key -> mac -> done + accept -> key -> mac -> done +*/ + +import {VerificationEventType} from "../../../matrix/verification/SAS/channel/types"; + +function generateResponses(userId: string, deviceId: string, txnId: string) { + const readyMessage = { + content: { + methods: ["m.sas.v1", "m.qr_code.show.v1", "m.reciprocate.v1"], + transaction_id: txnId, + from_device: deviceId, + }, + type: "m.key.verification.ready", + sender: userId, + }; + const startMessage = { + content: { + method: "m.sas.v1", + from_device: deviceId, + key_agreement_protocols: ["curve25519-hkdf-sha256", "curve25519"], + hashes: ["sha256"], + message_authentication_codes: [ + "hkdf-hmac-sha256.v2", + "org.matrix.msc3783.hkdf-hmac-sha256", + "hkdf-hmac-sha256", + "hmac-sha256", + ], + short_authentication_string: ["decimal", "emoji"], + transaction_id: txnId, + }, + type: "m.key.verification.start", + sender: userId, + }; + const acceptMessage = { + content: { + key_agreement_protocol: "curve25519-hkdf-sha256", + hash: "sha256", + message_authentication_code: "hkdf-hmac-sha256.v2", + short_authentication_string: ["decimal", "emoji"], + commitment: "h2YJESkiXwoGF+i5luu0YmPAKuAsWVeC2VaZOwdzggE", + transaction_id: txnId, + }, + type: "m.key.verification.accept", + sender: userId, + }; + const keyMessage = { + content: { + key: "7XA92bSIAq14R69308U80wsJR0K4KAydFG1HtVRYBFA", + transaction_id: txnId, + }, + type: "m.key.verification.key", + sender: userId, + }; + const macMessage = { + content: { + mac: { + "ed25519:FWKXUYUHTF": + "uMOgfISlZTGja2VHmdnK/xe1JNGi7irTzdaVAYSs6Q8", + "ed25519:Ot8Y58PueQ7hJVpYWAJkg2qaREJAY/UhGZYOrsd52oo": + "SavNqO8PPcAp0+eoLwlU4JWpuMm8GdGuMopPFaS8alY", + }, + keys: "cHnoX3rt9x86RUUb1nyFOa4U/dCJty+EmXCYPeNg6uU", + transaction_id: txnId, + }, + type: "m.key.verification.mac", + sender: userId, + }; + const doneMessage = { + content: { + transaction_id: txnId, + }, + type: "m.key.verification.done", + sender: userId, + }; + const result = {}; + for (const message of [readyMessage, startMessage, keyMessage, macMessage, doneMessage, acceptMessage]) { + result[message.type] = message; + } + return result; +} + +const enum COMBINATIONS { + YOU_SENT_REQUEST, + YOU_SENT_START, + THEY_SENT_START, +} + +export class SASFixtures { + private order: COMBINATIONS[] = []; + private _youWinConflict: boolean = false; + + constructor(private userId: string, private deviceId: string, private txnId: string) { } + + youSentRequest() { + this.order.push(COMBINATIONS.YOU_SENT_REQUEST); + return this; + } + + youSentStart() { + this.order.push(COMBINATIONS.YOU_SENT_START); + return this; + } + + theySentStart() { + this.order.push(COMBINATIONS.THEY_SENT_START); + return this; + } + + youWinConflict() { + this._youWinConflict = true; + return this; + } + + theyWinConflict() { + this._youWinConflict = false; + return this; + } + + fixtures(): Map { + const responses = generateResponses(this.userId, this.deviceId, this.txnId); + const array: any[] = []; + const addToArray = (type) => array.push([type, responses[type]]); + let i = 0; + while(i < this.order.length) { + const item = this.order[i]; + switch (item) { + case COMBINATIONS.YOU_SENT_REQUEST: + addToArray(VerificationEventType.Ready); + break; + case COMBINATIONS.THEY_SENT_START: { + addToArray(VerificationEventType.Start); + const nextItem = this.order[i+1]; + if (nextItem === COMBINATIONS.YOU_SENT_START) { + if (this._youWinConflict) { + addToArray(VerificationEventType.Accept); + i = i + 2; + continue; + } + } + break; + } + case COMBINATIONS.YOU_SENT_START: { + const nextItem = this.order[i+1] + if (nextItem === COMBINATIONS.THEY_SENT_START) { + if (this._youWinConflict) { + addToArray(VerificationEventType.Accept); + + } + break; + } + if (this.order[i-1] === COMBINATIONS.THEY_SENT_START) { + break; + } + addToArray(VerificationEventType.Accept); + break; + } + } + i = i + 1; + } + addToArray(VerificationEventType.Key); + addToArray(VerificationEventType.Mac); + addToArray(VerificationEventType.Done); + return new Map(array); + } +} diff --git a/src/lib.ts b/src/lib.ts index 238fd85afa..74282b4e62 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -28,6 +28,7 @@ export {CallIntent} from "./matrix/calls/callEventTypes"; export {OidcApi} from "./matrix/net/OidcApi"; export {OIDCLoginMethod} from "./matrix/login/OIDCLoginMethod"; export { makeTxnId } from './matrix/common' +export { submitLogsToRageshakeServer } from './domain/rageshake' // export everything needed to observe state events on all rooms using session.observeRoomState export type {RoomStateHandler} from "./matrix/room/state/types"; export type {MemberChange} from "./matrix/room/members/RoomMember"; diff --git a/src/logging/ConsoleReporter.ts b/src/logging/ConsoleReporter.ts index 328b4c239f..9a43a123c5 100644 --- a/src/logging/ConsoleReporter.ts +++ b/src/logging/ConsoleReporter.ts @@ -49,12 +49,26 @@ function filterValues(values: LogItemValues): LogItemValues | null { }, null); } +function hasChildWithError(item: LogItem): boolean { + if (item.error) { + return true; + } + if (item.children) { + for(const c of item.children) { + if (hasChildWithError(c)) { + return true; + } + } + } + return false; +} + function printToConsole(item: LogItem): void { const label = `${itemCaption(item)} (@${item.start}ms, duration: ${item.duration}ms)`; const filteredValues = filterValues(item.values); const shouldGroup = item.children || filteredValues; if (shouldGroup) { - if (item.error) { + if (hasChildWithError(item)) { console.group(label); } else { console.groupCollapsed(label); diff --git a/src/logging/LogItem.ts b/src/logging/LogItem.ts index 3199115906..7b283ea080 100644 --- a/src/logging/LogItem.ts +++ b/src/logging/LogItem.ts @@ -28,6 +28,7 @@ export class LogItem implements ILogItem { protected _logger: Logger; private _filterCreator?: FilterCreator; private _children?: Array; + private _discard: boolean = false; constructor(labelOrValues: LabelOrValues, logLevel: LogLevel, logger: Logger, filterCreator?: FilterCreator) { this._logger = logger; @@ -38,6 +39,13 @@ export class LogItem implements ILogItem { this._filterCreator = filterCreator; } + /** + * Prevents this log item from being present in the exported output. + */ + discard(): void { + this._discard = true; + } + /** start a new root log item and run it detached mode, see Logger.runDetached */ runDetached(labelOrValues: LabelOrValues, callback: LogCallback, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem { return this._logger.runDetached(labelOrValues, callback, logLevel, filterCreator); @@ -119,6 +127,9 @@ export class LogItem implements ILogItem { } serialize(filter: LogFilter, parentStartTime: number | undefined, forced: boolean): ISerializedItem | undefined { + if (this._discard) { + return; + } if (this._filterCreator) { try { filter = this._filterCreator(new LogFilter(filter), this); diff --git a/src/logging/NullLogger.ts b/src/logging/NullLogger.ts index 83e5fc0037..0952704b09 100644 --- a/src/logging/NullLogger.ts +++ b/src/logging/NullLogger.ts @@ -74,6 +74,10 @@ export class NullLogItem implements ILogItem { this.logger = logger; } + discard(): void { + // noop + } + wrap(_: LabelOrValues, callback: LogCallback): T { return this.run(callback); } diff --git a/src/logging/types.ts b/src/logging/types.ts index 5443642396..7e81d92918 100644 --- a/src/logging/types.ts +++ b/src/logging/types.ts @@ -55,6 +55,7 @@ export interface ILogItem { finish(): void; forceFinish(): void; child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem; + discard(): void; } /* extend both ILogger and ILogItem from this interface, but need to rename ILogger.run => wrap then. Or both to `span`? diff --git a/src/matrix/DeviceMessageHandler.js b/src/matrix/DeviceMessageHandler.js index f6e7cad7f1..1af139b0a7 100644 --- a/src/matrix/DeviceMessageHandler.js +++ b/src/matrix/DeviceMessageHandler.js @@ -14,12 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {OLM_ALGORITHM} from "./e2ee/common.js"; +import {OLM_ALGORITHM} from "./e2ee/common"; import {countBy, groupBy} from "../utils/groupBy"; import {LRUCache} from "../utils/LRUCache"; +import {EventEmitter} from "../utils/EventEmitter"; -export class DeviceMessageHandler { +export class DeviceMessageHandler extends EventEmitter{ constructor({storage, callHandler}) { + super(); this._storage = storage; this._olmDecryption = null; this._megolmDecryption = null; @@ -39,6 +41,7 @@ export class DeviceMessageHandler { async prepareSync(toDeviceEvents, lock, txn, log) { log.set("messageTypes", countBy(toDeviceEvents, e => e.type)); const encryptedEvents = toDeviceEvents.filter(e => e.type === "m.room.encrypted"); + this._emitUnencryptedEvents(toDeviceEvents); if (!this._olmDecryption) { log.log("can't decrypt, encryption not enabled", log.level.Warn); return; @@ -74,6 +77,7 @@ export class DeviceMessageHandler { } async afterSyncCompleted(decryptionResults, deviceTracker, hsApi, log) { + this._emitEncryptedEvents(decryptionResults); if (this._callHandler) { // if we don't have a device, we need to fetch the device keys the message claims // and check the keys, and we should only do network requests during @@ -101,6 +105,20 @@ export class DeviceMessageHandler { } } } + + _emitUnencryptedEvents(toDeviceEvents) { + const unencryptedEvents = toDeviceEvents.filter(e => e.type !== "m.room.encrypted"); + for (const event of unencryptedEvents) { + this.emit("message", { unencrypted: event }); + } + } + + _emitEncryptedEvents(decryptionResults) { + // We don't emit for now as we're not verifying the identity of the sender + // for (const result of decryptionResults) { + // this.emit("message", { encrypted: result }); + // } + } } class SyncPreparation { diff --git a/src/matrix/Session.js b/src/matrix/Session.js index ad4a400273..519cd3bb9b 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -33,9 +33,9 @@ import {KeyLoader as MegOlmKeyLoader} from "./e2ee/megolm/decryption/KeyLoader"; import {KeyBackup} from "./e2ee/megolm/keybackup/KeyBackup"; import {CrossSigning} from "./verification/CrossSigning"; import {Encryption as MegOlmEncryption} from "./e2ee/megolm/Encryption.js"; -import {MEGOLM_ALGORITHM} from "./e2ee/common.js"; +import {MEGOLM_ALGORITHM} from "./e2ee/common"; import {RoomEncryption} from "./e2ee/RoomEncryption.js"; -import {DeviceTracker} from "./e2ee/DeviceTracker.js"; +import {DeviceTracker} from "./e2ee/DeviceTracker"; import {LockMap} from "../utils/LockMap"; import {groupBy} from "../utils/groupBy"; import { @@ -78,6 +78,9 @@ export class Session { this._roomsBeingCreated = new ObservableMap(); this._user = new User(sessionInfo.userId); this._roomStateHandler = new RoomStateHandlerSet(); + if (features.calls) { + this._setupCallHandler(); + } this._deviceMessageHandler = new DeviceMessageHandler({storage, callHandler: this._callHandler}); this._olm = olm; this._olmUtil = null; @@ -90,7 +93,7 @@ export class Session { this._getSyncToken = () => this.syncToken; this._olmWorker = olmWorker; this._keyBackup = new ObservableValue(undefined); - this._crossSigning = undefined; + this._crossSigning = new ObservableValue(undefined); this._observedRoomStatus = new Map(); if (olm) { @@ -106,10 +109,6 @@ export class Session { this._createRoomEncryption = this._createRoomEncryption.bind(this); this._forgetArchivedRoom = this._forgetArchivedRoom.bind(this); this.needsKeyBackup = new ObservableValue(false); - - if (features.calls) { - this._setupCallHandler(); - } } get hsApi() { @@ -258,18 +257,20 @@ export class Session { } if (this._keyBackup.get()) { this._keyBackup.get().dispose(); - this._keyBackup.set(null); + this._keyBackup.set(undefined); + } + const crossSigning = this._crossSigning.get(); + if (crossSigning) { + crossSigning.dispose(); + this._crossSigning.set(undefined); } const key = await ssssKeyFromCredential(type, credential, this._storage, this._platform, this._olm); - // and create key backup, which needs to read from accountData - const readTxn = await this._storage.readTxn([ - this._storage.storeNames.accountData, - ]); - if (await this._createKeyBackup(key, readTxn, log)) { + if (await this._tryLoadSecretStorage(key, log)) { // only after having read a secret, write the key // as we only find out if it was good if the MAC verification succeeds await this._writeSSSSKey(key, log); - this._keyBackup.get().flush(log); + await this._keyBackup.get()?.start(log); + await this._crossSigning.get()?.start(log); return key; } else { throw new Error("Could not read key backup with the given key"); @@ -323,39 +324,38 @@ export class Session { } } this._keyBackup.get().dispose(); - this._keyBackup.set(null); + this._keyBackup.set(undefined); + } + const crossSigning = this._crossSigning.get(); + if (crossSigning) { + crossSigning.dispose(); + this._crossSigning.set(undefined); } } - _createKeyBackup(ssssKey, txn, log) { - return log.wrap("enable key backup", async log => { - try { - const secretStorage = new SecretStorage({key: ssssKey, platform: this._platform}); - const keyBackup = await KeyBackup.fromSecretStorage( - this._platform, - this._olm, - secretStorage, + _tryLoadSecretStorage(ssssKey, log) { + return log.wrap("enable secret storage", async log => { + const secretStorage = new SecretStorage({key: ssssKey, platform: this._platform, storage: this._storage}); + const isValid = await secretStorage.hasValidKeyForAnyAccountData(); + log.set("isValid", isValid); + if (isValid) { + await this._loadSecretStorageServices(secretStorage, log); + } + return isValid; + }); + } + + async _loadSecretStorageServices(secretStorage, log) { + try { + await log.wrap("enable key backup", async log => { + const keyBackup = new KeyBackup( this._hsApi, + this._olm, this._keyLoader, this._storage, - txn + this._platform, ); - if (keyBackup) { - if (this._features.crossSigning) { - this._crossSigning = new CrossSigning({ - storage: this._storage, - secretStorage, - platform: this._platform, - olm: this._olm, - deviceTracker: this._deviceTracker, - hsApi: this._hsApi, - ownUserId: this.userId, - e2eeAccount: this._e2eeAccount - }); - await log.wrap("enable cross-signing", log => { - return this._crossSigning.init(log); - }); - } + if (await keyBackup.load(secretStorage, log)) { for (const room of this._rooms.values()) { if (room.isEncrypted) { room.enableKeyBackup(keyBackup); @@ -366,11 +366,33 @@ export class Session { } else { log.set("no_backup", true); } - } catch (err) { - log.catch(err); + }); + if (this._features.crossSigning) { + await log.wrap("enable cross-signing", async log => { + const crossSigning = new CrossSigning({ + storage: this._storage, + secretStorage, + platform: this._platform, + olm: this._olm, + olmUtil: this._olmUtil, + deviceTracker: this._deviceTracker, + deviceMessageHandler: this._deviceMessageHandler, + hsApi: this._hsApi, + ownUserId: this.userId, + e2eeAccount: this._e2eeAccount, + deviceId: this.deviceId, + }); + if (await crossSigning.load(log)) { + this._crossSigning.set(crossSigning); + } + else { + crossSigning.dispose(); + } + }); } - return false; - }); + } catch (err) { + log.catch(err); + } } /** @@ -474,6 +496,8 @@ export class Session { this._storage.storeNames.timelineEvents, this._storage.storeNames.timelineFragments, this._storage.storeNames.pendingEvents, + this._storage.storeNames.accountData, + this._storage.storeNames.crossSigningKeys, ]); // restore session object this._syncInfo = await txn.session.get("sync"); @@ -490,8 +514,8 @@ export class Session { }); if (this._e2eeAccount) { log.set("keys", this._e2eeAccount.identityKeys); - this._setupEncryption(); } + this._setupEncryption(); } const pendingEventsByRoomId = await this._getPendingEventsByRoom(txn); // load invites @@ -516,6 +540,14 @@ export class Session { room.setInvite(invite); } } + if (this._olm && this._e2eeAccount) { + // try set up session backup and cross-signing if we stored the ssss key + const ssssKey = await ssssReadKey(txn); + if (ssssKey) { + // this will close the txn above, so we do it last + await this._tryLoadSecretStorage(ssssKey, log); + } + } } dispose() { @@ -529,6 +561,7 @@ export class Session { this._e2eeAccount = undefined; this._callHandler?.dispose(); this._callHandler = undefined; + this._crossSigning.get()?.dispose(); for (const room of this._rooms.values()) { room.dispose(); } @@ -551,35 +584,21 @@ export class Session { // TODO: what can we do if this throws? await txn.complete(); } - // enable session backup, this requests the latest backup version - if (!this._keyBackup.get()) { - if (dehydratedDevice) { - await log.wrap("SSSSKeyFromDehydratedDeviceKey", async log => { - const ssssKey = await createSSSSKeyFromDehydratedDeviceKey(dehydratedDevice.key, this._storage, this._platform); - if (ssssKey) { + // try if the key used to decrypt the dehydrated device also fits for secret storage + if (dehydratedDevice) { + await log.wrap("SSSSKeyFromDehydratedDeviceKey", async log => { + const ssssKey = await createSSSSKeyFromDehydratedDeviceKey(dehydratedDevice.key, this._storage, this._platform); + if (ssssKey) { + if (await this._tryLoadSecretStorage(ssssKey, log)) { log.set("success", true); await this._writeSSSSKey(ssssKey); } - }); - } - const txn = await this._storage.readTxn([ - this._storage.storeNames.session, - this._storage.storeNames.accountData, - ]); - // try set up session backup if we stored the ssss key - const ssssKey = await ssssReadKey(txn); - if (ssssKey) { - // txn will end here as this does a network request - if (await this._createKeyBackup(ssssKey, txn, log)) { - this._keyBackup.get()?.flush(log); } - } - if (!this._keyBackup.get()) { - // null means key backup isn't configured yet - // as opposed to undefined, which means we're still checking - this._keyBackup.set(null); - } + }); } + await this._keyBackup.get()?.start(log); + await this._crossSigning.get()?.start(log); + // restore unfinished operations, like sending out room keys const opsTxn = await this._storage.readWriteTxn([ this._storage.storeNames.operations @@ -784,7 +803,7 @@ export class Session { // to-device messages, to help us avoid throwing away one-time-keys that we // are about to receive messages for // (https://github.com/vector-im/riot-web/issues/2782). - if (!isCatchupSync) { + if (this._e2eeAccount && !isCatchupSync) { const needsToUploadOTKs = await this._e2eeAccount.generateOTKsIfNeeded(this._storage, log); if (needsToUploadOTKs) { await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, false, log)); diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 4b590454cc..e65c696d03 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -224,7 +224,7 @@ export class Sync { _openPrepareSyncTxn() { const storeNames = this._storage.storeNames; return this._storage.readTxn([ - storeNames.deviceIdentities, // to read device from olm messages + storeNames.deviceKeys, // to read device from olm messages storeNames.olmSessions, storeNames.inboundGroupSessions, // to read fragments when loading sync writer when rejoining archived room @@ -335,7 +335,7 @@ export class Sync { storeNames.pendingEvents, storeNames.userIdentities, storeNames.groupSessionDecryptions, - storeNames.deviceIdentities, + storeNames.deviceKeys, // to discard outbound session when somebody leaves a room // and to create room key messages when somebody joins storeNames.outboundGroupSessions, diff --git a/src/matrix/calls/TurnServerSource.ts b/src/matrix/calls/TurnServerSource.ts index a9163349ca..dd8e8f549d 100644 --- a/src/matrix/calls/TurnServerSource.ts +++ b/src/matrix/calls/TurnServerSource.ts @@ -152,6 +152,8 @@ function toIceServer(settings: TurnServerSettings): RTCIceServer { urls: settings.uris, username: settings.username, credential: settings.password, + // @ts-ignore + // this field is deprecated but providing it nonetheless credentialType: "password" } } diff --git a/src/matrix/calls/group/GroupCall.ts b/src/matrix/calls/group/GroupCall.ts index 4c2b0f2893..56ec48648d 100644 --- a/src/matrix/calls/group/GroupCall.ts +++ b/src/matrix/calls/group/GroupCall.ts @@ -440,6 +440,11 @@ export class GroupCall extends EventEmitter<{change: never}> { member.dispose(); this._members.remove(memberKey); log.set("removed", true); + } else { + // We don't want to pollute the logs with all the expired members. + // This can be an issue for long lived calls that have had a large number + // of users join and leave at some point in time. + log.discard(); } return; } diff --git a/src/matrix/calls/group/Member.ts b/src/matrix/calls/group/Member.ts index d1c780291c..679caced47 100644 --- a/src/matrix/calls/group/Member.ts +++ b/src/matrix/calls/group/Member.ts @@ -33,6 +33,8 @@ import type {ILogItem} from "../../../logging/types"; import type {BaseObservableValue} from "../../../observable/value"; import type {Clock, Timeout} from "../../../platform/web/dom/Clock"; +export type MemberUpdateEmitter = (participant: Member, params?: any) => void; + export type Options = Omit & { confId: string, ownUserId: string, @@ -41,7 +43,7 @@ export type Options = Omit, log: ILogItem) => Promise, - emitUpdate: (participant: Member, params?: any) => void, + emitUpdate: MemberUpdateEmitter, clock: Clock } @@ -105,8 +107,9 @@ class MemberConnection { export class Member { private connection?: MemberConnection; private expireTimeout?: Timeout; + private emitMemberUpdate: MemberUpdateEmitter; private errorBoundary = new ErrorBoundary(err => { - this.options.emitUpdate(this, "error"); + this.emitMemberUpdate(this, "error"); if (this.connection) { // in case the error happens in code that does not log, // log it here to make sure it isn't swallowed @@ -122,6 +125,7 @@ export class Member { private options: Options, updateMemberLog: ILogItem ) { + this.emitMemberUpdate = this.options.emitUpdate; this._renewExpireTimeout(updateMemberLog); } @@ -146,7 +150,7 @@ export class Member { // add 10ms to make sure isExpired returns true this.expireTimeout = this.options.clock.createTimeout(expiresFromNow + 10); this.expireTimeout.elapsed().then( - () => { this.options.emitUpdate(this, "isExpired"); }, + () => { this.emitMemberUpdate(this, "isExpired"); }, (err) => { /* ignore abort error */ }, ); } @@ -185,6 +189,16 @@ export class Member { return this.callDeviceMembership.device_id; } + /** @internal, to emulate deviceKey properties when calling formatToDeviceMessagesPayload */ + get user_id(): string { + return this.userId; + } + + /** @internal, to emulate deviceKey properties when calling formatToDeviceMessagesPayload */ + get device_id(): string { + return this.deviceId; + } + /** session id of the member */ get sessionId(): string { return this.callDeviceMembership.session_id; @@ -293,7 +307,7 @@ export class Member { updateRoomMember(roomMember: RoomMember) { this.member = roomMember; // TODO: this emits an update during the writeSync phase, which we usually try to avoid - this.options.emitUpdate(this); + this.emitMemberUpdate(this); } /** @internal */ @@ -325,7 +339,7 @@ export class Member { }); } } - this.options.emitUpdate(this, params); + this.emitMemberUpdate(this, params); } /** @internal */ @@ -474,8 +488,8 @@ export class Member { this.connection = undefined; this.expireTimeout?.dispose(); this.expireTimeout = undefined; - // ensure the emitUpdate callback can't be called anymore - this.options.emitUpdate = () => {}; + // ensure the emitMemberUpdate callback can't be called anymore + this.emitMemberUpdate = () => {}; } } diff --git a/src/matrix/common.js b/src/matrix/common.js index 7cd72ae1ac..489846bb71 100644 --- a/src/matrix/common.js +++ b/src/matrix/common.js @@ -33,11 +33,11 @@ export function isTxnId(txnId) { } export function formatToDeviceMessagesPayload(messages) { - const messagesByUser = groupBy(messages, message => message.device.userId); + const messagesByUser = groupBy(messages, message => message.device.user_id); const payload = { messages: Array.from(messagesByUser.entries()).reduce((userMap, [userId, messages]) => { userMap[userId] = messages.reduce((deviceMap, message) => { - deviceMap[message.device.deviceId] = message.content; + deviceMap[message.device.device_id] = message.content; return deviceMap; }, {}); return userMap; diff --git a/src/matrix/e2ee/Account.js b/src/matrix/e2ee/Account.js index 0238f0cf91..8fa2db0254 100644 --- a/src/matrix/e2ee/Account.js +++ b/src/matrix/e2ee/Account.js @@ -15,7 +15,7 @@ limitations under the License. */ import anotherjson from "another-json"; -import {SESSION_E2EE_KEY_PREFIX, OLM_ALGORITHM, MEGOLM_ALGORITHM} from "./common.js"; +import {SESSION_E2EE_KEY_PREFIX, OLM_ALGORITHM, MEGOLM_ALGORITHM} from "./common"; // use common prefix so it's easy to clear properties that are not e2ee related during session clear const ACCOUNT_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "olmAccount"; @@ -259,7 +259,7 @@ export class Account { return obj; } - getDeviceKeysToSignWithCrossSigning() { + getUnsignedDeviceKey() { const identityKeys = JSON.parse(this._account.identity_keys()); return this._keysAsSignableObject(identityKeys); } diff --git a/src/matrix/e2ee/DecryptionResult.ts b/src/matrix/e2ee/DecryptionResult.ts index 83ad7a1efb..146a1ad3be 100644 --- a/src/matrix/e2ee/DecryptionResult.ts +++ b/src/matrix/e2ee/DecryptionResult.ts @@ -26,7 +26,8 @@ limitations under the License. * see DeviceTracker */ -import type {DeviceIdentity} from "../storage/idb/stores/DeviceIdentityStore"; +import {getDeviceEd25519Key} from "./common"; +import type {DeviceKey} from "./common"; import type {TimelineEvent} from "../storage/types"; type DecryptedEvent = { @@ -35,7 +36,7 @@ type DecryptedEvent = { } export class DecryptionResult { - private device?: DeviceIdentity; + private device?: DeviceKey; constructor( public readonly event: DecryptedEvent, @@ -44,13 +45,13 @@ export class DecryptionResult { public readonly encryptedEvent?: TimelineEvent ) {} - setDevice(device: DeviceIdentity): void { + setDevice(device: DeviceKey): void { this.device = device; } get isVerified(): boolean { if (this.device) { - const comesFromDevice = this.device.ed25519Key === this.claimedEd25519Key; + const comesFromDevice = getDeviceEd25519Key(this.device) === this.claimedEd25519Key; return comesFromDevice; } return false; @@ -65,11 +66,11 @@ export class DecryptionResult { } get userId(): string | undefined { - return this.device?.userId; + return this.device?.user_id; } get deviceId(): string | undefined { - return this.device?.deviceId; + return this.device?.device_id; } get isVerificationUnknown(): boolean { diff --git a/src/matrix/e2ee/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.ts similarity index 64% rename from src/matrix/e2ee/DeviceTracker.js rename to src/matrix/e2ee/DeviceTracker.ts index b669629ea5..dc3e400844 100644 --- a/src/matrix/e2ee/DeviceTracker.js +++ b/src/matrix/e2ee/DeviceTracker.ts @@ -14,23 +14,42 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {verifyEd25519Signature, SIGNATURE_ALGORITHM} from "./common.js"; -import {HistoryVisibility, shouldShareKey} from "./common.js"; +import {verifyEd25519Signature, getEd25519Signature, SIGNATURE_ALGORITHM, SignatureVerification} from "./common"; +import {HistoryVisibility, shouldShareKey, DeviceKey, getDeviceEd25519Key, getDeviceCurve25519Key} from "./common"; import {RoomMember} from "../room/members/RoomMember.js"; +import {getKeyUsage, getKeyEd25519Key, getKeyUserId, KeyUsage} from "../verification/CrossSigning"; +import {MemberChange} from "../room/members/RoomMember"; +import type {CrossSigningKey} from "../verification/CrossSigning"; +import type {HomeServerApi} from "../net/HomeServerApi"; +import type {ObservableMap} from "../../observable/map"; +import type {Room} from "../room/Room"; +import type {ILogItem} from "../../logging/types"; +import type {Storage} from "../storage/idb/Storage"; +import type {Transaction} from "../storage/idb/Transaction"; +import type * as OlmNamespace from "@matrix-org/olm"; +type Olm = typeof OlmNamespace; + +// tracking status for cross-signing and device keys +export enum KeysTrackingStatus { + Outdated = 0, + UpToDate = 1 +} -const TRACKING_STATUS_OUTDATED = 0; -const TRACKING_STATUS_UPTODATE = 1; +export type UserIdentity = { + userId: string, + roomIds: string[], + keysTrackingStatus: KeysTrackingStatus, +} -function createUserIdentity(userId, initialRoomId = undefined) { +function createUserIdentity(userId: string, initialRoomId?: string): UserIdentity { return { userId: userId, roomIds: initialRoomId ? [initialRoomId] : [], - crossSigningKeys: undefined, - deviceTrackingStatus: TRACKING_STATUS_OUTDATED, + keysTrackingStatus: KeysTrackingStatus.Outdated, }; } -function addRoomToIdentity(identity, userId, roomId) { +function addRoomToIdentity(identity: UserIdentity | undefined, userId: string, roomId: string): UserIdentity | undefined { if (!identity) { identity = createUserIdentity(userId, roomId); return identity; @@ -42,31 +61,22 @@ function addRoomToIdentity(identity, userId, roomId) { } } -// map 1 device from /keys/query response to DeviceIdentity -function deviceKeysAsDeviceIdentity(deviceSection) { - const deviceId = deviceSection["device_id"]; - const userId = deviceSection["user_id"]; - return { - userId, - deviceId, - ed25519Key: deviceSection.keys[`ed25519:${deviceId}`], - curve25519Key: deviceSection.keys[`curve25519:${deviceId}`], - algorithms: deviceSection.algorithms, - displayName: deviceSection.unsigned?.device_display_name, - }; -} - export class DeviceTracker { - constructor({storage, getSyncToken, olmUtil, ownUserId, ownDeviceId}) { - this._storage = storage; - this._getSyncToken = getSyncToken; - this._identityChangedForRoom = null; - this._olmUtil = olmUtil; - this._ownUserId = ownUserId; - this._ownDeviceId = ownDeviceId; + private readonly _storage: Storage; + private readonly _getSyncToken: () => string; + private readonly _olmUtil: Olm.Utility; + private readonly _ownUserId: string; + private readonly _ownDeviceId: string; + + constructor(options: {storage: Storage, getSyncToken: () => string, olmUtil: Olm.Utility, ownUserId: string, ownDeviceId: string}) { + this._storage = options.storage; + this._getSyncToken = options.getSyncToken; + this._olmUtil = options.olmUtil; + this._ownUserId = options.ownUserId; + this._ownDeviceId = options.ownDeviceId; } - async writeDeviceChanges(changed, txn, log) { + async writeDeviceChanges(changedUserIds: ReadonlyArray, txn: Transaction, log: ILogItem): Promise { const {userIdentities} = txn; // TODO: should we also look at left here to handle this?: // the usual problem here is that you share a room with a user, @@ -75,12 +85,12 @@ export class DeviceTracker { // At which point you come online, all of this happens in the gap, // and you don't notice that they ever left, // and so the client doesn't invalidate their device cache for the user - log.set("changed", changed.length); - await Promise.all(changed.map(async userId => { + log.set("changed", changedUserIds.length); + await Promise.all(changedUserIds.map(async userId => { const user = await userIdentities.get(userId); if (user) { log.log({l: "outdated", id: userId}); - user.deviceTrackingStatus = TRACKING_STATUS_OUTDATED; + user.keysTrackingStatus = KeysTrackingStatus.Outdated; userIdentities.set(user); } })); @@ -89,9 +99,9 @@ export class DeviceTracker { /** @return Promise<{added: string[], removed: string[]}> the user ids for who the room was added or removed to the userIdentity, * and with who a key should be now be shared **/ - async writeMemberChanges(room, memberChanges, historyVisibility, txn) { - const added = []; - const removed = []; + async writeMemberChanges(room: Room, memberChanges: Map, historyVisibility: HistoryVisibility, txn: Transaction): Promise<{added: string[], removed: string[]}> { + const added: string[] = []; + const removed: string[] = []; await Promise.all(Array.from(memberChanges.values()).map(async memberChange => { // keys should now be shared with this member? // add the room to the userIdentity if so @@ -117,7 +127,7 @@ export class DeviceTracker { return {added, removed}; } - async trackRoom(room, historyVisibility, log) { + async trackRoom(room: Room, historyVisibility: HistoryVisibility, log: ILogItem): Promise { if (room.isTrackingMembers || !room.isEncrypted) { return; } @@ -125,13 +135,13 @@ export class DeviceTracker { const txn = await this._storage.readWriteTxn([ this._storage.storeNames.roomSummary, this._storage.storeNames.userIdentities, - this._storage.storeNames.deviceIdentities, // to remove all devices in _removeRoomFromUserIdentity + this._storage.storeNames.deviceKeys, // to remove all devices in _removeRoomFromUserIdentity ]); try { let isTrackingChanges; try { isTrackingChanges = room.writeIsTrackingMembers(true, txn); - const members = Array.from(memberList.members.values()); + const members = Array.from((memberList.members as ObservableMap).values()); log.set("members", members.length); // TODO: should we remove any userIdentities we should not share the key with?? // e.g. as an extra security measure if we had a mistake in other code? @@ -153,34 +163,52 @@ export class DeviceTracker { } } - async getCrossSigningKeysForUser(userId, hsApi, log) { - return await log.wrap("DeviceTracker.getMasterKeyForUser", async log => { - let txn = await this._storage.readTxn([ - this._storage.storeNames.userIdentities + async invalidateUserKeys(userId: string): Promise { + const txn = await this._storage.readWriteTxn([this._storage.storeNames.userIdentities]); + const userIdentity = await txn.userIdentities.get(userId); + if (userIdentity) { + userIdentity.keysTrackingStatus = KeysTrackingStatus.Outdated; + txn.userIdentities.set(userIdentity); + } + await txn.complete(); + } + + async getCrossSigningKeyForUser(userId: string, usage: KeyUsage, hsApi: HomeServerApi | undefined, log: ILogItem): Promise { + return await log.wrap({l: "DeviceTracker.getCrossSigningKeyForUser", id: userId, usage}, async log => { + const txn = await this._storage.readTxn([ + this._storage.storeNames.userIdentities, + this._storage.storeNames.crossSigningKeys, ]); - let userIdentity = await txn.userIdentities.get(userId); - if (userIdentity && userIdentity.deviceTrackingStatus !== TRACKING_STATUS_OUTDATED) { - return userIdentity.crossSigningKeys; + const userIdentity = await txn.userIdentities.get(userId); + if (userIdentity && userIdentity.keysTrackingStatus !== KeysTrackingStatus.Outdated) { + return await txn.crossSigningKeys.get(userId, usage); + } + // not allowed to access the network, bail out + if (!hsApi) { + return undefined; } // fetch from hs - await this._queryKeys([userId], hsApi, log); - // Retreive from storage now - txn = await this._storage.readTxn([ - this._storage.storeNames.userIdentities - ]); - userIdentity = await txn.userIdentities.get(userId); - return userIdentity?.crossSigningKeys; + const keys = await this._queryKeys([userId], hsApi, log); + switch (usage) { + case KeyUsage.Master: + return keys.masterKeys.get(userId); + case KeyUsage.SelfSigning: + return keys.selfSigningKeys.get(userId); + case KeyUsage.UserSigning: + return keys.userSigningKeys.get(userId); + } }); } - async writeHistoryVisibility(room, historyVisibility, syncTxn, log) { - const added = []; - const removed = []; + async writeHistoryVisibility(room: Room, historyVisibility: HistoryVisibility, syncTxn: Transaction, log: ILogItem): Promise<{added: string[], removed: string[]}> { + const added: string[] = []; + const removed: string[] = []; if (room.isTrackingMembers && room.isEncrypted) { await log.wrap("rewriting userIdentities", async log => { + // TODO: how do we know that we won't fetch the members from the server here and hence close the syncTxn? const memberList = await room.loadMemberList(syncTxn, log); try { - const members = Array.from(memberList.members.values()); + const members = Array.from((memberList.members as ObservableMap).values()); log.set("members", members.length); await Promise.all(members.map(async member => { if (shouldShareKey(member.membership, historyVisibility)) { @@ -201,7 +229,7 @@ export class DeviceTracker { return {added, removed}; } - async _addRoomToUserIdentity(roomId, userId, txn) { + async _addRoomToUserIdentity(roomId: string, userId: string, txn: Transaction): Promise { const {userIdentities} = txn; const identity = await userIdentities.get(userId); const updatedIdentity = addRoomToIdentity(identity, userId, roomId); @@ -212,15 +240,15 @@ export class DeviceTracker { return false; } - async _removeRoomFromUserIdentity(roomId, userId, txn) { - const {userIdentities, deviceIdentities} = txn; + async _removeRoomFromUserIdentity(roomId: string, userId: string, txn: Transaction): Promise { + const {userIdentities, deviceKeys} = txn; const identity = await userIdentities.get(userId); if (identity) { identity.roomIds = identity.roomIds.filter(id => id !== roomId); // no more encrypted rooms with this user, remove if (identity.roomIds.length === 0) { userIdentities.remove(userId); - deviceIdentities.removeAllForUser(userId); + deviceKeys.removeAllForUser(userId); } else { userIdentities.set(identity); } @@ -229,7 +257,12 @@ export class DeviceTracker { return false; } - async _queryKeys(userIds, hsApi, log) { + async _queryKeys(userIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise<{ + deviceKeys: Map, + masterKeys: Map, + selfSigningKeys: Map, + userSigningKeys: Map + }> { // TODO: we need to handle the race here between /sync and /keys/query just like we need to do for the member list ... // there are multiple requests going out for /keys/query though and only one for /members // So, while doing /keys/query, writeDeviceChanges should add userIds marked as outdated to a list @@ -245,62 +278,79 @@ export class DeviceTracker { "token": this._getSyncToken() }, {log}).response(); - const masterKeys = log.wrap("master keys", log => this._filterValidMasterKeys(deviceKeyResponse, log)); - const selfSigningKeys = log.wrap("self-signing keys", log => this._filterVerifiedCrossSigningKeys(deviceKeyResponse["self_signing_keys"], "self_signing", masterKeys, log)) - const verifiedKeysPerUser = log.wrap("verify", log => this._filterVerifiedDeviceKeys(deviceKeyResponse["device_keys"], log)); + const masterKeys = log.wrap("master keys", log => this._filterVerifiedCrossSigningKeys(deviceKeyResponse["master_keys"], KeyUsage.Master, log)); + const selfSigningKeys = log.wrap("self-signing keys", log => this._filterVerifiedCrossSigningKeys(deviceKeyResponse["self_signing_keys"], KeyUsage.SelfSigning, log)); + const userSigningKeys = log.wrap("user-signing keys", log => this._filterVerifiedCrossSigningKeys(deviceKeyResponse["user_signing_keys"], KeyUsage.UserSigning, log)); + const deviceKeys = log.wrap("device keys", log => this._filterVerifiedDeviceKeys(deviceKeyResponse["device_keys"], log)); const txn = await this._storage.readWriteTxn([ this._storage.storeNames.userIdentities, - this._storage.storeNames.deviceIdentities, + this._storage.storeNames.deviceKeys, + this._storage.storeNames.crossSigningKeys, ]); let deviceIdentities; try { - const devicesIdentitiesPerUser = await Promise.all(verifiedKeysPerUser.map(async ({userId, verifiedKeys}) => { - const deviceIdentities = verifiedKeys.map(deviceKeysAsDeviceIdentity); - const crossSigningKeys = { - masterKey: masterKeys.get(userId), - selfSigningKey: selfSigningKeys.get(userId), - }; - return await this._storeQueriedDevicesForUserId(userId, crossSigningKeys, deviceIdentities, txn); + for (const key of masterKeys.values()) { + txn.crossSigningKeys.set(key); + } + for (const key of selfSigningKeys.values()) { + txn.crossSigningKeys.set(key); + } + for (const key of userSigningKeys.values()) { + txn.crossSigningKeys.set(key); + } + let totalCount = 0; + await Promise.all(Array.from(deviceKeys.keys()).map(async (userId) => { + let deviceKeysForUser = deviceKeys.get(userId)!; + totalCount += deviceKeysForUser.length; + // check for devices that changed their keys and keep the old key + deviceKeysForUser = await this._storeQueriedDevicesForUserId(userId, deviceKeysForUser, txn); + deviceKeys.set(userId, deviceKeysForUser); })); - deviceIdentities = devicesIdentitiesPerUser.reduce((all, devices) => all.concat(devices), []); - log.set("devices", deviceIdentities.length); + log.set("devices", totalCount); } catch (err) { txn.abort(); throw err; } await txn.complete(); - return deviceIdentities; + return { + deviceKeys, + masterKeys, + selfSigningKeys, + userSigningKeys + }; } - async _storeQueriedDevicesForUserId(userId, crossSigningKeys, deviceIdentities, txn) { - const knownDeviceIds = await txn.deviceIdentities.getAllDeviceIds(userId); + async _storeQueriedDevicesForUserId(userId: string, deviceKeys: DeviceKey[], txn: Transaction): Promise { + // TODO: we should obsolete (flag) the device keys that have been removed, + // but keep them to verify messages encrypted with it? + const knownDeviceIds = await txn.deviceKeys.getAllDeviceIds(userId); // delete any devices that we know off but are not in the response anymore. // important this happens before checking if the ed25519 key changed, // otherwise we would end up deleting existing devices with changed keys. for (const deviceId of knownDeviceIds) { - if (deviceIdentities.every(di => di.deviceId !== deviceId)) { - txn.deviceIdentities.remove(userId, deviceId); + if (deviceKeys.every(di => di.device_id !== deviceId)) { + txn.deviceKeys.remove(userId, deviceId); } } // all the device identities as we will have them in storage - const allDeviceIdentities = []; - const deviceIdentitiesToStore = []; + const allDeviceKeys: DeviceKey[] = []; + const deviceKeysToStore: DeviceKey[] = []; // filter out devices that have changed their ed25519 key since last time we queried them - await Promise.all(deviceIdentities.map(async deviceIdentity => { - if (knownDeviceIds.includes(deviceIdentity.deviceId)) { - const existingDevice = await txn.deviceIdentities.get(deviceIdentity.userId, deviceIdentity.deviceId); - if (existingDevice.ed25519Key !== deviceIdentity.ed25519Key) { - allDeviceIdentities.push(existingDevice); + await Promise.all(deviceKeys.map(async deviceKey => { + if (knownDeviceIds.includes(deviceKey.device_id)) { + const existingDevice = await txn.deviceKeys.get(deviceKey.user_id, deviceKey.device_id); + if (existingDevice && getDeviceEd25519Key(existingDevice) !== getDeviceEd25519Key(deviceKey)) { + allDeviceKeys.push(existingDevice); return; } } - allDeviceIdentities.push(deviceIdentity); - deviceIdentitiesToStore.push(deviceIdentity); + allDeviceKeys.push(deviceKey); + deviceKeysToStore.push(deviceKey); })); // store devices - for (const deviceIdentity of deviceIdentitiesToStore) { - txn.deviceIdentities.set(deviceIdentity); + for (const deviceKey of deviceKeysToStore) { + txn.deviceKeys.set(deviceKey); } // mark user identities as up to date let identity = await txn.userIdentities.get(userId); @@ -312,116 +362,108 @@ export class DeviceTracker { // checked, we could share keys with that user without them being in the room identity = createUserIdentity(userId); } - identity.deviceTrackingStatus = TRACKING_STATUS_UPTODATE; - identity.crossSigningKeys = crossSigningKeys; + identity.keysTrackingStatus = KeysTrackingStatus.UpToDate; txn.userIdentities.set(identity); - return allDeviceIdentities; - } - - _filterValidMasterKeys(keyQueryResponse, log) { - const masterKeys = new Map(); - const masterKeysResponse = keyQueryResponse["master_keys"]; - if (!masterKeysResponse) { - return masterKeys; - } - const validMasterKeyResponses = Object.entries(masterKeysResponse).filter(([userId, keyInfo]) => { - if (keyInfo["user_id"] !== userId) { - return false; - } - if (!Array.isArray(keyInfo.usage) || !keyInfo.usage.includes("master")) { - return false; - } - return true; - }); - validMasterKeyResponses.reduce((msks, [userId, keyInfo]) => { - const keyIds = Object.keys(keyInfo.keys); - if (keyIds.length !== 1) { - return false; - } - const masterKey = keyInfo.keys[keyIds[0]]; - msks.set(userId, masterKey); - return msks; - }, masterKeys); - return masterKeys; + return allDeviceKeys; } - _filterVerifiedCrossSigningKeys(crossSigningKeysResponse, usage, masterKeys, log) { - const keys = new Map(); + _filterVerifiedCrossSigningKeys(crossSigningKeysResponse: {[userId: string]: CrossSigningKey}, usage: KeyUsage, log: ILogItem): Map { + const keys: Map = new Map(); if (!crossSigningKeysResponse) { return keys; } - const validKeysResponses = Object.entries(crossSigningKeysResponse).filter(([userId, keyInfo]) => { - if (keyInfo["user_id"] !== userId) { - return false; - } - if (!Array.isArray(keyInfo.usage) || !keyInfo.usage.includes(usage)) { - return false; - } - // verify with master key - const masterKey = masterKeys.get(userId); - return verifyEd25519Signature(this._olmUtil, userId, masterKey, masterKey, keyInfo, log); - }); - validKeysResponses.reduce((keys, [userId, keyInfo]) => { - const keyIds = Object.keys(keyInfo.keys); - if (keyIds.length !== 1) { - return false; - } - const key = keyInfo.keys[keyIds[0]]; - keys.set(userId, key); - return keys; - }, keys); + for (const [userId, keyInfo] of Object.entries(crossSigningKeysResponse)) { + log.wrap({l: userId}, log => { + if (this._validateCrossSigningKey(userId, keyInfo, usage, log)) { + keys.set(getKeyUserId(keyInfo)!, keyInfo); + } + }); + } return keys; } + _validateCrossSigningKey(userId: string, keyInfo: CrossSigningKey, usage: KeyUsage, log: ILogItem): boolean { + if (getKeyUserId(keyInfo) !== userId) { + log.log({l: "user_id mismatch", userId: keyInfo["user_id"]}); + return false; + } + if (getKeyUsage(keyInfo) !== usage) { + log.log({l: "usage mismatch", usage: keyInfo.usage}); + return false; + } + const publicKey = getKeyEd25519Key(keyInfo); + if (!publicKey) { + log.log({l: "no ed25519 key", keys: keyInfo.keys}); + return false; + } + return true; + } + /** * @return {Array<{userId, verifiedKeys: Array>} */ - _filterVerifiedDeviceKeys(keyQueryDeviceKeysResponse, parentLog) { - const curve25519Keys = new Set(); - const verifiedKeys = Object.entries(keyQueryDeviceKeysResponse).map(([userId, keysByDevice]) => { - const verifiedEntries = Object.entries(keysByDevice).filter(([deviceId, deviceKeys]) => { - const deviceIdOnKeys = deviceKeys["device_id"]; - const userIdOnKeys = deviceKeys["user_id"]; - if (userIdOnKeys !== userId) { - return false; - } - if (deviceIdOnKeys !== deviceId) { - return false; - } - const ed25519Key = deviceKeys.keys?.[`ed25519:${deviceId}`]; - const curve25519Key = deviceKeys.keys?.[`curve25519:${deviceId}`]; - if (typeof ed25519Key !== "string" || typeof curve25519Key !== "string") { - return false; - } - if (curve25519Keys.has(curve25519Key)) { - parentLog.log({ - l: "ignore device with duplicate curve25519 key", - keys: deviceKeys - }, parentLog.level.Warn); - return false; - } - curve25519Keys.add(curve25519Key); - const isValid = this._hasValidSignature(deviceKeys, parentLog); - if (!isValid) { - parentLog.log({ - l: "ignore device with invalid signature", - keys: deviceKeys - }, parentLog.level.Warn); - } - return isValid; + _filterVerifiedDeviceKeys( + keyQueryDeviceKeysResponse: {[userId: string]: {[deviceId: string]: DeviceKey}}, + parentLog: ILogItem + ): Map { + const curve25519Keys: Set = new Set(); + const keys: Map = new Map(); + if (!keyQueryDeviceKeysResponse) { + return keys; + } + for (const [userId, keysByDevice] of Object.entries(keyQueryDeviceKeysResponse)) { + parentLog.wrap(userId, log => { + const verifiedEntries = Object.entries(keysByDevice).filter(([deviceId, deviceKey]) => { + return log.wrap(deviceId, log => { + if (this._validateDeviceKey(userId, deviceId, deviceKey, log)) { + const curve25519Key = getDeviceCurve25519Key(deviceKey); + if (curve25519Keys.has(curve25519Key)) { + parentLog.log({ + l: "ignore device with duplicate curve25519 key", + keys: deviceKey + }, parentLog.level.Warn); + return false; + } + curve25519Keys.add(curve25519Key); + return true; + } else { + return false; + } + }); + }); + const verifiedKeys = verifiedEntries.map(([, deviceKeys]) => deviceKeys); + keys.set(userId, verifiedKeys); }); - const verifiedKeys = verifiedEntries.map(([, deviceKeys]) => deviceKeys); - return {userId, verifiedKeys}; - }); - return verifiedKeys; + } + return keys; } - _hasValidSignature(deviceSection, parentLog) { - const deviceId = deviceSection["device_id"]; - const userId = deviceSection["user_id"]; - const ed25519Key = deviceSection?.keys?.[`${SIGNATURE_ALGORITHM}:${deviceId}`]; - return verifyEd25519Signature(this._olmUtil, userId, deviceId, ed25519Key, deviceSection, parentLog); + _validateDeviceKey(userIdFromServer: string, deviceIdFromServer: string, deviceKey: DeviceKey, log: ILogItem): boolean { + const deviceId = deviceKey["device_id"]; + const userId = deviceKey["user_id"]; + if (userId !== userIdFromServer) { + log.log("user_id mismatch"); + return false; + } + if (deviceId !== deviceIdFromServer) { + log.log("device_id mismatch"); + return false; + } + const ed25519Key = getDeviceEd25519Key(deviceKey); + const curve25519Key = getDeviceCurve25519Key(deviceKey); + if (typeof ed25519Key !== "string" || typeof curve25519Key !== "string") { + log.log("ed25519 and/or curve25519 key invalid").set({deviceKey}); + return false; + } + const isValid = verifyEd25519Signature(this._olmUtil, userId, deviceId, ed25519Key, deviceKey, log) === SignatureVerification.Valid; + if (!isValid) { + log.log({ + l: "ignore device with invalid signature", + keys: deviceKey + }, log.level.Warn); + } + return isValid; } /** @@ -431,7 +473,7 @@ export class DeviceTracker { * @param {String} roomId [description] * @return {[type]} [description] */ - async devicesForTrackedRoom(roomId, hsApi, log) { + async devicesForTrackedRoom(roomId: string, hsApi: HomeServerApi, log: ILogItem): Promise { const txn = await this._storage.readTxn([ this._storage.storeNames.roomMembers, this._storage.storeNames.userIdentities, @@ -450,8 +492,9 @@ export class DeviceTracker { /** * Can be used to decide which users to share keys with. * Assumes room is already tracked. Call `trackRoom` first if unsure. + * This will not return the device key for our own user, as we don't need to share keys with ourselves. */ - async devicesForRoomMembers(roomId, userIds, hsApi, log) { + async devicesForRoomMembers(roomId: string, userIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise { const txn = await this._storage.readTxn([ this._storage.storeNames.userIdentities, ]); @@ -461,19 +504,20 @@ export class DeviceTracker { /** * Cannot be used to decide which users to share keys with. * Does not assume membership to any room or whether any room is tracked. + * This will return device keys for our own user, including our own device. */ - async devicesForUsers(userIds, hsApi, log) { + async devicesForUsers(userIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise { const txn = await this._storage.readTxn([ this._storage.storeNames.userIdentities, ]); - const upToDateIdentities = []; - const outdatedUserIds = []; + const upToDateIdentities: UserIdentity[] = []; + const outdatedUserIds: string[] = []; await Promise.all(userIds.map(async userId => { const i = await txn.userIdentities.get(userId); - if (i && i.deviceTrackingStatus === TRACKING_STATUS_UPTODATE) { + if (i && i.keysTrackingStatus === KeysTrackingStatus.UpToDate) { upToDateIdentities.push(i); - } else if (!i || i.deviceTrackingStatus === TRACKING_STATUS_OUTDATED) { + } else if (!i || i.keysTrackingStatus === KeysTrackingStatus.Outdated) { // allow fetching for userIdentities we don't know about yet, // as we don't assume the room is tracked here. outdatedUserIds.push(userId); @@ -482,13 +526,13 @@ export class DeviceTracker { return this._devicesForUserIdentities(upToDateIdentities, outdatedUserIds, hsApi, log); } - /** gets a single device */ - async deviceForId(userId, deviceId, hsApi, log) { + /** Gets a single device */ + async deviceForId(userId: string, deviceId: string, hsApi: HomeServerApi, log: ILogItem) { const txn = await this._storage.readTxn([ - this._storage.storeNames.deviceIdentities, + this._storage.storeNames.deviceKeys, ]); - let device = await txn.deviceIdentities.get(userId, deviceId); - if (device) { + let deviceKey = await txn.deviceKeys.get(userId, deviceId); + if (deviceKey) { log.set("existingDevice", true); } else { //// BEGIN EXTRACT (deviceKeysMap) @@ -502,29 +546,26 @@ export class DeviceTracker { // verify signature const verifiedKeysPerUser = log.wrap("verify", log => this._filterVerifiedDeviceKeys(deviceKeyResponse["device_keys"], log)); //// END EXTRACT - // TODO: what if verifiedKeysPerUser is empty or does not contain userId? - const verifiedKeys = verifiedKeysPerUser - .find(vkpu => vkpu.userId === userId).verifiedKeys - .find(vk => vk["device_id"] === deviceId); + const verifiedKey = verifiedKeysPerUser.get(userId)?.find(d => d.device_id === deviceId); // user hasn't uploaded keys for device? - if (!verifiedKeys) { + if (!verifiedKey) { return undefined; } - device = deviceKeysAsDeviceIdentity(verifiedKeys); const txn = await this._storage.readWriteTxn([ - this._storage.storeNames.deviceIdentities, + this._storage.storeNames.deviceKeys, ]); // check again we don't have the device already. // when updating all keys for a user we allow updating the // device when the key hasn't changed so the device display name // can be updated, but here we don't. - const existingDevice = await txn.deviceIdentities.get(userId, deviceId); + const existingDevice = await txn.deviceKeys.get(userId, deviceId); if (existingDevice) { - device = existingDevice; + deviceKey = existingDevice; log.set("existingDeviceAfterFetch", true); } else { try { - txn.deviceIdentities.set(device); + txn.deviceKeys.set(verifiedKey); + deviceKey = verifiedKey; log.set("newDevice", true); } catch (err) { txn.abort(); @@ -533,7 +574,7 @@ export class DeviceTracker { await txn.complete(); } } - return device; + return deviceKey; } /** @@ -543,9 +584,9 @@ export class DeviceTracker { * @param {Array} userIds a set of user ids to try and find the identity for. * @param {Transaction} userIdentityTxn to read the user identities * @param {HomeServerApi} hsApi - * @return {Array} all devices identities for the given users we should share keys with. + * @return {Array} all devices identities for the given users we should share keys with. */ - async _devicesForUserIdsInTrackedRoom(roomId, userIds, userIdentityTxn, hsApi, log) { + async _devicesForUserIdsInTrackedRoom(roomId: string, userIds: string[], userIdentityTxn: Transaction, hsApi: HomeServerApi, log: ILogItem): Promise { const allMemberIdentities = await Promise.all(userIds.map(userId => userIdentityTxn.userIdentities.get(userId))); const identities = allMemberIdentities.filter(identity => { // we use roomIds to decide with whom we should share keys for a given room, @@ -554,15 +595,15 @@ export class DeviceTracker { // Given we assume the room is tracked, // also exclude any userId which doesn't have a userIdentity yet. return identity && identity.roomIds.includes(roomId); - }); - const upToDateIdentities = identities.filter(i => i.deviceTrackingStatus === TRACKING_STATUS_UPTODATE); + }) as UserIdentity[]; // undefined has been filter out + const upToDateIdentities = identities.filter(i => i.keysTrackingStatus === KeysTrackingStatus.UpToDate); const outdatedUserIds = identities - .filter(i => i.deviceTrackingStatus === TRACKING_STATUS_OUTDATED) + .filter(i => i.keysTrackingStatus === KeysTrackingStatus.Outdated) .map(i => i.userId); let devices = await this._devicesForUserIdentities(upToDateIdentities, outdatedUserIds, hsApi, log); // filter out our own device as we should never share keys with it. devices = devices.filter(device => { - const isOwnDevice = device.userId === this._ownUserId && device.deviceId === this._ownDeviceId; + const isOwnDevice = device.user_id === this._ownUserId && device.device_id === this._ownDeviceId; return !isOwnDevice; }); return devices; @@ -572,42 +613,44 @@ export class DeviceTracker { * are known to be up to date, and a set of userIds that are known * to be absent from our store our outdated. The outdated user ids * will have their keys fetched from the homeserver. */ - async _devicesForUserIdentities(upToDateIdentities, outdatedUserIds, hsApi, log) { + async _devicesForUserIdentities(upToDateIdentities: UserIdentity[], outdatedUserIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise { log.set("uptodate", upToDateIdentities.length); log.set("outdated", outdatedUserIds.length); - let queriedDevices; + let queriedDeviceKeys: Map | undefined; if (outdatedUserIds.length) { // TODO: ignore the race between /sync and /keys/query for now, // where users could get marked as outdated or added/removed from the room while // querying keys - queriedDevices = await this._queryKeys(outdatedUserIds, hsApi, log); + const {deviceKeys} = await this._queryKeys(outdatedUserIds, hsApi, log); + queriedDeviceKeys = deviceKeys; } const deviceTxn = await this._storage.readTxn([ - this._storage.storeNames.deviceIdentities, + this._storage.storeNames.deviceKeys, ]); const devicesPerUser = await Promise.all(upToDateIdentities.map(identity => { - return deviceTxn.deviceIdentities.getAllForUserId(identity.userId); + return deviceTxn.deviceKeys.getAllForUserId(identity.userId); })); let flattenedDevices = devicesPerUser.reduce((all, devicesForUser) => all.concat(devicesForUser), []); - if (queriedDevices && queriedDevices.length) { - flattenedDevices = flattenedDevices.concat(queriedDevices); + if (queriedDeviceKeys && queriedDeviceKeys.size) { + for (const deviceKeysForUser of queriedDeviceKeys.values()) { + flattenedDevices = flattenedDevices.concat(deviceKeysForUser); + } } return flattenedDevices; } - async getDeviceByCurve25519Key(curve25519Key, txn) { - return await txn.deviceIdentities.getByCurve25519Key(curve25519Key); + async getDeviceByCurve25519Key(curve25519Key, txn: Transaction): Promise { + return await txn.deviceKeys.getByCurve25519Key(curve25519Key); } } import {createMockStorage} from "../../mocks/Storage"; import {Instance as NullLoggerInstance} from "../../logging/NullLogger"; -import {MemberChange} from "../room/members/RoomMember"; export function tests() { - function createUntrackedRoomMock(roomId, joinedUserIds, invitedUserIds = []) { + function createUntrackedRoomMock(roomId: string, joinedUserIds: string[], invitedUserIds: string[] = []) { return { id: roomId, isTrackingMembers: false, @@ -636,11 +679,11 @@ export function tests() { } } - function createQueryKeysHSApiMock(createKey = (algorithm, userId, deviceId) => `${algorithm}:${userId}:${deviceId}:key`) { + function createQueryKeysHSApiMock(createKey = (algorithm, userId, deviceId) => `${algorithm}:${userId}:${deviceId}:key`): HomeServerApi { return { queryKeys(payload) { const {device_keys: deviceKeys} = payload; - const userKeys = Object.entries(deviceKeys).reduce((userKeys, [userId, deviceIds]) => { + const userKeys = Object.entries(deviceKeys as {[userId: string]: string[]}).reduce((userKeys, [userId, deviceIds]) => { if (deviceIds.length === 0) { deviceIds = ["device1"]; } @@ -676,7 +719,7 @@ export function tests() { } }; } - }; + } as unknown as HomeServerApi; } async function writeMemberListToStorage(room, storage) { @@ -705,7 +748,7 @@ export function tests() { const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); @@ -714,15 +757,13 @@ export function tests() { const txn = await storage.readTxn([storage.storeNames.userIdentities]); assert.deepEqual(await txn.userIdentities.get("@alice:hs.tld"), { userId: "@alice:hs.tld", - crossSigningKeys: undefined, roomIds: [roomId], - deviceTrackingStatus: TRACKING_STATUS_OUTDATED + keysTrackingStatus: KeysTrackingStatus.Outdated }); assert.deepEqual(await txn.userIdentities.get("@bob:hs.tld"), { userId: "@bob:hs.tld", roomIds: [roomId], - crossSigningKeys: undefined, - deviceTrackingStatus: TRACKING_STATUS_OUTDATED + keysTrackingStatus: KeysTrackingStatus.Outdated }); assert.equal(await txn.userIdentities.get("@charly:hs.tld"), undefined); }, @@ -731,7 +772,7 @@ export function tests() { const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); @@ -740,15 +781,15 @@ export function tests() { const hsApi = createQueryKeysHSApiMock(); const devices = await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApi, NullLoggerInstance.item); assert.equal(devices.length, 2); - assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key"); - assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key"); + assert.equal(getDeviceEd25519Key(devices.find(d => d.user_id === "@alice:hs.tld")!), "ed25519:@alice:hs.tld:device1:key"); + assert.equal(getDeviceEd25519Key(devices.find(d => d.user_id === "@bob:hs.tld")!), "ed25519:@bob:hs.tld:device1:key"); }, "device with changed key is ignored": async assert => { const storage = await createMockStorage(); const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); @@ -766,18 +807,18 @@ export function tests() { }); const devices = await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApiWithChangedAliceKey, NullLoggerInstance.item); assert.equal(devices.length, 2); - assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key"); - assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key"); - const txn2 = await storage.readTxn([storage.storeNames.deviceIdentities]); + assert.equal(getDeviceEd25519Key(devices.find(d => d.user_id === "@alice:hs.tld")!), "ed25519:@alice:hs.tld:device1:key"); + assert.equal(getDeviceEd25519Key(devices.find(d => d.user_id === "@bob:hs.tld")!), "ed25519:@bob:hs.tld:device1:key"); + const txn2 = await storage.readTxn([storage.storeNames.deviceKeys]); // also check the modified key was not stored - assert.equal((await txn2.deviceIdentities.get("@alice:hs.tld", "device1")).ed25519Key, "ed25519:@alice:hs.tld:device1:key"); + assert.equal(getDeviceEd25519Key((await txn2.deviceKeys.get("@alice:hs.tld", "device1"))!), "ed25519:@alice:hs.tld:device1:key"); }, "change history visibility from joined to invited adds invitees": async assert => { const storage = await createMockStorage(); const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); @@ -785,10 +826,10 @@ export function tests() { const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"], ["@bob:hs.tld"]); await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item); - const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceKeys]); assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined); const {added, removed} = await tracker.writeHistoryVisibility(room, HistoryVisibility.Invited, txn, NullLoggerInstance.item); - assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld"); + assert.equal((await txn.userIdentities.get("@bob:hs.tld"))!.userId, "@bob:hs.tld"); assert.deepEqual(added, ["@bob:hs.tld"]); assert.deepEqual(removed, []); }, @@ -797,7 +838,7 @@ export function tests() { const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); @@ -805,8 +846,8 @@ export function tests() { const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"], ["@bob:hs.tld"]); await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item); - const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); - assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld"); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceKeys]); + assert.equal((await txn.userIdentities.get("@bob:hs.tld"))!.userId, "@bob:hs.tld"); const {added, removed} = await tracker.writeHistoryVisibility(room, HistoryVisibility.Joined, txn, NullLoggerInstance.item); assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined); assert.deepEqual(added, []); @@ -817,32 +858,32 @@ export function tests() { const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"]); await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item); - const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceKeys]); // inviting a new member const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "invite")); - const {added, removed} = await tracker.writeMemberChanges(room, [inviteChange], HistoryVisibility.Invited, txn); + const {added, removed} = await tracker.writeMemberChanges(room, new Map([[inviteChange.userId, inviteChange]]), HistoryVisibility.Invited, txn); assert.deepEqual(added, ["@bob:hs.tld"]); assert.deepEqual(removed, []); - assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld"); + assert.equal((await txn.userIdentities.get("@bob:hs.tld"))!.userId, "@bob:hs.tld"); }, "adding invitee with history visibility of joined doesn't add room": async assert => { const storage = await createMockStorage(); const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"]); await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item); - const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceKeys]); // inviting a new member const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "invite")); const memberChanges = new Map([[inviteChange.userId, inviteChange]]); @@ -856,7 +897,7 @@ export function tests() { const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); @@ -868,22 +909,22 @@ export function tests() { await writeMemberListToStorage(room, storage); const devices = await tracker.devicesForTrackedRoom(roomId, hsApi, NullLoggerInstance.item); assert.equal(devices.length, 2); - assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key"); - assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key"); + assert.equal(getDeviceEd25519Key(devices.find(d => d.user_id === "@alice:hs.tld")!), "ed25519:@alice:hs.tld:device1:key"); + assert.equal(getDeviceEd25519Key(devices.find(d => d.user_id === "@bob:hs.tld")!), "ed25519:@bob:hs.tld:device1:key"); }, "rejecting invite with history visibility of invited removes room from user identity": async assert => { const storage = await createMockStorage(); const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); // alice is joined, bob is invited const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"], ["@bob:hs.tld"]); await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item); - const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceKeys]); // reject invite const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "leave"), "invite"); const memberChanges = new Map([[inviteChange.userId, inviteChange]]); @@ -897,7 +938,7 @@ export function tests() { const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); @@ -907,21 +948,21 @@ export function tests() { await tracker.trackRoom(room1, HistoryVisibility.Joined, NullLoggerInstance.item); await tracker.trackRoom(room2, HistoryVisibility.Joined, NullLoggerInstance.item); const txn1 = await storage.readTxn([storage.storeNames.userIdentities]); - assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld", "!def:hs.tld"]); + assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld"))!.roomIds, ["!abc:hs.tld", "!def:hs.tld"]); const leaveChange = new MemberChange(RoomMember.fromUserId(room2.id, "@bob:hs.tld", "leave"), "join"); const memberChanges = new Map([[leaveChange.userId, leaveChange]]); - const txn2 = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + const txn2 = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceKeys]); await tracker.writeMemberChanges(room2, memberChanges, HistoryVisibility.Joined, txn2); await txn2.complete(); const txn3 = await storage.readTxn([storage.storeNames.userIdentities]); - assert.deepEqual((await txn3.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld"]); + assert.deepEqual((await txn3.userIdentities.get("@bob:hs.tld"))!.roomIds, ["!abc:hs.tld"]); }, "add room to user identity sharing multiple rooms with us preserves other room": async assert => { const storage = await createMockStorage(); const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); @@ -930,40 +971,40 @@ export function tests() { const room2 = await createUntrackedRoomMock("!def:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]); await tracker.trackRoom(room1, HistoryVisibility.Joined, NullLoggerInstance.item); const txn1 = await storage.readTxn([storage.storeNames.userIdentities]); - assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld"]); + assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld"))!.roomIds, ["!abc:hs.tld"]); await tracker.trackRoom(room2, HistoryVisibility.Joined, NullLoggerInstance.item); const txn2 = await storage.readTxn([storage.storeNames.userIdentities]); - assert.deepEqual((await txn2.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld", "!def:hs.tld"]); + assert.deepEqual((await txn2.userIdentities.get("@bob:hs.tld"))!.roomIds, ["!abc:hs.tld", "!def:hs.tld"]); }, "devicesForUsers fetches users even though they aren't in any tracked room": async assert => { const storage = await createMockStorage(); const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); const hsApi = createQueryKeysHSApiMock(); const devices = await tracker.devicesForUsers(["@bob:hs.tld"], hsApi, NullLoggerInstance.item); assert.equal(devices.length, 1); - assert.equal(devices[0].curve25519Key, "curve25519:@bob:hs.tld:device1:key"); + assert.equal(getDeviceCurve25519Key(devices[0]), "curve25519:@bob:hs.tld:device1:key"); const txn1 = await storage.readTxn([storage.storeNames.userIdentities]); - assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, []); + assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld"))!.roomIds, []); }, "devicesForUsers doesn't add any roomId when creating userIdentity": async assert => { const storage = await createMockStorage(); const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); const hsApi = createQueryKeysHSApiMock(); await tracker.devicesForUsers(["@bob:hs.tld"], hsApi, NullLoggerInstance.item); const txn1 = await storage.readTxn([storage.storeNames.userIdentities]); - assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, []); + assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld"))!.roomIds, []); } } } diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index b74dc710f1..241ee83c9a 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MEGOLM_ALGORITHM, DecryptionSource} from "./common.js"; +import {MEGOLM_ALGORITHM, DecryptionSource} from "./common"; import {groupEventsBySession} from "./megolm/decryption/utils"; import {mergeMap} from "../../utils/mergeMap"; import {groupBy} from "../../utils/groupBy"; @@ -235,7 +235,7 @@ export class RoomEncryption { // Use devicesForUsers rather than devicesForRoomMembers as the room might not be tracked yet await this._deviceTracker.devicesForUsers(sendersWithoutDevice, hsApi, log); // now that we've fetched the missing devices, try verifying the results again - const txn = await this._storage.readTxn([this._storage.storeNames.deviceIdentities]); + const txn = await this._storage.readTxn([this._storage.storeNames.deviceKeys]); await this._verifyDecryptionResults(resultsWithoutDevice, txn); const resultsWithFoundDevice = resultsWithoutDevice.filter(r => !r.isVerificationUnknown); const resultsToEventIdMap = resultsWithFoundDevice.reduce((map, r) => { @@ -353,7 +353,7 @@ export class RoomEncryption { this._historyVisibility = await this._loadHistoryVisibilityIfNeeded(this._historyVisibility); await this._deviceTracker.trackRoom(this._room, this._historyVisibility, log); const devices = await this._deviceTracker.devicesForTrackedRoom(this._room.id, hsApi, log); - const userIds = Array.from(devices.reduce((set, device) => set.add(device.userId), new Set())); + const userIds = Array.from(devices.reduce((set, device) => set.add(device.user_id), new Set())); let writeOpTxn = await this._storage.readWriteTxn([this._storage.storeNames.operations]); let operation; @@ -431,8 +431,8 @@ export class RoomEncryption { await log.wrap("send", log => this._sendMessagesToDevices(ENCRYPTED_TYPE, messages, hsApi, log)); if (missingDevices.length) { await log.wrap("missingDevices", async log => { - log.set("devices", missingDevices.map(d => d.deviceId)); - const unsentUserIds = operation.userIds.filter(userId => missingDevices.some(d => d.userId === userId)); + log.set("devices", missingDevices.map(d => d.device_id)); + const unsentUserIds = operation.userIds.filter(userId => missingDevices.some(d => d.user_id === userId)); log.set("unsentUserIds", unsentUserIds); operation.userIds = unsentUserIds; // first remove the users that we've sent the keys already from the operation, @@ -459,11 +459,11 @@ export class RoomEncryption { // TODO: make this use _sendMessagesToDevices async _sendSharedMessageToDevices(type, message, devices, hsApi, log) { - const devicesByUser = groupBy(devices, device => device.userId); + const devicesByUser = groupBy(devices, device => device.user_id); const payload = { messages: Array.from(devicesByUser.entries()).reduce((userMap, [userId, devices]) => { userMap[userId] = devices.reduce((deviceMap, device) => { - deviceMap[device.deviceId] = message; + deviceMap[device.device_id] = message; return deviceMap; }, {}); return userMap; diff --git a/src/matrix/e2ee/common.js b/src/matrix/e2ee/common.js deleted file mode 100644 index cc3bfff5f9..0000000000 --- a/src/matrix/e2ee/common.js +++ /dev/null @@ -1,96 +0,0 @@ -/* -Copyright 2020 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 anotherjson from "another-json"; -import {createEnum} from "../../utils/enum"; - -export const DecryptionSource = createEnum("Sync", "Timeline", "Retry"); - -// use common prefix so it's easy to clear properties that are not e2ee related during session clear -export const SESSION_E2EE_KEY_PREFIX = "e2ee:"; -export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2"; -export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2"; - -export class DecryptionError extends Error { - constructor(code, event, detailsObj = null) { - super(`Decryption error ${code}${detailsObj ? ": "+JSON.stringify(detailsObj) : ""}`); - this.code = code; - this.event = event; - this.details = detailsObj; - } -} - -export const SIGNATURE_ALGORITHM = "ed25519"; - -export function verifyEd25519Signature(olmUtil, userId, deviceOrKeyId, ed25519Key, value, log = undefined) { - const clone = Object.assign({}, value); - delete clone.unsigned; - delete clone.signatures; - const canonicalJson = anotherjson.stringify(clone); - const signature = value?.signatures?.[userId]?.[`${SIGNATURE_ALGORITHM}:${deviceOrKeyId}`]; - try { - if (!signature) { - throw new Error("no signature"); - } - // throws when signature is invalid - olmUtil.ed25519_verify(ed25519Key, canonicalJson, signature); - return true; - } catch (err) { - if (log) { - const logItem = log.log({l: "Invalid signature, ignoring.", ed25519Key, canonicalJson, signature}); - logItem.error = err; - logItem.logLevel = log.level.Warn; - } - return false; - } -} - -export function createRoomEncryptionEvent() { - return { - "type": "m.room.encryption", - "state_key": "", - "content": { - "algorithm": MEGOLM_ALGORITHM, - "rotation_period_ms": 604800000, - "rotation_period_msgs": 100 - } - } -} - - -// Use enum when converting to TS -export const HistoryVisibility = Object.freeze({ - Joined: "joined", - Invited: "invited", - WorldReadable: "world_readable", - Shared: "shared", -}); - -export function shouldShareKey(membership, historyVisibility) { - switch (historyVisibility) { - case HistoryVisibility.WorldReadable: - return true; - case HistoryVisibility.Shared: - // was part of room at some time - return membership !== undefined; - case HistoryVisibility.Joined: - return membership === "join"; - case HistoryVisibility.Invited: - return membership === "invite" || membership === "join"; - default: - return false; - } -} diff --git a/src/matrix/e2ee/common.ts b/src/matrix/e2ee/common.ts new file mode 100644 index 0000000000..c8a5ec0f4c --- /dev/null +++ b/src/matrix/e2ee/common.ts @@ -0,0 +1,134 @@ +/* +Copyright 2020 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 anotherjson from "another-json"; + +import type {UnsentStateEvent} from "../room/common"; +import type {ILogItem} from "../../logging/types"; +import type * as OlmNamespace from "@matrix-org/olm"; +type Olm = typeof OlmNamespace; + +export enum DecryptionSource { + Sync, Timeline, Retry +}; + +// use common prefix so it's easy to clear properties that are not e2ee related during session clear +export const SESSION_E2EE_KEY_PREFIX = "e2ee:"; +export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2"; +export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2"; + +export class DecryptionError extends Error { + constructor(private readonly code: string, private readonly event: object, private readonly detailsObj?: object) { + super(`Decryption error ${code}${detailsObj ? ": "+JSON.stringify(detailsObj) : ""}`); + } +} + +export const SIGNATURE_ALGORITHM = "ed25519"; + +export type SignedValue = { + signatures?: {[userId: string]: {[keyId: string]: string}} + unsigned?: object +} + +// we store device keys (and cross-signing) in the format we get them from the server +// as that is what the signature is calculated on, so to verify and sign, we need +// it in this format anyway. +export type DeviceKey = SignedValue & { + readonly user_id: string; + readonly device_id: string; + readonly algorithms: ReadonlyArray; + readonly keys: {[keyId: string]: string}; + readonly unsigned: { + device_display_name?: string + } +} + +export function getDeviceEd25519Key(deviceKey: DeviceKey): string { + return deviceKey.keys[`ed25519:${deviceKey.device_id}`]; +} + +export function getDeviceCurve25519Key(deviceKey: DeviceKey): string { + return deviceKey.keys[`curve25519:${deviceKey.device_id}`]; +} + +export function getEd25519Signature(signedValue: SignedValue, userId: string, deviceOrKeyId: string): string | undefined { + return signedValue?.signatures?.[userId]?.[`${SIGNATURE_ALGORITHM}:${deviceOrKeyId}`]; +} + +export enum SignatureVerification { + Valid, + Invalid, + NotSigned, +} + +export function verifyEd25519Signature(olmUtil: Olm.Utility, userId: string, deviceOrKeyId: string, ed25519Key: string, value: SignedValue, log?: ILogItem): SignatureVerification { + const signature = getEd25519Signature(value, userId, deviceOrKeyId); + if (!signature) { + log?.set("no_signature", true); + return SignatureVerification.NotSigned; + } + const clone = Object.assign({}, value) as object; + delete clone["unsigned"]; + delete clone["signatures"]; + const canonicalJson = anotherjson.stringify(clone); + try { + // throws when signature is invalid + olmUtil.ed25519_verify(ed25519Key, canonicalJson, signature); + return SignatureVerification.Valid; + } catch (err) { + if (log) { + const logItem = log.log({l: "Invalid signature, ignoring.", ed25519Key, canonicalJson, signature}); + logItem.error = err; + logItem.logLevel = log.level.Warn; + } + return SignatureVerification.Invalid; + } +} + +export function createRoomEncryptionEvent(): UnsentStateEvent { + return { + "type": "m.room.encryption", + "state_key": "", + "content": { + "algorithm": MEGOLM_ALGORITHM, + "rotation_period_ms": 604800000, + "rotation_period_msgs": 100 + } + } +} + +export enum HistoryVisibility { + Joined = "joined", + Invited = "invited", + WorldReadable = "world_readable", + Shared = "shared", +}; + +export function shouldShareKey(membership: string, historyVisibility: HistoryVisibility) { + switch (historyVisibility) { + case HistoryVisibility.WorldReadable: + return true; + case HistoryVisibility.Shared: + // was part of room at some time + return membership !== undefined; + case HistoryVisibility.Joined: + return membership === "join"; + case HistoryVisibility.Invited: + return membership === "invite" || membership === "join"; + default: + return false; + } +} diff --git a/src/matrix/e2ee/megolm/Decryption.ts b/src/matrix/e2ee/megolm/Decryption.ts index e139e8c9a0..c2d562074f 100644 --- a/src/matrix/e2ee/megolm/Decryption.ts +++ b/src/matrix/e2ee/megolm/Decryption.ts @@ -14,10 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {DecryptionError} from "../common.js"; import {DecryptionPreparation} from "./decryption/DecryptionPreparation.js"; import {SessionDecryption} from "./decryption/SessionDecryption"; -import {MEGOLM_ALGORITHM} from "../common.js"; +import {DecryptionError, MEGOLM_ALGORITHM} from "../common"; import {validateEvent, groupEventsBySession} from "./decryption/utils"; import {keyFromStorage, keyFromDeviceMessage, keyFromBackup} from "./decryption/RoomKey"; import type {RoomKey, IncomingRoomKey} from "./decryption/RoomKey"; diff --git a/src/matrix/e2ee/megolm/Encryption.js b/src/matrix/e2ee/megolm/Encryption.js index eb5f68d304..681344fe98 100644 --- a/src/matrix/e2ee/megolm/Encryption.js +++ b/src/matrix/e2ee/megolm/Encryption.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MEGOLM_ALGORITHM} from "../common.js"; +import {MEGOLM_ALGORITHM} from "../common"; import {OutboundRoomKey} from "./decryption/RoomKey"; export class Encryption { diff --git a/src/matrix/e2ee/megolm/decryption/DecryptionChanges.js b/src/matrix/e2ee/megolm/decryption/DecryptionChanges.js index b45ab6dd94..24226e25b0 100644 --- a/src/matrix/e2ee/megolm/decryption/DecryptionChanges.js +++ b/src/matrix/e2ee/megolm/decryption/DecryptionChanges.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {DecryptionError} from "../../common.js"; +import {DecryptionError} from "../../common"; export class DecryptionChanges { constructor(roomId, results, errors, replayEntries) { diff --git a/src/matrix/e2ee/megolm/decryption/KeyLoader.ts b/src/matrix/e2ee/megolm/decryption/KeyLoader.ts index 884203a34a..ee3996aae6 100644 --- a/src/matrix/e2ee/megolm/decryption/KeyLoader.ts +++ b/src/matrix/e2ee/megolm/decryption/KeyLoader.ts @@ -243,7 +243,7 @@ export function tests() { get keySource(): KeySource { return KeySource.DeviceMessage; } loadInto(session: Olm.InboundGroupSession) { - const mockSession = session as MockInboundSession; + const mockSession = session as unknown as MockInboundSession; mockSession.sessionId = this.sessionId; mockSession.firstKnownIndex = this._firstKnownIndex; } diff --git a/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts b/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts index ca294460c6..508e625834 100644 --- a/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts +++ b/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts @@ -15,7 +15,7 @@ limitations under the License. */ import {DecryptionResult} from "../../DecryptionResult"; -import {DecryptionError} from "../../common.js"; +import {DecryptionError} from "../../common"; import {ReplayDetectionEntry} from "./ReplayDetectionEntry"; import type {RoomKey} from "./RoomKey"; import type {KeyLoader, OlmDecryptionResult} from "./KeyLoader"; @@ -58,7 +58,9 @@ export class SessionDecryption { this.decryptionRequests!.push(request); decryptionResult = await request.response(); } else { - decryptionResult = session.decrypt(ciphertext) as OlmDecryptionResult; + // the return type of Olm.InboundGroupSession::decrypt is likely wrong, message_index is a number and not a string AFAIK + // getting it fixed upstream but fixing it like this for now. + decryptionResult = session.decrypt(ciphertext) as unknown as OlmDecryptionResult; } const {plaintext} = decryptionResult!; let payload; diff --git a/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts b/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts index bcfbf85aa7..da1075021d 100644 --- a/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts +++ b/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts @@ -20,6 +20,8 @@ import {MEGOLM_ALGORITHM} from "../../common"; import * as Curve25519 from "./Curve25519"; import {AbortableOperation} from "../../../../utils/AbortableOperation"; import {ObservableValue} from "../../../../observable/value"; +import {Deferred} from "../../../../utils/Deferred"; +import {EventEmitter} from "../../../../utils/EventEmitter"; import {SetAbortableFn} from "../../../../utils/AbortableOperation"; import type {BackupInfo, SessionData, SessionKeyInfo, SessionInfo, KeyBackupPayload} from "./types"; @@ -31,41 +33,69 @@ import type {Storage} from "../../../storage/idb/Storage"; import type {ILogItem} from "../../../../logging/types"; import type {Platform} from "../../../../platform/web/Platform"; import type {Transaction} from "../../../storage/idb/Transaction"; +import type {IHomeServerRequest} from "../../../net/HomeServerRequest"; import type * as OlmNamespace from "@matrix-org/olm"; type Olm = typeof OlmNamespace; const KEYS_PER_REQUEST = 200; -export class KeyBackup { - public readonly operationInProgress = new ObservableValue, Progress> | undefined>(undefined); +// a set of fields we need to store once we've fetched +// the backup info from the homeserver, which happens in start() +class BackupConfig { + constructor( + public readonly info: BackupInfo, + public readonly crypto: Curve25519.BackupEncryption + ) {} +} +export class KeyBackup extends EventEmitter<{change: never}> { + private _operationInProgress?: AbortableOperation, Progress>; private _stopped = false; private _needsNewKey = false; private _hasBackedUpAllKeys = false; private _error?: Error; + private crypto?: Curve25519.BackupEncryption; + private backupInfo?: BackupInfo; + private privateKey?: Uint8Array; + private backupConfigDeferred: Deferred = new Deferred(); + private backupInfoRequest?: IHomeServerRequest; constructor( - private readonly backupInfo: BackupInfo, - private readonly crypto: Curve25519.BackupEncryption, private readonly hsApi: HomeServerApi, + private readonly olm: Olm, private readonly keyLoader: KeyLoader, private readonly storage: Storage, private readonly platform: Platform, private readonly maxDelay: number = 10000 - ) {} + ) { + super(); + // doing the network request for getting the backup info + // and hence creating the crypto instance depending on the chose algorithm + // is delayed until start() is called, but we want to already take requests + // for fetching the room keys, so put the crypto and backupInfo in a deferred. + this.backupConfigDeferred = new Deferred(); + } get hasStopped(): boolean { return this._stopped; } get error(): Error | undefined { return this._error; } - get version(): string { return this.backupInfo.version; } + get version(): string | undefined { return this.backupConfigDeferred.value?.info?.version; } get needsNewKey(): boolean { return this._needsNewKey; } get hasBackedUpAllKeys(): boolean { return this._hasBackedUpAllKeys; } + get operationInProgress(): AbortableOperation, Progress> | undefined { return this._operationInProgress; } async getRoomKey(roomId: string, sessionId: string, log: ILogItem): Promise { - const sessionResponse = await this.hsApi.roomKeyForRoomAndSession(this.backupInfo.version, roomId, sessionId, {log}).response(); + if (this.needsNewKey) { + return; + } + const backupConfig = await this.backupConfigDeferred.promise; + if (!backupConfig) { + return; + } + const sessionResponse = await this.hsApi.roomKeyForRoomAndSession(backupConfig.info.version, roomId, sessionId, {log}).response(); if (!sessionResponse.session_data) { return; } - const sessionKeyInfo = this.crypto.decryptRoomKey(sessionResponse.session_data as SessionData); + const sessionKeyInfo = backupConfig.crypto.decryptRoomKey(sessionResponse.session_data as SessionData); if (sessionKeyInfo?.algorithm === MEGOLM_ALGORITHM) { return keyFromBackup(roomId, sessionId, sessionKeyInfo); } else if (sessionKeyInfo?.algorithm) { @@ -77,8 +107,52 @@ export class KeyBackup { return txn.inboundGroupSessions.markAllAsNotBackedUp(); } + async load(secretStorage: SecretStorage, log: ILogItem) { + const base64PrivateKey = await secretStorage.readSecret("m.megolm_backup.v1"); + if (base64PrivateKey) { + this.privateKey = new Uint8Array(this.platform.encoding.base64.decode(base64PrivateKey)); + return true; + } else { + this.backupConfigDeferred.resolve(undefined); + return false; + } + } + + async start(log: ILogItem) { + await log.wrap("KeyBackup.start", async log => { + if (this.privateKey && !this.backupInfoRequest) { + let backupInfo: BackupInfo; + try { + this.backupInfoRequest = this.hsApi.roomKeysVersion(undefined, {log}); + backupInfo = await this.backupInfoRequest.response() as BackupInfo; + } catch (err) { + if (err.name === "AbortError") { + log.set("aborted", true); + return; + } else { + throw err; + } + } finally { + this.backupInfoRequest = undefined; + } + // TODO: what if backupInfo is undefined or we get 404 or something? + if (backupInfo.algorithm === Curve25519.Algorithm) { + const crypto = Curve25519.BackupEncryption.fromAuthData(backupInfo.auth_data, this.privateKey, this.olm); + this.backupConfigDeferred.resolve(new BackupConfig(backupInfo, crypto)); + this.emit("change"); + } else { + this.backupConfigDeferred.resolve(undefined); + log.log({l: `Unknown backup algorithm`, algorithm: backupInfo.algorithm}); + } + this.privateKey = undefined; + } + // fetch latest version + this.flush(log); + }); + } + flush(log: ILogItem): void { - if (!this.operationInProgress.get()) { + if (!this._operationInProgress) { log.wrapDetached("flush key backup", async log => { if (this._needsNewKey) { log.set("needsNewKey", this._needsNewKey); @@ -88,7 +162,8 @@ export class KeyBackup { this._error = undefined; this._hasBackedUpAllKeys = false; const operation = this._runFlushOperation(log); - this.operationInProgress.set(operation); + this._operationInProgress = operation; + this.emit("change"); try { await operation.result; this._hasBackedUpAllKeys = true; @@ -105,13 +180,18 @@ export class KeyBackup { } log.catch(err); } - this.operationInProgress.set(undefined); + this._operationInProgress = undefined; + this.emit("change"); }); } } private _runFlushOperation(log: ILogItem): AbortableOperation, Progress> { return new AbortableOperation(async (setAbortable, setProgress) => { + const backupConfig = await this.backupConfigDeferred.promise; + if (!backupConfig) { + return; + } let total = 0; let amountFinished = 0; while (true) { @@ -130,8 +210,8 @@ export class KeyBackup { log.set("total", total); return; } - const payload = await this.encodeKeysForBackup(keysNeedingBackup); - const uploadRequest = this.hsApi.uploadRoomKeysToBackup(this.backupInfo.version, payload, {log}); + const payload = await this.encodeKeysForBackup(keysNeedingBackup, backupConfig.crypto); + const uploadRequest = this.hsApi.uploadRoomKeysToBackup(backupConfig.info.version, payload, {log}); setAbortable(uploadRequest); await uploadRequest.response(); await this.markKeysAsBackedUp(keysNeedingBackup, setAbortable); @@ -141,7 +221,7 @@ export class KeyBackup { }); } - private async encodeKeysForBackup(roomKeys: RoomKey[]): Promise { + private async encodeKeysForBackup(roomKeys: RoomKey[], crypto: Curve25519.BackupEncryption): Promise { const payload: KeyBackupPayload = { rooms: {} }; const payloadRooms = payload.rooms; for (const key of roomKeys) { @@ -149,7 +229,7 @@ export class KeyBackup { if (!roomPayload) { roomPayload = payloadRooms[key.roomId] = { sessions: {} }; } - roomPayload.sessions[key.sessionId] = await this.encodeRoomKey(key); + roomPayload.sessions[key.sessionId] = await this.encodeRoomKey(key, crypto); } return payload; } @@ -170,7 +250,7 @@ export class KeyBackup { await txn.complete(); } - private async encodeRoomKey(roomKey: RoomKey): Promise { + private async encodeRoomKey(roomKey: RoomKey, crypto: Curve25519.BackupEncryption): Promise { return await this.keyLoader.useKey(roomKey, session => { const firstMessageIndex = session.first_known_index(); const sessionKey = session.export_session(firstMessageIndex); @@ -178,27 +258,14 @@ export class KeyBackup { first_message_index: firstMessageIndex, forwarded_count: 0, is_verified: false, - session_data: this.crypto.encryptRoomKey(roomKey, sessionKey) + session_data: crypto.encryptRoomKey(roomKey, sessionKey) }; }); } dispose() { - this.crypto.dispose(); - } - - static async fromSecretStorage(platform: Platform, olm: Olm, secretStorage: SecretStorage, hsApi: HomeServerApi, keyLoader: KeyLoader, storage: Storage, txn: Transaction): Promise { - const base64PrivateKey = await secretStorage.readSecret("m.megolm_backup.v1", txn); - if (base64PrivateKey) { - const privateKey = new Uint8Array(platform.encoding.base64.decode(base64PrivateKey)); - const backupInfo = await hsApi.roomKeysVersion().response() as BackupInfo; - if (backupInfo.algorithm === Curve25519.Algorithm) { - const crypto = Curve25519.BackupEncryption.fromAuthData(backupInfo.auth_data, privateKey, olm); - return new KeyBackup(backupInfo, crypto, hsApi, keyLoader, storage, platform); - } else { - throw new Error(`Unknown backup algorithm: ${backupInfo.algorithm}`); - } - } + this.backupInfoRequest?.abort(); + this.backupConfigDeferred.value?.crypto?.dispose(); } } diff --git a/src/matrix/e2ee/megolm/keybackup/types.ts b/src/matrix/e2ee/megolm/keybackup/types.ts index ce56cca74c..f433a7d1d0 100644 --- a/src/matrix/e2ee/megolm/keybackup/types.ts +++ b/src/matrix/e2ee/megolm/keybackup/types.ts @@ -42,7 +42,7 @@ export type SessionInfo = { } export type MegOlmSessionKeyInfo = { - algorithm: MEGOLM_ALGORITHM, + algorithm: typeof MEGOLM_ALGORITHM, sender_key: string, sender_claimed_keys: {[algorithm: string]: string}, forwarding_curve25519_key_chain: string[], diff --git a/src/matrix/e2ee/olm/Decryption.ts b/src/matrix/e2ee/olm/Decryption.ts index 0f96f2fc6e..e1546b0b2b 100644 --- a/src/matrix/e2ee/olm/Decryption.ts +++ b/src/matrix/e2ee/olm/Decryption.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {DecryptionError} from "../common.js"; +import {DecryptionError} from "../common"; import {groupBy} from "../../../utils/groupBy"; import {MultiLock, ILock} from "../../../utils/Lock"; import {Session} from "./Session"; diff --git a/src/matrix/e2ee/olm/Encryption.ts b/src/matrix/e2ee/olm/Encryption.ts index 5fd1f25bb8..ef16ba45b6 100644 --- a/src/matrix/e2ee/olm/Encryption.ts +++ b/src/matrix/e2ee/olm/Encryption.ts @@ -15,7 +15,7 @@ limitations under the License. */ import {groupByWithCreator} from "../../../utils/groupBy"; -import {verifyEd25519Signature, OLM_ALGORITHM} from "../common.js"; +import {verifyEd25519Signature, OLM_ALGORITHM, getDeviceCurve25519Key, getDeviceEd25519Key, SignatureVerification} from "../common"; import {createSessionEntry} from "./Session"; import type {OlmMessage, OlmPayload, OlmEncryptedMessageContent} from "./types"; @@ -24,7 +24,7 @@ import type {LockMap} from "../../../utils/LockMap"; import {Lock, MultiLock, ILock} from "../../../utils/Lock"; import type {Storage} from "../../storage/idb/Storage"; import type {Transaction} from "../../storage/idb/Transaction"; -import type {DeviceIdentity} from "../../storage/idb/stores/DeviceIdentityStore"; +import type {DeviceKey} from "../common"; import type {HomeServerApi} from "../../net/HomeServerApi"; import type {ILogItem} from "../../../logging/types"; import type * as OlmNamespace from "@matrix-org/olm"; @@ -99,7 +99,7 @@ export class Encryption { return new MultiLock(locks); } - async encrypt(type: string, content: Record, devices: DeviceIdentity[], hsApi: HomeServerApi, log: ILogItem): Promise { + async encrypt(type: string, content: Record, devices: DeviceKey[], hsApi: HomeServerApi, log: ILogItem): Promise { let messages: EncryptedMessage[] = []; for (let i = 0; i < devices.length ; i += MAX_BATCH_SIZE) { const batchDevices = devices.slice(i, i + MAX_BATCH_SIZE); @@ -115,12 +115,12 @@ export class Encryption { return messages; } - async _encryptForMaxDevices(type: string, content: Record, devices: DeviceIdentity[], hsApi: HomeServerApi, log: ILogItem): Promise { + async _encryptForMaxDevices(type: string, content: Record, devices: DeviceKey[], hsApi: HomeServerApi, log: ILogItem): Promise { // TODO: see if we can only hold some of the locks until after the /keys/claim call (if needed) // take a lock on all senderKeys so decryption and other calls to encrypt (should not happen) // don't modify the sessions at the same time const locks = await Promise.all(devices.map(device => { - return this.senderKeyLock.takeLock(device.curve25519Key); + return this.senderKeyLock.takeLock(getDeviceCurve25519Key(device)); })); try { const { @@ -158,10 +158,10 @@ export class Encryption { } } - async _findExistingSessions(devices: DeviceIdentity[]): Promise<{devicesWithoutSession: DeviceIdentity[], existingEncryptionTargets: EncryptionTarget[]}> { + async _findExistingSessions(devices: DeviceKey[]): Promise<{devicesWithoutSession: DeviceKey[], existingEncryptionTargets: EncryptionTarget[]}> { const txn = await this.storage.readTxn([this.storage.storeNames.olmSessions]); const sessionIdsForDevice = await Promise.all(devices.map(async device => { - return await txn.olmSessions.getSessionIds(device.curve25519Key); + return await txn.olmSessions.getSessionIds(getDeviceCurve25519Key(device)); })); const devicesWithoutSession = devices.filter((_, i) => { const sessionIds = sessionIdsForDevice[i]; @@ -184,36 +184,36 @@ export class Encryption { const plaintext = JSON.stringify(this._buildPlainTextMessageForDevice(type, content, device)); const message = session!.encrypt(plaintext); const encryptedContent = { - algorithm: OLM_ALGORITHM, + algorithm: OLM_ALGORITHM as typeof OLM_ALGORITHM, sender_key: this.account.identityKeys.curve25519, ciphertext: { - [device.curve25519Key]: message + [getDeviceCurve25519Key(device)]: message } }; return encryptedContent; } - _buildPlainTextMessageForDevice(type: string, content: Record, device: DeviceIdentity): OlmPayload { + _buildPlainTextMessageForDevice(type: string, content: Record, device: DeviceKey): OlmPayload { return { keys: { "ed25519": this.account.identityKeys.ed25519 }, recipient_keys: { - "ed25519": device.ed25519Key + "ed25519": getDeviceEd25519Key(device) }, - recipient: device.userId, + recipient: device.user_id, sender: this.ownUserId, content, type } } - async _createNewSessions(devicesWithoutSession: DeviceIdentity[], hsApi: HomeServerApi, timestamp: number, log: ILogItem): Promise { + async _createNewSessions(devicesWithoutSession: DeviceKey[], hsApi: HomeServerApi, timestamp: number, log: ILogItem): Promise { const newEncryptionTargets = await log.wrap("claim", log => this._claimOneTimeKeys(hsApi, devicesWithoutSession, log)); try { for (const target of newEncryptionTargets) { const {device, oneTimeKey} = target; - target.session = await this.account.createOutboundOlmSession(device.curve25519Key, oneTimeKey); + target.session = await this.account.createOutboundOlmSession(getDeviceCurve25519Key(device), oneTimeKey); } await this._storeSessions(newEncryptionTargets, timestamp); } catch (err) { @@ -225,16 +225,16 @@ export class Encryption { return newEncryptionTargets; } - async _claimOneTimeKeys(hsApi: HomeServerApi, deviceIdentities: DeviceIdentity[], log: ILogItem): Promise { + async _claimOneTimeKeys(hsApi: HomeServerApi, deviceIdentities: DeviceKey[], log: ILogItem): Promise { // create a Map> const devicesByUser = groupByWithCreator(deviceIdentities, - (device: DeviceIdentity) => device.userId, - (): Map => new Map(), - (deviceMap: Map, device: DeviceIdentity) => deviceMap.set(device.deviceId, device) + (device: DeviceKey) => device.user_id, + (): Map => new Map(), + (deviceMap: Map, device: DeviceKey) => deviceMap.set(device.device_id, device) ); const oneTimeKeys = Array.from(devicesByUser.entries()).reduce((usersObj, [userId, deviceMap]) => { usersObj[userId] = Array.from(deviceMap.values()).reduce((devicesObj, device) => { - devicesObj[device.deviceId] = OTK_ALGORITHM; + devicesObj[device.device_id] = OTK_ALGORITHM; return devicesObj; }, {}); return usersObj; @@ -250,7 +250,7 @@ export class Encryption { return this._verifyAndCreateOTKTargets(userKeyMap, devicesByUser, log); } - _verifyAndCreateOTKTargets(userKeyMap: ClaimedOTKResponse, devicesByUser: Map>, log: ILogItem): EncryptionTarget[] { + _verifyAndCreateOTKTargets(userKeyMap: ClaimedOTKResponse, devicesByUser: Map>, log: ILogItem): EncryptionTarget[] { const verifiedEncryptionTargets: EncryptionTarget[] = []; for (const [userId, userSection] of Object.entries(userKeyMap)) { for (const [deviceId, deviceSection] of Object.entries(userSection)) { @@ -260,7 +260,7 @@ export class Encryption { const device = devicesByUser.get(userId)?.get(deviceId); if (device) { const isValidSignature = verifyEd25519Signature( - this.olmUtil, userId, deviceId, device.ed25519Key, keySection, log); + this.olmUtil, userId, deviceId, getDeviceEd25519Key(device), keySection, log) === SignatureVerification.Valid; if (isValidSignature) { const target = EncryptionTarget.fromOTK(device, keySection.key); verifiedEncryptionTargets.push(target); @@ -281,7 +281,7 @@ export class Encryption { try { await Promise.all(encryptionTargets.map(async encryptionTarget => { const sessionEntry = await txn.olmSessions.get( - encryptionTarget.device.curve25519Key, encryptionTarget.sessionId!); + getDeviceCurve25519Key(encryptionTarget.device), encryptionTarget.sessionId!); if (sessionEntry && !failed) { const olmSession = new this.olm.Session(); olmSession.unpickle(this.pickleKey, sessionEntry.session); @@ -303,7 +303,7 @@ export class Encryption { try { for (const target of encryptionTargets) { const sessionEntry = createSessionEntry( - target.session!, target.device.curve25519Key, timestamp, this.pickleKey); + target.session!, getDeviceCurve25519Key(target.device), timestamp, this.pickleKey); txn.olmSessions.set(sessionEntry); } } catch (err) { @@ -323,16 +323,16 @@ class EncryptionTarget { public session: Olm.Session | null = null; constructor( - public readonly device: DeviceIdentity, + public readonly device: DeviceKey, public readonly oneTimeKey: string | null, public readonly sessionId: string | null ) {} - static fromOTK(device: DeviceIdentity, oneTimeKey: string): EncryptionTarget { + static fromOTK(device: DeviceKey, oneTimeKey: string): EncryptionTarget { return new EncryptionTarget(device, oneTimeKey, null); } - static fromSessionId(device: DeviceIdentity, sessionId: string): EncryptionTarget { + static fromSessionId(device: DeviceKey, sessionId: string): EncryptionTarget { return new EncryptionTarget(device, null, sessionId); } @@ -346,6 +346,6 @@ class EncryptionTarget { export class EncryptedMessage { constructor( public readonly content: OlmEncryptedMessageContent, - public readonly device: DeviceIdentity + public readonly device: DeviceKey ) {} } diff --git a/src/matrix/e2ee/olm/types.ts b/src/matrix/e2ee/olm/types.ts index 5302dad80a..164854ad03 100644 --- a/src/matrix/e2ee/olm/types.ts +++ b/src/matrix/e2ee/olm/types.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type {OLM_ALGORITHM} from "../common"; + export const enum OlmPayloadType { PreKey = 0, Normal = 1 @@ -25,7 +27,7 @@ export type OlmMessage = { } export type OlmEncryptedMessageContent = { - algorithm?: "m.olm.v1.curve25519-aes-sha2" + algorithm?: typeof OLM_ALGORITHM sender_key?: string, ciphertext?: { [deviceCurve25519Key: string]: OlmMessage diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index 1e003e491e..920e792015 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -26,7 +26,7 @@ import {MemberList} from "./members/MemberList.js"; import {Heroes} from "./members/Heroes.js"; import {EventEntry} from "./timeline/entries/EventEntry.js"; import {ObservedEventMap} from "./ObservedEventMap.js"; -import {DecryptionSource} from "../e2ee/common.js"; +import {DecryptionSource} from "../e2ee/common"; import {ensureLogItem} from "../../logging/utils"; import {PowerLevels} from "./PowerLevels.js"; import {RetainedObservableValue} from "../../observable/value"; @@ -179,7 +179,7 @@ export class BaseRoom extends EventEmitter { const isTimelineOpen = this._isTimelineOpen; if (isTimelineOpen) { // read to fetch devices if timeline is open - stores.push(this._storage.storeNames.deviceIdentities); + stores.push(this._storage.storeNames.deviceKeys); } const writeTxn = await this._storage.readWriteTxn(stores); let decryption; diff --git a/src/matrix/room/PowerLevels.js b/src/matrix/room/PowerLevels.js index 76e062ef37..aefbfaf154 100644 --- a/src/matrix/room/PowerLevels.js +++ b/src/matrix/room/PowerLevels.js @@ -16,6 +16,9 @@ limitations under the License. export const EVENT_TYPE = "m.room.power_levels"; +// See https://spec.matrix.org/latest/client-server-api/#mroompower_levels +const STATE_DEFAULT_POWER_LEVEL = 50; + export class PowerLevels { constructor({powerLevelEvent, createEvent, ownUserId, membership}) { this._plEvent = powerLevelEvent; @@ -66,11 +69,11 @@ export class PowerLevels { /** @param {string} action either "invite", "kick", "ban" or "redact". */ _getActionLevel(action) { - const level = this._plEvent?.content[action]; + const level = this._plEvent?.content?.[action]; if (typeof level === "number") { return level; } else { - return 50; + return STATE_DEFAULT_POWER_LEVEL; } } diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index b87d7a88cd..47da3c03af 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -22,7 +22,7 @@ import {SendQueue} from "./sending/SendQueue.js"; import {WrappedError} from "../error.js" import {Heroes} from "./members/Heroes.js"; import {AttachmentUpload} from "./AttachmentUpload.js"; -import {DecryptionSource} from "../e2ee/common.js"; +import {DecryptionSource} from "../e2ee/common"; import {iterateResponseStateEvents} from "./common"; import {PowerLevels, EVENT_TYPE as POWERLEVELS_EVENT_TYPE } from "./PowerLevels.js"; diff --git a/src/matrix/room/RoomBeingCreated.ts b/src/matrix/room/RoomBeingCreated.ts index 6d0e4ad631..b1daa3c062 100644 --- a/src/matrix/room/RoomBeingCreated.ts +++ b/src/matrix/room/RoomBeingCreated.ts @@ -20,7 +20,7 @@ import {MediaRepository} from "../net/MediaRepository"; import {EventEmitter} from "../../utils/EventEmitter"; import {AttachmentUpload} from "./AttachmentUpload"; import {loadProfiles, Profile, UserIdProfile} from "../profile"; -import {RoomType, RoomVisibility} from "./common"; +import {RoomType, RoomVisibility, UnsentStateEvent} from "./common"; import type {HomeServerApi} from "../net/HomeServerApi"; import type {ILogItem} from "../../logging/types"; @@ -37,7 +37,7 @@ type CreateRoomPayload = { invite?: string[]; room_alias_name?: string; creation_content?: {"m.federate"?: boolean, type?: string}; - initial_state: { type: string; state_key: string; content: Record }[]; + initial_state: UnsentStateEvent[]; power_level_content_override?: Record; } diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index c91145947a..c53ac41d03 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MEGOLM_ALGORITHM} from "../e2ee/common.js"; +import {MEGOLM_ALGORITHM} from "../e2ee/common"; import {iterateResponseStateEvents} from "./common"; function applyTimelineEntries(data, timelineEntries, isInitialSync, canMarkUnread, ownUserId) { diff --git a/src/matrix/room/common.ts b/src/matrix/room/common.ts index fe796cfbf1..ba7532f386 100644 --- a/src/matrix/room/common.ts +++ b/src/matrix/room/common.ts @@ -28,6 +28,8 @@ export function isRedacted(event) { return !!event?.unsigned?.redacted_because; } +export type UnsentStateEvent = { type: string; state_key: string; content: Record }; + export enum RoomStatus { None = 1 << 0, BeingCreated = 1 << 1, diff --git a/src/matrix/ssss/SecretStorage.ts b/src/matrix/ssss/SecretStorage.ts index c026b4534d..4c767bbbfe 100644 --- a/src/matrix/ssss/SecretStorage.ts +++ b/src/matrix/ssss/SecretStorage.ts @@ -16,6 +16,8 @@ limitations under the License. import type {Key} from "./common"; import type {Platform} from "../../platform/web/Platform.js"; import type {Transaction} from "../storage/idb/Transaction"; +import type {Storage} from "../storage/idb/Storage"; +import type {AccountDataEntry} from "../storage/idb/stores/AccountDataStore"; type EncryptedData = { iv: string; @@ -23,29 +25,72 @@ type EncryptedData = { mac: string; } +export enum DecryptionFailure { + NotEncryptedWithKey, + BadMAC, + UnsupportedAlgorithm, +} + +class DecryptionError extends Error { + constructor(msg: string, public readonly reason: DecryptionFailure) { + super(msg); + } +} + export class SecretStorage { private readonly _key: Key; private readonly _platform: Platform; + private readonly _storage: Storage; - constructor({key, platform}: {key: Key, platform: Platform}) { + constructor({key, platform, storage}: {key: Key, platform: Platform, storage: Storage}) { this._key = key; this._platform = platform; + this._storage = storage; } - async readSecret(name: string, txn: Transaction): Promise { + /** this method will auto-commit any indexeddb transaction because of its use of the webcrypto api */ + async hasValidKeyForAnyAccountData() { + const txn = await this._storage.readTxn([ + this._storage.storeNames.accountData, + ]); + const allAccountData = await txn.accountData.getAll(); + for (const accountData of allAccountData) { + try { + const secret = await this._decryptAccountData(accountData); + return true; // decryption succeeded + } catch (err) { + if (err instanceof DecryptionError && err.reason !== DecryptionFailure.NotEncryptedWithKey) { + throw err; + } else { + continue; + } + } + } + return false; + } + + /** this method will auto-commit any indexeddb transaction because of its use of the webcrypto api */ + async readSecret(name: string): Promise { + const txn = await this._storage.readTxn([ + this._storage.storeNames.accountData, + ]); const accountData = await txn.accountData.get(name); if (!accountData) { return; } + return await this._decryptAccountData(accountData); + } + + async _decryptAccountData(accountData: AccountDataEntry): Promise { const encryptedData = accountData?.content?.encrypted?.[this._key.id] as EncryptedData; if (!encryptedData) { - throw new Error(`Secret ${accountData.type} is not encrypted for key ${this._key.id}`); + throw new DecryptionError(`Secret ${accountData.type} is not encrypted for key ${this._key.id}`, DecryptionFailure.NotEncryptedWithKey); } if (this._key.algorithm === "m.secret_storage.v1.aes-hmac-sha2") { return await this._decryptAESSecret(accountData.type, encryptedData); } else { - throw new Error(`Unsupported algorithm for key ${this._key.id}: ${this._key.algorithm}`); + throw new DecryptionError(`Unsupported algorithm for key ${this._key.id}: ${this._key.algorithm}`, DecryptionFailure.UnsupportedAlgorithm); } } @@ -68,7 +113,7 @@ export class SecretStorage { ciphertextBytes, "SHA-256"); if (!isVerified) { - throw new Error("Bad MAC"); + throw new DecryptionError("Bad MAC", DecryptionFailure.BadMAC); } const plaintextBytes = await this._platform.crypto.aes.decryptCTR({ diff --git a/src/matrix/ssss/index.ts b/src/matrix/ssss/index.ts index fd4c22456c..02f3290e70 100644 --- a/src/matrix/ssss/index.ts +++ b/src/matrix/ssss/index.ts @@ -17,7 +17,7 @@ limitations under the License. import {KeyDescription, Key} from "./common"; import {keyFromPassphrase} from "./passphrase"; import {keyFromRecoveryKey} from "./recoveryKey"; -import {SESSION_E2EE_KEY_PREFIX} from "../e2ee/common.js"; +import {SESSION_E2EE_KEY_PREFIX} from "../e2ee/common"; import type {Storage} from "../storage/idb/Storage"; import type {Transaction} from "../storage/idb/Transaction"; import type {KeyDescriptionData} from "./common"; diff --git a/src/matrix/storage/common.ts b/src/matrix/storage/common.ts index e1e3491725..bf9ce39bc7 100644 --- a/src/matrix/storage/common.ts +++ b/src/matrix/storage/common.ts @@ -26,14 +26,15 @@ export enum StoreNames { timelineFragments = "timelineFragments", pendingEvents = "pendingEvents", userIdentities = "userIdentities", - deviceIdentities = "deviceIdentities", + deviceKeys = "deviceKeys", olmSessions = "olmSessions", inboundGroupSessions = "inboundGroupSessions", outboundGroupSessions = "outboundGroupSessions", groupSessionDecryptions = "groupSessionDecryptions", operations = "operations", accountData = "accountData", - calls = "calls" + calls = "calls", + crossSigningKeys = "crossSigningKeys" } export const STORE_NAMES: Readonly = Object.values(StoreNames); diff --git a/src/matrix/storage/idb/Transaction.ts b/src/matrix/storage/idb/Transaction.ts index 7a8de420be..4c76608ca1 100644 --- a/src/matrix/storage/idb/Transaction.ts +++ b/src/matrix/storage/idb/Transaction.ts @@ -29,7 +29,8 @@ import {RoomMemberStore} from "./stores/RoomMemberStore"; import {TimelineFragmentStore} from "./stores/TimelineFragmentStore"; import {PendingEventStore} from "./stores/PendingEventStore"; import {UserIdentityStore} from "./stores/UserIdentityStore"; -import {DeviceIdentityStore} from "./stores/DeviceIdentityStore"; +import {DeviceKeyStore} from "./stores/DeviceKeyStore"; +import {CrossSigningKeyStore} from "./stores/CrossSigningKeyStore"; import {OlmSessionStore} from "./stores/OlmSessionStore"; import {InboundGroupSessionStore} from "./stores/InboundGroupSessionStore"; import {OutboundGroupSessionStore} from "./stores/OutboundGroupSessionStore"; @@ -141,8 +142,12 @@ export class Transaction { return this._store(StoreNames.userIdentities, idbStore => new UserIdentityStore(idbStore)); } - get deviceIdentities(): DeviceIdentityStore { - return this._store(StoreNames.deviceIdentities, idbStore => new DeviceIdentityStore(idbStore)); + get deviceKeys(): DeviceKeyStore { + return this._store(StoreNames.deviceKeys, idbStore => new DeviceKeyStore(idbStore)); + } + + get crossSigningKeys(): CrossSigningKeyStore { + return this._store(StoreNames.crossSigningKeys, idbStore => new CrossSigningKeyStore(idbStore)); } get olmSessions(): OlmSessionStore { diff --git a/src/matrix/storage/idb/schema.ts b/src/matrix/storage/idb/schema.ts index d88f535e98..9b4d55471d 100644 --- a/src/matrix/storage/idb/schema.ts +++ b/src/matrix/storage/idb/schema.ts @@ -2,7 +2,7 @@ import {IDOMStorage} from "./types"; import {ITransaction} from "./QueryTarget"; import {iterateCursor, NOT_DONE, reqAsPromise} from "./utils"; import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/members/RoomMember.js"; -import {SESSION_E2EE_KEY_PREFIX} from "../../e2ee/common.js"; +import {SESSION_E2EE_KEY_PREFIX} from "../../e2ee/common"; import {SummaryData} from "../../room/RoomSummary"; import {RoomMemberStore, MemberData} from "./stores/RoomMemberStore"; import {InboundGroupSessionStore, InboundGroupSessionEntry, BackupStatus, KeySource} from "./stores/InboundGroupSessionStore"; @@ -13,6 +13,8 @@ import {encodeScopeTypeKey} from "./stores/OperationStore"; import {MAX_UNICODE} from "./stores/common"; import {ILogItem} from "../../../logging/types"; +import type {UserIdentity} from "../../e2ee/DeviceTracker"; +import {KeysTrackingStatus} from "../../e2ee/DeviceTracker"; export type MigrationFunc = (db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage, log: ILogItem) => Promise | void; // FUNCTIONS SHOULD ONLY BE APPENDED!! @@ -34,7 +36,8 @@ export const schema: MigrationFunc[] = [ clearAllStores, addInboundSessionBackupIndex, migrateBackupStatus, - createCallStore + createCallStore, + applyCrossSigningChanges ]; // TODO: how to deal with git merge conflicts of this array? @@ -275,3 +278,24 @@ async function migrateBackupStatus(db: IDBDatabase, txn: IDBTransaction, localSt function createCallStore(db: IDBDatabase) : void { db.createObjectStore("calls", {keyPath: "key"}); } + +//v18 add crossSigningKeys store, rename deviceIdentities to deviceKeys and empties userIdentities +async function applyCrossSigningChanges(db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage, log: ILogItem) : Promise { + db.createObjectStore("crossSigningKeys", {keyPath: "key"}); + db.deleteObjectStore("deviceIdentities"); + const deviceKeys = db.createObjectStore("deviceKeys", {keyPath: "key"}); + deviceKeys.createIndex("byCurve25519Key", "curve25519Key", {unique: true}); + // mark all userIdentities as outdated as cross-signing keys won't be stored + // also rename the deviceTrackingStatus field to keysTrackingStatus + const userIdentities = txn.objectStore("userIdentities"); + let counter = 0; + await iterateCursor(userIdentities.openCursor(), (value, key, cursor) => { + delete value["deviceTrackingStatus"]; + delete value["crossSigningKeys"]; + value.keysTrackingStatus = KeysTrackingStatus.Outdated; + cursor.update(value); + counter += 1; + return NOT_DONE; + }); + log.set("marked_outdated", counter); +} diff --git a/src/matrix/storage/idb/stores/AccountDataStore.ts b/src/matrix/storage/idb/stores/AccountDataStore.ts index 2081ad8feb..33c8a1621b 100644 --- a/src/matrix/storage/idb/stores/AccountDataStore.ts +++ b/src/matrix/storage/idb/stores/AccountDataStore.ts @@ -16,7 +16,7 @@ limitations under the License. import {Store} from "../Store"; import {Content} from "../../types"; -interface AccountDataEntry { +export interface AccountDataEntry { type: string; content: Content; } @@ -35,4 +35,8 @@ export class AccountDataStore { set(event: AccountDataEntry): void { this._store.put(event); } + + async getAll(): Promise> { + return await this._store.selectAll(); + } } diff --git a/src/matrix/storage/idb/stores/CrossSigningKeyStore.ts b/src/matrix/storage/idb/stores/CrossSigningKeyStore.ts new file mode 100644 index 0000000000..bbda15c05d --- /dev/null +++ b/src/matrix/storage/idb/stores/CrossSigningKeyStore.ts @@ -0,0 +1,63 @@ +/* +Copyright 2020 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 {MAX_UNICODE, MIN_UNICODE} from "./common"; +import {Store} from "../Store"; +import type {CrossSigningKey} from "../../../verification/CrossSigning"; + +type CrossSigningKeyEntry = { + crossSigningKey: CrossSigningKey + key: string; // key in storage, not a crypto key +} + +function encodeKey(userId: string, usage: string): string { + return `${userId}|${usage}`; +} + +function decodeKey(key: string): { userId: string, usage: string } { + const [userId, usage] = key.split("|"); + return {userId, usage}; +} + +export class CrossSigningKeyStore { + private _store: Store; + + constructor(store: Store) { + this._store = store; + } + + async get(userId: string, deviceId: string): Promise { + return (await this._store.get(encodeKey(userId, deviceId)))?.crossSigningKey; + } + + set(crossSigningKey: CrossSigningKey): void { + this._store.put({ + key:encodeKey(crossSigningKey["user_id"], crossSigningKey.usage[0]), + crossSigningKey + }); + } + + remove(userId: string, usage: string): void { + this._store.delete(encodeKey(userId, usage)); + } + + removeAllForUser(userId: string): void { + // exclude both keys as they are theoretical min and max, + // but we should't have a match for just the room id, or room id with max + const range = this._store.IDBKeyRange.bound(encodeKey(userId, MIN_UNICODE), encodeKey(userId, MAX_UNICODE), true, true); + this._store.delete(range); + } +} diff --git a/src/matrix/storage/idb/stores/DeviceIdentityStore.ts b/src/matrix/storage/idb/stores/DeviceKeyStore.ts similarity index 63% rename from src/matrix/storage/idb/stores/DeviceIdentityStore.ts rename to src/matrix/storage/idb/stores/DeviceKeyStore.ts index 2936f07981..897d645329 100644 --- a/src/matrix/storage/idb/stores/DeviceIdentityStore.ts +++ b/src/matrix/storage/idb/stores/DeviceKeyStore.ts @@ -16,15 +16,13 @@ limitations under the License. import {MAX_UNICODE, MIN_UNICODE} from "./common"; import {Store} from "../Store"; +import {getDeviceCurve25519Key} from "../../../e2ee/common"; +import type {DeviceKey} from "../../../e2ee/common"; -export interface DeviceIdentity { - userId: string; - deviceId: string; - ed25519Key: string; +type DeviceKeyEntry = { + key: string; // key in storage, not a crypto key curve25519Key: string; - algorithms: string[]; - displayName: string; - key: string; + deviceKey: DeviceKey } function encodeKey(userId: string, deviceId: string): string { @@ -36,23 +34,24 @@ function decodeKey(key: string): { userId: string, deviceId: string } { return {userId, deviceId}; } -export class DeviceIdentityStore { - private _store: Store; +export class DeviceKeyStore { + private _store: Store; - constructor(store: Store) { + constructor(store: Store) { this._store = store; } - getAllForUserId(userId: string): Promise { - const range = this._store.IDBKeyRange.lowerBound(encodeKey(userId, "")); - return this._store.selectWhile(range, device => { - return device.userId === userId; + async getAllForUserId(userId: string): Promise { + const range = this._store.IDBKeyRange.lowerBound(encodeKey(userId, MIN_UNICODE)); + const entries = await this._store.selectWhile(range, device => { + return device.deviceKey.user_id === userId; }); + return entries.map(e => e.deviceKey); } async getAllDeviceIds(userId: string): Promise { const deviceIds: string[] = []; - const range = this._store.IDBKeyRange.lowerBound(encodeKey(userId, "")); + const range = this._store.IDBKeyRange.lowerBound(encodeKey(userId, MIN_UNICODE)); await this._store.iterateKeys(range, key => { const decodedKey = decodeKey(key as string); // prevent running into the next room @@ -65,17 +64,21 @@ export class DeviceIdentityStore { return deviceIds; } - get(userId: string, deviceId: string): Promise { - return this._store.get(encodeKey(userId, deviceId)); + async get(userId: string, deviceId: string): Promise { + return (await this._store.get(encodeKey(userId, deviceId)))?.deviceKey; } - set(deviceIdentity: DeviceIdentity): void { - deviceIdentity.key = encodeKey(deviceIdentity.userId, deviceIdentity.deviceId); - this._store.put(deviceIdentity); + set(deviceKey: DeviceKey): void { + this._store.put({ + key: encodeKey(deviceKey.user_id, deviceKey.device_id), + curve25519Key: getDeviceCurve25519Key(deviceKey)!, + deviceKey + }); } - getByCurve25519Key(curve25519Key: string): Promise { - return this._store.index("byCurve25519Key").get(curve25519Key); + async getByCurve25519Key(curve25519Key: string): Promise { + const entry = await this._store.index("byCurve25519Key").get(curve25519Key); + return entry?.deviceKey; } remove(userId: string, deviceId: string): void { diff --git a/src/matrix/storage/idb/stores/SessionStore.ts b/src/matrix/storage/idb/stores/SessionStore.ts index 9ae9bb7e21..24b7099abf 100644 --- a/src/matrix/storage/idb/stores/SessionStore.ts +++ b/src/matrix/storage/idb/stores/SessionStore.ts @@ -15,7 +15,7 @@ limitations under the License. */ import {Store} from "../Store"; import {IDOMStorage} from "../types"; -import {SESSION_E2EE_KEY_PREFIX} from "../../../e2ee/common.js"; +import {SESSION_E2EE_KEY_PREFIX} from "../../../e2ee/common"; import {parse, stringify} from "../../../../utils/typedJSON"; import type {ILogItem} from "../../../../logging/types"; diff --git a/src/matrix/storage/idb/stores/UserIdentityStore.ts b/src/matrix/storage/idb/stores/UserIdentityStore.ts index 1c55baf094..76bb208034 100644 --- a/src/matrix/storage/idb/stores/UserIdentityStore.ts +++ b/src/matrix/storage/idb/stores/UserIdentityStore.ts @@ -14,12 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ import {Store} from "../Store"; - -interface UserIdentity { - userId: string; - roomIds: string[]; - deviceTrackingStatus: number; -} +import type {UserIdentity} from "../../../e2ee/DeviceTracker"; export class UserIdentityStore { private _store: Store; diff --git a/src/matrix/verification/CrossSigning.ts b/src/matrix/verification/CrossSigning.ts index db480dd08a..c23c2f54bc 100644 --- a/src/matrix/verification/CrossSigning.ts +++ b/src/matrix/verification/CrossSigning.ts @@ -14,29 +14,86 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {verifyEd25519Signature, SignatureVerification} from "../e2ee/common"; +import {BaseObservableValue, RetainedObservableValue} from "../../observable/value"; +import {pkSign} from "./common"; +import {SASVerification} from "./SAS/SASVerification"; +import {ToDeviceChannel} from "./SAS/channel/Channel"; +import {VerificationEventType} from "./SAS/channel/types"; +import {ObservableMap} from "../../observable/map"; +import {SASRequest} from "./SAS/SASRequest"; import type {SecretStorage} from "../ssss/SecretStorage"; import type {Storage} from "../storage/idb/Storage"; import type {Platform} from "../../platform/web/Platform"; import type {DeviceTracker} from "../e2ee/DeviceTracker"; -import type * as OlmNamespace from "@matrix-org/olm"; import type {HomeServerApi} from "../net/HomeServerApi"; import type {Account} from "../e2ee/Account"; -import { ILogItem } from "../../lib"; -import {pkSign} from "./common"; -import type {ISignatures} from "./common"; +import type {ILogItem} from "../../logging/types"; +import type {DeviceMessageHandler} from "../DeviceMessageHandler.js"; +import type {SignedValue, DeviceKey} from "../e2ee/common"; +import type * as OlmNamespace from "@matrix-org/olm"; type Olm = typeof OlmNamespace; +// we store cross-signing (and device) keys in the format we get them from the server +// as that is what the signature is calculated on, so to verify and sign, we need +// it in this format anyway. +export type CrossSigningKey = SignedValue & { + readonly user_id: string; + readonly usage: ReadonlyArray; + readonly keys: {[keyId: string]: string}; +} + +export enum KeyUsage { + Master = "master", + SelfSigning = "self_signing", + UserSigning = "user_signing" +}; + +export enum UserTrust { + /** We trust the user, the whole signature chain checks out from our MSK to all of their device keys. */ + Trusted = 1, + /** We haven't signed this user's identity yet. Verify this user first to sign it. */ + UserNotSigned, + /** We have signed the user already, but the signature isn't valid. + One possible cause could be that an attacker is uploading signatures in our name. */ + UserSignatureMismatch, + /** We trust the user, but they don't trust one of their devices. */ + UserDeviceNotSigned, + /** We trust the user, but the signatures of one of their devices is invalid. + * One possible cause could be that an attacker is uploading signatures in their name. */ + UserDeviceSignatureMismatch, + /** The user doesn't have a valid signature for the SSK with their MSK, or the SSK is missing. + * This likely means bootstrapping cross-signing on their end didn't finish correctly. */ + UserSetupError, + /** We don't have a valid signature for our SSK with our MSK, the SSK is missing, or we don't trust our own MSK. + * This likely means bootstrapping cross-signing on our end didn't finish correctly. */ + OwnSetupError +} + +enum MSKVerification { + NoPrivKey, + NoPubKey, + DerivedPubKeyMismatch, + Valid +} + export class CrossSigning { private readonly storage: Storage; private readonly secretStorage: SecretStorage; private readonly platform: Platform; private readonly deviceTracker: DeviceTracker; private readonly olm: Olm; + private readonly olmUtil: Olm.Utility; private readonly hsApi: HomeServerApi; private readonly ownUserId: string; private readonly e2eeAccount: Account; + private readonly deviceMessageHandler: DeviceMessageHandler; private _isMasterKeyTrusted: boolean = false; + private readonly observedUsers: Map> = new Map(); + private readonly deviceId: string; + private sasVerificationInProgress?: SASVerification; + public receivedSASVerifications: ObservableMap = new ObservableMap(); constructor(options: { storage: Storage, @@ -44,69 +101,379 @@ export class CrossSigning { deviceTracker: DeviceTracker, platform: Platform, olm: Olm, + olmUtil: Olm.Utility, ownUserId: string, + deviceId: string, hsApi: HomeServerApi, - e2eeAccount: Account + e2eeAccount: Account, + deviceMessageHandler: DeviceMessageHandler, }) { this.storage = options.storage; this.secretStorage = options.secretStorage; this.platform = options.platform; this.deviceTracker = options.deviceTracker; this.olm = options.olm; + this.olmUtil = options.olmUtil; this.hsApi = options.hsApi; this.ownUserId = options.ownUserId; + this.deviceId = options.deviceId; this.e2eeAccount = options.e2eeAccount + this.deviceMessageHandler = options.deviceMessageHandler; + this.handleSASDeviceMessage = this.handleSASDeviceMessage.bind(this); + this.deviceMessageHandler.on("message", this.handleSASDeviceMessage); + } + + /** @return {boolean} whether cross signing has been enabled on this account */ + async load(log: ILogItem): Promise { + // try to verify the msk without accessing the network + const verification = await this.verifyMSKFrom4S(false, log); + return verification !== MSKVerification.NoPrivKey; + } + + async start(log: ILogItem): Promise { + if (!this.isMasterKeyTrusted) { + // try to verify the msk _with_ access to the network + await this.verifyMSKFrom4S(true, log); + } } - async init(log: ILogItem) { - log.wrap("CrossSigning.init", async log => { + private async verifyMSKFrom4S(allowNetwork: boolean, log: ILogItem): Promise { + return await log.wrap("CrossSigning.verifyMSKFrom4S", async log => { // TODO: use errorboundary here - const txn = await this.storage.readTxn([this.storage.storeNames.accountData]); - - const mskSeed = await this.secretStorage.readSecret("m.cross_signing.master", txn); + const privateMasterKey = await this.getSigningKey(KeyUsage.Master); + if (!privateMasterKey) { + log.set("failure", "no_priv_msk"); + return MSKVerification.NoPrivKey; + } const signing = new this.olm.PkSigning(); let derivedPublicKey; try { - const seed = new Uint8Array(this.platform.encoding.base64.decode(mskSeed)); - derivedPublicKey = signing.init_with_seed(seed); + derivedPublicKey = signing.init_with_seed(privateMasterKey); } finally { signing.free(); } - const publishedKeys = await this.deviceTracker.getCrossSigningKeysForUser(this.ownUserId, this.hsApi, log); - log.set({publishedMasterKey: publishedKeys.masterKey, derivedPublicKey}); - this._isMasterKeyTrusted = publishedKeys.masterKey === derivedPublicKey; - log.set("isMasterKeyTrusted", this.isMasterKeyTrusted); + const publishedMasterKey = await this.deviceTracker.getCrossSigningKeyForUser(this.ownUserId, KeyUsage.Master, allowNetwork ? this.hsApi : undefined, log); + if (!publishedMasterKey) { + log.set("failure", "no_pub_msk"); + return MSKVerification.NoPubKey; + } + const publisedEd25519Key = publishedMasterKey && getKeyEd25519Key(publishedMasterKey); + log.set({publishedMasterKey: publisedEd25519Key, derivedPublicKey}); + this._isMasterKeyTrusted = !!publisedEd25519Key && publisedEd25519Key === derivedPublicKey; + if (!this._isMasterKeyTrusted) { + log.set("failure", "mismatch"); + return MSKVerification.DerivedPubKeyMismatch; + } + return MSKVerification.Valid; + }); + } + + get isMasterKeyTrusted(): boolean { + return this._isMasterKeyTrusted; + } + + startVerification(requestOrUserId: SASRequest, log: ILogItem): SASVerification | undefined; + startVerification(requestOrUserId: string, log: ILogItem): SASVerification | undefined; + startVerification(requestOrUserId: string | SASRequest, log: ILogItem): SASVerification | undefined { + if (this.sasVerificationInProgress && !this.sasVerificationInProgress.finished) { + return; + } + const otherUserId = requestOrUserId instanceof SASRequest ? requestOrUserId.sender : requestOrUserId; + const startingMessage = requestOrUserId instanceof SASRequest ? requestOrUserId.startingMessage : undefined; + const channel = new ToDeviceChannel({ + deviceTracker: this.deviceTracker, + hsApi: this.hsApi, + otherUserId, + clock: this.platform.clock, + deviceMessageHandler: this.deviceMessageHandler, + ourUserDeviceId: this.deviceId, + log + }, startingMessage); + + this.sasVerificationInProgress = new SASVerification({ + olm: this.olm, + olmUtil: this.olmUtil, + ourUserId: this.ownUserId, + ourUserDeviceId: this.deviceId, + otherUserId, + log, + channel, + e2eeAccount: this.e2eeAccount, + deviceTracker: this.deviceTracker, + hsApi: this.hsApi, + clock: this.platform.clock, + crossSigning: this, + }); + return this.sasVerificationInProgress; + } + + private handleSASDeviceMessage({ unencrypted: event }) { + const txnId = event.content.transaction_id; + /** + * If we receive an event for the current/previously finished + * SAS verification, we should ignore it because the device channel + * object (who also listens for to_device messages) will take care of it (if needed). + */ + const shouldIgnoreEvent = this.sasVerificationInProgress?.channel.id === txnId; + if (shouldIgnoreEvent) { return; } + /** + * 1. If we receive the cancel message, we need to update the requests map. + * 2. If we receive an starting message (viz request/start), we need to create the SASRequest from it. + */ + switch (event.type) { + case VerificationEventType.Cancel: + this.receivedSASVerifications.remove(txnId); + return; + case VerificationEventType.Request: + case VerificationEventType.Start: + this.platform.logger.run("Create SASRequest", () => { + this.receivedSASVerifications.set(txnId, new SASRequest(event)); + }); + return; + default: + // we don't care about this event! + return; + } + } + + /** returns our own device key signed by our self-signing key. Other signatures will be missing. */ + async signOwnDevice(log: ILogItem): Promise { + return log.wrap("CrossSigning.signOwnDevice", async log => { + if (!this._isMasterKeyTrusted) { + log.set("mskNotTrusted", true); + return; + } + const ownDeviceKey = this.e2eeAccount.getUnsignedDeviceKey() as DeviceKey; + return this.signDeviceKey(ownDeviceKey, log); + }); + } + + /** @return the signed device key for the given device id */ + async signDevice(deviceId: string, log: ILogItem): Promise { + return log.wrap("CrossSigning.signDevice", async log => { + log.set("id", deviceId); + if (!this._isMasterKeyTrusted) { + log.set("mskNotTrusted", true); + return; + } + const keyToSign = await this.deviceTracker.deviceForId(this.ownUserId, deviceId, this.hsApi, log); + if (!keyToSign) { + return undefined; + } + delete keyToSign.signatures; + return this.signDeviceKey(keyToSign, log); }); } - async signOwnDevice(log: ILogItem) { - log.wrap("CrossSigning.signOwnDevice", async log => { + /** @return the signed MSK for the given user id */ + async signUser(userId: string, log: ILogItem): Promise { + return log.wrap("CrossSigning.signUser", async log => { + log.set("id", userId); if (!this._isMasterKeyTrusted) { log.set("mskNotTrusted", true); return; } - const deviceKey = this.e2eeAccount.getDeviceKeysToSignWithCrossSigning(); - const signedDeviceKey = await this.signDevice(deviceKey); + // can't sign own user + if (userId === this.ownUserId) { + return; + } + const keyToSign = await this.deviceTracker.getCrossSigningKeyForUser(userId, KeyUsage.Master, this.hsApi, log); + if (!keyToSign) { + return; + } + const signingKey = await this.getSigningKey(KeyUsage.UserSigning); + if (!signingKey) { + return; + } + delete keyToSign.signatures; + // add signature to keyToSign + this.signKey(keyToSign, signingKey); const payload = { - [signedDeviceKey["user_id"]]: { - [signedDeviceKey["device_id"]]: signedDeviceKey + [keyToSign.user_id]: { + [getKeyEd25519Key(keyToSign)!]: keyToSign } }; const request = this.hsApi.uploadSignatures(payload, {log}); await request.response(); + // we don't write the signatures to storage, as we don't want to have too many special + // cases in the trust algorithm, so instead we just clear the cross signing keys + // so that they will be refetched when trust is recalculated + await this.deviceTracker.invalidateUserKeys(userId); + this.emitUserTrustUpdate(userId, log); + return keyToSign; }); } - private async signDevice(data: T): Promise { - const txn = await this.storage.readTxn([this.storage.storeNames.accountData]); - const seedStr = await this.secretStorage.readSecret(`m.cross_signing.self_signing`, txn); - const seed = new Uint8Array(this.platform.encoding.base64.decode(seedStr)); - pkSign(this.olm, data, seed, this.ownUserId, ""); - return data as T & { signatures: ISignatures }; + getUserTrust(userId: string, log: ILogItem): Promise { + return log.wrap("CrossSigning.getUserTrust", async log => { + log.set("id", userId); + const logResult = (trust: UserTrust): UserTrust => { + log.set("result", trust); + return trust; + }; + if (!this.isMasterKeyTrusted) { + return logResult(UserTrust.OwnSetupError); + } + const ourMSK = await log.wrap("get our msk", log => this.deviceTracker.getCrossSigningKeyForUser(this.ownUserId, KeyUsage.Master, this.hsApi, log)); + if (!ourMSK) { + return logResult(UserTrust.OwnSetupError); + } + const ourUSK = await log.wrap("get our usk", log => this.deviceTracker.getCrossSigningKeyForUser(this.ownUserId, KeyUsage.UserSigning, this.hsApi, log)); + if (!ourUSK) { + return logResult(UserTrust.OwnSetupError); + } + const ourUSKVerification = log.wrap("verify our usk", log => this.hasValidSignatureFrom(ourUSK, ourMSK, log)); + if (ourUSKVerification !== SignatureVerification.Valid) { + return logResult(UserTrust.OwnSetupError); + } + const theirMSK = await log.wrap("get their msk", log => this.deviceTracker.getCrossSigningKeyForUser(userId, KeyUsage.Master, this.hsApi, log)); + if (!theirMSK) { + /* assume that when they don't have an MSK, they've never enabled cross-signing on their client + (or it's not supported) rather than assuming a setup error on their side. + Later on, for their SSK, we _do_ assume it's a setup error as it doesn't make sense to have an MSK without a SSK */ + return logResult(UserTrust.UserNotSigned); + } + const theirMSKVerification = log.wrap("verify their msk", log => this.hasValidSignatureFrom(theirMSK, ourUSK, log)); + if (theirMSKVerification !== SignatureVerification.Valid) { + if (theirMSKVerification === SignatureVerification.NotSigned) { + return logResult(UserTrust.UserNotSigned); + } else { /* SignatureVerification.Invalid */ + return logResult(UserTrust.UserSignatureMismatch); + } + } + const theirSSK = await log.wrap("get their ssk", log => this.deviceTracker.getCrossSigningKeyForUser(userId, KeyUsage.SelfSigning, this.hsApi, log)); + if (!theirSSK) { + return logResult(UserTrust.UserSetupError); + } + const theirSSKVerification = log.wrap("verify their ssk", log => this.hasValidSignatureFrom(theirSSK, theirMSK, log)); + if (theirSSKVerification !== SignatureVerification.Valid) { + return logResult(UserTrust.UserSetupError); + } + const theirDeviceKeys = await log.wrap("get their devices", log => this.deviceTracker.devicesForUsers([userId], this.hsApi, log)); + const lowestDeviceVerification = theirDeviceKeys.reduce((lowest, dk) => log.wrap({l: "verify device", id: dk.device_id}, log => { + const verification = this.hasValidSignatureFrom(dk, theirSSK, log); + // first Invalid, then NotSigned, then Valid + if (lowest === SignatureVerification.Invalid || verification === SignatureVerification.Invalid) { + return SignatureVerification.Invalid; + } else if (lowest === SignatureVerification.NotSigned || verification === SignatureVerification.NotSigned) { + return SignatureVerification.NotSigned; + } else if (lowest === SignatureVerification.Valid || verification === SignatureVerification.Valid) { + return SignatureVerification.Valid; + } + // should never happen as we went over all the enum options + return SignatureVerification.Invalid; + }), SignatureVerification.Valid); + if (lowestDeviceVerification !== SignatureVerification.Valid) { + if (lowestDeviceVerification === SignatureVerification.NotSigned) { + return logResult(UserTrust.UserDeviceNotSigned); + } else { /* SignatureVerification.Invalid */ + return logResult(UserTrust.UserDeviceSignatureMismatch); + } + } + return logResult(UserTrust.Trusted); + }); } - get isMasterKeyTrusted(): boolean { - return this._isMasterKeyTrusted; + dispose(): void { + this.deviceMessageHandler.off("message", this.handleSASDeviceMessage); + } + + observeUserTrust(userId: string, log: ILogItem): BaseObservableValue { + const existingValue = this.observedUsers.get(userId); + if (existingValue) { + return existingValue; + } + const observable = new RetainedObservableValue(undefined, () => { + this.observedUsers.delete(userId); + }); + this.observedUsers.set(userId, observable); + log.wrapDetached("get user trust", async log => { + if (observable.get() === undefined) { + observable.set(await this.getUserTrust(userId, log)); + } + }); + return observable; + } + + private async signDeviceKey(keyToSign: DeviceKey, log: ILogItem): Promise { + const signingKey = await this.getSigningKey(KeyUsage.SelfSigning); + if (!signingKey) { + return undefined; + } + // add signature to keyToSign + this.signKey(keyToSign, signingKey); + // so the payload format of a signature is a map from userid to key id of the signed key + // (without the algoritm prefix though according to example, e.g. just device id or base 64 public key) + // to the complete signed key with the signature of the signing key in the signatures section. + const payload = { + [keyToSign.user_id]: { + [keyToSign.device_id]: keyToSign + } + }; + const request = this.hsApi.uploadSignatures(payload, {log}); + await request.response(); + // we don't write the signatures to storage, as we don't want to have too many special + // cases in the trust algorithm, so instead we just clear the device keys + // so that they will be refetched when trust is recalculated + await this.deviceTracker.invalidateUserKeys(this.ownUserId); + this.emitUserTrustUpdate(this.ownUserId, log); + return keyToSign; + } + + private async getSigningKey(usage: KeyUsage): Promise { + const seedStr = await this.secretStorage.readSecret(`m.cross_signing.${usage}`); + if (seedStr) { + return new Uint8Array(this.platform.encoding.base64.decode(seedStr)); + } + } + + private signKey(keyToSign: DeviceKey | CrossSigningKey, signingKey: Uint8Array) { + pkSign(this.olm, keyToSign, signingKey, this.ownUserId, ""); + } + + private hasValidSignatureFrom(key: DeviceKey | CrossSigningKey, signingKey: CrossSigningKey, log: ILogItem): SignatureVerification { + const pubKey = getKeyEd25519Key(signingKey); + if (!pubKey) { + return SignatureVerification.NotSigned; + } + return verifyEd25519Signature(this.olmUtil, signingKey.user_id, pubKey, pubKey, key, log); } + + private emitUserTrustUpdate(userId: string, log: ILogItem) { + const observable = this.observedUsers.get(userId); + if (observable && observable.get() !== undefined) { + observable.set(undefined); + log.wrapDetached("update user trust", async log => { + observable.set(await this.getUserTrust(userId, log)); + }); + } + } +} + +export function getKeyUsage(keyInfo: CrossSigningKey): KeyUsage | undefined { + if (!Array.isArray(keyInfo.usage) || keyInfo.usage.length !== 1) { + return undefined; + } + const usage = keyInfo.usage[0]; + if (usage !== KeyUsage.Master && usage !== KeyUsage.SelfSigning && usage !== KeyUsage.UserSigning) { + return undefined; + } + return usage; } +const algorithm = "ed25519"; +const prefix = `${algorithm}:`; + +export function getKeyEd25519Key(keyInfo: CrossSigningKey): string | undefined { + const ed25519KeyIds = Object.keys(keyInfo.keys).filter(keyId => keyId.startsWith(prefix)); + if (ed25519KeyIds.length !== 1) { + return undefined; + } + const keyId = ed25519KeyIds[0]; + const publicKey = keyInfo.keys[keyId]; + return publicKey; +} + +export function getKeyUserId(keyInfo: CrossSigningKey): string | undefined { + return keyInfo["user_id"]; +} diff --git a/src/matrix/verification/SAS/SASRequest.ts b/src/matrix/verification/SAS/SASRequest.ts new file mode 100644 index 0000000000..69bc197a77 --- /dev/null +++ b/src/matrix/verification/SAS/SASRequest.ts @@ -0,0 +1,31 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export class SASRequest { + constructor(public readonly startingMessage: any) {} + + get deviceId(): string { + return this.startingMessage.content.from_device; + } + + get sender(): string { + return this.startingMessage.sender; + } + + get id(): string { + return this.startingMessage.content.transaction_id; + } +} diff --git a/src/matrix/verification/SAS/SASVerification.ts b/src/matrix/verification/SAS/SASVerification.ts new file mode 100644 index 0000000000..b6265db584 --- /dev/null +++ b/src/matrix/verification/SAS/SASVerification.ts @@ -0,0 +1,654 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import {SendRequestVerificationStage} from "./stages/SendRequestVerificationStage"; +import type {ILogItem} from "../../../logging/types"; +import type {BaseSASVerificationStage} from "./stages/BaseSASVerificationStage"; +import type {Account} from "../../e2ee/Account.js"; +import type {DeviceTracker} from "../../e2ee/DeviceTracker.js"; +import type * as OlmNamespace from "@matrix-org/olm"; +import type {IChannel} from "./channel/Channel"; +import type {HomeServerApi} from "../../net/HomeServerApi"; +import type {Timeout} from "../../../platform/types/types"; +import type {Clock} from "../../../platform/web/dom/Clock.js"; +import {CancelReason, VerificationEventType} from "./channel/types"; +import {SendReadyStage} from "./stages/SendReadyStage"; +import {SelectVerificationMethodStage} from "./stages/SelectVerificationMethodStage"; +import {VerificationCancelledError} from "./VerificationCancelledError"; +import {EventEmitter} from "../../../utils/EventEmitter"; +import {SASProgressEvents} from "./types"; +import type {CrossSigning} from "../CrossSigning"; + +type Olm = typeof OlmNamespace; + +type Options = { + olm: Olm; + olmUtil: Olm.Utility; + ourUserId: string; + ourUserDeviceId: string; + otherUserId: string; + channel: IChannel; + log: ILogItem; + e2eeAccount: Account; + deviceTracker: DeviceTracker; + hsApi: HomeServerApi; + clock: Clock; + crossSigning: CrossSigning +} + +export class SASVerification extends EventEmitter { + private startStage: BaseSASVerificationStage; + private olmSas: Olm.SAS; + public finished: boolean = false; + public readonly channel: IChannel; + private timeout: Timeout; + + constructor(options: Options) { + super(); + const { olm, channel, clock } = options; + const olmSas = new olm.SAS(); + this.olmSas = olmSas; + this.channel = channel; + this.setupCancelAfterTimeout(clock); + const stageOptions = {...options, olmSas, eventEmitter: this}; + if (channel.getReceivedMessage(VerificationEventType.Start)) { + this.startStage = new SelectVerificationMethodStage(stageOptions); + } + else if (channel.getReceivedMessage(VerificationEventType.Request)) { + this.startStage = new SendReadyStage(stageOptions); + } + else { + this.startStage = new SendRequestVerificationStage(stageOptions); + } + } + + private async setupCancelAfterTimeout(clock: Clock) { + try { + const tenMinutes = 10 * 60 * 1000; + this.timeout = clock.createTimeout(tenMinutes); + await this.timeout.elapsed(); + await this.channel.cancelVerification(CancelReason.TimedOut); + } + catch { + // Ignore errors + } + } + + async abort() { + await this.channel.cancelVerification(CancelReason.UserCancelled); + } + + async start() { + try { + let stage = this.startStage; + do { + await stage.completeStage(); + stage = stage.nextStage; + } while (stage); + } + catch (e) { + if (!(e instanceof VerificationCancelledError)) { + throw e; + } + } + finally { + if (this.channel.isCancelled) { + this.emit("VerificationCancelled", this.channel.cancellation); + } + this.olmSas.free(); + this.timeout.abort(); + this.finished = true; + } + } +} + +import {HomeServer} from "../../../mocks/HomeServer.js"; +import Olm from "@matrix-org/olm/olm.js"; +import {MockChannel} from "./channel/MockChannel"; +import {Clock as MockClock} from "../../../mocks/Clock.js"; +import {NullLogger} from "../../../logging/NullLogger"; +import {SASFixtures} from "../../../fixtures/matrix/sas/events"; +import {SendKeyStage} from "./stages/SendKeyStage"; +import {CalculateSASStage} from "./stages/CalculateSASStage"; +import {SendMacStage} from "./stages/SendMacStage"; +import {VerifyMacStage} from "./stages/VerifyMacStage"; +import {SendDoneStage} from "./stages/SendDoneStage"; +import {SendAcceptVerificationStage} from "./stages/SendAcceptVerificationStage"; + +export function tests() { + async function createSASRequest( + ourUserId: string, + ourDeviceId: string, + theirUserId: string, + theirDeviceId: string, + txnId: string, + receivedMessages, + startingMessage?: any + ) { + const homeserverMock = new HomeServer(); + const hsApi = homeserverMock.api; + const olm = Olm; + await olm.init(); + const olmUtil = new Olm.Utility(); + const e2eeAccount = { + getUnsignedDeviceKey: () => { + return { + keys: { + [`ed25519:${ourDeviceId}`]: + "srsWWbrnQFIOmUSdrt3cS/unm03qAIgXcWwQg9BegKs", + }, + }; + }, + }; + const deviceTracker = { + getCrossSigningKeyForUser: (userId, __, _hsApi, _) => { + let masterKey = + userId === ourUserId + ? "5HIrEawRiiQioViNfezPDWfPWH2pdaw3pbQNHEVN2jM" + : "Ot8Y58PueQ7hJVpYWAJkg2qaREJAY/UhGZYOrsd52oo"; + return { + user_id: userId, + usage: ["master"], + keys: { + [`ed25519:${masterKey}`]: masterKey, + } + }; + }, + deviceForId: (_userId, deviceId, _hsApi, _log) => { + return { + device_id: deviceId, + keys: { + [`ed25519:${deviceId}`]: "D8w9mrokGdEZPdPgrU0kQkYi4vZyzKEBfvGyZsGK7+Q", + }, + unsigned: { + device_display_name: "lala10", + } + }; + }, + }; + const channel = new MockChannel( + theirDeviceId, + theirUserId, + ourUserId, + ourDeviceId, + receivedMessages, + deviceTracker, + txnId, + olm, + startingMessage, + ); + const crossSigning = new MockCrossSigning() as unknown as CrossSigning; + const clock = new MockClock(); + const logger = new NullLogger(); + return logger.run("log", (log) => { + // @ts-ignore + const sas = new SASVerification({ + channel, + clock, + hsApi, + // @ts-ignore + deviceTracker, + e2eeAccount, + olm, + olmUtil, + otherUserId: theirUserId!, + ourUserId, + ourUserDeviceId: ourDeviceId, + log, + crossSigning + }); + // @ts-ignore + channel.setOlmSas(sas.olmSas); + sas.on("EmojiGenerated", async (stage) => { + await stage?.setEmojiMatch(true); + }); + return { sas, clock, logger }; + }); + } + + class MockCrossSigning { + signDevice(deviceId: string, log: ILogItem) { + return Promise.resolve({}); // device keys, means signing succeeded + } + + signUser(userId: string, log: ILogItem) { + return Promise.resolve({}); // cross-signing keys, means signing succeeded + } + } + + return { + "Order of stages created matches expected order when I sent request, they sent start": async (assert) => { + const ourDeviceId = "ILQHOACESQ"; + const ourUserId = "@foobaraccount:matrix.org"; + const theirUserId = "@foobaraccount3:matrix.org"; + const theirDeviceId = "FWKXUYUHTF"; + const txnId = "t150836b91a7bed"; + const receivedMessages = new SASFixtures(theirUserId, theirDeviceId, txnId) + .youSentRequest() + .theySentStart() + .fixtures(); + const { sas } = await createSASRequest( + ourUserId, + ourDeviceId, + theirUserId, + theirDeviceId, + txnId, + receivedMessages + ); + await sas.start(); + const expectedOrder = [ + SendRequestVerificationStage, + SelectVerificationMethodStage, + SendAcceptVerificationStage, + SendKeyStage, + CalculateSASStage, + SendMacStage, + VerifyMacStage, + SendDoneStage + ] + //@ts-ignore + let stage = sas.startStage; + for (const stageClass of expectedOrder) { + assert.strictEqual(stage instanceof stageClass, true); + stage = stage.nextStage; + } + assert.strictEqual(sas.finished, true); + }, + "Order of stages created matches expected order when I sent request, I sent start": async (assert) => { + const ourDeviceId = "ILQHOACESQ"; + const ourUserId = "@foobaraccount:matrix.org"; + const theirUserId = "@foobaraccount3:matrix.org"; + const theirDeviceId = "FWKXUYUHTF"; + const txnId = "t150836b91a7bed"; + const receivedMessages = new SASFixtures(theirUserId, theirDeviceId, txnId) + .youSentRequest() + .youSentStart() + .fixtures(); + const { sas, logger } = await createSASRequest( + ourUserId, + ourDeviceId, + theirUserId, + theirDeviceId, + txnId, + receivedMessages + ); + sas.on("SelectVerificationStage", (stage) => { + logger.run("send start", async (log) => { + await stage?.selectEmojiMethod(log); + }); + }); + await sas.start(); + const expectedOrder = [ + SendRequestVerificationStage, + SelectVerificationMethodStage, + SendKeyStage, + CalculateSASStage, + SendMacStage, + VerifyMacStage, + SendDoneStage + ] + //@ts-ignore + let stage = sas.startStage; + for (const stageClass of expectedOrder) { + assert.strictEqual(stage instanceof stageClass, true); + stage = stage.nextStage; + } + assert.strictEqual(sas.finished, true); + }, + "Order of stages created matches expected order when request is received": async (assert) => { + const ourDeviceId = "ILQHOACESQ"; + const ourUserId = "@foobaraccount:matrix.org"; + const theirUserId = "@foobaraccount3:matrix.org"; + const theirDeviceId = "FWKXUYUHTF"; + const txnId = "t150836b91a7bed"; + const receivedMessages = new SASFixtures(theirUserId, theirDeviceId, txnId) + .theySentStart() + .fixtures(); + const startingMessage = receivedMessages.get(VerificationEventType.Start); + const { sas } = await createSASRequest( + ourUserId, + ourDeviceId, + theirUserId, + theirDeviceId, + txnId, + receivedMessages, + startingMessage, + ); + await sas.start(); + const expectedOrder = [ + SelectVerificationMethodStage, + SendAcceptVerificationStage, + SendKeyStage, + CalculateSASStage, + SendMacStage, + VerifyMacStage, + SendDoneStage + ] + //@ts-ignore + let stage = sas.startStage; + for (const stageClass of expectedOrder) { + assert.strictEqual(stage instanceof stageClass, true); + stage = stage.nextStage; + } + assert.strictEqual(sas.finished, true); + }, + "Order of stages created matches expected order when request is sent with start conflict (they win)": async (assert) => { + const ourDeviceId = "ILQHOACESQ"; + const ourUserId = "@foobaraccount:matrix.org"; + const theirUserId = "@foobaraccount3:matrix.org"; + const theirDeviceId = "FWKXUYUHTF"; + const txnId = "t150836b91a7bed"; + const receivedMessages = new SASFixtures(theirUserId, theirDeviceId, txnId) + .youSentRequest() + .theySentStart() + .youSentStart() + .theyWinConflict() + .fixtures(); + const { sas } = await createSASRequest( + ourUserId, + ourDeviceId, + theirUserId, + theirDeviceId, + txnId, + receivedMessages + ); + await sas.start(); + const expectedOrder = [ + SendRequestVerificationStage, + SelectVerificationMethodStage, + SendAcceptVerificationStage, + SendKeyStage, + CalculateSASStage, + SendMacStage, + VerifyMacStage, + SendDoneStage + ] + //@ts-ignore + let stage = sas.startStage; + for (const stageClass of expectedOrder) { + assert.strictEqual(stage instanceof stageClass, true); + stage = stage.nextStage; + } + assert.strictEqual(sas.finished, true); + }, + "Order of stages created matches expected order when request is sent with start conflict (I win)": async (assert) => { + const ourDeviceId = "ILQHOACESQ"; + const ourUserId = "@foobaraccount3:matrix.org"; + const theirUserId = "@foobaraccount:matrix.org"; + const theirDeviceId = "FWKXUYUHTF"; + const txnId = "t150836b91a7bed"; + const receivedMessages = new SASFixtures(theirUserId, theirDeviceId, txnId) + .youSentRequest() + .theySentStart() + .youSentStart() + .youWinConflict() + .fixtures(); + const { sas, logger } = await createSASRequest( + ourUserId, + ourDeviceId, + theirUserId, + theirDeviceId, + txnId, + receivedMessages + ); + sas.on("SelectVerificationStage", (stage) => { + logger.run("send start", async (log) => { + await stage?.selectEmojiMethod(log); + }); + }); + await sas.start(); + const expectedOrder = [ + SendRequestVerificationStage, + SelectVerificationMethodStage, + SendKeyStage, + CalculateSASStage, + SendMacStage, + VerifyMacStage, + SendDoneStage + ] + //@ts-ignore + let stage = sas.startStage; + for (const stageClass of expectedOrder) { + assert.strictEqual(stage instanceof stageClass, true); + stage = stage.nextStage; + } + assert.strictEqual(sas.finished, true); + }, + "Order of stages created matches expected order when request is received with start conflict (they win)": async (assert) => { + const ourDeviceId = "ILQHOACESQ"; + const ourUserId = "@foobaraccount:matrix.org"; + const theirUserId = "@foobaraccount3:matrix.org"; + const theirDeviceId = "FWKXUYUHTF"; + const txnId = "t150836b91a7bed"; + const receivedMessages = new SASFixtures(theirUserId, theirDeviceId, txnId) + .theySentStart() + .youSentStart() + .theyWinConflict() + .fixtures(); + const startingMessage = receivedMessages.get(VerificationEventType.Start); + console.log(receivedMessages); + const { sas } = await createSASRequest( + ourUserId, + ourDeviceId, + theirUserId, + theirDeviceId, + txnId, + receivedMessages, + startingMessage, + ); + await sas.start(); + const expectedOrder = [ + SelectVerificationMethodStage, + SendAcceptVerificationStage, + SendKeyStage, + CalculateSASStage, + SendMacStage, + VerifyMacStage, + SendDoneStage + ] + //@ts-ignore + let stage = sas.startStage; + for (const stageClass of expectedOrder) { + console.log("Checking", stageClass.constructor.name, stage.constructor.name); + assert.strictEqual(stage instanceof stageClass, true); + stage = stage.nextStage; + } + assert.strictEqual(sas.finished, true); + }, + "Order of stages created matches expected order when request is received with start conflict (I win)": async (assert) => { + const ourDeviceId = "ILQHOACESQ"; + const ourUserId = "@foobaraccount3:matrix.org"; + const theirUserId = "@foobaraccount:matrix.org"; + const theirDeviceId = "FWKXUYUHTF"; + const txnId = "t150836b91a7bed"; + const receivedMessages = new SASFixtures(theirUserId, theirDeviceId, txnId) + .theySentStart() + .youSentStart() + .youWinConflict() + .fixtures(); + const startingMessage = receivedMessages.get(VerificationEventType.Start); + console.log(receivedMessages); + const { sas, logger } = await createSASRequest( + ourUserId, + ourDeviceId, + theirUserId, + theirDeviceId, + txnId, + receivedMessages, + startingMessage, + ); + sas.on("SelectVerificationStage", (stage) => { + logger.run("send start", async (log) => { + await stage?.selectEmojiMethod(log); + }); + }); + await sas.start(); + const expectedOrder = [ + SelectVerificationMethodStage, + SendKeyStage, + CalculateSASStage, + SendMacStage, + VerifyMacStage, + SendDoneStage + ] + //@ts-ignore + let stage = sas.startStage; + for (const stageClass of expectedOrder) { + console.log("Checking", stageClass.constructor.name, stage.constructor.name); + assert.strictEqual(stage instanceof stageClass, true); + stage = stage.nextStage; + } + assert.strictEqual(sas.finished, true); + }, + "Order of stages created matches expected order when request is sent with start conflict (I win), same user": async (assert) => { + const ourDeviceId = "FWKXUYUHTF"; + const ourUserId = "@foobaraccount3:matrix.org"; + const theirUserId = "@foobaraccount3:matrix.org"; + const theirDeviceId = "ILQHOACESQ"; + const txnId = "t150836b91a7bed"; + const receivedMessages = new SASFixtures(theirUserId, theirDeviceId, txnId) + .youSentRequest() + .theySentStart() + .youSentStart() + .youWinConflict() + .fixtures(); + const { sas, logger } = await createSASRequest( + ourUserId, + ourDeviceId, + theirUserId, + theirDeviceId, + txnId, + receivedMessages + ); + sas.on("SelectVerificationStage", (stage) => { + logger.run("send start", async (log) => { + await stage?.selectEmojiMethod(log); + }); + }); + await sas.start(); + const expectedOrder = [ + SendRequestVerificationStage, + SelectVerificationMethodStage, + SendKeyStage, + CalculateSASStage, + SendMacStage, + VerifyMacStage, + SendDoneStage + ] + //@ts-ignore + let stage = sas.startStage; + for (const stageClass of expectedOrder) { + assert.strictEqual(stage instanceof stageClass, true); + stage = stage.nextStage; + } + assert.strictEqual(sas.finished, true); + }, + "Order of stages created matches expected order when request is sent with start conflict (they win), same user": async (assert) => { + const ourDeviceId = "ILQHOACESQ"; + const ourUserId = "@foobaraccount3:matrix.org"; + const theirUserId = "@foobaraccount3:matrix.org"; + const theirDeviceId = "FWKXUYUHTF"; + const txnId = "t150836b91a7bed"; + const receivedMessages = new SASFixtures(theirUserId, theirDeviceId, txnId) + .youSentRequest() + .theySentStart() + .youSentStart() + .theyWinConflict() + .fixtures(); + const { sas } = await createSASRequest( + ourUserId, + ourDeviceId, + theirUserId, + theirDeviceId, + txnId, + receivedMessages + ); + await sas.start(); + const expectedOrder = [ + SendRequestVerificationStage, + SelectVerificationMethodStage, + SendAcceptVerificationStage, + SendKeyStage, + CalculateSASStage, + SendMacStage, + VerifyMacStage, + SendDoneStage + ] + //@ts-ignore + let stage = sas.startStage; + for (const stageClass of expectedOrder) { + assert.strictEqual(stage instanceof stageClass, true); + stage = stage.nextStage; + } + assert.strictEqual(sas.finished, true); + }, + "Verification is cancelled after 10 minutes": async (assert) => { + const ourDeviceId = "ILQHOACESQ"; + const ourUserId = "@foobaraccount:matrix.org"; + const theirUserId = "@foobaraccount3:matrix.org"; + const theirDeviceId = "FWKXUYUHTF"; + const txnId = "t150836b91a7bed"; + const receivedMessages = new SASFixtures(theirUserId, theirDeviceId, txnId) + .youSentRequest() + .theySentStart() + .fixtures(); + console.log("receivedMessages", receivedMessages); + const { sas, clock } = await createSASRequest( + ourUserId, + ourDeviceId, + theirUserId, + theirDeviceId, + txnId, + receivedMessages + ); + const promise = sas.start(); + clock.elapse(10 * 60 * 1000); + try { + await promise; + } + catch (e) { + assert.strictEqual(e instanceof VerificationCancelledError, true); + } + assert.strictEqual(sas.finished, true); + }, + "Verification is cancelled when there's no common hash algorithm": async (assert) => { + const ourDeviceId = "ILQHOACESQ"; + const ourUserId = "@foobaraccount:matrix.org"; + const theirUserId = "@foobaraccount3:matrix.org"; + const theirDeviceId = "FWKXUYUHTF"; + const txnId = "t150836b91a7bed"; + const receivedMessages = new SASFixtures(theirUserId, theirDeviceId, txnId) + .youSentRequest() + .theySentStart() + .fixtures(); + receivedMessages.get(VerificationEventType.Start).content.key_agreement_protocols = ["foo"]; + const { sas } = await createSASRequest( + ourUserId, + ourDeviceId, + theirUserId, + theirDeviceId, + txnId, + receivedMessages + ); + try { + await sas.start() + } + catch (e) { + assert.strictEqual(e instanceof VerificationCancelledError, true); + } + assert.strictEqual(sas.finished, true); + }, + } +} diff --git a/src/matrix/verification/SAS/VerificationCancelledError.ts b/src/matrix/verification/SAS/VerificationCancelledError.ts new file mode 100644 index 0000000000..12d2a40222 --- /dev/null +++ b/src/matrix/verification/SAS/VerificationCancelledError.ts @@ -0,0 +1,25 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export class VerificationCancelledError extends Error { + get name(): string { + return "VerificationCancelledError"; + } + + get message(): string { + return "Verification is cancelled!"; + } +} diff --git a/src/matrix/verification/SAS/channel/Channel.ts b/src/matrix/verification/SAS/channel/Channel.ts new file mode 100644 index 0000000000..10adbd7f84 --- /dev/null +++ b/src/matrix/verification/SAS/channel/Channel.ts @@ -0,0 +1,293 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type {HomeServerApi} from "../../../net/HomeServerApi"; +import type {DeviceTracker} from "../../../e2ee/DeviceTracker.js"; +import type {ILogItem} from "../../../../logging/types"; +import type {Clock} from "../../../../platform/web/dom/Clock.js"; +import type {DeviceMessageHandler} from "../../../DeviceMessageHandler.js"; +import {makeTxnId} from "../../../common.js"; +import {CancelReason, VerificationEventType} from "./types"; +import {Disposables} from "../../../../utils/Disposables"; +import {VerificationCancelledError} from "../VerificationCancelledError"; +import {Deferred} from "../../../../utils/Deferred"; + +const messageFromErrorType = { + [CancelReason.UserCancelled]: "User declined", + [CancelReason.InvalidMessage]: "Invalid Message.", + [CancelReason.KeyMismatch]: "Key Mismatch.", + [CancelReason.OtherDeviceAccepted]: "Another device has accepted this request.", + [CancelReason.TimedOut]: "Timed Out", + [CancelReason.UnexpectedMessage]: "Unexpected Message.", + [CancelReason.UnknownMethod]: "Unknown method.", + [CancelReason.UnknownTransaction]: "Unknown Transaction.", + [CancelReason.UserMismatch]: "User Mismatch", + [CancelReason.MismatchedCommitment]: "Hash commitment does not match.", + [CancelReason.MismatchedSAS]: "Emoji/decimal does not match.", +} + +export interface IChannel { + send(eventType: VerificationEventType, content: any, log: ILogItem): Promise; + waitForEvent(eventType: VerificationEventType): Promise; + getSentMessage(event: VerificationEventType): any; + getReceivedMessage(event: VerificationEventType): any; + setStartMessage(content: any): void; + cancelVerification(cancellationType: CancelReason): Promise; + acceptMessage: any; + startMessage: any; + initiatedByUs: boolean; + isCancelled: boolean; + cancellation?: { code: CancelReason, cancelledByUs: boolean }; + id: string; + otherUserDeviceId: string; +} + +type Options = { + hsApi: HomeServerApi; + deviceTracker: DeviceTracker; + otherUserId: string; + clock: Clock; + deviceMessageHandler: DeviceMessageHandler; + log: ILogItem; + ourUserDeviceId: string; +} + +export class ToDeviceChannel extends Disposables implements IChannel { + private readonly hsApi: HomeServerApi; + private readonly deviceTracker: DeviceTracker; + private ourDeviceId: string; + private readonly otherUserId: string; + private readonly clock: Clock; + private readonly deviceMessageHandler: DeviceMessageHandler; + private readonly sentMessages: Map = new Map(); + private readonly receivedMessages: Map = new Map(); + private readonly waitMap: Map> = new Map(); + private readonly log: ILogItem; + public otherUserDeviceId: string; + public startMessage: any; + public id: string; + private _initiatedByUs: boolean; + private _cancellation?: { code: CancelReason, cancelledByUs: boolean }; + + /** + * + * @param startingMessage Create the channel with existing message in the receivedMessage buffer + */ + constructor(options: Options, startingMessage?: any) { + super(); + this.hsApi = options.hsApi; + this.deviceTracker = options.deviceTracker; + this.otherUserId = options.otherUserId; + this.ourDeviceId = options.ourUserDeviceId; + this.clock = options.clock; + this.log = options.log; + this.deviceMessageHandler = options.deviceMessageHandler; + this.track( + this.deviceMessageHandler.disposableOn( + "message", + async ({ unencrypted }) => + await this.handleDeviceMessage(unencrypted) + ) + ); + this.track(() => { + this.waitMap.forEach((value) => { + value.reject(new VerificationCancelledError()); + }); + }); + // Copy over request message + if (startingMessage) { + /** + * startingMessage may be the ready message or the start message. + */ + this.id = startingMessage.content.transaction_id; + this.receivedMessages.set(startingMessage.type, startingMessage); + this.otherUserDeviceId = startingMessage.content.from_device; + } + } + + get cancellation(): IChannel["cancellation"] { + return this._cancellation; + }; + + get isCancelled(): boolean { + return !!this._cancellation; + } + + async send(eventType: VerificationEventType, content: any, log: ILogItem): Promise { + await log.wrap("ToDeviceChannel.send", async () => { + if (this.isCancelled) { + throw new VerificationCancelledError(); + } + if (eventType === VerificationEventType.Request) { + // Handle this case specially + await this.handleRequestEventSpecially(eventType, content, log); + return; + } + Object.assign(content, { transaction_id: this.id }); + const payload = { + messages: { + [this.otherUserId]: { + [this.otherUserDeviceId]: content + } + } + } + await this.hsApi.sendToDevice(eventType, payload, makeTxnId(), { log }).response(); + this.sentMessages.set(eventType, {content}); + }); + } + + private async handleRequestEventSpecially(eventType: VerificationEventType, content: any, log: ILogItem) { + await log.wrap("ToDeviceChannel.handleRequestEventSpecially", async () => { + const timestamp = this.clock.now(); + const txnId = makeTxnId(); + this.id = txnId; + Object.assign(content, { timestamp, transaction_id: txnId }); + const payload = { + messages: { + [this.otherUserId]: { + "*": content + } + } + } + await this.hsApi.sendToDevice(eventType, payload, makeTxnId(), { log }).response(); + this.sentMessages.set(eventType, {content}); + }); + } + + getReceivedMessage(event: VerificationEventType) { + return this.receivedMessages.get(event); + } + + getSentMessage(event: VerificationEventType) { + return this.sentMessages.get(event); + } + + get acceptMessage(): any { + return this.receivedMessages.get(VerificationEventType.Accept) ?? + this.sentMessages.get(VerificationEventType.Accept); + } + + + private async handleDeviceMessage(event) { + await this.log.wrap("ToDeviceChannel.handleDeviceMessage", async (log) => { + if (!event.type.startsWith("m.key.verification.")) { + return; + } + if (event.content.transaction_id !== this.id) { + /** + * When a device receives an unknown transaction_id, it should send an appropriate + * m.key.verification.cancel message to the other device indicating as such. + * This does not apply for inbound m.key.verification.start or m.key.verification.cancel messages. + */ + console.log("Received event with unknown transaction id: ", event); + await this.cancelVerification(CancelReason.UnknownTransaction); + return; + } + console.log("event", event); + log.log({ l: "event", event }); + this.resolveAnyWaits(event); + this.receivedMessages.set(event.type, event); + if (event.type === VerificationEventType.Ready) { + this.handleReadyMessage(event, log); + return; + } + if (event.type === VerificationEventType.Cancel) { + this._cancellation = { code: event.content.code, cancelledByUs: false }; + this.dispose(); + return; + } + }); + } + + private async handleReadyMessage(event, log: ILogItem) { + const fromDevice = event.content.from_device; + this.otherUserDeviceId = fromDevice; + // We need to send cancel messages to all other devices + const devices = await this.deviceTracker.devicesForUsers([this.otherUserId], this.hsApi, log); + const otherDevices = devices.filter(device => device.device_id !== fromDevice && device.device_id !== this.ourDeviceId); + const cancelMessage = { + code: CancelReason.OtherDeviceAccepted, + reason: messageFromErrorType[CancelReason.OtherDeviceAccepted], + transaction_id: this.id, + }; + const deviceMessages = otherDevices.reduce((acc, device) => { acc[device.device_id] = cancelMessage; return acc; }, {}); + const payload = { + messages: { + [this.otherUserId]: deviceMessages + } + } + await this.hsApi.sendToDevice(VerificationEventType.Cancel, payload, makeTxnId(), { log }).response(); + } + + async cancelVerification(cancellationType: CancelReason) { + await this.log.wrap("Channel.cancelVerification", async log => { + if (this.isCancelled) { + throw new VerificationCancelledError(); + } + const payload = { + messages: { + [this.otherUserId]: { + [this.otherUserDeviceId ?? "*"]: { + code: cancellationType, + reason: messageFromErrorType[cancellationType], + transaction_id: this.id, + } + } + } + } + await this.hsApi.sendToDevice(VerificationEventType.Cancel, payload, makeTxnId(), { log }).response(); + this._cancellation = { code: cancellationType, cancelledByUs: true }; + this.dispose(); + }); + } + + private resolveAnyWaits(event) { + const { type } = event; + const wait = this.waitMap.get(type); + if (wait) { + wait.resolve(event); + this.waitMap.delete(type); + } + } + + waitForEvent(eventType: VerificationEventType): Promise { + if (this.isCancelled) { + throw new VerificationCancelledError(); + } + // Check if we already received the message + const receivedMessage = this.receivedMessages.get(eventType); + if (receivedMessage) { + return Promise.resolve(receivedMessage); + } + // Check if we're already waiting for this message + const existingWait = this.waitMap.get(eventType); + if (existingWait) { + return existingWait.promise; + } + const deferred = new Deferred(); + this.waitMap.set(eventType, deferred); + return deferred.promise; + } + + setStartMessage(event) { + this.startMessage = event; + this._initiatedByUs = event.content.from_device === this.ourDeviceId; + } + + get initiatedByUs(): boolean { + return this._initiatedByUs; + }; +} diff --git a/src/matrix/verification/SAS/channel/MockChannel.ts b/src/matrix/verification/SAS/channel/MockChannel.ts new file mode 100644 index 0000000000..cb99013817 --- /dev/null +++ b/src/matrix/verification/SAS/channel/MockChannel.ts @@ -0,0 +1,141 @@ +import type {ILogItem} from "../../../../lib"; +import {createCalculateMAC} from "../mac"; +import {VerificationCancelledError} from "../VerificationCancelledError"; +import {IChannel} from "./Channel"; +import {CancelReason, VerificationEventType} from "./types"; +import {getKeyEd25519Key} from "../../CrossSigning"; +import {getDeviceEd25519Key} from "../../../e2ee/common"; +import anotherjson from "another-json"; +import {NullLogger} from "../../../../logging/NullLogger"; + +interface ITestChannel extends IChannel { + setOlmSas(olmSas): void; +} + +export class MockChannel implements ITestChannel { + public sentMessages: Map = new Map(); + public receivedMessages: Map = new Map(); + public initiatedByUs: boolean; + public startMessage: any; + public isCancelled: boolean = false; + public cancellation: { code: CancelReason; cancelledByUs: boolean; }; + private olmSas: any; + + constructor( + public otherUserDeviceId: string, + public otherUserId: string, + public ourUserId: string, + public ourUserDeviceId: string, + private fixtures: Map, + private deviceTracker: any, + public id: string, + private olm: any, + startingMessage?: any, + ) { + if (startingMessage) { + const eventType = startingMessage.content.method ? VerificationEventType.Start : VerificationEventType.Request; + this.id = startingMessage.content.transaction_id; + this.receivedMessages.set(eventType, startingMessage); + } + } + + async send(eventType: string, content: any, _: ILogItem) { + if (this.isCancelled) { + throw new VerificationCancelledError(); + } + Object.assign(content, { transaction_id: this.id }); + this.sentMessages.set(eventType, {content}); + } + + async waitForEvent(eventType: string): Promise { + if (this.isCancelled) { + throw new VerificationCancelledError(); + } + const event = this.fixtures.get(eventType); + if (event) { + this.receivedMessages.set(eventType, event); + } + else { + await new Promise(() => {}); + } + if (eventType === VerificationEventType.Mac) { + await this.recalculateMAC(); + } + if(eventType === VerificationEventType.Accept && this.startMessage) { + } + return event; + } + + private recalculateCommitment() { + const acceptMessage = this.acceptMessage?.content; + if (!acceptMessage) { + return; + } + const {content} = this.startMessage; + const {content: keyMessage} = this.fixtures.get(VerificationEventType.Key); + const key = keyMessage.key; + const commitmentStr = key + anotherjson.stringify(content); + const olmUtil = new this.olm.Utility(); + const commitment = olmUtil.sha256(commitmentStr); + olmUtil.free(); + acceptMessage.commitment = commitment; + } + + private async recalculateMAC() { + // We need to replace the mac with calculated mac + await new NullLogger().run("log", async (log) => { + const baseInfo = + "MATRIX_KEY_VERIFICATION_MAC" + + this.otherUserId + + this.otherUserDeviceId + + this.ourUserId + + this.ourUserDeviceId + + this.id; + const { content: macContent } = this.receivedMessages.get(VerificationEventType.Mac); + const macMethod = this.acceptMessage.content.message_authentication_code; + const calculateMac = createCalculateMAC(this.olmSas, macMethod); + const input = Object.keys(macContent.mac).sort().join(","); + const properMac = calculateMac(input, baseInfo + "KEY_IDS", log); + macContent.keys = properMac; + for (const keyId of Object.keys(macContent.mac)) { + const deviceId = keyId.split(":", 2)[1]; + const device = await this.deviceTracker.deviceForId(this.otherUserDeviceId, deviceId); + if (device) { + macContent.mac[keyId] = calculateMac(getDeviceEd25519Key(device), baseInfo + keyId, log); + } + else { + const key = await this.deviceTracker.getCrossSigningKeyForUser(this.otherUserId); + const masterKey = getKeyEd25519Key(key)!; + macContent.mac[keyId] = calculateMac(masterKey, baseInfo + keyId, log); + } + } + }); + } + + setStartMessage(event: any): void { + this.startMessage = event; + this.initiatedByUs = event.content.from_device === this.ourUserDeviceId; + this.recalculateCommitment(); + } + + async cancelVerification(_: CancelReason): Promise { + this.isCancelled = true; + } + + get acceptMessage(): any { + return this.receivedMessages.get(VerificationEventType.Accept) ?? + this.sentMessages.get(VerificationEventType.Accept); + } + + getReceivedMessage(event: VerificationEventType) { + return this.receivedMessages.get(event); + } + + getSentMessage(event: VerificationEventType) { + return this.sentMessages.get(event); + } + + setOlmSas(olmSas: any): void { + this.olmSas = olmSas; + } +} diff --git a/src/matrix/verification/SAS/channel/types.ts b/src/matrix/verification/SAS/channel/types.ts new file mode 100644 index 0000000000..4a1483b7d3 --- /dev/null +++ b/src/matrix/verification/SAS/channel/types.ts @@ -0,0 +1,25 @@ +export const enum VerificationEventType { + Request = "m.key.verification.request", + Ready = "m.key.verification.ready", + Start = "m.key.verification.start", + Accept = "m.key.verification.accept", + Key = "m.key.verification.key", + Cancel = "m.key.verification.cancel", + Mac = "m.key.verification.mac", + Done = "m.key.verification.done", +} + +export const enum CancelReason { + UserCancelled = "m.user", + TimedOut = "m.timeout", + UnknownTransaction = "m.unknown_transaction", + UnknownMethod = "m.unknown_method", + UnexpectedMessage = "m.unexpected_message", + KeyMismatch = "m.key_mismatch", + UserMismatch = "m.user_mismatch", + InvalidMessage = "m.invalid_message", + OtherDeviceAccepted = "m.accepted", + // SAS specific + MismatchedCommitment = "m.mismatched_commitment", + MismatchedSAS = "m.mismatched_sas", +} diff --git a/src/matrix/verification/SAS/generator.ts b/src/matrix/verification/SAS/generator.ts new file mode 100644 index 0000000000..cff46f6f24 --- /dev/null +++ b/src/matrix/verification/SAS/generator.ts @@ -0,0 +1,122 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Copied from element-web + +type EmojiMapping = [emoji: string, name: string]; + +const emojiMapping: EmojiMapping[] = [ + ["🐶", "dog"], // 0 + ["🐱", "cat"], // 1 + ["🦁", "lion"], // 2 + ["🐎", "horse"], // 3 + ["🦄", "unicorn"], // 4 + ["🐷", "pig"], // 5 + ["🐘", "elephant"], // 6 + ["🐰", "rabbit"], // 7 + ["🐼", "panda"], // 8 + ["🐓", "rooster"], // 9 + ["🐧", "penguin"], // 10 + ["🐢", "turtle"], // 11 + ["🐟", "fish"], // 12 + ["🐙", "octopus"], // 13 + ["🦋", "butterfly"], // 14 + ["🌷", "flower"], // 15 + ["🌳", "tree"], // 16 + ["🌵", "cactus"], // 17 + ["🍄", "mushroom"], // 18 + ["🌏", "globe"], // 19 + ["🌙", "moon"], // 20 + ["☁️", "cloud"], // 21 + ["🔥", "fire"], // 22 + ["🍌", "banana"], // 23 + ["🍎", "apple"], // 24 + ["🍓", "strawberry"], // 25 + ["🌽", "corn"], // 26 + ["🍕", "pizza"], // 27 + ["🎂", "cake"], // 28 + ["❤️", "heart"], // 29 + ["🙂", "smiley"], // 30 + ["🤖", "robot"], // 31 + ["🎩", "hat"], // 32 + ["👓", "glasses"], // 33 + ["🔧", "spanner"], // 34 + ["🎅", "santa"], // 35 + ["👍", "thumbs up"], // 36 + ["☂️", "umbrella"], // 37 + ["⌛", "hourglass"], // 38 + ["⏰", "clock"], // 39 + ["🎁", "gift"], // 40 + ["💡", "light bulb"], // 41 + ["📕", "book"], // 42 + ["✏️", "pencil"], // 43 + ["📎", "paperclip"], // 44 + ["✂️", "scissors"], // 45 + ["🔒", "lock"], // 46 + ["🔑", "key"], // 47 + ["🔨", "hammer"], // 48 + ["☎️", "telephone"], // 49 + ["🏁", "flag"], // 50 + ["🚂", "train"], // 51 + ["🚲", "bicycle"], // 52 + ["✈️", "aeroplane"], // 53 + ["🚀", "rocket"], // 54 + ["🏆", "trophy"], // 55 + ["⚽", "ball"], // 56 + ["🎸", "guitar"], // 57 + ["🎺", "trumpet"], // 58 + ["🔔", "bell"], // 59 + ["⚓️", "anchor"], // 60 + ["🎧", "headphones"], // 61 + ["📁", "folder"], // 62 + ["📌", "pin"], // 63 +]; + +export function generateEmojiSas(sasBytes: number[]): EmojiMapping[] { + const emojis = [ + // just like base64 encoding + sasBytes[0] >> 2, + ((sasBytes[0] & 0x3) << 4) | (sasBytes[1] >> 4), + ((sasBytes[1] & 0xf) << 2) | (sasBytes[2] >> 6), + sasBytes[2] & 0x3f, + sasBytes[3] >> 2, + ((sasBytes[3] & 0x3) << 4) | (sasBytes[4] >> 4), + ((sasBytes[4] & 0xf) << 2) | (sasBytes[5] >> 6), + ]; + return emojis.map((num) => emojiMapping[num]); +} + +/** + * Implementation of decimal encoding of SAS as per: + * https://spec.matrix.org/v1.4/client-server-api/#sas-method-decimal + * @param sasBytes - the five bytes generated by HKDF + * @returns the derived three numbers between 1000 and 9191 inclusive + */ +export function generateDecimalSas(sasBytes: number[]): [number, number, number] { + /* + * +--------+--------+--------+--------+--------+ + * | Byte 0 | Byte 1 | Byte 2 | Byte 3 | Byte 4 | + * +--------+--------+--------+--------+--------+ + * bits: 87654321 87654321 87654321 87654321 87654321 + * \____________/\_____________/\____________/ + * 1st number 2nd number 3rd number + */ + return [ + ((sasBytes[0] << 5) | (sasBytes[1] >> 3)) + 1000, + (((sasBytes[1] & 0x7) << 10) | (sasBytes[2] << 2) | (sasBytes[3] >> 6)) + 1000, + (((sasBytes[3] & 0x3f) << 7) | (sasBytes[4] >> 1)) + 1000, + ]; +} diff --git a/src/matrix/verification/SAS/mac.ts b/src/matrix/verification/SAS/mac.ts new file mode 100644 index 0000000000..54e1c1e796 --- /dev/null +++ b/src/matrix/verification/SAS/mac.ts @@ -0,0 +1,33 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import type {ILogItem} from "../../../logging/types"; +import type {MacMethod} from "./stages/constants"; + +const macMethods: Record = { + "hkdf-hmac-sha256": "calculate_mac", + "org.matrix.msc3783.hkdf-hmac-sha256": "calculate_mac_fixed_base64", + "hkdf-hmac-sha256.v2": "calculate_mac_fixed_base64", + "hmac-sha256": "calculate_mac_long_kdf", +}; + +export function createCalculateMAC(olmSAS: Olm.SAS, method: MacMethod) { + return function (input: string, info: string, log: ILogItem): string { + return log.wrap({ l: "calculate MAC", method}, () => { + const mac = olmSAS[macMethods[method]](input, info); + return mac; + }); + }; +} diff --git a/src/matrix/verification/SAS/stages/BaseSASVerificationStage.ts b/src/matrix/verification/SAS/stages/BaseSASVerificationStage.ts new file mode 100644 index 0000000000..1c2506e093 --- /dev/null +++ b/src/matrix/verification/SAS/stages/BaseSASVerificationStage.ts @@ -0,0 +1,87 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import type {ILogItem} from "../../../../logging/types"; +import type {Account} from "../../../e2ee/Account.js"; +import type {DeviceTracker} from "../../../e2ee/DeviceTracker.js"; +import type {CrossSigning} from "../../CrossSigning"; +import {IChannel} from "../channel/Channel"; +import {HomeServerApi} from "../../../net/HomeServerApi"; +import {SASProgressEvents} from "../types"; +import {EventEmitter} from "../../../../utils/EventEmitter"; + +export type Options = { + ourUserId: string; + ourUserDeviceId: string; + otherUserId: string; + log: ILogItem; + olmSas: Olm.SAS; + olmUtil: Olm.Utility; + channel: IChannel; + e2eeAccount: Account; + deviceTracker: DeviceTracker; + hsApi: HomeServerApi; + eventEmitter: EventEmitter + crossSigning: CrossSigning +} + +export abstract class BaseSASVerificationStage { + protected ourUserId: string; + protected ourUserDeviceId: string; + protected otherUserId: string; + protected log: ILogItem; + protected olmSAS: Olm.SAS; + protected olmUtil: Olm.Utility; + protected _nextStage: BaseSASVerificationStage; + protected channel: IChannel; + protected options: Options; + protected e2eeAccount: Account; + protected deviceTracker: DeviceTracker; + protected hsApi: HomeServerApi; + protected eventEmitter: EventEmitter; + + constructor(options: Options) { + this.options = options; + this.ourUserId = options.ourUserId; + this.ourUserDeviceId = options.ourUserDeviceId + this.otherUserId = options.otherUserId; + this.log = options.log; + this.olmSAS = options.olmSas; + this.olmUtil = options.olmUtil; + this.channel = options.channel; + this.e2eeAccount = options.e2eeAccount; + this.deviceTracker = options.deviceTracker; + this.hsApi = options.hsApi; + this.eventEmitter = options.eventEmitter; + } + + setNextStage(stage: BaseSASVerificationStage) { + this._nextStage = stage; + } + + get nextStage(): BaseSASVerificationStage { + return this._nextStage; + } + + get otherUserDeviceId(): string { + const id = this.channel.otherUserDeviceId; + if (!id) { + throw new Error("Accessed otherUserDeviceId before it was set in channel!"); + } + return id; + } + + abstract completeStage(): Promise; +} diff --git a/src/matrix/verification/SAS/stages/CalculateSASStage.ts b/src/matrix/verification/SAS/stages/CalculateSASStage.ts new file mode 100644 index 0000000000..4a991897ce --- /dev/null +++ b/src/matrix/verification/SAS/stages/CalculateSASStage.ts @@ -0,0 +1,133 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import anotherjson from "another-json"; +import {BaseSASVerificationStage} from "./BaseSASVerificationStage"; +import {CancelReason, VerificationEventType} from "../channel/types"; +import {generateEmojiSas} from "../generator"; +import {ILogItem} from "../../../../logging/types"; +import {SendMacStage} from "./SendMacStage"; +import {VerificationCancelledError} from "../VerificationCancelledError"; + +type SASUserInfo = { + userId: string; + deviceId: string; + publicKey: string; +}; + +type SASUserInfoCollection = { + our: SASUserInfo; + their: SASUserInfo; + id: string; + initiatedByMe: boolean; +}; + +const calculateKeyAgreement = { + // eslint-disable-next-line @typescript-eslint/naming-convention + "curve25519-hkdf-sha256": function (sas: SASUserInfoCollection, olmSAS: Olm.SAS, bytes: number): Uint8Array { + const ourInfo = `${sas.our.userId}|${sas.our.deviceId}|` + `${sas.our.publicKey}|`; + const theirInfo = `${sas.their.userId}|${sas.their.deviceId}|${sas.their.publicKey}|`; + const sasInfo = + "MATRIX_KEY_VERIFICATION_SAS|" + + (sas.initiatedByMe ? ourInfo + theirInfo : theirInfo + ourInfo) + sas.id; + return olmSAS.generate_bytes(sasInfo, bytes); + }, + "curve25519": function (sas: SASUserInfoCollection, olmSAS: Olm.SAS, bytes: number): Uint8Array { + const ourInfo = `${sas.our.userId}${sas.our.deviceId}`; + const theirInfo = `${sas.their.userId}${sas.their.deviceId}`; + const sasInfo = + "MATRIX_KEY_VERIFICATION_SAS" + + (sas.initiatedByMe ? ourInfo + theirInfo : theirInfo + ourInfo) + sas.id; + return olmSAS.generate_bytes(sasInfo, bytes); + }, +} as const; + +export class CalculateSASStage extends BaseSASVerificationStage { + private resolve: () => void; + private reject: (error: VerificationCancelledError) => void; + + public emoji: ReturnType; + + async completeStage() { + await this.log.wrap("CalculateSASStage.completeStage", async (log) => { + // 1. Check the hash commitment + if (this.channel.initiatedByUs && !await this.verifyHashCommitment(log)) { + return; + } + // 2. Calculate the SAS + const emojiConfirmationPromise: Promise = new Promise((res, rej) => { + this.resolve = res; + this.reject = rej; + }); + this.olmSAS.set_their_key(this.theirKey); + const sasBytes = this.generateSASBytes(); + this.emoji = generateEmojiSas(Array.from(sasBytes)); + this.eventEmitter.emit("EmojiGenerated", this); + await emojiConfirmationPromise; + this.setNextStage(new SendMacStage(this.options)); + }); + } + + async verifyHashCommitment(log: ILogItem) { + return await log.wrap("CalculateSASStage.verifyHashCommitment", async () => { + const acceptMessage = this.channel.getReceivedMessage(VerificationEventType.Accept).content; + const keyMessage = this.channel.getReceivedMessage(VerificationEventType.Key).content; + const commitmentStr = keyMessage.key + anotherjson.stringify(this.channel.startMessage.content); + const receivedCommitment = acceptMessage.commitment; + const hash = this.olmUtil.sha256(commitmentStr); + if (hash !== receivedCommitment) { + log.log({l: "Commitment mismatched!", received: receivedCommitment, calculated: hash}); + await this.channel.cancelVerification(CancelReason.MismatchedCommitment); + return false; + } + return true; + }); + } + + private generateSASBytes(): Uint8Array { + const keyAgreement = this.channel.acceptMessage.content.key_agreement_protocol; + const otherUserDeviceId = this.otherUserDeviceId; + const sasBytes = calculateKeyAgreement[keyAgreement]({ + our: { + userId: this.ourUserId, + deviceId: this.ourUserDeviceId, + publicKey: this.olmSAS.get_pubkey(), + }, + their: { + userId: this.otherUserId, + deviceId: otherUserDeviceId, + publicKey: this.theirKey, + }, + id: this.channel.id, + initiatedByMe: this.channel.initiatedByUs, + }, this.olmSAS, 6); + return sasBytes; + } + + async setEmojiMatch(match: boolean) { + if (match) { + this.resolve(); + } + else { + await this.channel.cancelVerification(CancelReason.MismatchedSAS); + this.reject(new VerificationCancelledError()); + } + } + + get theirKey(): string { + const {content} = this.channel.getReceivedMessage(VerificationEventType.Key); + return content.key; + } +} diff --git a/src/matrix/verification/SAS/stages/SelectVerificationMethodStage.ts b/src/matrix/verification/SAS/stages/SelectVerificationMethodStage.ts new file mode 100644 index 0000000000..aa2302fb91 --- /dev/null +++ b/src/matrix/verification/SAS/stages/SelectVerificationMethodStage.ts @@ -0,0 +1,115 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import {BaseSASVerificationStage} from "./BaseSASVerificationStage"; +import {CancelReason, VerificationEventType} from "../channel/types"; +import {KEY_AGREEMENT_LIST, HASHES_LIST, MAC_LIST, SAS_LIST} from "./constants"; +import {SendAcceptVerificationStage} from "./SendAcceptVerificationStage"; +import {SendKeyStage} from "./SendKeyStage"; +import type {ILogItem} from "../../../../logging/types"; + +export class SelectVerificationMethodStage extends BaseSASVerificationStage { + private hasSentStartMessage = false; + private allowSelection = true; + public otherDeviceName: string; + + async completeStage() { + await this.log.wrap("SelectVerificationMethodStage.completeStage", async (log) => { + await this.findDeviceName(log); + this.eventEmitter.emit("SelectVerificationStage", this); + const startMessage = this.channel.waitForEvent(VerificationEventType.Start); + const acceptMessage = this.channel.waitForEvent(VerificationEventType.Accept); + const { content } = await Promise.race([startMessage, acceptMessage]); + if (content.method) { + // We received the start message + this.allowSelection = false; + if (this.hasSentStartMessage) { + await this.resolveStartConflict(log); + } + else { + this.channel.setStartMessage(this.channel.getReceivedMessage(VerificationEventType.Start)); + } + } + else { + // We received the accept message + this.channel.setStartMessage(this.channel.getSentMessage(VerificationEventType.Start)); + } + if (this.channel.initiatedByUs) { + await acceptMessage; + this.setNextStage(new SendKeyStage(this.options)); + } + else { + // We need to send the accept message next + this.setNextStage(new SendAcceptVerificationStage(this.options)); + } + }); + } + + private async resolveStartConflict(log: ILogItem) { + await log.wrap("resolveStartConflict", async () => { + const receivedStartMessage = this.channel.getReceivedMessage(VerificationEventType.Start); + const sentStartMessage = this.channel.getSentMessage(VerificationEventType.Start); + if (receivedStartMessage.content.method !== sentStartMessage.content.method) { + /** + * If the two m.key.verification.start messages do not specify the same verification method, + * then the verification should be cancelled with a code of m.unexpected_message. + */ + log.log({ + l: "Methods don't match for the start messages", + received: receivedStartMessage.content.method, + sent: sentStartMessage.content.method, + }); + await this.channel.cancelVerification(CancelReason.UnexpectedMessage); + return; + } + // In the case of conflict, the lexicographically smaller id wins + const our = this.ourUserId === this.otherUserId ? this.ourUserDeviceId : this.ourUserId; + const their = this.ourUserId === this.otherUserId ? this.otherUserDeviceId : this.otherUserId; + const startMessageToUse = our < their ? sentStartMessage : receivedStartMessage; + log.log({ l: "Start message resolved", message: startMessageToUse, our, their }) + this.channel.setStartMessage(startMessageToUse); + }); + } + + private async findDeviceName(log: ILogItem) { + await log.wrap("SelectVerificationMethodStage.findDeviceName", async () => { + const device = await this.options.deviceTracker.deviceForId(this.otherUserId, this.otherUserDeviceId, this.options.hsApi, log); + if (!device) { + log.log({ l: "Cannot find device", userId: this.otherUserId, deviceId: this.otherUserDeviceId }); + throw new Error("Cannot find device"); + } + this.otherDeviceName = device.unsigned.device_display_name ?? device.device_id; + }) + } + + async selectEmojiMethod(log: ILogItem) { + if (!this.allowSelection) { return; } + const content = { + method: "m.sas.v1", + from_device: this.ourUserDeviceId, + key_agreement_protocols: KEY_AGREEMENT_LIST, + hashes: HASHES_LIST, + message_authentication_codes: MAC_LIST, + short_authentication_string: SAS_LIST, + }; + /** + * Once we send the start event, we should eventually receive the accept message. + * This will cause the Promise.race in completeStage() to resolve and we'll move + * to the next stage (where we will send the key). + */ + await this.channel.send(VerificationEventType.Start, content, log); + this.hasSentStartMessage = true; + } +} diff --git a/src/matrix/verification/SAS/stages/SendAcceptVerificationStage.ts b/src/matrix/verification/SAS/stages/SendAcceptVerificationStage.ts new file mode 100644 index 0000000000..1606a53fff --- /dev/null +++ b/src/matrix/verification/SAS/stages/SendAcceptVerificationStage.ts @@ -0,0 +1,53 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import anotherjson from "another-json"; +import {BaseSASVerificationStage} from "./BaseSASVerificationStage"; +import {HASHES_LIST, MAC_LIST, SAS_SET, KEY_AGREEMENT_LIST} from "./constants"; +import {CancelReason, VerificationEventType} from "../channel/types"; +import {SendKeyStage} from "./SendKeyStage"; + +// from element-web +function intersection(anArray: T[], aSet: Set): T[] { + return Array.isArray(anArray) ? anArray.filter((x) => aSet.has(x)) : []; +} + +export class SendAcceptVerificationStage extends BaseSASVerificationStage { + async completeStage() { + await this.log.wrap("SendAcceptVerificationStage.completeStage", async (log) => { + const {content: startMessage} = this.channel.startMessage; + const keyAgreement = intersection(KEY_AGREEMENT_LIST, new Set(startMessage.key_agreement_protocols))[0]; + const hashMethod = intersection(HASHES_LIST, new Set(startMessage.hashes))[0]; + const macMethod = intersection(MAC_LIST, new Set(startMessage.message_authentication_codes))[0]; + const sasMethod = intersection(startMessage.short_authentication_string, SAS_SET); + if (!keyAgreement || !hashMethod || !macMethod || !sasMethod.length) { + await this.channel.cancelVerification(CancelReason.UnknownMethod); + return; + } + const ourPubKey = this.olmSAS.get_pubkey(); + const commitmentStr = ourPubKey + anotherjson.stringify(startMessage); + const content = { + key_agreement_protocol: keyAgreement, + hash: hashMethod, + message_authentication_code: macMethod, + short_authentication_string: sasMethod, + commitment: this.olmUtil.sha256(commitmentStr), + }; + await this.channel.send(VerificationEventType.Accept, content, log); + await this.channel.waitForEvent(VerificationEventType.Key); + this.setNextStage(new SendKeyStage(this.options)); + }); + } +} diff --git a/src/matrix/verification/SAS/stages/SendDoneStage.ts b/src/matrix/verification/SAS/stages/SendDoneStage.ts new file mode 100644 index 0000000000..dcdeba3a02 --- /dev/null +++ b/src/matrix/verification/SAS/stages/SendDoneStage.ts @@ -0,0 +1,27 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import {BaseSASVerificationStage} from "./BaseSASVerificationStage"; +import {VerificationEventType} from "../channel/types"; + +export class SendDoneStage extends BaseSASVerificationStage { + async completeStage() { + await this.log.wrap("SendDoneStage.completeStage", async (log) => { + await this.channel.send(VerificationEventType.Done, {}, log); + await this.channel.waitForEvent(VerificationEventType.Done); + this.eventEmitter.emit("VerificationCompleted", this.otherUserDeviceId); + }); + } +} diff --git a/src/matrix/verification/SAS/stages/SendKeyStage.ts b/src/matrix/verification/SAS/stages/SendKeyStage.ts new file mode 100644 index 0000000000..4f9f45def0 --- /dev/null +++ b/src/matrix/verification/SAS/stages/SendKeyStage.ts @@ -0,0 +1,35 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import {BaseSASVerificationStage} from "./BaseSASVerificationStage"; +import {VerificationEventType} from "../channel/types"; +import {CalculateSASStage} from "./CalculateSASStage"; + +export class SendKeyStage extends BaseSASVerificationStage { + async completeStage() { + await this.log.wrap("SendKeyStage.completeStage", async (log) => { + const ourSasKey = this.olmSAS.get_pubkey(); + await this.channel.send(VerificationEventType.Key, {key: ourSasKey}, log); + /** + * We may have already got the key in SendAcceptVerificationStage, + * in which case waitForEvent will return a resolved promise with + * that content. Otherwise, waitForEvent will actually wait for the + * key message. + */ + await this.channel.waitForEvent(VerificationEventType.Key); + this.setNextStage(new CalculateSASStage(this.options)); + }); + } +} diff --git a/src/matrix/verification/SAS/stages/SendMacStage.ts b/src/matrix/verification/SAS/stages/SendMacStage.ts new file mode 100644 index 0000000000..5f8fe87249 --- /dev/null +++ b/src/matrix/verification/SAS/stages/SendMacStage.ts @@ -0,0 +1,67 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import {BaseSASVerificationStage} from "./BaseSASVerificationStage"; +import {ILogItem} from "../../../../logging/types"; +import {VerificationEventType} from "../channel/types"; +import {createCalculateMAC} from "../mac"; +import {VerifyMacStage} from "./VerifyMacStage"; +import {getKeyEd25519Key, KeyUsage} from "../../CrossSigning"; + +export class SendMacStage extends BaseSASVerificationStage { + async completeStage() { + await this.log.wrap("SendMacStage.completeStage", async (log) => { + const acceptMessage = this.channel.acceptMessage.content; + const macMethod = acceptMessage.message_authentication_code; + const calculateMAC = createCalculateMAC(this.olmSAS, macMethod); + await this.sendMAC(calculateMAC, log); + await this.channel.waitForEvent(VerificationEventType.Mac); + this.setNextStage(new VerifyMacStage(this.options)); + }); + } + + private async sendMAC(calculateMAC: (input: string, info: string, log: ILogItem) => string, log: ILogItem): Promise { + const mac: Record = {}; + const keyList: string[] = []; + const baseInfo = + "MATRIX_KEY_VERIFICATION_MAC" + + this.ourUserId + + this.ourUserDeviceId + + this.otherUserId + + this.otherUserDeviceId + + this.channel.id; + + const deviceKeyId = `ed25519:${this.ourUserDeviceId}`; + const deviceKeys = this.e2eeAccount.getUnsignedDeviceKey(); + mac[deviceKeyId] = calculateMAC(deviceKeys.keys[deviceKeyId], baseInfo + deviceKeyId, log); + keyList.push(deviceKeyId); + + const key = await this.deviceTracker.getCrossSigningKeyForUser(this.ourUserId, KeyUsage.Master, this.hsApi, log); + if (!key) { + log.log({ l: "Fetching msk failed", userId: this.ourUserId }); + throw new Error("Fetching MSK for user failed!"); + } + const crossSigningKey = getKeyEd25519Key(key); + if (crossSigningKey) { + const crossSigningKeyId = `ed25519:${crossSigningKey}`; + mac[crossSigningKeyId] = calculateMAC(crossSigningKey, baseInfo + crossSigningKeyId, log); + keyList.push(crossSigningKeyId); + } + + const keys = calculateMAC(keyList.sort().join(","), baseInfo + "KEY_IDS", log); + await this.channel.send(VerificationEventType.Mac, { mac, keys }, log); + } +} + diff --git a/src/matrix/verification/SAS/stages/SendReadyStage.ts b/src/matrix/verification/SAS/stages/SendReadyStage.ts new file mode 100644 index 0000000000..c4fc0cc6a4 --- /dev/null +++ b/src/matrix/verification/SAS/stages/SendReadyStage.ts @@ -0,0 +1,31 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import {BaseSASVerificationStage} from "./BaseSASVerificationStage"; +import {VerificationEventType} from "../channel/types"; +import {SelectVerificationMethodStage} from "./SelectVerificationMethodStage"; + +export class SendReadyStage extends BaseSASVerificationStage { + async completeStage() { + await this.log.wrap("SendReadyStage.completeStage", async (log) => { + const content = { + "from_device": this.ourUserDeviceId, + "methods": ["m.sas.v1"], + }; + await this.channel.send(VerificationEventType.Ready, content, log); + this.setNextStage(new SelectVerificationMethodStage(this.options)); + }); + } +} diff --git a/src/matrix/verification/SAS/stages/SendRequestVerificationStage.ts b/src/matrix/verification/SAS/stages/SendRequestVerificationStage.ts new file mode 100644 index 0000000000..73c8019e90 --- /dev/null +++ b/src/matrix/verification/SAS/stages/SendRequestVerificationStage.ts @@ -0,0 +1,32 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import {BaseSASVerificationStage} from "./BaseSASVerificationStage"; +import {SelectVerificationMethodStage} from "./SelectVerificationMethodStage"; +import {VerificationEventType} from "../channel/types"; + +export class SendRequestVerificationStage extends BaseSASVerificationStage { + async completeStage() { + await this.log.wrap("SendRequestVerificationStage.completeStage", async (log) => { + const content = { + "from_device": this.ourUserDeviceId, + "methods": ["m.sas.v1"], + }; + await this.channel.send(VerificationEventType.Request, content, log); + this.setNextStage(new SelectVerificationMethodStage(this.options)); + await this.channel.waitForEvent(VerificationEventType.Ready); + }); + } +} diff --git a/src/matrix/verification/SAS/stages/VerifyMacStage.ts b/src/matrix/verification/SAS/stages/VerifyMacStage.ts new file mode 100644 index 0000000000..a1ef55157a --- /dev/null +++ b/src/matrix/verification/SAS/stages/VerifyMacStage.ts @@ -0,0 +1,94 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import {BaseSASVerificationStage} from "./BaseSASVerificationStage"; +import {ILogItem} from "../../../../logging/types"; +import {CancelReason, VerificationEventType} from "../channel/types"; +import {createCalculateMAC} from "../mac"; +import {SendDoneStage} from "./SendDoneStage"; +import {KeyUsage, getKeyEd25519Key} from "../../CrossSigning"; +import {getDeviceEd25519Key} from "../../../e2ee/common"; + +export type KeyVerifier = (keyId: string, publicKey: string, keyInfo: string) => boolean; + +export class VerifyMacStage extends BaseSASVerificationStage { + async completeStage() { + await this.log.wrap("VerifyMacStage.completeStage", async (log) => { + const acceptMessage = this.channel.acceptMessage.content; + const macMethod = acceptMessage.message_authentication_code; + const calculateMAC = createCalculateMAC(this.olmSAS, macMethod); + await this.checkMAC(calculateMAC, log); + this.setNextStage(new SendDoneStage(this.options)); + }); + } + + private async checkMAC(calculateMAC: (input: string, info: string, log: ILogItem) => string, log: ILogItem): Promise { + const {content} = this.channel.getReceivedMessage(VerificationEventType.Mac); + const baseInfo = + "MATRIX_KEY_VERIFICATION_MAC" + + this.otherUserId + + this.otherUserDeviceId + + this.ourUserId + + this.ourUserDeviceId + + this.channel.id; + + const calculatedMAC = calculateMAC(Object.keys(content.mac).sort().join(","), baseInfo + "KEY_IDS", log); + if (content.keys !== calculatedMAC) { + log.log({ l: "MAC verification failed for keys field", keys: content.keys, calculated: calculatedMAC }); + this.channel.cancelVerification(CancelReason.KeyMismatch); + return; + } + + await this.verifyKeys(content.mac, (keyId, key, keyInfo) => { + const calculatedMAC = calculateMAC(key, baseInfo + keyId, log); + const matches = keyInfo === calculatedMAC; + if (!matches) { + log.log({ l: "Mac verification failed for key", keyMac: keyInfo, calculatedMAC, keyId, key }); + this.channel.cancelVerification(CancelReason.KeyMismatch); + } + return matches; + }, log); + } + + protected async verifyKeys(keys: Record, verifier: KeyVerifier, log: ILogItem): Promise { + const userId = this.otherUserId; + for (const [keyId, keyInfo] of Object.entries(keys)) { + const deviceIdOrMSK = keyId.split(":", 2)[1]; + const device = await this.deviceTracker.deviceForId(userId, deviceIdOrMSK, this.hsApi, log); + if (device) { + if (verifier(keyId, getDeviceEd25519Key(device), keyInfo)) { + await log.wrap("signing device", async log => { + const signedKey = await this.options.crossSigning.signDevice(device.device_id, log); + log.set("success", !!signedKey); + }); + } + } else { + // If we were not able to find the device, then deviceIdOrMSK is actually the MSK! + const key = await this.deviceTracker.getCrossSigningKeyForUser(userId, KeyUsage.Master, this.hsApi, log); + if (!key) { + log.log({ l: "Fetching msk failed", userId }); + throw new Error("Fetching MSK for user failed!"); + } + const masterKey = getKeyEd25519Key(key); + if(masterKey && verifier(keyId, masterKey, keyInfo)) { + await log.wrap("signing user", async log => { + const signedKey = await this.options.crossSigning.signUser(userId, log); + log.set("success", !!signedKey); + }); + } + } + } + } +} diff --git a/src/matrix/verification/SAS/stages/constants.ts b/src/matrix/verification/SAS/stages/constants.ts new file mode 100644 index 0000000000..8112ce4453 --- /dev/null +++ b/src/matrix/verification/SAS/stages/constants.ts @@ -0,0 +1,14 @@ +// From element-web +export type KeyAgreement = "curve25519-hkdf-sha256" | "curve25519"; +export type MacMethod = "hkdf-hmac-sha256.v2" | "org.matrix.msc3783.hkdf-hmac-sha256" | "hkdf-hmac-sha256" | "hmac-sha256"; + +export const KEY_AGREEMENT_LIST: KeyAgreement[] = ["curve25519-hkdf-sha256", "curve25519"]; +export const HASHES_LIST = ["sha256"]; +export const MAC_LIST: MacMethod[] = [ + "hkdf-hmac-sha256.v2", + "org.matrix.msc3783.hkdf-hmac-sha256", + "hkdf-hmac-sha256", + "hmac-sha256", +]; +export const SAS_LIST = ["decimal", "emoji"]; +export const SAS_SET = new Set(SAS_LIST); diff --git a/src/matrix/verification/SAS/types.ts b/src/matrix/verification/SAS/types.ts new file mode 100644 index 0000000000..a46ee0856c --- /dev/null +++ b/src/matrix/verification/SAS/types.ts @@ -0,0 +1,25 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import type {IChannel} from "./channel/Channel"; +import type {CalculateSASStage} from "./stages/CalculateSASStage"; +import type {SelectVerificationMethodStage} from "./stages/SelectVerificationMethodStage"; + +export type SASProgressEvents = { + SelectVerificationStage: SelectVerificationMethodStage; + EmojiGenerated: CalculateSASStage; + VerificationCompleted: string; + VerificationCancelled: IChannel["cancellation"]; +} diff --git a/src/matrix/verification/common.ts b/src/matrix/verification/common.ts index 369b561826..de9b1b1b97 100644 --- a/src/matrix/verification/common.ts +++ b/src/matrix/verification/common.ts @@ -16,24 +16,10 @@ limitations under the License. import { PkSigning } from "@matrix-org/olm"; import anotherjson from "another-json"; +import type {SignedValue} from "../e2ee/common"; import type * as OlmNamespace from "@matrix-org/olm"; type Olm = typeof OlmNamespace; -export interface IObject { - unsigned?: object; - signatures?: ISignatures; -} - -export interface ISignatures { - [entity: string]: { - [keyId: string]: string; - }; -} - -export interface ISigned { - signatures?: ISignatures; -} - // from matrix-js-sdk /** * Sign a JSON object using public key cryptography @@ -45,7 +31,7 @@ export interface ISigned { * @param pubKey - The public key (ignored if key is a seed) * @returns the signature for the object */ - export function pkSign(olmUtil: Olm, obj: object & IObject, key: Uint8Array | PkSigning, userId: string, pubKey: string): string { + export function pkSign(olmUtil: Olm, obj: SignedValue, key: Uint8Array | PkSigning, userId: string, pubKey: string): string { let createdKey = false; if (key instanceof Uint8Array) { const keyObj = new olmUtil.PkSigning(); @@ -69,4 +55,4 @@ export interface ISigned { key.free(); } } -} \ No newline at end of file +} diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index be8c997078..e4986cc78f 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -43,6 +43,7 @@ import {MediaDevicesWrapper} from "./dom/MediaDevices"; import {DOMWebRTC} from "./dom/WebRTC"; import {ThemeLoader} from "./theming/ThemeLoader"; import {TimeFormatter} from "./dom/TimeFormatter"; +import {copyPlaintext} from "./dom/utils"; function addScript(src) { return new Promise(function (resolve, reject) { @@ -283,6 +284,10 @@ export class Platform { } } + async copyPlaintext(text) { + return await copyPlaintext(text); + } + restart() { document.location.reload(); } @@ -300,7 +305,8 @@ export class Platform { const file = input.files[0]; this._container.removeChild(input); if (file) { - resolve({name: file.name, blob: BlobHandle.fromBlob(file)}); + // ok to not filter mimetypes as these are local files + resolve({name: file.name, blob: BlobHandle.fromBlobUnsafe(file)}); } else { resolve(); } diff --git a/src/platform/web/dom/BlobHandle.js b/src/platform/web/dom/BlobHandle.js index 932fa53c5c..8ffd919e9d 100644 --- a/src/platform/web/dom/BlobHandle.js +++ b/src/platform/web/dom/BlobHandle.js @@ -99,8 +99,10 @@ export class BlobHandle { return new BlobHandle(new Blob([buffer], {type: mimetype}), buffer); } - static fromBlob(blob) { - // ok to not filter mimetypes as these are local files + /** Does not filter out mimetypes that could execute embedded javascript. + * It's up to the callee of this method to ensure that the blob won't be + * rendered by the browser in a way that could allow cross-signing scripting. */ + static fromBlobUnsafe(blob) { return new BlobHandle(blob); } diff --git a/src/platform/web/dom/ImageHandle.js b/src/platform/web/dom/ImageHandle.js index 4ac3a6cd2e..21eed5ff79 100644 --- a/src/platform/web/dom/ImageHandle.js +++ b/src/platform/web/dom/ImageHandle.js @@ -15,7 +15,7 @@ limitations under the License. */ import {BlobHandle} from "./BlobHandle.js"; -import {domEventAsPromise} from "./utils.js"; +import {domEventAsPromise} from "./utils"; export class ImageHandle { static async fromBlob(blob) { @@ -64,7 +64,8 @@ export class ImageHandle { } else { throw new Error("canvas can't be turned into blob"); } - const blob = BlobHandle.fromBlob(nativeBlob); + // unsafe is ok because it's a jpeg or png image + const blob = BlobHandle.fromBlobUnsafe(nativeBlob); return new ImageHandle(blob, scaledWidth, scaledHeight, null); } diff --git a/src/platform/web/dom/MediaDevices.ts b/src/platform/web/dom/MediaDevices.ts index d6439faa11..04d3e8e4ca 100644 --- a/src/platform/web/dom/MediaDevices.ts +++ b/src/platform/web/dom/MediaDevices.ts @@ -65,7 +65,7 @@ export class MediaDevicesWrapper implements IMediaDevices { }; } - private getScreenshareContraints(): DisplayMediaStreamConstraints { + private getScreenshareContraints(): MediaStreamConstraints { return { audio: false, video: true, diff --git a/src/platform/web/dom/utils.js b/src/platform/web/dom/utils.js deleted file mode 100644 index 43a2664033..0000000000 --- a/src/platform/web/dom/utils.js +++ /dev/null @@ -1,35 +0,0 @@ -/* -Copyright 2020 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 function domEventAsPromise(element, successEvent) { - return new Promise((resolve, reject) => { - let detach; - const handleError = evt => { - detach(); - reject(evt.target.error); - }; - const handleSuccess = () => { - detach(); - resolve(); - }; - detach = () => { - element.removeEventListener(successEvent, handleSuccess); - element.removeEventListener("error", handleError); - }; - element.addEventListener(successEvent, handleSuccess); - element.addEventListener("error", handleError); - }); -} diff --git a/src/platform/web/dom/utils.ts b/src/platform/web/dom/utils.ts new file mode 100644 index 0000000000..8013b49161 --- /dev/null +++ b/src/platform/web/dom/utils.ts @@ -0,0 +1,79 @@ +/* +Copyright 2020 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 function domEventAsPromise(element: HTMLElement, successEvent: string): Promise { + return new Promise((resolve, reject) => { + let detach; + const handleError = evt => { + detach(); + reject(evt.target.error); + }; + const handleSuccess = () => { + detach(); + resolve(); + }; + detach = () => { + element.removeEventListener(successEvent, handleSuccess); + element.removeEventListener("error", handleError); + }; + element.addEventListener(successEvent, handleSuccess); + element.addEventListener("error", handleError); + }); +} + +// Copies the given text to clipboard and returns a boolean of whether the action was +// successful +export async function copyPlaintext(text: string): Promise { + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } else { + const textArea = document.createElement("textarea"); + textArea.value = text; + + // Avoid scrolling to bottom + textArea.style.top = "0"; + textArea.style.left = "0"; + textArea.style.position = "fixed"; + + document.body.appendChild(textArea); + + const selection = document.getSelection(); + if (!selection) { + console.error('copyPlaintext: Unable to copy text to clipboard in fallback mode because `selection` was null/undefined'); + return false; + } + + const range = document.createRange(); + // range.selectNodeContents(textArea); + range.selectNode(textArea); + selection.removeAllRanges(); + selection.addRange(range); + + const successful = document.execCommand("copy"); + selection.removeAllRanges(); + document.body.removeChild(textArea); + if(!successful) { + console.error('copyPlaintext: Unable to copy text to clipboard in fallback mode because the `copy` command is unsupported or disabled'); + } + return successful; + } + } catch (err) { + console.error("copyPlaintext: Ran into an error", err); + } + return false; +} diff --git a/src/platform/web/ui/css/right-panel.css b/src/platform/web/ui/css/right-panel.css index 92a89c0a1f..5af0e6a059 100644 --- a/src/platform/web/ui/css/right-panel.css +++ b/src/platform/web/ui/css/right-panel.css @@ -19,6 +19,37 @@ text-align: center; } +.MemberDetailsView_shield_container { + display: flex; + gap: 4px; +} + +.MemberDetailsView_shield_red, .MemberDetailsView_shield_green, .MemberDetailsView_shield_black { + background-size: contain; + background-repeat: no-repeat; + width: 24px; + height: 24px; + display: block; + flex-shrink: 0; +} + +.MemberDetailsView_shield_description { + flex-grow: 1; + margin: 0; +} + +.MemberDetailsView_shield_red { + background-image: url("./icons/verification-error.svg?primary=error-color"); +} + +.MemberDetailsView_shield_green { + background-image: url("./icons/verified.svg?primary=accent-color"); +} + +.MemberDetailsView_shield_black { + background-image: url("./icons/encryption-status.svg?primary=text-color"); +} + .RoomDetailsView_label, .RoomDetailsView_row, .RoomDetailsView, .MemberDetailsView, .EncryptionIconView { display: flex; align-items: center; diff --git a/src/platform/web/ui/css/themes/element/icons/verification-error.svg b/src/platform/web/ui/css/themes/element/icons/verification-error.svg new file mode 100644 index 0000000000..9733f563b8 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/verification-error.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/web/ui/css/themes/element/icons/verified.svg b/src/platform/web/ui/css/themes/element/icons/verified.svg new file mode 100644 index 0000000000..d158e607d8 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/verified.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 4c617386fa..5f13bb7ce8 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -1182,6 +1182,7 @@ button.RoomDetailsView_row::after { border: none; background: none; cursor: pointer; + text-align: left; } .LazyListParent { @@ -1263,27 +1264,52 @@ button.RoomDetailsView_row::after { padding: 0; } +.VerificationToastNotificationView:not(:first-child), .CallToastNotificationView:not(:first-child) { margin-top: 12px; } +.VerificationToastNotificationView { + display: flex; + flex-direction: column; +} + .CallToastNotificationView { display: grid; grid-template-rows: 40px 1fr 1fr 48px; row-gap: 4px; - width: 260px; +} + + +.VerificationToastNotificationView, +.CallToastNotificationView { background-color: var(--background-color-secondary); border-radius: 8px; color: var(--text-color); box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.5); } +.CallToastNotificationView { + width: 260px; +} + +.VerificationToastNotificationView { + width: 248px; +} + +.VerificationToastNotificationView__top { + padding: 8px; + display: flex; +} + .CallToastNotificationView__top { display: grid; grid-template-columns: auto 176px auto; align-items: center; justify-items: center; } + +.VerificationToastNotificationView__dismiss-btn, .CallToastNotificationView__dismiss-btn { background: center var(--background-color-secondary--darker-5) url("./icons/dismiss.svg?primary=text-color") no-repeat; border-radius: 100%; @@ -1291,11 +1317,16 @@ button.RoomDetailsView_row::after { width: 15px; } +.VerificationToastNotificationView__title, .CallToastNotificationView__name { font-weight: 600; width: 100%; } +.VerificationToastNotificationView__description { + padding: 8px; +} + .CallToastNotificationView__description { margin-left: 42px; } @@ -1349,7 +1380,105 @@ button.RoomDetailsView_row::after { margin-right: 10px; } +.VerificationToastNotificationView__action { + display: flex; + justify-content: space-between; + padding: 8px; +} + .CallToastNotificationView__action .button-action { width: 100px; height: 40px; } + +.VerificationToastNotificationView__action .button-action { + width: 100px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.VerificationCompleteView, +.DeviceVerificationView, +.SelectMethodView { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; +} + +.VerificationCompleteView__heading, +.VerifyEmojisView__heading, +.SelectMethodView__heading, +.WaitingForOtherUserView__heading { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + justify-content: center; + padding: 8px; +} + +.VerificationCompleteView>*, +.SelectMethodView>*, +.VerifyEmojisView>*, +.WaitingForOtherUserView>* { + padding: 16px; +} + +.VerificationCompleteView__title, +.VerifyEmojisView__title, +.SelectMethodView__title, +.WaitingForOtherUserView__title, +.VerificationCancelledView__description, +.VerificationCompleteView__description, +.VerifyEmojisView__description, +.SelectMethodView__description, +.WaitingForOtherUserView__description { + text-align: center; + margin: 0; +} + +.VerificationCancelledView__actions, +.SelectMethodView__actions, +.VerifyEmojisView__actions, +.WaitingForOtherUserView__actions { + display: flex; + justify-content: center; + gap: 12px; + padding: 16px; +} + +.EmojiCollection { + display: flex; + justify-content: center; + gap: 16px; +} + +.EmojiContainer__emoji { + font-size: 3.2rem; +} + +.VerifyEmojisView__waiting, +.EmojiContainer__name, +.EmojiContainer__emoji { + display: flex; + justify-content: center; + align-items: center; +} + +.EmojiContainer__name { + font-weight: bold; +} + +.VerifyEmojisView__waiting { + gap: 12px; +} + +.VerificationCompleteView__icon { + background: url("./icons/verified.svg?primary=accent-color") no-repeat; + background-size: contain; + width: 128px; + height: 128px; +} diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 4a82260572..91c069d323 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -433,7 +433,7 @@ only loads when the top comes into view*/ .DateHeader time { margin: 0 auto; padding: 12px 4px; - width: 250px; + max-width: 350px; padding: 12px; display: block; color: var(--light-text-color); diff --git a/src/platform/web/ui/general/TemplateView.ts b/src/platform/web/ui/general/TemplateView.ts index 3b65ed9cd1..68a6fa4e8e 100644 --- a/src/platform/web/ui/general/TemplateView.ts +++ b/src/platform/web/ui/general/TemplateView.ts @@ -29,17 +29,17 @@ function objHasFns(obj: ClassNames): obj is { [className: string]: bool return false; } -export type RenderFn = (t: Builder, vm: T) => ViewNode; -type TextBinding = (T) => string | number | boolean | undefined | null; -type Child = NonBoundChild | TextBinding; -type Children = Child | Child[]; +export type RenderFn = (t: Builder, vm: T) => ViewNode; +type TextBinding = (T) => string | number | boolean | undefined | null; +type Child = NonBoundChild | TextBinding; +type Children = Child | Child[]; type EventHandler = ((event: Event) => void); type AttributeStaticValue = string | boolean; -type AttributeBinding = (value: T) => AttributeStaticValue; -export type AttrValue = AttributeStaticValue | AttributeBinding | EventHandler | ClassNames; -export type Attributes = { [attribute: string]: AttrValue }; -type ElementFn = (attributes?: Attributes | Children, children?: Children) => Element; -export type Builder = TemplateBuilder & { [tagName in typeof TAG_NAMES[string][number]]: ElementFn }; +type AttributeBinding = (value: T) => AttributeStaticValue; +export type AttrValue = AttributeStaticValue | AttributeBinding | EventHandler | ClassNames; +export type Attributes = { [attribute: string]: AttrValue }; +type ElementFn = (attributes?: Attributes | Children, children?: Children) => Element; +export type Builder = TemplateBuilder & { [tagName in typeof TAG_NAMES[string][number]]: ElementFn }; /** Bindable template. Renders once, and allows bindings for given nodes. If you need @@ -394,7 +394,7 @@ for (const [ns, tags] of Object.entries(TAG_NAMES)) { } } -export class InlineTemplateView extends TemplateView { +export class InlineTemplateView extends TemplateView { private _render: RenderFn; constructor(value: T, render: RenderFn) { diff --git a/src/platform/web/ui/login/AccountSetupView.js b/src/platform/web/ui/login/AccountSetupView.js index e0d416931b..cf2b544fda 100644 --- a/src/platform/web/ui/login/AccountSetupView.js +++ b/src/platform/web/ui/login/AccountSetupView.js @@ -15,7 +15,7 @@ limitations under the License. */ import {TemplateView} from "../general/TemplateView"; -import {KeyBackupSettingsView} from "../session/settings/KeyBackupSettingsView.js"; +import {KeyBackupSettingsView} from "../session/settings/KeyBackupSettingsView"; export class AccountSetupView extends TemplateView { render(t, vm) { diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index 9f84e872ad..8156085cc5 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -30,6 +30,7 @@ import {CreateRoomView} from "./CreateRoomView.js"; import {RightPanelView} from "./rightpanel/RightPanelView.js"; import {viewClassForTile} from "./room/common"; import {JoinRoomView} from "./JoinRoomView"; +import {DeviceVerificationView} from "./verification/DeviceVerificationView"; import {ToastCollectionView} from "./toast/ToastCollectionView"; export class SessionView extends TemplateView { @@ -53,6 +54,8 @@ export class SessionView extends TemplateView { return new CreateRoomView(vm.createRoomViewModel); } else if (vm.joinRoomViewModel) { return new JoinRoomView(vm.joinRoomViewModel); + } else if (vm.verificationViewModel) { + return new DeviceVerificationView(vm.verificationViewModel); } else if (vm.currentRoomViewModel) { if (vm.currentRoomViewModel.kind === "invite") { return new InviteView(vm.currentRoomViewModel); diff --git a/src/platform/web/ui/session/rightpanel/MemberDetailsView.js b/src/platform/web/ui/session/rightpanel/MemberDetailsView.js index 5d2f9387e3..c02d8d73fd 100644 --- a/src/platform/web/ui/session/rightpanel/MemberDetailsView.js +++ b/src/platform/web/ui/session/rightpanel/MemberDetailsView.js @@ -19,15 +19,25 @@ import {TemplateView} from "../../general/TemplateView"; export class MemberDetailsView extends TemplateView { render(t, vm) { + const securityNodes = [ + t.p(vm.isEncrypted ? + vm.i18n`Messages in this room are end-to-end encrypted.` : + vm.i18n`Messages in this room are not end-to-end encrypted.`), + ] + + if (vm.features.crossSigning) { + securityNodes.push(t.div({className: "MemberDetailsView_shield_container"}, [ + t.span({className: vm => `MemberDetailsView_shield_${vm.trustShieldColor}`}), + t.p({className: "MemberDetailsView_shield_description"}, vm => vm.trustDescription) + ])); + } + return t.div({className: "MemberDetailsView"}, [ t.view(new AvatarView(vm, 128)), t.div({className: "MemberDetailsView_name"}, t.h2(vm => vm.name)), t.div({className: "MemberDetailsView_id"}, vm.userId), this._createSection(t, vm.i18n`Role`, vm => vm.role), - this._createSection(t, vm.i18n`Security`, vm.isEncrypted ? - vm.i18n`Messages in this room are end-to-end encrypted.` : - vm.i18n`Messages in this room are not end-to-end encrypted.` - ), + this._createSection(t, vm.i18n`Security`, securityNodes), this._createOptions(t, vm) ]); } @@ -41,14 +51,22 @@ export class MemberDetailsView extends TemplateView { } _createOptions(t, vm) { + const options = [ + t.a({href: vm.linkToUser, target: "_blank", rel: "noopener"}, vm.i18n`Open Link to User`), + t.button({className: "text", onClick: () => vm.openDirectMessage()}, vm.i18n`Open direct message`) + ]; + if (vm.features.crossSigning) { + const onClick = () => { + if (confirm("You don't want to do this with any account but a test account. This will cross-sign this user without verifying their keys first. You won't be able to undo this apart from resetting your cross-signing keys.")) { + vm.signUser(); + } + }; + options.push(t.button({className: "text", onClick}, vm.i18n`Cross-sign user (DO NOT USE, TESTING ONLY)`)) + } return t.div({ className: "MemberDetailsView_section" }, [ t.div({className: "MemberDetailsView_label"}, vm.i18n`Options`), - t.div({className: "MemberDetailsView_options"}, - [ - t.a({href: vm.linkToUser, target: "_blank", rel: "noopener"}, vm.i18n`Open Link to User`), - t.button({className: "text", onClick: () => vm.openDirectMessage()}, vm.i18n`Open direct message`) - ]) + t.div({className: "MemberDetailsView_options"}, options) ]); } } diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index d998e8269b..84a4a1ad0d 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -126,6 +126,7 @@ export class BaseMessageView extends TemplateView { } else if (vm.canRedact) { options.push(Menu.option(vm.i18n`Delete`, () => vm.redact()).setDestructive()); } + options.push(Menu.option(vm.i18n`Copy matrix.to permalink`, () => vm.copyPermalink())); return options; } diff --git a/src/platform/web/ui/session/room/timeline/VideoView.js b/src/platform/web/ui/session/room/timeline/VideoView.js index 340cae6d24..9b092ed091 100644 --- a/src/platform/web/ui/session/room/timeline/VideoView.js +++ b/src/platform/web/ui/session/room/timeline/VideoView.js @@ -15,7 +15,7 @@ limitations under the License. */ import {BaseMediaView} from "./BaseMediaView.js"; -import {domEventAsPromise} from "../../../../dom/utils.js"; +import {domEventAsPromise} from "../../../../dom/utils"; export class VideoView extends BaseMediaView { renderMedia(t) { diff --git a/src/platform/web/ui/session/settings/KeyBackupSettingsView.js b/src/platform/web/ui/session/settings/KeyBackupSettingsView.ts similarity index 68% rename from src/platform/web/ui/session/settings/KeyBackupSettingsView.js rename to src/platform/web/ui/session/settings/KeyBackupSettingsView.ts index a68a80b3ed..7c3d64914f 100644 --- a/src/platform/web/ui/session/settings/KeyBackupSettingsView.js +++ b/src/platform/web/ui/session/settings/KeyBackupSettingsView.ts @@ -14,32 +14,35 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView"; +import {TemplateView, Builder} from "../../general/TemplateView"; import {disableTargetCallback} from "../../general/utils"; +import {ViewNode} from "../../general/types"; +import {KeyBackupViewModel, Status, BackupWriteStatus} from "../../../../../domain/session/settings/KeyBackupViewModel"; +import {KeyType} from "../../../../../matrix/ssss/index"; -export class KeyBackupSettingsView extends TemplateView { - render(t, vm) { +export class KeyBackupSettingsView extends TemplateView { + render(t: Builder, vm: KeyBackupViewModel): ViewNode { return t.div([ t.map(vm => vm.status, (status, t, vm) => { switch (status) { - case "Enabled": return renderEnabled(t, vm); - case "NewVersionAvailable": return renderNewVersionAvailable(t, vm); - case "SetupKey": return renderEnableFromKey(t, vm); - case "SetupPhrase": return renderEnableFromPhrase(t, vm); - case "Pending": return t.p(vm.i18n`Waiting to go online…`); + case Status.Enabled: return renderEnabled(t, vm); + case Status.NewVersionAvailable: return renderNewVersionAvailable(t, vm); + case Status.SetupWithPassphrase: return renderEnableFromPhrase(t, vm); + case Status.SetupWithRecoveryKey: return renderEnableFromKey(t, vm); + case Status.Pending: return t.p(vm.i18n`Waiting to go online…`); } }), t.map(vm => vm.backupWriteStatus, (status, t, vm) => { switch (status) { - case "Writing": { + case BackupWriteStatus.Writing: { const progress = t.progress({ - min: 0, - max: 100, + min: 0+"", + max: 100+"", value: vm => vm.backupPercentage, }); return t.div([`Backup in progress `, progress, " ", vm => vm.backupInProgressLabel]); } - case "Stopped": { + case BackupWriteStatus.Stopped: { let label; const error = vm.backupError; if (error) { @@ -47,30 +50,43 @@ export class KeyBackupSettingsView extends TemplateView { } else { label = `Backup has stopped`; } - return t.p(label, " ", t.button({onClick: () => vm.startBackup()}, `Backup now`)); + return t.p([label, " ", t.button({onClick: () => vm.startBackup()}, `Backup now`)]); } - case "Done": + case BackupWriteStatus.Done: return t.p(`All keys are backed up.`); default: - return null; + return undefined; } }), t.if(vm => vm.isMasterKeyTrusted, t => { return t.p("Cross-signing master key found and trusted.") }), t.if(vm => vm.canSignOwnDevice, t => { - return t.button({ - onClick: disableTargetCallback(async evt => { - await vm.signOwnDevice(); - }) - }, "Sign own device"); + return t.div([ + t.button( + { + onClick: disableTargetCallback(async (evt) => { + await vm.signOwnDevice(); + }), + }, + "Sign own device" + ), + t.button( + { + onClick: disableTargetCallback(async () => { + vm.navigateToVerification(); + }), + }, + "Verify by emoji" + ), + ]); }), ]); } } -function renderEnabled(t, vm) { +function renderEnabled(t: Builder, vm: KeyBackupViewModel): ViewNode { const items = [ t.p([vm.i18n`Key backup is enabled, using backup version ${vm.backupVersion}. `, t.button({onClick: () => vm.disable()}, vm.i18n`Disable`)]) ]; @@ -80,14 +96,14 @@ function renderEnabled(t, vm) { return t.div(items); } -function renderNewVersionAvailable(t, vm) { +function renderNewVersionAvailable(t: Builder, vm: KeyBackupViewModel): ViewNode { const items = [ t.p([vm.i18n`A new backup version has been created from another device. Disable key backup and enable it again with the new key.`, t.button({onClick: () => vm.disable()}, vm.i18n`Disable`)]) ]; return t.div(items); } -function renderEnableFromKey(t, vm) { +function renderEnableFromKey(t: Builder, vm: KeyBackupViewModel): ViewNode { const useASecurityPhrase = t.button({className: "link", onClick: () => vm.showPhraseSetup()}, vm.i18n`use a security phrase`); return t.div([ t.p(vm.i18n`Enter your secret storage security key below to ${vm.purpose}, which will enable you to decrypt messages received before you logged into this session. The security key is a code of 12 groups of 4 characters separated by a space that Element created for you when setting up security.`), @@ -97,7 +113,7 @@ function renderEnableFromKey(t, vm) { ]); } -function renderEnableFromPhrase(t, vm) { +function renderEnableFromPhrase(t: Builder, vm: KeyBackupViewModel): ViewNode { const useASecurityKey = t.button({className: "link", onClick: () => vm.showKeySetup()}, vm.i18n`use your security key`); return t.div([ t.p(vm.i18n`Enter your secret storage security phrase below to ${vm.purpose}, which will enable you to decrypt messages received before you logged into this session. The security phrase is a freeform secret phrase you optionally chose when setting up security in Element. It is different from your password to login, unless you chose to set them to the same value.`), @@ -107,7 +123,7 @@ function renderEnableFromPhrase(t, vm) { ]); } -function renderEnableFieldRow(t, vm, label, callback) { +function renderEnableFieldRow(t, vm, label, callback): ViewNode { let setupDehydrationCheck; const eventHandler = () => callback(input.value, setupDehydrationCheck?.checked || false); const input = t.input({type: "password", disabled: vm => vm.isBusy, placeholder: label}); @@ -131,8 +147,8 @@ function renderEnableFieldRow(t, vm, label, callback) { ]); } -function renderError(t) { - return t.if(vm => vm.error, (t, vm) => { +function renderError(t: Builder): ViewNode { + return t.if(vm => vm.error !== undefined, (t, vm) => { return t.div([ t.p({className: "error"}, vm => vm.i18n`Could not enable key backup: ${vm.error}.`), t.p(vm.i18n`Try double checking that you did not mix up your security key, security phrase and login password as explained above.`) diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index 8f5b73ca95..e4e385a145 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -16,7 +16,7 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView"; import {disableTargetCallback} from "../../general/utils"; -import {KeyBackupSettingsView} from "./KeyBackupSettingsView.js" +import {KeyBackupSettingsView} from "./KeyBackupSettingsView" import {FeaturesView} from "./FeaturesView" export class SettingsView extends TemplateView { diff --git a/src/platform/web/ui/session/toast/CallToastNotificationView.ts b/src/platform/web/ui/session/toast/CallToastNotificationView.ts index 50adcc7b33..093a5d0fe6 100644 --- a/src/platform/web/ui/session/toast/CallToastNotificationView.ts +++ b/src/platform/web/ui/session/toast/CallToastNotificationView.ts @@ -17,7 +17,7 @@ limitations under the License. import {AvatarView} from "../../AvatarView.js"; import {ErrorView} from "../../general/ErrorView"; import {TemplateView, Builder} from "../../general/TemplateView"; -import type {CallToastNotificationViewModel} from "../../../../../domain/session/toast/CallToastNotificationViewModel"; +import type {CallToastNotificationViewModel} from "../../../../../domain/session/toast/calls/CallToastNotificationViewModel"; export class CallToastNotificationView extends TemplateView { render(t: Builder, vm: CallToastNotificationViewModel) { diff --git a/src/platform/web/ui/session/toast/ToastCollectionView.ts b/src/platform/web/ui/session/toast/ToastCollectionView.ts index 3dc99c77d6..7bce15aef7 100644 --- a/src/platform/web/ui/session/toast/ToastCollectionView.ts +++ b/src/platform/web/ui/session/toast/ToastCollectionView.ts @@ -15,19 +15,35 @@ limitations under the License. */ import {CallToastNotificationView} from "./CallToastNotificationView"; +import {VerificationToastNotificationView} from "./VerificationToastNotificationView"; import {ListView} from "../../general/ListView"; import {TemplateView, Builder} from "../../general/TemplateView"; -import type {CallToastNotificationViewModel} from "../../../../../domain/session/toast/CallToastNotificationViewModel"; +import type {IView} from "../../general/types"; +import type {CallToastNotificationViewModel} from "../../../../../domain/session/toast/calls/CallToastNotificationViewModel"; import type {ToastCollectionViewModel} from "../../../../../domain/session/toast/ToastCollectionViewModel"; +import type {BaseToastNotificationViewModel} from "../../../../../domain/session/toast/BaseToastNotificationViewModel"; +import type {VerificationToastNotificationViewModel} from "../../../../../domain/session/toast/verification/VerificationToastNotificationViewModel"; + +function toastViewModelToView(vm: BaseToastNotificationViewModel): IView { + switch (vm.kind) { + case "calls": + return new CallToastNotificationView(vm as CallToastNotificationViewModel); + case "verification": + return new VerificationToastNotificationView(vm as VerificationToastNotificationViewModel); + default: + throw new Error(`Cannot find view class for notification kind ${vm.kind}`); + } +} export class ToastCollectionView extends TemplateView { render(t: Builder, vm: ToastCollectionViewModel) { - const view = new ListView({ - list: vm.toastViewModels, - parentProvidesUpdates: false, - }, (vm: CallToastNotificationViewModel) => new CallToastNotificationView(vm)); return t.div({ className: "ToastCollectionView" }, [ - t.view(view), + t.ifView(vm => !!vm.toastViewModels, t => { + return new ListView({ + list: vm.toastViewModels, + parentProvidesUpdates: false, + }, (vm: CallToastNotificationViewModel) => toastViewModelToView(vm)); + }), ]); } } diff --git a/src/platform/web/ui/session/toast/VerificationToastNotificationView.ts b/src/platform/web/ui/session/toast/VerificationToastNotificationView.ts new file mode 100644 index 0000000000..691dc30e90 --- /dev/null +++ b/src/platform/web/ui/session/toast/VerificationToastNotificationView.ts @@ -0,0 +1,45 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import {TemplateView, Builder} from "../../general/TemplateView"; +import type {VerificationToastNotificationViewModel} from "../../../../../domain/session/toast/verification/VerificationToastNotificationViewModel"; + +export class VerificationToastNotificationView extends TemplateView { + render(t: Builder, vm: VerificationToastNotificationViewModel) { + return t.div({ className: "VerificationToastNotificationView" }, [ + t.div({ className: "VerificationToastNotificationView__top" }, [ + t.span({ className: "VerificationToastNotificationView__title" }, + vm.i18n`Device Verification`), + t.button({ + className: "button-action VerificationToastNotificationView__dismiss-btn", + onClick: () => vm.dismiss(), + }), + ]), + t.div({ className: "VerificationToastNotificationView__description" }, [ + t.span(vm.i18n`Do you want to verify device ${vm.otherDeviceId}?`), + ]), + t.div({ className: "VerificationToastNotificationView__action" }, [ + t.button({ + className: "button-action primary destructive", + onClick: () => vm.dismiss(), + }, vm.i18n`Ignore`), + t.button({ + className: "button-action primary", + onClick: () => vm.accept(), + }, vm.i18n`Accept`), + ]), + ]); + } +} diff --git a/src/platform/web/ui/session/verification/DeviceVerificationView.ts b/src/platform/web/ui/session/verification/DeviceVerificationView.ts new file mode 100644 index 0000000000..d107ca1347 --- /dev/null +++ b/src/platform/web/ui/session/verification/DeviceVerificationView.ts @@ -0,0 +1,45 @@ +/* +Copyright 2022 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 {Builder, TemplateView} from "../../general/TemplateView"; +import {DeviceVerificationViewModel} from "../../../../../domain/session/verification/DeviceVerificationViewModel"; +import {WaitingForOtherUserView} from "./stages/WaitingForOtherUserView"; +import {VerificationCancelledView} from "./stages/VerificationCancelledView"; +import {SelectMethodView} from "./stages/SelectMethodView"; +import {VerifyEmojisView} from "./stages/VerifyEmojisView"; +import {VerificationCompleteView} from "./stages/VerificationCompleteView"; + +export class DeviceVerificationView extends TemplateView { + render(t: Builder) { + return t.div({ + className: { + "middle": true, + "DeviceVerificationView": true, + } + }, [ + t.mapView(vm => vm.currentStageViewModel, (vm) => { + switch (vm?.kind) { + case "waiting-for-user": return new WaitingForOtherUserView(vm); + case "verification-cancelled": return new VerificationCancelledView(vm); + case "select-method": return new SelectMethodView(vm); + case "verify-emojis": return new VerifyEmojisView(vm); + case "verification-completed": return new VerificationCompleteView(vm); + default: return null; + } + }) + ]) + } +} diff --git a/src/platform/web/ui/session/verification/stages/SelectMethodView.ts b/src/platform/web/ui/session/verification/stages/SelectMethodView.ts new file mode 100644 index 0000000000..e760370063 --- /dev/null +++ b/src/platform/web/ui/session/verification/stages/SelectMethodView.ts @@ -0,0 +1,62 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {Builder, TemplateView} from "../../../general/TemplateView"; +import {spinner} from "../../../common.js" +import type {SelectMethodViewModel} from "../../../../../../domain/session/verification/stages/SelectMethodViewModel"; + +export class SelectMethodView extends TemplateView { + render(t: Builder) { + return t.div({ className: "SelectMethodView" }, [ + t.map(vm => vm.hasProceeded, (hasProceeded, t, vm) => { + if (hasProceeded) { + return spinner(t); + } + else return t.div([ + t.div({ className: "SelectMethodView__heading" }, [ + t.h2( { className: "SelectMethodView__title" }, vm.i18n`Verify device '${vm.deviceName}' by comparing emojis?`), + ]), + t.p({ className: "SelectMethodView__description" }, + vm.i18n`You are about to verify your other device by comparing emojis.` + ), + t.div({ className: "SelectMethodView__actions" }, [ + t.button( + { + className: { + "button-action": true, + primary: true, + destructive: true, + }, + onclick: () => vm.cancel(), + }, + "Cancel" + ), + t.button( + { + className: { + "button-action": true, + primary: true, + }, + onclick: () => vm.proceed(), + }, + "Proceed" + ), + ]), + ]); + }), + ]); + } +} diff --git a/src/platform/web/ui/session/verification/stages/VerificationCancelledView.ts b/src/platform/web/ui/session/verification/stages/VerificationCancelledView.ts new file mode 100644 index 0000000000..d2832ddb9d --- /dev/null +++ b/src/platform/web/ui/session/verification/stages/VerificationCancelledView.ts @@ -0,0 +1,78 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {Builder, TemplateView} from "../../../general/TemplateView"; +import {VerificationCancelledViewModel} from "../../../../../../domain/session/verification/stages/VerificationCancelledViewModel"; +import {CancelReason} from "../../../../../../matrix/verification/SAS/channel/types"; + +export class VerificationCancelledView extends TemplateView { + render(t: Builder, vm: VerificationCancelledViewModel) { + const headerTextStart = vm.isCancelledByUs ? "You" : "The other device"; + + return t.div( + { + className: "VerificationCancelledView", + }, + [ + t.h2( + { className: "VerificationCancelledView__title" }, + vm.i18n`${headerTextStart} cancelled the verification!` + ), + t.p( + { className: "VerificationCancelledView__description" }, + vm.i18n`${this.getDescriptionFromCancellationCode(vm.cancelCode, vm.isCancelledByUs)}` + ), + t.div({ className: "VerificationCancelledView__actions" }, [ + t.button({ + className: { + "button-action": true, + "primary": true, + }, + onclick: () => vm.gotoSettings(), + }, "Got it") + ]), + ] + ); + } + + getDescriptionFromCancellationCode(code: CancelReason, isCancelledByUs: boolean): string { + const descriptionsWhenWeCancelled = { + [CancelReason.InvalidMessage]: "You other device sent an invalid message.", + [CancelReason.KeyMismatch]: "The key could not be verified.", + [CancelReason.TimedOut]: "The verification process timed out.", + [CancelReason.UnexpectedMessage]: "Your other device sent an unexpected message.", + [CancelReason.UnknownMethod]: "Your other device is using an unknown method for verification.", + [CancelReason.UnknownTransaction]: "Your other device sent a message with an unknown transaction id.", + [CancelReason.UserMismatch]: "The expected user did not match the user verified.", + [CancelReason.MismatchedCommitment]: "The hash commitment does not match.", + [CancelReason.MismatchedSAS]: "The emoji/decimal did not match.", + } + const descriptionsWhenTheyCancelled = { + [CancelReason.UserCancelled]: "Your other device cancelled the verification!", + [CancelReason.InvalidMessage]: "Invalid message sent to the other device.", + [CancelReason.KeyMismatch]: "The other device could not verify our keys", + [CancelReason.TimedOut]: "The verification process timed out.", + [CancelReason.UnexpectedMessage]: "Unexpected message sent to the other device.", + [CancelReason.UnknownMethod]: "Your other device does not understand the method you chose", + [CancelReason.UnknownTransaction]: "Your other device rejected our message.", + [CancelReason.UserMismatch]: "The expected user did not match the user verified.", + [CancelReason.MismatchedCommitment]: "Your other device was not able to verify the hash commitment", + [CancelReason.MismatchedSAS]: "The emoji/decimal did not match.", + } + const map = isCancelledByUs ? descriptionsWhenWeCancelled : descriptionsWhenTheyCancelled; + return map[code] ?? ""; + } +} diff --git a/src/platform/web/ui/session/verification/stages/VerificationCompleteView.ts b/src/platform/web/ui/session/verification/stages/VerificationCompleteView.ts new file mode 100644 index 0000000000..26f6f32680 --- /dev/null +++ b/src/platform/web/ui/session/verification/stages/VerificationCompleteView.ts @@ -0,0 +1,45 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {Builder, TemplateView} from "../../../general/TemplateView"; +import type {VerificationCompleteViewModel} from "../../../../../../domain/session/verification/stages/VerificationCompleteViewModel"; + +export class VerificationCompleteView extends TemplateView { + render(t: Builder, vm: VerificationCompleteViewModel) { + return t.div({ className: "VerificationCompleteView" }, [ + t.div({className: "VerificationCompleteView__icon"}), + t.div({ className: "VerificationCompleteView__heading" }, [ + t.h2( + { className: "VerificationCompleteView__title" }, + vm.i18n`Verification completed successfully!` + ), + ]), + t.p( + { className: "VerificationCompleteView__description" }, + vm.i18n`You successfully verified device ${vm.otherDeviceId}` + ), + t.div({ className: "VerificationCompleteView__actions" }, [ + t.button({ + className: { + "button-action": true, + "primary": true, + }, + onclick: () => vm.gotoSettings(), + }, "Got it") + ]), + ]); + } +} diff --git a/src/platform/web/ui/session/verification/stages/VerifyEmojisView.ts b/src/platform/web/ui/session/verification/stages/VerifyEmojisView.ts new file mode 100644 index 0000000000..32aba69178 --- /dev/null +++ b/src/platform/web/ui/session/verification/stages/VerifyEmojisView.ts @@ -0,0 +1,79 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {Builder, TemplateView} from "../../../general/TemplateView"; +import {spinner} from "../../../common.js" +import type {VerifyEmojisViewModel} from "../../../../../../domain/session/verification/stages/VerifyEmojisViewModel"; + +export class VerifyEmojisView extends TemplateView { + render(t: Builder, vm: VerifyEmojisViewModel) { + const emojiList = vm.emojis.reduce((acc, [emoji, name]) => { + const e = t.div({ className: "EmojiContainer" }, [ + t.div({ className: "EmojiContainer__emoji" }, emoji), + t.div({ className: "EmojiContainer__name" }, name), + ]); + acc.push(e); + return acc; + }, [] as any); + const emojiCollection = t.div({ className: "EmojiCollection" }, emojiList); + return t.div({ className: "VerifyEmojisView" }, [ + t.div({ className: "VerifyEmojisView__heading" }, [ + t.h2( + { className: "VerifyEmojisView__title" }, + vm.i18n`Do the emojis match?` + ), + ]), + t.p( + { className: "VerifyEmojisView__description" }, + vm.i18n`Confirm the emoji below are displayed on both devices, in the same order:` + ), + t.div({ className: "VerifyEmojisView__emojis" }, emojiCollection), + t.map(vm => vm.isWaiting, (isWaiting, t, vm) => { + if (isWaiting) { + return t.div({ className: "VerifyEmojisView__waiting" }, [ + spinner(t), + t.span(vm.i18n`Waiting for you to verify on your other device`), + ]); + } + else { + return t.div({ className: "VerifyEmojisView__actions" }, [ + t.button( + { + className: { + "button-action": true, + primary: true, + destructive: true, + }, + onclick: () => vm.setEmojiMatch(false), + }, + vm.i18n`They don't match` + ), + t.button( + { + className: { + "button-action": true, + primary: true, + }, + onclick: () => vm.setEmojiMatch(true), + }, + vm.i18n`They match` + ), + ]); + } + }) + ]); + } +} diff --git a/src/platform/web/ui/session/verification/stages/WaitingForOtherUserView.ts b/src/platform/web/ui/session/verification/stages/WaitingForOtherUserView.ts new file mode 100644 index 0000000000..0018a4b3cd --- /dev/null +++ b/src/platform/web/ui/session/verification/stages/WaitingForOtherUserView.ts @@ -0,0 +1,46 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {Builder, TemplateView} from "../../../general/TemplateView"; +import {spinner} from "../../../common.js"; +import {WaitingForOtherUserViewModel} from "../../../../../../domain/session/verification/stages/WaitingForOtherUserViewModel"; + +export class WaitingForOtherUserView extends TemplateView { + render(t: Builder, vm: WaitingForOtherUserViewModel) { + return t.div({ className: "WaitingForOtherUserView" }, [ + t.div({ className: "WaitingForOtherUserView__heading" }, [ + spinner(t), + t.h2( + { className: "WaitingForOtherUserView__title" }, + vm.i18n`Waiting for any of your device to accept the verification request` + ), + ]), + t.p({ className: "WaitingForOtherUserView__description" }, + vm.i18n`Accept the request from the device you wish to verify!` + ), + t.div({ className: "WaitingForOtherUserView__actions" }, + t.button({ + className: { + "button-action": true, + "primary": true, + "destructive": true, + }, + onclick: () => vm.cancel(), + }, "Cancel") + ), + ]); + } +} diff --git a/src/utils/AbortableOperation.ts b/src/utils/AbortableOperation.ts index e0afecd38d..3592c95103 100644 --- a/src/utils/AbortableOperation.ts +++ b/src/utils/AbortableOperation.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableValue, ObservableValue} from "../observable/value"; +import {EventEmitter} from "../utils/EventEmitter"; export interface IAbortable { abort(); @@ -24,25 +24,27 @@ export type SetAbortableFn = (a: IAbortable) => typeof a; export type SetProgressFn

= (progress: P) => void; type RunFn = (setAbortable: SetAbortableFn, setProgress: SetProgressFn

) => T; -export class AbortableOperation implements IAbortable { +export class AbortableOperation extends EventEmitter<{change: keyof AbortableOperation}> implements IAbortable { public readonly result: T; private _abortable?: IAbortable; - private _progress: ObservableValue

; + private _progress?: P; constructor(run: RunFn) { + super(); this._abortable = undefined; const setAbortable: SetAbortableFn = abortable => { this._abortable = abortable; return abortable; }; - this._progress = new ObservableValue

(undefined); + this._progress = undefined; const setProgress: SetProgressFn

= (progress: P) => { - this._progress.set(progress); + this._progress = progress; + this.emit("change", "progress"); }; this.result = run(setAbortable, setProgress); } - get progress(): BaseObservableValue

{ + get progress(): P | undefined { return this._progress; } diff --git a/src/utils/Deferred.ts b/src/utils/Deferred.ts new file mode 100644 index 0000000000..051708e4e7 --- /dev/null +++ b/src/utils/Deferred.ts @@ -0,0 +1,40 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export class Deferred { + public readonly promise: Promise; + public readonly resolve: (value: T) => void; + public readonly reject: (err: Error) => void; + private _value?: T; + + constructor() { + let resolve; + let reject; + this.promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }) + this.resolve = (value: T) => { + this._value = value; + resolve(value); + }; + this.reject = reject; + } + + get value(): T | undefined { + return this._value; + } +} diff --git a/yarn.lock b/yarn.lock index 876917a8d9..f7540f2da0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -52,9 +52,9 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz#87de7af9c231826fdd68ac7258f77c429e0e5fcf" integrity sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w== -"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz": - version "3.2.8" - resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz#8d53636d045e1776e2a2ec6613e57330dd9ce856" +"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz": + version "3.2.14" + resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz#acd96c00a881d0f462e1f97a56c73742c8dbc984" "@matrixdotorg/structured-logviewer@^0.0.3": version "0.0.3" @@ -83,12 +83,14 @@ fastq "^1.6.0" "@playwright/test@^1.27.1": - version "1.27.1" - resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.27.1.tgz#9364d1e02021261211c8ff586d903faa79ce95c4" - integrity sha512-mrL2q0an/7tVqniQQF6RBL2saskjljXzqNcCOVMUjRIgE6Y38nCNaP+Dc2FBW06bcpD3tqIws/HT9qiMHbNU0A== + version "1.32.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.32.1.tgz#749c9791adb048c266277a39ba0f7e33fe593ffe" + integrity sha512-FTwjCuhlm1qHUGf4hWjfr64UMJD/z0hXYbk+O387Ioe6WdyZQ+0TBDAc6P+pHjx2xCv1VYNgrKbYrNixFWy4Dg== dependencies: "@types/node" "*" - playwright-core "1.27.1" + playwright-core "1.32.1" + optionalDependencies: + fsevents "2.3.2" "@trysound/sax@0.2.0": version "0.2.0" @@ -101,9 +103,9 @@ integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ== "@types/node@*": - version "18.7.13" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.13.tgz#23e6c5168333480d454243378b69e861ab5c011a" - integrity sha512-46yIhxSe5xEaJZXWdIBP7GU4HDTG8/eo0qd9atdiL+lFpA03y8KS+lkTN834TWJj5767GbWv4n/P6efyTFt1Dw== + version "18.15.10" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.10.tgz#4ee2171c3306a185d1208dad5f44dae3dee4cfe3" + integrity sha512-9avDaQJczATcXgfmMAW3MIWArOO7A+m90vuCFLr8AotWf8igO/mRoYukrk2cqZVtv38tHs33retzHEilM7FpeQ== "@typescript-eslint/eslint-plugin@^4.29.2": version "4.29.2" @@ -1003,7 +1005,7 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= -fsevents@~2.3.2: +fsevents@2.3.2, fsevents@~2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== @@ -1382,10 +1384,10 @@ picomatch@^2.2.3: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972" integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw== -playwright-core@1.27.1: - version "1.27.1" - resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.27.1.tgz#840ef662e55a3ed759d8b5d3d00a5f885a7184f4" - integrity sha512-9EmeXDncC2Pmp/z+teoVYlvmPWUC6ejSSYZUln7YaP89Z6lpAaiaAnqroUt/BoLo8tn7WYShcfaCh+xofZa44Q== +playwright-core@1.32.1: + version "1.32.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.32.1.tgz#5a10c32403323b07d75ea428ebeed866a80b76a1" + integrity sha512-KZYUQC10mXD2Am1rGlidaalNGYk3LU1vZqqNk0gT4XPty1jOqgup8KDP8l2CUlqoNKhXM5IfGjWgW37xvGllBA== postcss-css-variables@^0.18.0: version "0.18.0" @@ -1689,9 +1691,9 @@ type-fest@^0.20.2: integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== typescript@^4.7.0: - version "4.7.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" - integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== + version "4.9.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" + integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39"