diff --git a/src/domain/session/settings/KeyBackupViewModel.ts b/src/domain/session/settings/KeyBackupViewModel.ts index 43681a29..dc1a15d9 100644 --- a/src/domain/session/settings/KeyBackupViewModel.ts +++ b/src/domain/session/settings/KeyBackupViewModel.ts @@ -66,6 +66,9 @@ export class KeyBackupViewModel extends ViewModel { this._onKeyBackupChange(); // update status }; this.track(this._session.keyBackup.subscribe(onKeyBackupSet)); + this.track(this._session.crossSigning.subscribe(() => { + this.emitChange("crossSigning"); + })); onKeyBackupSet(this._keyBackup); } @@ -148,7 +151,7 @@ export class KeyBackupViewModel extends ViewModel { return !!this._crossSigning; } - async signOwnDevice(): Promise { + private async _signOwnDevice(): Promise { const crossSigning = this._crossSigning; if (crossSigning) { await this.logger.run("KeyBackupViewModel.signOwnDevice", async log => { @@ -205,6 +208,7 @@ export class KeyBackupViewModel extends ViewModel { if (setupDehydratedDevice) { this._dehydratedDeviceId = await this._session.setupDehydratedDevice(key); } + await this._signOwnDevice(); } catch (err) { console.error(err); this._error = err; diff --git a/src/domain/session/verification/DeviceVerificationViewModel.ts b/src/domain/session/verification/DeviceVerificationViewModel.ts index f282d7d8..884119cb 100644 --- a/src/domain/session/verification/DeviceVerificationViewModel.ts +++ b/src/domain/session/verification/DeviceVerificationViewModel.ts @@ -22,9 +22,12 @@ import {VerificationCancelledViewModel} from "./stages/VerificationCancelledView import {SelectMethodViewModel} from "./stages/SelectMethodViewModel"; import {VerifyEmojisViewModel} from "./stages/VerifyEmojisViewModel"; import {VerificationCompleteViewModel} from "./stages/VerificationCompleteViewModel"; +import {MissingKeysViewModel} from "./stages/MissingKeysViewModel"; import type {Session} from "../../../matrix/Session.js"; import type {SASVerification} from "../../../matrix/verification/SAS/SASVerification"; import type {SASRequest} from "../../../matrix/verification/SAS/SASRequest"; +import type {CrossSigning} from "../../../matrix/verification/CrossSigning"; +import type {ILogItem} from "../../../logging/types"; import type {Room} from "../../../matrix/room/Room.js"; type Options = BaseOptions & { @@ -34,9 +37,16 @@ type Options = BaseOptions & { userId?: string; }; +const neededSecrets = [ + "m.cross_signing.master", + "m.cross_signing.self_signing", + "m.cross_signing.user_signing", +]; + export class DeviceVerificationViewModel extends ErrorReportViewModel { private sas: SASVerification; private _currentStageViewModel: any; + private _needsToRequestSecret: boolean; constructor(options: Readonly) { super(options); @@ -54,12 +64,16 @@ export class DeviceVerificationViewModel extends ErrorReportViewModel { - const crossSigning = this.getOption("session").crossSigning.get(); + await this.logAndCatch("DeviceVerificationViewModel.startVerification", async (log) => { + const crossSigningObservable = this.getOption("session").crossSigning; + const crossSigning = await crossSigningObservable.waitFor(c => !!c).promise; this.sas = crossSigning.startVerification(requestOrUserId, room, log); if (!this.sas) { throw new Error("CrossSigning.startVerification did not return a sas object!"); } + if (!await this.performPreVerificationChecks(crossSigning, requestOrUserId, log)) { + return; + } this.addEventListeners(); if (typeof requestOrUserId === "string") { this.updateCurrentStageViewModel(new WaitingForOtherUserViewModel(this.childOptions({ sas: this.sas }))); @@ -72,6 +86,39 @@ export class DeviceVerificationViewModel extends ErrorReportViewModel { + return await log.wrap("DeviceVerificationViewModel.performPreVerificationChecks", async (_log) => { + const areWeVerified = await crossSigning.areWeVerified(log); + // If we're not verified, we'll need to ask the other device for secrets later + const otherUserId = typeof requestOrUserId === "string" ? requestOrUserId : requestOrUserId.sender; + const isDeviceVerification = otherUserId === this.getOption("session").userId; + this._needsToRequestSecret = isDeviceVerification && !areWeVerified; + if (this._needsToRequestSecret) { + return true; + } + /** + * It's possible that we are verified but don't have access + * to the private cross-signing keys. In this case we really + * can't verify the other device because we need these keys + * to sign their device. + * + * If this happens, we'll simply ask the user to enable key-backup + * (and secret storage) and try again later. + */ + const session = this.getOption("session"); + const promises = neededSecrets.map(s => session.secretFetcher.getSecret(s)); + const secrets = await Promise.all(promises) + for (const secret of secrets) { + if (!secret) { + // We really can't proceed! + this.updateCurrentStageViewModel(new MissingKeysViewModel(this.childOptions({}))); + return false; + } + } + return true; + }); + } private addEventListeners() { this.track(this.sas.disposableOn("SelectVerificationStage", (stage) => { @@ -95,9 +142,24 @@ export class DeviceVerificationViewModel extends ErrorReportViewModel { + if (this._needsToRequestSecret) { + const secretSharing = this.getOption("session").secretSharing; + const requestPromises = neededSecrets.map((secret) => secretSharing.requestSecret(secret, log)); + const secretRequests = await Promise.all(requestPromises); + const receivedSecretPromises = secretRequests.map(r => r.waitForResponse()); + await Promise.all(receivedSecretPromises); + const crossSigning = this.getOption("session").crossSigning.get(); + crossSigning.start(log); + } + }); + } + private updateCurrentStageViewModel(vm) { this._currentStageViewModel = this.disposeTracked(this._currentStageViewModel); this._currentStageViewModel = this.track(vm); @@ -105,8 +167,8 @@ export class DeviceVerificationViewModel extends ErrorReportViewModel {/** ignore */}); + if (this.sas && !this.sas.finished) { + this.sas.abort().catch((e) => { console.error(e); }); } super.dispose(); } @@ -118,4 +180,8 @@ export class DeviceVerificationViewModel extends ErrorReportViewModel { + gotoSettings() { + this.navigation.push("settings", true); + } + + get kind(): string { + return "keys-missing"; + } +} diff --git a/src/domain/session/verification/stages/VerificationCancelledViewModel.ts b/src/domain/session/verification/stages/VerificationCancelledViewModel.ts index 935c39c3..e732a2b5 100644 --- a/src/domain/session/verification/stages/VerificationCancelledViewModel.ts +++ b/src/domain/session/verification/stages/VerificationCancelledViewModel.ts @@ -16,7 +16,7 @@ limitations under the License. import {Options as BaseOptions} from "../../../ViewModel"; import {DismissibleVerificationViewModel} from "./DismissibleVerificationViewModel"; -import type {CancelReason} from "../../../../matrix/verification/SAS/channel/types"; +import {CancelReason} from "../../../../matrix/verification/SAS/channel/types"; import type {Session} from "../../../../matrix/Session.js"; import type {IChannel} from "../../../../matrix/verification/SAS/channel/IChannel"; import type {SASVerification} from "../../../../matrix/verification/SAS/SASVerification"; @@ -39,4 +39,74 @@ export class VerificationCancelledViewModel extends DismissibleVerificationViewM get kind(): string { return "verification-cancelled"; } + + get title(): string { + if (this.isCancelledByUs) { + return this.i18n`You cancelled the verification!`; + } + if (this.getOption("sas").isCrossSigningAnotherUser) { + return this.i18n`The other user cancelled the verification!`; + } + else { + return this.i18n`The other device cancelled the verification!`; + } + } + + get description(): string { + const descriptionsWhenWeCancelledForDeviceVerification = { + [CancelReason.InvalidMessage]: "Your 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 descriptionsWhenTheyCancelledForDeviceVerification = { + [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 descriptionsWhenWeCancelledForCrossSigning = { + [CancelReason.InvalidMessage]: "The other user sent an invalid message.", + [CancelReason.KeyMismatch]: "The key could not be verified.", + [CancelReason.TimedOut]: "The verification process timed out.", + [CancelReason.UnexpectedMessage]: "The other user sent an unexpected message.", + [CancelReason.UnknownMethod]: "The other user is using an unknown method for verification.", + [CancelReason.UnknownTransaction]: "The other user 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 descriptionsWhenTheyCancelledForCrossSigning = { + [CancelReason.UserCancelled]: "The other user cancelled the verification!", + [CancelReason.InvalidMessage]: "Invalid message sent to the other user.", + [CancelReason.KeyMismatch]: "The other user could not verify our keys", + [CancelReason.TimedOut]: "The verification process timed out.", + [CancelReason.UnexpectedMessage]: "Unexpected message sent to the other user.", + [CancelReason.UnknownMethod]: "The other user does not understand the method you chose", + [CancelReason.UnknownTransaction]: "The other user rejected our message.", + [CancelReason.UserMismatch]: "The expected user did not match the user verified.", + [CancelReason.MismatchedCommitment]: "The other user was not able to verify the hash commitment", + [CancelReason.MismatchedSAS]: "The emoji/decimal did not match.", + } + let map; + if (this.getOption("sas").isCrossSigningAnotherUser) { + map = this.isCancelledByUs ? descriptionsWhenWeCancelledForCrossSigning : descriptionsWhenTheyCancelledForCrossSigning; + } else { + map = this.isCancelledByUs ? descriptionsWhenWeCancelledForDeviceVerification : descriptionsWhenTheyCancelledForDeviceVerification; + } + const description = map[this.cancelCode] ?? "" + return this.i18n`${description}`; + + } } diff --git a/src/domain/session/verification/stages/WaitingForOtherUserViewModel.ts b/src/domain/session/verification/stages/WaitingForOtherUserViewModel.ts index ca68c941..512c8edd 100644 --- a/src/domain/session/verification/stages/WaitingForOtherUserViewModel.ts +++ b/src/domain/session/verification/stages/WaitingForOtherUserViewModel.ts @@ -27,6 +27,20 @@ export class WaitingForOtherUserViewModel extends ViewModel { + for (const result of decryptionResults) { + const sender = result.event.sender; + const device = await deviceTracker.deviceForCurveKey( + sender, + result.senderCurve25519Key, + hsApi, + log + ); + result.setDevice(device); + if (result.isVerified) { + this.emit("message", { encrypted: result }); + } + else { + log.log({ + l: "could not verify olm fingerprint key matches, ignoring", + ed25519Key: result.device.ed25519Key, + claimedEd25519Key: result.claimedEd25519Key, + deviceId: device.deviceId, + userId: device.userId, + }); + } + } + }); + // todo: Refactor the following to use to device messages 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 @@ -113,12 +137,6 @@ export class DeviceMessageHandler extends EventEmitter{ } } - _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 fd9e4591..2d9ad66e 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -43,9 +43,11 @@ import { readKey as ssssReadKey, writeKey as ssssWriteKey, removeKey as ssssRemoveKey, - keyFromDehydratedDeviceKey as createSSSSKeyFromDehydratedDeviceKey + keyFromDehydratedDeviceKey as createSSSSKeyFromDehydratedDeviceKey, + SecretStorage, + SecretSharing, + SecretFetcher } from "./ssss/index"; -import {SecretStorage} from "./ssss/SecretStorage"; import {ObservableValue, RetainedObservableValue} from "../observable/value"; import {CallHandler} from "./calls/CallHandler"; import {RoomStateHandlerSet} from "./room/state/RoomStateHandlerSet"; @@ -109,6 +111,9 @@ export class Session { this._createRoomEncryption = this._createRoomEncryption.bind(this); this._forgetArchivedRoom = this._forgetArchivedRoom.bind(this); this.needsKeyBackup = new ObservableValue(false); + this._secretFetcher = new SecretFetcher(); + this._secretSharing = null; + this._secretStorage = null; } get fingerprintKey() { @@ -168,7 +173,7 @@ export class Session { } // called once this._e2eeAccount is assigned - _setupEncryption() { + async _setupEncryption() { // TODO: this should all go in a wrapper in e2ee/ that is bootstrapped by passing in the account // and can create RoomEncryption objects and handle encrypted to_device messages and device list changes. const senderKeyLock = new LockMap(); @@ -202,6 +207,20 @@ export class Session { }); this._megolmDecryption = new MegOlmDecryption(this._keyLoader, this._olmWorker); this._deviceMessageHandler.enableEncryption({olmDecryption, megolmDecryption: this._megolmDecryption}); + this._secretSharing = new SecretSharing({ + hsApi: this._hsApi, + storage: this._storage, + deviceMessageHandler: this._deviceMessageHandler, + deviceTracker: this._deviceTracker, + ourUserId: this.userId, + olmEncryption: this._olmEncryption, + crypto: this._platform.crypto, + encoding: this._platform.encoding, + crossSigning: this._crossSigning, + logger: this._platform.logger, + }); + await this._secretSharing.load(); + this._secretFetcher.setSecretSharing(this._secretSharing); } _createRoomEncryption(room, encryptionParams) { @@ -255,11 +274,6 @@ export class Session { this._keyBackup.get().dispose(); 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); if (await this._tryLoadSecretStorage(key, log)) { // only after having read a secret, write the key @@ -335,7 +349,9 @@ export class Session { const isValid = await secretStorage.hasValidKeyForAnyAccountData(); log.set("isValid", isValid); if (isValid) { + this._secretStorage = secretStorage; await this._loadSecretStorageServices(secretStorage, log); + this._secretFetcher.setSecretStorage(secretStorage); } return isValid; }); @@ -363,29 +379,6 @@ export class Session { log.set("no_backup", true); } }); - 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(); - } - }); - } } catch (err) { log.catch(err); } @@ -404,6 +397,14 @@ export class Session { return this._crossSigning; } + get secretSharing() { + return this._secretSharing; + } + + get secretFetcher() { + return this._secretFetcher; + } + get hasIdentity() { return !!this._e2eeAccount; } @@ -414,10 +415,11 @@ export class Session { if (!this._e2eeAccount) { this._e2eeAccount = await this._createNewAccount(this._sessionInfo.deviceId, this._storage); log.set("keys", this._e2eeAccount.identityKeys); - this._setupEncryption(); + await this._setupEncryption(); } await this._e2eeAccount.generateOTKsIfNeeded(this._storage, log); await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, false, log)); + await this._createCrossSigning(); } } @@ -544,6 +546,31 @@ export class Session { await this._tryLoadSecretStorage(ssssKey, log); } } + if (this._e2eeAccount) { + await this._createCrossSigning(); + } + } + + async _createCrossSigning() { + if (this._features.crossSigning) { + this._platform.logger.run("enable cross-signing", async log => { + const crossSigning = new CrossSigning({ + storage: this._storage, + secretFetcher: this._secretFetcher, + 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, + }); + await crossSigning.load(log); + this._crossSigning.set(crossSigning); + }); + } } dispose() { @@ -745,6 +772,7 @@ export class Session { e2eeAccountChanges: null, hasNewRoomKeys: false, deviceMessageDecryptionResults: null, + changedDevices: null, }; const syncToken = syncResponse.next_batch; if (syncToken !== this.syncToken) { @@ -762,6 +790,7 @@ export class Session { const deviceLists = syncResponse.device_lists; if (this._deviceTracker && Array.isArray(deviceLists?.changed) && deviceLists.changed.length) { await log.wrap("deviceLists", log => this._deviceTracker.writeDeviceChanges(deviceLists.changed, txn, log)); + changes.changedDevices = deviceLists.changed; } if (preparation) { @@ -811,6 +840,9 @@ export class Session { if (changes.deviceMessageDecryptionResults) { await this._deviceMessageHandler.afterSyncCompleted(changes.deviceMessageDecryptionResults, this._deviceTracker, this._hsApi, log); } + if (changes.changedDevices?.includes(this.userId)) { + this._secretSharing?.checkSecretValidity(); + } } _tryReplaceRoomBeingCreated(roomId, log) { diff --git a/src/matrix/e2ee/DecryptionResult.ts b/src/matrix/e2ee/DecryptionResult.ts index 146a1ad3..1360811c 100644 --- a/src/matrix/e2ee/DecryptionResult.ts +++ b/src/matrix/e2ee/DecryptionResult.ts @@ -36,7 +36,7 @@ type DecryptedEvent = { } export class DecryptionResult { - private device?: DeviceKey; + public device?: DeviceKey; constructor( public readonly event: DecryptedEvent, diff --git a/src/matrix/e2ee/DeviceTracker.ts b/src/matrix/e2ee/DeviceTracker.ts index dc3e4008..47902dd5 100644 --- a/src/matrix/e2ee/DeviceTracker.ts +++ b/src/matrix/e2ee/DeviceTracker.ts @@ -527,7 +527,22 @@ export class DeviceTracker { } /** Gets a single device */ - async deviceForId(userId: string, deviceId: string, hsApi: HomeServerApi, log: ILogItem) { + async deviceForId(userId: string, deviceId: string, hsApi: HomeServerApi, log: ILogItem): Promise { + /** + * 1. If the device keys are outdated, we will fetch all the keys and update them. + */ + const userIdentityTxn = await this._storage.readTxn([this._storage.storeNames.userIdentities]); + const userIdentity = await userIdentityTxn.userIdentities.get(userId); + if (userIdentity?.keysTrackingStatus !== KeysTrackingStatus.UpToDate) { + const {deviceKeys} = await this._queryKeys([userId], hsApi, log); + const keyList = deviceKeys.get(userId); + const device = keyList!.find(device => device.device_id === deviceId); + return device; + } + + /** + * 2. If keys are up to date, return from storage. + */ const txn = await this._storage.readTxn([ this._storage.storeNames.deviceKeys, ]); @@ -554,6 +569,9 @@ export class DeviceTracker { const txn = await this._storage.readWriteTxn([ this._storage.storeNames.deviceKeys, ]); + // todo: the following comment states what the code does + // but it fails to explain why it does what it does... + // 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 @@ -577,6 +595,22 @@ export class DeviceTracker { return deviceKey; } + async deviceForCurveKey(userId: string, key: string, hsApi: HomeServerApi, log: ILogItem): Promise { + const txn = await this._storage.readTxn([ + this._storage.storeNames.deviceKeys, + this._storage.storeNames.userIdentities, + ]); + const userIdentity = await txn.userIdentities.get(userId); + if (userIdentity?.keysTrackingStatus !== KeysTrackingStatus.UpToDate) { + const {deviceKeys} = await this._queryKeys([userId], hsApi, log); + const keyList = deviceKeys.get(userId); + const device = keyList!.find(device => getDeviceCurve25519Key(device) === key); + return device; + } + const device = await txn.deviceKeys.getByCurve25519Key(key); + return device; + } + /** * Gets all the device identities with which keys should be shared for a set of users in a tracked room. * If any userIdentities are outdated, it will fetch them from the homeserver. @@ -611,7 +645,7 @@ export class DeviceTracker { /** Gets the device identites for a set of user identities that * 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 + * to be absent from our store or are outdated. The outdated user ids * will have their keys fetched from the homeserver. */ async _devicesForUserIdentities(upToDateIdentities: UserIdentity[], outdatedUserIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise { log.set("uptodate", upToDateIdentities.length); @@ -643,6 +677,10 @@ export class DeviceTracker { async getDeviceByCurve25519Key(curve25519Key, txn: Transaction): Promise { return await txn.deviceKeys.getByCurve25519Key(curve25519Key); } + + get ownDeviceId(): string { + return this._ownDeviceId; + } } import {createMockStorage} from "../../mocks/Storage"; diff --git a/src/matrix/ssss/SecretFetcher.ts b/src/matrix/ssss/SecretFetcher.ts new file mode 100644 index 00000000..498de13e --- /dev/null +++ b/src/matrix/ssss/SecretFetcher.ts @@ -0,0 +1,52 @@ +/* +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 {SecretStorage} from "./SecretStorage"; +import type {SecretSharing} from "./SecretSharing"; + +/** + * This is a wrapper around SecretStorage and SecretSharing so that + * you don't need to check both sources for a secret. + */ +export class SecretFetcher { + public secretStorage: SecretStorage; + public secretSharing: SecretSharing; + + async getSecret(name: string): Promise { + /** + * Note that we don't ask another device for secret here; + * that should be done explicitly since it can take arbitrary + * amounts of time to be fulfilled as the other devices may + * be offline etc... + */ + return await this.secretStorage?.readSecret(name) ?? + await this.secretSharing?.getLocallyStoredSecret(name); + } + + setSecretStorage(storage: SecretStorage) { + this.secretStorage = storage; + } + + setSecretSharing(sharing: SecretSharing) { + this.secretSharing = sharing; + /** + * SecretSharing also needs to respond to secret requests + * from other devices, so it needs the secret fetcher as + * well + */ + this.secretSharing.setSecretFetcher(this); + } +} diff --git a/src/matrix/ssss/SecretSharing.ts b/src/matrix/ssss/SecretSharing.ts new file mode 100644 index 00000000..2ac96d9a --- /dev/null +++ b/src/matrix/ssss/SecretSharing.ts @@ -0,0 +1,383 @@ +/* +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 {Storage} from "../storage/idb/Storage"; +import type {DeviceMessageHandler} from "../DeviceMessageHandler.js" +import type {DeviceTracker} from "../e2ee/DeviceTracker"; +import type {ILogger, ILogItem} from "../../logging/types"; +import type {Encryption as OlmEncryption} from "../e2ee/olm/Encryption"; +import type {Crypto} from "../../platform/web/dom/Crypto.js"; +import type {Encoding} from "../../platform/web/utils/Encoding.js"; +import type {CrossSigning} from "../verification/CrossSigning"; +import type {SecretFetcher} from "./SecretFetcher"; +import type {ObservableValue} from "../../observable/value"; +import type {DecryptionResult} from "../e2ee/DecryptionResult"; +import {makeTxnId, formatToDeviceMessagesPayload} from "../common.js"; +import {Deferred} from "../../utils/Deferred"; +import {StoreNames} from "../storage/common"; +import {SESSION_E2EE_KEY_PREFIX} from "../e2ee/common"; + +type Options = { + hsApi: HomeServerApi; + storage: Storage; + deviceMessageHandler: DeviceMessageHandler; + deviceTracker: DeviceTracker; + ourUserId: string; + olmEncryption: OlmEncryption; + crypto: Crypto; + encoding: Encoding; + crossSigning: ObservableValue; + logger: ILogger; +}; + +const enum EVENT_TYPE { + REQUEST = "m.secret.request", + SEND = "m.secret.send", +} + + +const STORAGE_KEY = "secretRequestIds"; + +export class SecretSharing { + private readonly hsApi: HomeServerApi; + private readonly storage: Storage; + private readonly deviceMessageHandler: DeviceMessageHandler; + private readonly deviceTracker: DeviceTracker; + private readonly ourUserId: string; + private readonly olmEncryption: OlmEncryption; + private readonly waitMap: Map, name: string }> = new Map(); + private readonly encoding: Encoding; + private readonly aesEncryption: AESEncryption; + private readonly crossSigning: ObservableValue; + private readonly logger: ILogger; + private secretFetcher: SecretFetcher; + + constructor(options: Options) { + this.hsApi = options.hsApi; + this.storage = options.storage; + this.deviceMessageHandler = options.deviceMessageHandler; + this.deviceTracker = options.deviceTracker; + this.ourUserId = options.ourUserId; + this.olmEncryption = options.olmEncryption; + this.encoding = options.encoding; + this.crossSigning = options.crossSigning; + this.logger = options.logger; + this.aesEncryption = new AESEncryption(this.storage, options.crypto, this.encoding); + } + + async load(): Promise { + this.deviceMessageHandler.on("message", async ({ encrypted }) => { + const type: EVENT_TYPE = encrypted?.event.type; + switch (type) { + case EVENT_TYPE.REQUEST: { + await this._respondToRequest(encrypted); + break; + } + case EVENT_TYPE.SEND: { + const {secret} = encrypted.event.content; + const name = await this.shouldAcceptSecret(encrypted); + if (name) { + this.writeSecretToStorage(name, secret); + } + break; + } + } + }); + await this.aesEncryption.load(); + } + + private async _respondToRequest(request): Promise { + await this.logger.run("SharedSecret.respondToRequest", async (log) => { + if (!await this.shouldRespondToRequest(request, log)) { + return; + } + const requestContent = request.event.content; + const id = requestContent.request_id; + const deviceId = requestContent.requesting_device_id; + const name = requestContent.name; + + const secret = await this.secretFetcher.getSecret(name); + if (!secret) { + // Can't share a secret that we don't know about. + log.log({ l: "Secret not available to share" }); + return; + } + + const content = { secret, request_id: id }; + const device = await this.deviceTracker.deviceForId(this.ourUserId, deviceId, this.hsApi, log); + if (!device) { + log.log({ l: "Cannot find device", deviceId }); + return; + } + const messages = await log.wrap("olm encrypt", log => this.olmEncryption.encrypt( + EVENT_TYPE.SEND, content, [device], this.hsApi, log)); + const payload = formatToDeviceMessagesPayload(messages); + await this.hsApi.sendToDevice("m.room.encrypted", payload, makeTxnId(), {log}).response(); + }); + } + + private async shouldRespondToRequest(request: any, log: ILogItem): Promise { + return log.wrap("SecretSharing.shouldRespondToRequest", async () => { + if (request.event.content.requesting_device_id === this.deviceTracker.ownDeviceId) { + // This is the request that we sent, so ignore + return false; + } + const crossSigning = this.crossSigning.get(); + if (!crossSigning) { + // We're not in a position to respond to this request + log.log({ crossSigningNotAvailable: true }); + return false; + } + + const content = request.event.content; + if ( + request.event.sender !== this.ourUserId || + !( + content.name && + content.action && + content.requesting_device_id && + content.request_id + ) || + content.action === "request_cancellation" + ) { + // 1. Ensure that the message came from the same user as us + // 2. Validate message format + // 3. Check if this is a cancellation + return false; + } + + // 3. Check that the device is verified + const deviceId = content.requesting_device_id; + const device = await this.deviceTracker.deviceForId(this.ourUserId, deviceId, this.hsApi, log); + if (!device) { + log.log({ l: "Device could not be acquired", deviceId }); + return false; + } + if (!await crossSigning.isOurUserDeviceTrusted(device, log)) { + log.log({ l: "Device not trusted, returning" }); + return false; + } + return true; + }); + } + + /** + * Returns name of the secret if we can accept the response. + * Returns undefined otherwise. + * @param decryptionResult Encrypted to-device event that contains the secret + */ + private async shouldAcceptSecret(decryptionResult: DecryptionResult): Promise { + // 1. Check if we can trust this response + const crossSigning = this.crossSigning.get(); + if (!crossSigning) { + return; + } + const device = decryptionResult.device; + if (!device) { + return; + } + if (!await crossSigning.isOurUserDeviceTrusted(device)) { + // We don't want to accept secrets from an untrusted device + console.log("received secret, but ignoring because not verified"); + return; + } + const content = decryptionResult.event.content!; + const requestId = content.request_id; + // 2. Check if this request is in waitMap + const obj = this.waitMap.get(requestId); + if (obj) { + const { name, deferred } = obj; + deferred.resolve(decryptionResult); + this.waitMap.delete(requestId); + await this.removeStoredRequestId(requestId); + return name; + } + // 3. Check if we've persisted the request to storage + const txn = await this.storage.readTxn([this.storage.storeNames.session]); + const storedIds = await txn.session.get(STORAGE_KEY); + const name = storedIds?.[requestId]; + if (name) { + await this.removeStoredRequestId(requestId); + return name; + } + } + + private async removeStoredRequestId(requestId: string): Promise { + const txn = await this.storage.readWriteTxn([this.storage.storeNames.session]); + const storedIds = await txn.session.get(STORAGE_KEY); + if (storedIds) { + delete storedIds[requestId]; + txn.session.set(STORAGE_KEY, storedIds); + } + } + + async checkSecretValidity(log: ILogItem): Promise { + const crossSigning = this.crossSigning.get(); + const needsDeleting = !await crossSigning?.areWeVerified(log); + if (needsDeleting) { + // User probably reset their cross-signing keys + // Can't trust the secrets anymore! + const txn = await this.storage.readWriteTxn([this.storage.storeNames.sharedSecrets]); + txn.sharedSecrets.deleteAllSecrets(); + } + } + + async getLocallyStoredSecret(name: string): Promise { + const txn = await this.storage.readTxn([ + this.storage.storeNames.sharedSecrets, + ]); + const storedSecret = await txn.sharedSecrets.get(name); + if (storedSecret) { + const secret = await this.aesEncryption.decrypt(storedSecret.encrypted); + return secret; + } + } + + // todo: this will break if two different pieces of code call this method + requestSecret(name: string, log: ILogItem): Promise { + return log.wrap("SharedSecret.requestSecret", async (_log) => { + const request_id = makeTxnId(); + const promise = this.trackSecretRequest(request_id, name); + await this.sendRequestForSecret(name, request_id, _log); + await this.writeRequestIdToStorage(request_id, name); + const request = new SecretRequest(promise); + return request; + }); + } + + /** + * We will store the request-id of every secret request that we send. + * If a device responds to our secret request when we're offline and we receive + * it via sync when we come online at some later time, we can use this persisted + * request-id to determine if we should accept the secret. + */ + private async writeRequestIdToStorage(requestId: string, name: string): Promise { + const txn = await this.storage.readWriteTxn([ + this.storage.storeNames.session, + ]); + const txnIds = await txn.session.get(STORAGE_KEY) ?? {}; + txnIds[requestId] = name; + txn.session.set(STORAGE_KEY, txnIds) + } + + private async writeSecretToStorage(name:string, secret: any): Promise { + const encrypted = await this.aesEncryption.encrypt(secret); + const txn = await this.storage.readWriteTxn([StoreNames.sharedSecrets]); + txn.sharedSecrets.set(name, { encrypted }); + } + + private trackSecretRequest(request_id: string, name: string): Promise { + const deferred = new Deferred(); + this.waitMap.set(request_id, { deferred, name }); + return deferred.promise; + } + + private async sendRequestForSecret(name: string, request_id: string, log: ILogItem): Promise { + const content = { + action: "request", + name, + request_id, + requesting_device_id: this.deviceTracker.ownDeviceId, + } + let devices = await this.deviceTracker.devicesForUsers([this.ourUserId], this.hsApi, log); + devices = devices.filter(d => d.device_id !== this.deviceTracker.ownDeviceId); + const messages = await log.wrap("olm encrypt", log => this.olmEncryption.encrypt( + EVENT_TYPE.REQUEST, content, devices, this.hsApi, log)); + const payload = formatToDeviceMessagesPayload(messages); + await this.hsApi.sendToDevice("m.room.encrypted", payload, makeTxnId(), {log}).response(); + } + + setSecretFetcher(secretFetcher: SecretFetcher): void { + this.secretFetcher = secretFetcher; + } +} + +class SecretRequest { + constructor(private receivedSecretPromise: Promise) { + } + + /** + * Wait for any of your device to respond to this secret request. + * If you're going to await this method, make sure you do that within a try catch block. + * @param timeout The max time (in seconds) that we will wait, after which the promise rejects + */ + async waitForResponse(timeout: number = 30): Promise { + const timeoutPromise: Promise = new Promise((_, reject) => { + setTimeout(reject, timeout * 1000); + }); + const response = await Promise.race([this.receivedSecretPromise, timeoutPromise]); + return response.event.content.secret; + } +} + + +/** + * The idea is to encrypt the secret with AES before persisting to storage. + * The AES key is also in storage so this isn't really that much more secure. + * But it's a tiny bit better than storing the secret in plaintext. + */ +// todo: We could also encrypt the access-token using AES like element does +class AESEncryption { + private key: JsonWebKey; + private iv: Uint8Array; + + constructor(private storage: Storage, private crypto: Crypto, private encoding: Encoding) { }; + + async load(): Promise { + const storageKey = `${SESSION_E2EE_KEY_PREFIX}localAESKey`; + // 1. Check if we're already storing the AES key + const txn = await this.storage.readTxn([StoreNames.session]); + let { key, iv } = await txn.session.get(storageKey) ?? {}; + + // 2. If no key, create it and store in session store + if (!key) { + /** + * Element creates the key as "non-extractable", meaning that it cannot + * be exported through the crypto DOM API. But since it's going + * to end up in indexeddb anyway, it really doesn't matter. + */ + key = await this.crypto.aes.generateKey("jwk"); + iv = await this.crypto.aes.generateIV(); + const txn = await this.storage.readWriteTxn([StoreNames.session]); + txn.session.set(storageKey, { key, iv }); + } + + // 3. Set props + this.key = key; + this.iv = iv; + } + + async encrypt(secret: string): Promise { + const data = this.encoding.utf8.encode(secret); + const encrypted = await this.crypto.aes.encryptCTR({ + jwkKey: this.key, + iv: this.iv, + data, + }); + return encrypted; + } + + async decrypt(ciphertext: Uint8Array): Promise { + const buffer = await this.crypto.aes.decryptCTR({ + jwkKey: this.key, + iv: this.iv, + data: ciphertext, + }); + const secret = this.encoding.utf8.decode(buffer); + return secret; + } +} diff --git a/src/matrix/ssss/index.ts b/src/matrix/ssss/index.ts index 02f3290e..7977991e 100644 --- a/src/matrix/ssss/index.ts +++ b/src/matrix/ssss/index.ts @@ -24,6 +24,11 @@ import type {KeyDescriptionData} from "./common"; import type {Platform} from "../../platform/web/Platform.js"; import type * as OlmNamespace from "@matrix-org/olm" +// Add exports for other classes +export {SecretFetcher} from "./SecretFetcher"; +export {SecretSharing} from "./SecretSharing"; +export {SecretStorage} from "./SecretStorage"; + type Olm = typeof OlmNamespace; const SSSS_KEY = `${SESSION_E2EE_KEY_PREFIX}ssssKey`; diff --git a/src/matrix/storage/common.ts b/src/matrix/storage/common.ts index bf9ce39b..ce85056d 100644 --- a/src/matrix/storage/common.ts +++ b/src/matrix/storage/common.ts @@ -34,7 +34,8 @@ export enum StoreNames { operations = "operations", accountData = "accountData", calls = "calls", - crossSigningKeys = "crossSigningKeys" + crossSigningKeys = "crossSigningKeys", + sharedSecrets = "sharedSecrets", } export const STORE_NAMES: Readonly = Object.values(StoreNames); diff --git a/src/matrix/storage/idb/Store.ts b/src/matrix/storage/idb/Store.ts index c9df33b2..de7a2733 100644 --- a/src/matrix/storage/idb/Store.ts +++ b/src/matrix/storage/idb/Store.ts @@ -118,6 +118,16 @@ export class QueryTargetWrapper { } } + clear(): IDBRequest { + try { + LOG_REQUESTS && logRequest("clear", [], this._qt); + return this._qtStore.clear(); + } + catch (err) { + throw new IDBRequestAttemptError("delete", this._qt, err, []); + } + } + count(keyRange?: IDBKeyRange): IDBRequest { try { return this._qt.count(keyRange); @@ -195,6 +205,11 @@ export class Store extends QueryTarget { this._prepareErrorLog(request, log, "delete", keyOrKeyRange, undefined); } + clear(log?: ILogItem): void { + const request = this._idbStore.clear(); + this._prepareErrorLog(request, log, "delete", undefined, undefined); + } + private _prepareErrorLog(request: IDBRequest, log: ILogItem | undefined, operationName: string, key: IDBKey | undefined, value: T | undefined) { if (log) { log.ensureRefId(); diff --git a/src/matrix/storage/idb/Transaction.ts b/src/matrix/storage/idb/Transaction.ts index 4c76608c..2070eaae 100644 --- a/src/matrix/storage/idb/Transaction.ts +++ b/src/matrix/storage/idb/Transaction.ts @@ -38,6 +38,7 @@ import {GroupSessionDecryptionStore} from "./stores/GroupSessionDecryptionStore" import {OperationStore} from "./stores/OperationStore"; import {AccountDataStore} from "./stores/AccountDataStore"; import {CallStore} from "./stores/CallStore"; +import {SharedSecretStore} from "./stores/SharedSecretStore"; import type {ILogger, ILogItem} from "../../../logging/types"; export type IDBKey = IDBValidKey | IDBKeyRange; @@ -178,6 +179,10 @@ export class Transaction { return this._store(StoreNames.calls, idbStore => new CallStore(idbStore)); } + get sharedSecrets(): SharedSecretStore { + return this._store(StoreNames.sharedSecrets, idbStore => new SharedSecretStore(idbStore)); + } + async complete(log?: ILogItem): Promise { try { await txnAsPromise(this._txn); diff --git a/src/matrix/storage/idb/schema.ts b/src/matrix/storage/idb/schema.ts index 9b4d5547..ce3c89c4 100644 --- a/src/matrix/storage/idb/schema.ts +++ b/src/matrix/storage/idb/schema.ts @@ -37,7 +37,8 @@ export const schema: MigrationFunc[] = [ addInboundSessionBackupIndex, migrateBackupStatus, createCallStore, - applyCrossSigningChanges + applyCrossSigningChanges, + createSharedSecretStore, ]; // TODO: how to deal with git merge conflicts of this array? @@ -299,3 +300,8 @@ async function applyCrossSigningChanges(db: IDBDatabase, txn: IDBTransaction, lo }); log.set("marked_outdated", counter); } + +//v19 create shared secrets store +function createSharedSecretStore(db: IDBDatabase) : void { + db.createObjectStore("sharedSecrets", {keyPath: "key"}); +} diff --git a/src/matrix/storage/idb/stores/SharedSecretStore.ts b/src/matrix/storage/idb/stores/SharedSecretStore.ts new file mode 100644 index 00000000..97f1ee74 --- /dev/null +++ b/src/matrix/storage/idb/stores/SharedSecretStore.ts @@ -0,0 +1,43 @@ +/* +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 {Store} from "../Store"; + +type SharedSecret = any; + +export class SharedSecretStore { + private _store: Store; + + constructor(store: Store) { + this._store = store; + } + + get(name: string): Promise { + return this._store.get(name); + } + + set(name: string, secret: SharedSecret): void { + secret.key = name; + this._store.put(secret); + } + + remove(name: string): void { + this._store.delete(name); + } + + deleteAllSecrets(): void { + this._store.clear(); + } +} diff --git a/src/matrix/verification/CrossSigning.ts b/src/matrix/verification/CrossSigning.ts index f9a2d02a..64845c32 100644 --- a/src/matrix/verification/CrossSigning.ts +++ b/src/matrix/verification/CrossSigning.ts @@ -23,7 +23,7 @@ import {RoomChannel} from "./SAS/channel/RoomChannel"; import {VerificationEventType} from "./SAS/channel/types"; import {ObservableMap} from "../../observable/map"; import {SASRequest} from "./SAS/SASRequest"; -import type {SecretStorage} from "../ssss/SecretStorage"; +import {SecretFetcher} from "../ssss"; import type {Storage} from "../storage/idb/Storage"; import type {Platform} from "../../platform/web/Platform"; import type {DeviceTracker} from "../e2ee/DeviceTracker"; @@ -89,7 +89,7 @@ export interface IVerificationMethod { export class CrossSigning { private readonly storage: Storage; - private readonly secretStorage: SecretStorage; + private readonly secretFetcher: SecretFetcher; private readonly platform: Platform; private readonly deviceTracker: DeviceTracker; private readonly olm: Olm; @@ -106,7 +106,7 @@ export class CrossSigning { constructor(options: { storage: Storage, - secretStorage: SecretStorage, + secretFetcher: SecretFetcher, deviceTracker: DeviceTracker, platform: Platform, olm: Olm, @@ -118,7 +118,7 @@ export class CrossSigning { deviceMessageHandler: DeviceMessageHandler, }) { this.storage = options.storage; - this.secretStorage = options.secretStorage; + this.secretFetcher = options.secretFetcher; this.platform = options.platform; this.deviceTracker = options.deviceTracker; this.olm = options.olm; @@ -231,33 +231,60 @@ export class CrossSigning { 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; + private async handleSASDeviceMessage({ unencrypted: event }) { + if (!event || + (event.type !== VerificationEventType.Request && event.type !== VerificationEventType.Start) + ) { + return; } + await this.platform.logger.run("CrossSigning.handleSASDeviceMessage", async log => { + const txnId = event.content.transaction_id; + const fromDevice = event.content.from_device; + const fromUser = event.sender; + if (!fromDevice || fromUser !== this.ownUserId) { + /** + * SAS verification may be started with a request or a start message but + * both should contain a from_device. + */ + return; + } + if (!await this.areWeVerified(log)) { + /** + * If we're not verified, then the other device MUST be verified. + * We check this so that verification between two unverified devices + * never happen! + */ + const device = await this.deviceTracker.deviceForId(this.ownUserId, fromDevice, this.hsApi, log); + if (!device || !await this.isOurUserDeviceTrusted(device!, log)) { + return; + } + } + /** + * 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. */ @@ -276,10 +303,17 @@ export class CrossSigning { async signDevice(verification: IVerificationMethod, log: ILogItem): Promise { return log.wrap("CrossSigning.signDevice", async log => { if (!this._isMasterKeyTrusted) { + /** + * If we're the unverified device that is participating in + * the verification process, it is expected that we do not + * have access to the private part of MSK and thus + * cannot determine if the MSK is trusted. In this case, we + * do not need to sign anything because the other (verified) + * device will sign our device key with the SSK. + */ log.set("mskNotTrusted", true); - return; } - const shouldSign = await verification.verify(); + const shouldSign = await verification.verify() && this._isMasterKeyTrusted; log.set("shouldSign", shouldSign); if (!shouldSign) { return; @@ -340,6 +374,27 @@ export class CrossSigning { }); } + async isOurUserDeviceTrusted(device: DeviceKey, log?: ILogItem): Promise { + return await this.platform.logger.wrapOrRun(log, "CrossSigning.isOurUserDeviceTrusted", async (_log) => { + const ourSSK = await this.deviceTracker.getCrossSigningKeyForUser(this.ownUserId, KeyUsage.SelfSigning, this.hsApi, _log); + if (!ourSSK) { + return false; + } + const verification = this.hasValidSignatureFrom(device, ourSSK, _log); + if (verification === SignatureVerification.Valid) { + return true; + } + return false; + }); + } + + areWeVerified(log?: ILogItem): Promise { + return this.platform.logger.wrapOrRun(log, "CrossSigning.areWeVerified", async (_log) => { + const device = await this.deviceTracker.deviceForId(this.ownUserId, this.deviceId, this.hsApi, _log); + return this.isOurUserDeviceTrusted(device!, log); + }); + } + getUserTrust(userId: string, log: ILogItem): Promise { return log.wrap("CrossSigning.getUserTrust", async log => { log.set("id", userId); @@ -457,7 +512,7 @@ export class CrossSigning { } private async getSigningKey(usage: KeyUsage): Promise { - const seedStr = await this.secretStorage.readSecret(`m.cross_signing.${usage}`); + const seedStr = await this.secretFetcher.getSecret(`m.cross_signing.${usage}`); if (seedStr) { return new Uint8Array(this.platform.encoding.base64.decode(seedStr)); } diff --git a/src/matrix/verification/SAS/channel/RoomChannel.ts b/src/matrix/verification/SAS/channel/RoomChannel.ts index c22a8cb1..5df15805 100644 --- a/src/matrix/verification/SAS/channel/RoomChannel.ts +++ b/src/matrix/verification/SAS/channel/RoomChannel.ts @@ -103,7 +103,7 @@ export class RoomChannel extends Disposables implements IChannel { } async send(eventType: VerificationEventType, content: any, log: ILogItem): Promise { - await log.wrap("RoomChannel.send", async () => { + await log.wrap("RoomChannel.send", async (_log) => { if (this.isCancelled) { throw new VerificationCancelledError(); } @@ -112,6 +112,15 @@ export class RoomChannel extends Disposables implements IChannel { await this.handleRequestEventSpecially(eventType, content, log); return; } + if (!this.id) { + /** + * This might happen if the user cancelled the verification from the UI, + * but no verification messages were yet sent (maybe because the keys are + * missing etc..). + */ + return; + } + await this.room.ensureMessageKeyIsShared(_log); Object.assign(content, createReference(this.id)); await this.room.sendEvent(eventType, content, undefined, log); this.sentMessages.set(eventType, {content}); diff --git a/src/matrix/verification/SAS/channel/ToDeviceChannel.ts b/src/matrix/verification/SAS/channel/ToDeviceChannel.ts index 247e2f3b..5df46cb2 100644 --- a/src/matrix/verification/SAS/channel/ToDeviceChannel.ts +++ b/src/matrix/verification/SAS/channel/ToDeviceChannel.ts @@ -70,8 +70,12 @@ export class ToDeviceChannel extends Disposables implements IChannel { this.track( this.deviceMessageHandler.disposableOn( "message", - async ({ unencrypted }) => - await this.handleDeviceMessage(unencrypted) + async ({ unencrypted }) => { + if (!unencrypted) { + return; + } + await this.handleDeviceMessage(unencrypted); + } ) ); this.track(() => { diff --git a/src/matrix/verification/SAS/stages/SelectVerificationMethodStage.ts b/src/matrix/verification/SAS/stages/SelectVerificationMethodStage.ts index aa2302fb..743c8ba0 100644 --- a/src/matrix/verification/SAS/stages/SelectVerificationMethodStage.ts +++ b/src/matrix/verification/SAS/stages/SelectVerificationMethodStage.ts @@ -18,10 +18,11 @@ 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 {Deferred} from "../../../../utils/Deferred"; import type {ILogItem} from "../../../../logging/types"; export class SelectVerificationMethodStage extends BaseSASVerificationStage { - private hasSentStartMessage = false; + private hasSentStartMessage?: Promise; private allowSelection = true; public otherDeviceName: string; @@ -36,6 +37,7 @@ export class SelectVerificationMethodStage extends BaseSASVerificationStage { // We received the start message this.allowSelection = false; if (this.hasSentStartMessage) { + await this.hasSentStartMessage; await this.resolveStartConflict(log); } else { @@ -96,6 +98,8 @@ export class SelectVerificationMethodStage extends BaseSASVerificationStage { async selectEmojiMethod(log: ILogItem) { if (!this.allowSelection) { return; } + const deferred = new Deferred(); + this.hasSentStartMessage = deferred.promise; const content = { method: "m.sas.v1", from_device: this.ourUserDeviceId, @@ -110,6 +114,6 @@ export class SelectVerificationMethodStage extends BaseSASVerificationStage { * to the next stage (where we will send the key). */ await this.channel.send(VerificationEventType.Start, content, log); - this.hasSentStartMessage = true; + deferred.resolve(); } } diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index a0f42b01..f7fbe732 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -101,6 +101,14 @@ the layout viewport up without resizing it when the keyboard shows */ .middle .close-middle { display: block !important; } /* hide grid button */ .LeftPanel .grid { display: none !important; } + + .VerificationReadyTileView { + flex-direction: column; + } + + .VerificationTileView__actions { + justify-content: center; + } } .LeftPanel { diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 0f94f9cd..1f7f2f13 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -1423,6 +1423,7 @@ button.RoomDetailsView_row::after { .VerificationCompleteView__heading, .VerifyEmojisView__heading, .SelectMethodView__heading, +.MissingKeysView__heading, .WaitingForOtherUserView__heading { display: flex; align-items: center; @@ -1432,6 +1433,10 @@ button.RoomDetailsView_row::after { padding: 8px; } +.MissingKeysView__heading { + text-align: center; +} + .VerificationCompleteView>*, .SelectMethodView>*, .VerifyEmojisView>*, @@ -1453,6 +1458,7 @@ button.RoomDetailsView_row::after { .SelectMethodView__title, .WaitingForOtherUserView__title, .VerificationCancelledView__description, +.MissingKeysView__description, .VerificationCompleteView__description, .VerifyEmojisView__description, .SelectMethodView__description, @@ -1462,6 +1468,7 @@ button.RoomDetailsView_row::after { } .VerificationCancelledView__actions, +.MissingKeysView__actions, .SelectMethodView__actions, .VerifyEmojisView__actions, .WaitingForOtherUserView__actions { @@ -1532,6 +1539,7 @@ button.RoomDetailsView_row::after { font-size: 1.4rem; color: var(--text-color); gap: 4px; + text-align: center; } .VerificationInProgressTileView, @@ -1539,7 +1547,7 @@ button.RoomDetailsView_row::after { .VerificationCancelledTileView, .VerificationReadyTileView { background: var(--background-color-primary--darker-5); - padding: 12px; + padding: 8px; box-sizing: border-box; border-radius: 8px; } @@ -1547,18 +1555,18 @@ button.RoomDetailsView_row::after { .VerificationTileView { display: flex; justify-content: center; - padding: 16px; + padding: 5px 10%; box-sizing: border-box; } .VerificationInProgressTileView .VerificationTileView__shield, .VerificationReadyTileView .VerificationTileView__shield { - background: url("./icons/e2ee-normal.svg?primary=background-color-secondary--darker-40"); + background: url("./icons/e2ee-normal.svg?primary=background-color-secondary--darker-40") no-repeat; } .VerificationCompletedTileView .VerificationTileView__shield { - background: url("./icons/e2ee-normal.svg?primary=accent-color"); + background: url("./icons/e2ee-normal.svg?primary=accent-color") no-repeat; } .VerificationTileView__shield { diff --git a/src/platform/web/ui/session/room/timeline/VerificationTileView.ts b/src/platform/web/ui/session/room/timeline/VerificationTileView.ts index 56e50211..7138de55 100644 --- a/src/platform/web/ui/session/room/timeline/VerificationTileView.ts +++ b/src/platform/web/ui/session/room/timeline/VerificationTileView.ts @@ -52,8 +52,8 @@ export class VerificationTileView extends TemplateView { class VerificationReadyTileView extends TemplateView { render(t: Builder, vm: VerificationTile) { return t.div({ className: "VerificationReadyTileView" }, [ + t.div({ className: "VerificationTileView__shield" }), t.div({ className: "VerificationTileView__description" }, [ - t.div({ className: "VerificationTileView__shield" }), t.div(vm.description) ]), t.div({ className: "VerificationTileView__actions" }, [ diff --git a/src/platform/web/ui/session/settings/KeyBackupSettingsView.ts b/src/platform/web/ui/session/settings/KeyBackupSettingsView.ts index 7c3d6491..4b60bfc6 100644 --- a/src/platform/web/ui/session/settings/KeyBackupSettingsView.ts +++ b/src/platform/web/ui/session/settings/KeyBackupSettingsView.ts @@ -63,14 +63,6 @@ export class KeyBackupSettingsView extends TemplateView { }), t.if(vm => vm.canSignOwnDevice, t => { return t.div([ - t.button( - { - onClick: disableTargetCallback(async (evt) => { - await vm.signOwnDevice(); - }), - }, - "Sign own device" - ), t.button( { onClick: disableTargetCallback(async () => { diff --git a/src/platform/web/ui/session/verification/DeviceVerificationView.ts b/src/platform/web/ui/session/verification/DeviceVerificationView.ts index d107ca13..a9222575 100644 --- a/src/platform/web/ui/session/verification/DeviceVerificationView.ts +++ b/src/platform/web/ui/session/verification/DeviceVerificationView.ts @@ -14,19 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Builder, TemplateView} from "../../general/TemplateView"; +import {Builder, InlineTemplateView, 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"; +import {MissingKeysView} from "./stages/MissingKeysView"; +import {spinner} from "../../common.js"; export class DeviceVerificationView extends TemplateView { - render(t: Builder) { + render(t: Builder, vm: DeviceVerificationViewModel) { return t.div({ className: { - "middle": true, + "middle": !vm.isHappeningInRoom, "DeviceVerificationView": true, } }, [ @@ -37,7 +39,8 @@ export class DeviceVerificationView extends TemplateView spinner(t)); } }) ]) diff --git a/src/platform/web/ui/session/verification/stages/MissingKeysView.ts b/src/platform/web/ui/session/verification/stages/MissingKeysView.ts new file mode 100644 index 00000000..4ee7fc64 --- /dev/null +++ b/src/platform/web/ui/session/verification/stages/MissingKeysView.ts @@ -0,0 +1,47 @@ +/* +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 {MissingKeysViewModel} from "../../../../../../domain/session/verification/stages/MissingKeysViewModel"; + +export class MissingKeysView extends TemplateView { + render(t: Builder, vm: MissingKeysViewModel) { + return t.div( + { + className: "MissingKeysView", + }, + [ + t.h2( + { className: "MissingKeysView__heading" }, + vm.i18n`Verification is currently not possible!` + ), + t.p( + { className: "MissingKeysView__description" }, + vm.i18n`Some keys needed for verification are missing. You can fix this by enabling key backup in settings.` + ), + t.div({ className: "MissingKeysView__actions" }, [ + t.button({ + className: { + "button-action": true, + "primary": true, + }, + onclick: () => vm.gotoSettings(), + }, "Open Settings") + ]), + ] + ); + } +} diff --git a/src/platform/web/ui/session/verification/stages/VerificationCancelledView.ts b/src/platform/web/ui/session/verification/stages/VerificationCancelledView.ts index 3e761607..4f02db78 100644 --- a/src/platform/web/ui/session/verification/stages/VerificationCancelledView.ts +++ b/src/platform/web/ui/session/verification/stages/VerificationCancelledView.ts @@ -16,12 +16,9 @@ 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", @@ -29,11 +26,11 @@ export class VerificationCancelledView extends TemplateView