diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index a2705944..4a573e15 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; }; export function createNavigation(): Navigation { @@ -51,7 +53,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/settings/KeyBackupViewModel.ts b/src/domain/session/settings/KeyBackupViewModel.ts index 663d1ea6..43681a29 100644 --- a/src/domain/session/settings/KeyBackupViewModel.ts +++ b/src/domain/session/settings/KeyBackupViewModel.ts @@ -157,6 +157,10 @@ export class KeyBackupViewModel extends ViewModel { } } + navigateToVerification(): void { + this.navigation.push("device-verification", true); + } + get backupWriteStatus(): BackupWriteStatus { const keyBackup = this._keyBackup; if (!keyBackup || keyBackup.version === undefined) { diff --git a/src/domain/session/toast/ToastCollectionViewModel.ts b/src/domain/session/toast/ToastCollectionViewModel.ts index 44a75144..0d31b6ee 100644 --- a/src/domain/session/toast/ToastCollectionViewModel.ts +++ b/src/domain/session/toast/ToastCollectionViewModel.ts @@ -17,9 +17,11 @@ limitations under the License. import {ConcatList} from "../../../observable"; import {ViewModel, Options as BaseOptions} from "../../ViewModel"; import {CallToastCollectionViewModel} from "./calls/CallsToastCollectionViewModel"; +import {VerificationToastCollectionViewModel} from "./verification/VerificationToastCollectionViewModel"; import type {Session} from "../../../matrix/Session.js"; import type {SegmentType} from "../../navigation"; import type {BaseToastNotificationViewModel} from "./BaseToastNotificationViewModel"; +import type {IToastCollection} from "./IToastCollection"; type Options = { session: Session; @@ -31,9 +33,16 @@ export class ToastCollectionViewModel extends ViewModel { constructor(options: Options) { super(options); const session = this.getOption("session"); - const vms = [ - this.track(new CallToastCollectionViewModel(this.childOptions({ session }))), - ].map(vm => vm.toastViewModels); - this.toastViewModels = new ConcatList(...vms); + const collectionVms: IToastCollection[] = []; + if (this.features.calls) { + collectionVms.push(this.track(new CallToastCollectionViewModel(this.childOptions({ session })))); + } + if (this.features.crossSigning) { + collectionVms.push(this.track(new VerificationToastCollectionViewModel(this.childOptions({ session })))); + } + const vms: IToastCollection["toastViewModels"][] = collectionVms.map(vm => vm.toastViewModels); + if (vms.length !== 0) { + this.toastViewModels = new ConcatList(...vms); + } } } diff --git a/src/domain/session/toast/verification/VerificationToastCollectionViewModel.ts b/src/domain/session/toast/verification/VerificationToastCollectionViewModel.ts new file mode 100644 index 00000000..2cd3da01 --- /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 00000000..9bf1e2f7 --- /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 00000000..76dab1a5 --- /dev/null +++ b/src/domain/session/verification/DeviceVerificationViewModel.ts @@ -0,0 +1,96 @@ +/* +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"); + } + + 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 00000000..e88d0f83 --- /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 00000000..75cc0e5d --- /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 00000000..c7ffd820 --- /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 00000000..061a8e08 --- /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 00000000..ca68c941 --- /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/matrix/Session.js b/src/matrix/Session.js index b999ba6b..c5aedcb3 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -365,9 +365,11 @@ export class Session { olm: this._olm, olmUtil: this._olmUtil, deviceTracker: this._deviceTracker, + deviceMessageHandler: this._deviceMessageHandler, hsApi: this._hsApi, ownUserId: this.userId, - e2eeAccount: this._e2eeAccount + e2eeAccount: this._e2eeAccount, + deviceId: this.deviceId, }); if (await crossSigning.load(log)) { this._crossSigning.set(crossSigning); diff --git a/src/matrix/verification/CrossSigning.ts b/src/matrix/verification/CrossSigning.ts index ba90bc3b..a6fb3091 100644 --- a/src/matrix/verification/CrossSigning.ts +++ b/src/matrix/verification/CrossSigning.ts @@ -19,6 +19,8 @@ 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"; @@ -29,6 +31,7 @@ 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 @@ -88,6 +91,7 @@ export class CrossSigning { private _isMasterKeyTrusted: boolean = false; private readonly deviceId: string; private sasVerificationInProgress?: SASVerification; + public receivedSASVerifications: ObservableMap = new ObservableMap(); constructor(options: { storage: Storage, @@ -115,21 +119,7 @@ export class CrossSigning { this.deviceMessageHandler = options.deviceMessageHandler; this.deviceMessageHandler.on("message", async ({ unencrypted: unencryptedEvent }) => { - if (this.sasVerificationInProgress && - ( - !this.sasVerificationInProgress.finished || - // If the start message is for the previous sasverification, ignore it. - this.sasVerificationInProgress.channel.id === unencryptedEvent.content.transaction_id - )) { - return; - } - if (unencryptedEvent.type === VerificationEventType.Request || - unencryptedEvent.type === VerificationEventType.Start) { - await this.platform.logger.run("Start verification from request", async (log) => { - const sas = this.startVerification(unencryptedEvent.sender, unencryptedEvent, log); - await sas?.start(); - }); - } + this._handleSASDeviceMessage(unencryptedEvent); }) } @@ -182,14 +172,18 @@ export class CrossSigning { return this._isMasterKeyTrusted; } - startVerification(userId: string, startingMessage: any, log: ILogItem): SASVerification | undefined { + 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: userId, + otherUserId, clock: this.platform.clock, deviceMessageHandler: this.deviceMessageHandler, ourUserDeviceId: this.deviceId, @@ -201,7 +195,7 @@ export class CrossSigning { olmUtil: this.olmUtil, ourUserId: this.ownUserId, ourUserDeviceId: this.deviceId, - otherUserId: userId, + otherUserId, log, channel, e2eeAccount: this.e2eeAccount, @@ -212,6 +206,35 @@ export class CrossSigning { return this.sasVerificationInProgress; } + private _handleSASDeviceMessage(event: any) { + 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 => { diff --git a/src/matrix/verification/SAS/SASRequest.ts b/src/matrix/verification/SAS/SASRequest.ts new file mode 100644 index 00000000..69bc197a --- /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 index 5850ba5d..ba2dc713 100644 --- a/src/matrix/verification/SAS/SASVerification.ts +++ b/src/matrix/verification/SAS/SASVerification.ts @@ -19,14 +19,14 @@ 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 {IChannel} from "./channel/Channel"; -import {HomeServerApi} from "../../net/HomeServerApi"; +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 {Timeout} from "../../../platform/types/types"; -import {Clock} from "../../../platform/web/dom/Clock.js"; import {EventEmitter} from "../../../utils/EventEmitter"; import {SASProgressEvents} from "./types"; @@ -84,6 +84,10 @@ export class SASVerification extends EventEmitter { } } + async abort() { + await this.channel.cancelVerification(CancelReason.UserCancelled); + } + async start() { try { let stage = this.startStage; @@ -98,6 +102,9 @@ export class SASVerification extends EventEmitter { } } finally { + if (this.channel.isCancelled) { + this.emit("VerificationCancelled", this.channel.cancellation); + } this.olmSas.free(); this.timeout.abort(); this.finished = true; @@ -163,6 +170,9 @@ export function tests() { device_id: deviceId, keys: { [`ed25519:${deviceId}`]: "D8w9mrokGdEZPdPgrU0kQkYi4vZyzKEBfvGyZsGK7+Q", + }, + unsigned: { + device_display_name: "lala10", } }; }, diff --git a/src/matrix/verification/SAS/channel/Channel.ts b/src/matrix/verification/SAS/channel/Channel.ts index 6492a6ae..10adbd7f 100644 --- a/src/matrix/verification/SAS/channel/Channel.ts +++ b/src/matrix/verification/SAS/channel/Channel.ts @@ -49,6 +49,8 @@ export interface IChannel { acceptMessage: any; startMessage: any; initiatedByUs: boolean; + isCancelled: boolean; + cancellation?: { code: CancelReason, cancelledByUs: boolean }; id: string; otherUserDeviceId: string; } @@ -78,7 +80,7 @@ export class ToDeviceChannel extends Disposables implements IChannel { public startMessage: any; public id: string; private _initiatedByUs: boolean; - private _isCancelled = false; + private _cancellation?: { code: CancelReason, cancelledByUs: boolean }; /** * @@ -116,8 +118,12 @@ export class ToDeviceChannel extends Disposables implements IChannel { } } + get cancellation(): IChannel["cancellation"] { + return this._cancellation; + }; + get isCancelled(): boolean { - return this._isCancelled; + return !!this._cancellation; } async send(eventType: VerificationEventType, content: any, log: ILogItem): Promise { @@ -199,7 +205,7 @@ export class ToDeviceChannel extends Disposables implements IChannel { return; } if (event.type === VerificationEventType.Cancel) { - this._isCancelled = true; + this._cancellation = { code: event.content.code, cancelledByUs: false }; this.dispose(); return; } @@ -234,7 +240,7 @@ export class ToDeviceChannel extends Disposables implements IChannel { const payload = { messages: { [this.otherUserId]: { - [this.otherUserDeviceId]: { + [this.otherUserDeviceId ?? "*"]: { code: cancellationType, reason: messageFromErrorType[cancellationType], transaction_id: this.id, @@ -243,7 +249,7 @@ export class ToDeviceChannel extends Disposables implements IChannel { } } await this.hsApi.sendToDevice(VerificationEventType.Cancel, payload, makeTxnId(), { log }).response(); - this._isCancelled = true; + this._cancellation = { code: cancellationType, cancelledByUs: true }; this.dispose(); }); } @@ -258,7 +264,7 @@ export class ToDeviceChannel extends Disposables implements IChannel { } waitForEvent(eventType: VerificationEventType): Promise { - if (this._isCancelled) { + if (this.isCancelled) { throw new VerificationCancelledError(); } // Check if we already received the message diff --git a/src/matrix/verification/SAS/channel/MockChannel.ts b/src/matrix/verification/SAS/channel/MockChannel.ts index 50197ba4..64ae1456 100644 --- a/src/matrix/verification/SAS/channel/MockChannel.ts +++ b/src/matrix/verification/SAS/channel/MockChannel.ts @@ -17,6 +17,7 @@ export class MockChannel implements ITestChannel { public initiatedByUs: boolean; public startMessage: any; public isCancelled: boolean = false; + public cancellation: { code: CancelReason; cancelledByUs: boolean; }; private olmSas: any; constructor( diff --git a/src/matrix/verification/SAS/stages/SelectVerificationMethodStage.ts b/src/matrix/verification/SAS/stages/SelectVerificationMethodStage.ts index da6099ee..aa2302fb 100644 --- a/src/matrix/verification/SAS/stages/SelectVerificationMethodStage.ts +++ b/src/matrix/verification/SAS/stages/SelectVerificationMethodStage.ts @@ -23,9 +23,11 @@ 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); @@ -81,6 +83,17 @@ export class SelectVerificationMethodStage extends BaseSASVerificationStage { }); } + 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 = { diff --git a/src/matrix/verification/SAS/stages/SendDoneStage.ts b/src/matrix/verification/SAS/stages/SendDoneStage.ts index 2d3195b1..95167e24 100644 --- a/src/matrix/verification/SAS/stages/SendDoneStage.ts +++ b/src/matrix/verification/SAS/stages/SendDoneStage.ts @@ -19,6 +19,7 @@ import {VerificationEventType} from "../channel/types"; export class SendDoneStage extends BaseSASVerificationStage { async completeStage() { await this.log.wrap("SendDoneStage.completeStage", async (log) => { + this.eventEmitter.emit("VerificationCompleted", this.otherUserDeviceId); await this.channel.send(VerificationEventType.Done, {}, log); }); } diff --git a/src/matrix/verification/SAS/stages/SendMacStage.ts b/src/matrix/verification/SAS/stages/SendMacStage.ts index 14384d3a..30a45e6e 100644 --- a/src/matrix/verification/SAS/stages/SendMacStage.ts +++ b/src/matrix/verification/SAS/stages/SendMacStage.ts @@ -44,7 +44,7 @@ export class SendMacStage extends BaseSASVerificationStage { this.channel.id; const deviceKeyId = `ed25519:${this.ourUserDeviceId}`; - const deviceKeys = this.e2eeAccount.getDeviceKeysToSignWithCrossSigning(); + const deviceKeys = this.e2eeAccount.getUnsignedDeviceKey(); mac[deviceKeyId] = calculateMAC(deviceKeys.keys[deviceKeyId], baseInfo + deviceKeyId); keyList.push(deviceKeyId); diff --git a/src/matrix/verification/SAS/types.ts b/src/matrix/verification/SAS/types.ts index d7be6921..a46ee085 100644 --- a/src/matrix/verification/SAS/types.ts +++ b/src/matrix/verification/SAS/types.ts @@ -13,10 +13,13 @@ 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 {CalculateSASStage} from "./stages/CalculateSASStage"; -import {SelectVerificationMethodStage} from "./stages/SelectVerificationMethodStage"; +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/platform/web/ui/css/themes/element/icons/verified.svg b/src/platform/web/ui/css/themes/element/icons/verified.svg index 340891f1..d158e607 100644 --- a/src/platform/web/ui/css/themes/element/icons/verified.svg +++ b/src/platform/web/ui/css/themes/element/icons/verified.svg @@ -1,3 +1,3 @@ - \ No newline at end of file + diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index ca64e15a..5f13bb7c 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -1264,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%; @@ -1292,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; } @@ -1350,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/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index 9f84e872..8156085c 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/settings/KeyBackupSettingsView.ts b/src/platform/web/ui/session/settings/KeyBackupSettingsView.ts index 668f4f17..7c3d6491 100644 --- a/src/platform/web/ui/session/settings/KeyBackupSettingsView.ts +++ b/src/platform/web/ui/session/settings/KeyBackupSettingsView.ts @@ -62,11 +62,24 @@ export class KeyBackupSettingsView extends TemplateView { return t.p("Cross-signing master key found and trusted.") }), t.if(vm => vm.canSignOwnDevice, t => { - return t.button({ - onClick: disableTargetCallback(async () => { - 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" + ), + ]); }), ]); diff --git a/src/platform/web/ui/session/toast/ToastCollectionView.ts b/src/platform/web/ui/session/toast/ToastCollectionView.ts index a3d734d2..7bce15ae 100644 --- a/src/platform/web/ui/session/toast/ToastCollectionView.ts +++ b/src/platform/web/ui/session/toast/ToastCollectionView.ts @@ -15,17 +15,21 @@ 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 {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}`); } @@ -33,12 +37,13 @@ function toastViewModelToView(vm: BaseToastNotificationViewModel): IView { export class ToastCollectionView extends TemplateView { render(t: Builder, vm: ToastCollectionViewModel) { - const view = new ListView({ - list: vm.toastViewModels, - parentProvidesUpdates: false, - }, (vm: CallToastNotificationViewModel) => toastViewModelToView(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 00000000..691dc30e --- /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 00000000..d107ca13 --- /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 00000000..e7603700 --- /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 00000000..d2832ddb --- /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 00000000..26f6f326 --- /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 00000000..32aba691 --- /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 00000000..0018a4b3 --- /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") + ), + ]); + } +}