diff --git a/src/domain/AccountSetupViewModel.js b/src/domain/AccountSetupViewModel.js index e7c1301f..3e643582 100644 --- a/src/domain/AccountSetupViewModel.js +++ b/src/domain/AccountSetupViewModel.js @@ -16,7 +16,7 @@ limitations under the License. import {ViewModel} from "./ViewModel"; import {KeyType} from "../matrix/ssss/index"; -import {Status} from "./session/settings/KeyBackupViewModel.js"; +import {Status} from "./session/settings/KeyBackupViewModel"; export class AccountSetupViewModel extends ViewModel { constructor(options) { diff --git a/src/domain/session/settings/KeyBackupViewModel.js b/src/domain/session/settings/KeyBackupViewModel.js deleted file mode 100644 index 22135e41..00000000 --- a/src/domain/session/settings/KeyBackupViewModel.js +++ /dev/null @@ -1,223 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import {ViewModel} from "../../ViewModel"; -import {KeyType} from "../../../matrix/ssss/index"; -import {createEnum} from "../../../utils/enum"; -import {FlatMapObservableValue} from "../../../observable/value"; - -export const Status = createEnum("Enabled", "SetupKey", "SetupPhrase", "Pending", "NewVersionAvailable"); -export const BackupWriteStatus = createEnum("Writing", "Stopped", "Done", "Pending"); - -export class KeyBackupViewModel extends ViewModel { - constructor(options) { - super(options); - this._session = options.session; - this._error = null; - this._isBusy = false; - this._dehydratedDeviceId = undefined; - this._status = undefined; - this._backupOperation = new FlatMapObservableValue(this._session.keyBackup, keyBackup => keyBackup.operationInProgress); - this._progress = new FlatMapObservableValue(this._backupOperation, op => op.progress); - this.track(this._backupOperation.subscribe(() => { - // see if needsNewKey might be set - this._reevaluateStatus(); - this.emitChange("isBackingUp"); - })); - this.track(this._progress.subscribe(() => this.emitChange("backupPercentage"))); - this._reevaluateStatus(); - this.track(this._session.keyBackup.subscribe(() => { - if (this._reevaluateStatus()) { - this.emitChange("status"); - } - })); - } - - _reevaluateStatus() { - if (this._isBusy) { - return false; - } - let status; - const keyBackup = this._session.keyBackup.get(); - if (keyBackup) { - status = keyBackup.needsNewKey ? Status.NewVersionAvailable : Status.Enabled; - } else if (keyBackup === null) { - status = this.showPhraseSetup() ? Status.SetupPhrase : Status.SetupKey; - } else { - status = Status.Pending; - } - const changed = status !== this._status; - this._status = status; - return changed; - } - - get decryptAction() { - return this.i18n`Set up`; - } - - get purpose() { - return this.i18n`set up key backup`; - } - - offerDehydratedDeviceSetup() { - return true; - } - - get dehydratedDeviceId() { - return this._dehydratedDeviceId; - } - - get isBusy() { - return this._isBusy; - } - - get backupVersion() { - return this._session.keyBackup.get()?.version; - } - - get isMasterKeyTrusted() { - return this._session.crossSigning?.isMasterKeyTrusted ?? false; - } - - get canSignOwnDevice() { - return !!this._session.crossSigning; - } - - async signOwnDevice() { - if (this._session.crossSigning) { - await this.logger.run("KeyBackupViewModel.signOwnDevice", async log => { - await this._session.crossSigning.signOwnDevice(log); - }); - } - } - - get backupWriteStatus() { - const keyBackup = this._session.keyBackup.get(); - if (!keyBackup) { - return BackupWriteStatus.Pending; - } else if (keyBackup.hasStopped) { - return BackupWriteStatus.Stopped; - } - const operation = keyBackup.operationInProgress.get(); - if (operation) { - return BackupWriteStatus.Writing; - } else if (keyBackup.hasBackedUpAllKeys) { - return BackupWriteStatus.Done; - } else { - return BackupWriteStatus.Pending; - } - } - - get backupError() { - return this._session.keyBackup.get()?.error?.message; - } - - get status() { - return this._status; - } - - get error() { - return this._error?.message; - } - - showPhraseSetup() { - if (this._status === Status.SetupKey) { - this._status = Status.SetupPhrase; - this.emitChange("status"); - } - } - - showKeySetup() { - if (this._status === Status.SetupPhrase) { - this._status = Status.SetupKey; - this.emitChange("status"); - } - } - - async _enterCredentials(keyType, credential, setupDehydratedDevice) { - if (credential) { - try { - this._isBusy = true; - this.emitChange("isBusy"); - const key = await this._session.enableSecretStorage(keyType, credential); - if (setupDehydratedDevice) { - this._dehydratedDeviceId = await this._session.setupDehydratedDevice(key); - } - } catch (err) { - console.error(err); - this._error = err; - this.emitChange("error"); - } finally { - this._isBusy = false; - this._reevaluateStatus(); - this.emitChange(""); - } - } - } - - enterSecurityPhrase(passphrase, setupDehydratedDevice) { - this._enterCredentials(KeyType.Passphrase, passphrase, setupDehydratedDevice); - } - - enterSecurityKey(securityKey, setupDehydratedDevice) { - this._enterCredentials(KeyType.RecoveryKey, securityKey, setupDehydratedDevice); - } - - async disable() { - try { - this._isBusy = true; - this.emitChange("isBusy"); - await this._session.disableSecretStorage(); - } catch (err) { - console.error(err); - this._error = err; - this.emitChange("error"); - } finally { - this._isBusy = false; - this._reevaluateStatus(); - this.emitChange(""); - } - } - - get isBackingUp() { - return !!this._backupOperation.get(); - } - - get backupPercentage() { - const progress = this._progress.get(); - if (progress) { - return Math.round((progress.finished / progress.total) * 100); - } - return 0; - } - - get backupInProgressLabel() { - const progress = this._progress.get(); - if (progress) { - return this.i18n`${progress.finished} of ${progress.total}`; - } - return this.i18n`…`; - } - - cancelBackup() { - this._backupOperation.get()?.abort(); - } - - startBackup() { - this._session.keyBackup.get()?.flush(); - } -} - diff --git a/src/domain/session/settings/KeyBackupViewModel.ts b/src/domain/session/settings/KeyBackupViewModel.ts new file mode 100644 index 00000000..cdfd4081 --- /dev/null +++ b/src/domain/session/settings/KeyBackupViewModel.ts @@ -0,0 +1,270 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {ViewModel} from "../../ViewModel"; +import {SegmentType} from "../../navigation/index"; +import {KeyType} from "../../../matrix/ssss/index"; + +import type {Options as BaseOptions} from "../../ViewModel"; +import type {Session} from "../../../matrix/Session"; +import type {Disposable} from "../../../utils/Disposables"; +import type {KeyBackup, Progress} from "../../../matrix/e2ee/megolm/keybackup/KeyBackup"; +import type {CrossSigning} from "../../../matrix/verification/CrossSigning"; + +export enum Status { + Enabled, + Setup, + Pending, + NewVersionAvailable +}; + +export enum BackupWriteStatus { + Writing, + Stopped, + Done, + Pending +}; + +type Options = { + session: Session, +} & BaseOptions; + +export class KeyBackupViewModel extends ViewModel { + private _error?: Error = undefined; + private _isBusy = false; + private _dehydratedDeviceId?: string = undefined; + private _status = Status.Pending; + private _backupOperationSubscription?: Disposable = undefined; + private _keyBackupSubscription?: Disposable = undefined; + private _progress?: Progress = undefined; + private _setupKeyType = KeyType.RecoveryKey; + + constructor(options) { + super(options); + const onKeyBackupSet = (keyBackup: KeyBackup | undefined) => { + if (keyBackup && !this._keyBackupSubscription) { + this._keyBackupSubscription = this.track(this._session.keyBackup.disposableOn("change", () => { + this._onKeyBackupChange(); + })); + } else if (!keyBackup && this._keyBackupSubscription) { + this._keyBackupSubscription = this.disposeTracked(this._keyBackupSubscription); + } + this._onKeyBackupChange(); // update status + }; + this.track(this._session.keyBackup.subscribe(onKeyBackupSet)); + onKeyBackupSet(this._keyBackup); + } + + private get _session(): Session { + return this.getOption("session"); + } + + private get _keyBackup(): KeyBackup | undefined { + return this._session.keyBackup.get(); + } + + private get _crossSigning(): CrossSigning | undefined { + return this._session.crossSigning.get(); + } + + private _onKeyBackupChange() { + const keyBackup = this._keyBackup; + if (keyBackup) { + const {operationInProgress} = keyBackup; + if (operationInProgress && !this._backupOperationSubscription) { + this._backupOperationSubscription = this.track(operationInProgress.disposableOn("change", () => { + this._progress = operationInProgress.progress; + this.emitChange("backupPercentage"); + })); + } else if (this._backupOperationSubscription && !operationInProgress) { + this._backupOperationSubscription = this.disposeTracked(this._backupOperationSubscription); + this._progress = undefined; + } + } + this.emitChange("status"); + } + + get status(): Status { + const keyBackup = this._keyBackup; + if (keyBackup) { + if (keyBackup.needsNewKey) { + return Status.NewVersionAvailable; + } else if (keyBackup.version === undefined) { + return Status.Pending; + } else { + return keyBackup.needsNewKey ? Status.NewVersionAvailable : Status.Enabled; + } + } else { + return Status.Setup; + } + } + + get decryptAction(): string { + return this.i18n`Set up`; + } + + get purpose(): string { + return this.i18n`set up key backup`; + } + + offerDehydratedDeviceSetup(): boolean { + return true; + } + + get dehydratedDeviceId(): string | undefined { + return this._dehydratedDeviceId; + } + + get isBusy(): boolean { + return this._isBusy; + } + + get backupVersion(): string { + return this._keyBackup?.version ?? ""; + } + + get isMasterKeyTrusted(): boolean { + return this._crossSigning?.isMasterKeyTrusted ?? false; + } + + get canSignOwnDevice(): boolean { + return !!this._crossSigning; + } + + async signOwnDevice(): Promise { + const crossSigning = this._crossSigning; + if (crossSigning) { + await this.logger.run("KeyBackupViewModel.signOwnDevice", async log => { + await crossSigning.signOwnDevice(log); + }); + } + } + + get backupWriteStatus(): BackupWriteStatus { + const keyBackup = this._keyBackup; + if (!keyBackup || keyBackup.version === undefined) { + return BackupWriteStatus.Pending; + } else if (keyBackup.hasStopped) { + return BackupWriteStatus.Stopped; + } + const operation = keyBackup.operationInProgress; + if (operation) { + return BackupWriteStatus.Writing; + } else if (keyBackup.hasBackedUpAllKeys) { + return BackupWriteStatus.Done; + } else { + return BackupWriteStatus.Pending; + } + } + + get backupError(): string | undefined { + return this._keyBackup?.error?.message; + } + + get error(): string | undefined { + return this._error?.message; + } + + showPhraseSetup(): void { + if (this._status === Status.Setup) { + this._setupKeyType = KeyType.Passphrase; + this.emitChange("setupKeyType"); + } + } + + showKeySetup(): void { + if (this._status === Status.Setup) { + this._setupKeyType = KeyType.Passphrase; + this.emitChange("setupKeyType"); + } + } + + get setupKeyType(): KeyType { + return this._setupKeyType; + } + + private async _enterCredentials(keyType, credential, setupDehydratedDevice): Promise { + if (credential) { + try { + this._isBusy = true; + this.emitChange("isBusy"); + const key = await this._session.enableSecretStorage(keyType, credential); + if (setupDehydratedDevice) { + this._dehydratedDeviceId = await this._session.setupDehydratedDevice(key); + } + } catch (err) { + console.error(err); + this._error = err; + this.emitChange("error"); + } finally { + this._isBusy = false; + this.emitChange(); + } + } + } + + enterSecurityPhrase(passphrase, setupDehydratedDevice): Promise { + return this._enterCredentials(KeyType.Passphrase, passphrase, setupDehydratedDevice); + } + + enterSecurityKey(securityKey, setupDehydratedDevice): Promise { + return this._enterCredentials(KeyType.RecoveryKey, securityKey, setupDehydratedDevice); + } + + async disable(): Promise { + try { + this._isBusy = true; + this.emitChange("isBusy"); + await this._session.disableSecretStorage(); + } catch (err) { + console.error(err); + this._error = err; + this.emitChange("error"); + } finally { + this._isBusy = false; + this.emitChange(); + } + } + + get isBackingUp(): boolean { + return this._keyBackup?.operationInProgress !== undefined; + } + + get backupPercentage(): number { + if (this._progress) { + return Math.round((this._progress.finished / this._progress.total) * 100); + } + return 0; + } + + get backupInProgressLabel(): string { + if (this._progress) { + return this.i18n`${this._progress.finished} of ${this._progress.total}`; + } + return this.i18n`…`; + } + + cancelBackup(): void { + this._keyBackup?.operationInProgress?.abort(); + } + + startBackup(): void { + this.logger.run("KeyBackupViewModel.startBackup", log => { + this._keyBackup?.flush(log); + }); + } +} + diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index f8420a53..7f4cab59 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -15,7 +15,7 @@ limitations under the License. */ import {ViewModel} from "../../ViewModel"; -import {KeyBackupViewModel} from "./KeyBackupViewModel.js"; +import {KeyBackupViewModel} from "./KeyBackupViewModel"; import {FeaturesViewModel} from "./FeaturesViewModel"; import {submitLogsFromSessionToDefaultServer} from "../../../domain/rageshake"; diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 0b5b8577..b10b2824 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -90,7 +90,7 @@ export class Session { this._getSyncToken = () => this.syncToken; this._olmWorker = olmWorker; this._keyBackup = new ObservableValue(undefined); - this._crossSigning = undefined; + this._crossSigning = new ObservableValue(undefined); this._observedRoomStatus = new Map(); if (olm) { @@ -250,7 +250,7 @@ export class Session { } if (this._keyBackup.get()) { this._keyBackup.get().dispose(); - this._keyBackup.set(null); + this._keyBackup.set(undefined); } // TODO: stop cross-signing const key = await ssssKeyFromCredential(type, credential, this._storage, this._platform, this._olm); @@ -258,8 +258,8 @@ export class Session { // only after having read a secret, write the key // as we only find out if it was good if the MAC verification succeeds await this._writeSSSSKey(key, log); - await this._keyBackup?.start(log); - await this._crossSigning?.start(log); + await this._keyBackup.get()?.start(log); + await this._crossSigning.get()?.start(log); return key; } else { throw new Error("Could not read key backup with the given key"); @@ -331,29 +331,21 @@ export class Session { if (isValid) { await this._loadSecretStorageServices(secretStorage, txn, log); } - if (!this._keyBackup.get()) { - // null means key backup isn't configured yet - // as opposed to undefined, which means we're still checking - this._keyBackup.set(null); - } return isValid; }); } - _loadSecretStorageServices(secretStorage, txn, log) { + async _loadSecretStorageServices(secretStorage, txn, log) { try { await log.wrap("enable key backup", async log => { - // TODO: delay network request here until start() - const keyBackup = await KeyBackup.fromSecretStorage( - this._platform, - this._olm, - secretStorage, + const keyBackup = new KeyBackup( this._hsApi, + this._olm, this._keyLoader, this._storage, - txn + this._platform, ); - if (keyBackup) { + if (await keyBackup.load(secretStorage, txn)) { for (const room of this._rooms.values()) { if (room.isEncrypted) { room.enableKeyBackup(keyBackup); @@ -378,8 +370,8 @@ export class Session { ownUserId: this.userId, e2eeAccount: this._e2eeAccount }); - if (crossSigning.load(txn, log)) { - this._crossSigning = crossSigning; + if (await crossSigning.load(txn, log)) { + this._crossSigning.set(crossSigning); } }); } @@ -585,8 +577,8 @@ export class Session { } }); } - this._keyBackup?.start(log); - this._crossSigning?.start(log); + this._keyBackup.get()?.start(log); + this._crossSigning.get()?.start(log); // restore unfinished operations, like sending out room keys const opsTxn = await this._storage.readWriteTxn([ diff --git a/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts b/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts index 0ef610ff..8e9a4a81 100644 --- a/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts +++ b/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts @@ -20,6 +20,8 @@ import {MEGOLM_ALGORITHM} from "../../common"; import * as Curve25519 from "./Curve25519"; import {AbortableOperation} from "../../../../utils/AbortableOperation"; import {ObservableValue} from "../../../../observable/value"; +import {Deferred} from "../../../../utils/Deferred"; +import {EventEmitter} from "../../../../utils/EventEmitter"; import {SetAbortableFn} from "../../../../utils/AbortableOperation"; import type {BackupInfo, SessionData, SessionKeyInfo, SessionInfo, KeyBackupPayload} from "./types"; @@ -31,43 +33,69 @@ import type {Storage} from "../../../storage/idb/Storage"; import type {ILogItem} from "../../../../logging/types"; import type {Platform} from "../../../../platform/web/Platform"; import type {Transaction} from "../../../storage/idb/Transaction"; +import type {IHomeServerRequest} from "../../../net/HomeServerRequest"; import type * as OlmNamespace from "@matrix-org/olm"; type Olm = typeof OlmNamespace; const KEYS_PER_REQUEST = 200; -export class KeyBackup { - public readonly operationInProgress = new ObservableValue, Progress> | undefined>(undefined); +// a set of fields we need to store once we've fetched +// the backup info from the homeserver, which happens in start() +class BackupConfig { + constructor( + public readonly info: BackupInfo, + public readonly crypto: Curve25519.BackupEncryption + ) {} +} + +export class KeyBackup extends EventEmitter<{change: never}> { + private _operationInProgress?: AbortableOperation, Progress>; private _stopped = false; private _needsNewKey = false; private _hasBackedUpAllKeys = false; private _error?: Error; private crypto?: Curve25519.BackupEncryption; + private backupInfo?: BackupInfo; + private privateKey?: Uint8Array; + private backupConfigDeferred: Deferred = new Deferred(); + private backupInfoRequest?: IHomeServerRequest; constructor( - private readonly backupInfo: BackupInfo, private readonly hsApi: HomeServerApi, + private readonly olm: Olm, private readonly keyLoader: KeyLoader, private readonly storage: Storage, private readonly platform: Platform, private readonly maxDelay: number = 10000 - ) {} + ) { + super(); + // doing the network request for getting the backup info + // and hence creating the crypto instance depending on the chose algorithm + // is delayed until start() is called, but we want to already take requests + // for fetching the room keys, so put the crypto and backupInfo in a deferred. + this.backupConfigDeferred = new Deferred(); + } get hasStopped(): boolean { return this._stopped; } get error(): Error | undefined { return this._error; } - get version(): string { return this.backupInfo.version; } + get version(): string | undefined { return this.backupConfigDeferred.value?.info?.version; } get needsNewKey(): boolean { return this._needsNewKey; } get hasBackedUpAllKeys(): boolean { return this._hasBackedUpAllKeys; } + get operationInProgress(): AbortableOperation, Progress> | undefined { return this._operationInProgress; } async getRoomKey(roomId: string, sessionId: string, log: ILogItem): Promise { - if (this.needsNewKey || !this.crypto) { + if (this.needsNewKey) { return; } - const sessionResponse = await this.hsApi.roomKeyForRoomAndSession(this.backupInfo.version, roomId, sessionId, {log}).response(); + const backupConfig = await this.backupConfigDeferred.promise; + if (!backupConfig) { + return; + } + const sessionResponse = await this.hsApi.roomKeyForRoomAndSession(backupConfig.info.version, roomId, sessionId, {log}).response(); if (!sessionResponse.session_data) { return; } - const sessionKeyInfo = this.crypto.decryptRoomKey(sessionResponse.session_data as SessionData); + const sessionKeyInfo = backupConfig.crypto.decryptRoomKey(sessionResponse.session_data as SessionData); if (sessionKeyInfo?.algorithm === MEGOLM_ALGORITHM) { return keyFromBackup(roomId, sessionId, sessionKeyInfo); } else if (sessionKeyInfo?.algorithm) { @@ -79,14 +107,53 @@ export class KeyBackup { return txn.inboundGroupSessions.markAllAsNotBackedUp(); } - start(log: ILogItem) { + async load(secretStorage: SecretStorage, txn: Transaction) { + // TODO: no exception here we should anticipate? + const base64PrivateKey = await secretStorage.readSecret("m.megolm_backup.v1", txn); + if (base64PrivateKey) { + this.privateKey = new Uint8Array(this.platform.encoding.base64.decode(base64PrivateKey)); + return true; + } else { + this.backupConfigDeferred.resolve(undefined); + return false; + } + } - // fetch latest version - this.flush(log); + async start(log: ILogItem) { + await log.wrap("KeyBackup.start", async log => { + if (this.privateKey && !this.backupInfoRequest) { + let backupInfo: BackupInfo; + try { + this.backupInfoRequest = this.hsApi.roomKeysVersion(undefined, {log}); + backupInfo = await this.backupInfoRequest.response() as BackupInfo; + } catch (err) { + if (err.name === "AbortError") { + log.set("aborted", true); + return; + } else { + throw err; + } + } finally { + this.backupInfoRequest = undefined; + } + // TODO: what if backupInfo is undefined or we get 404 or something? + if (backupInfo.algorithm === Curve25519.Algorithm) { + const crypto = Curve25519.BackupEncryption.fromAuthData(backupInfo.auth_data, this.privateKey, this.olm); + this.backupConfigDeferred.resolve(new BackupConfig(backupInfo, crypto)); + this.emit("change"); + } else { + this.backupConfigDeferred.resolve(undefined); + log.log({l: `Unknown backup algorithm`, algorithm: backupInfo.algorithm}); + } + this.privateKey = undefined; + } + // fetch latest version + this.flush(log); + }); } flush(log: ILogItem): void { - if (!this.operationInProgress.get()) { + if (!this._operationInProgress) { log.wrapDetached("flush key backup", async log => { if (this._needsNewKey) { log.set("needsNewKey", this._needsNewKey); @@ -96,7 +163,8 @@ export class KeyBackup { this._error = undefined; this._hasBackedUpAllKeys = false; const operation = this._runFlushOperation(log); - this.operationInProgress.set(operation); + this._operationInProgress = operation; + this.emit("change"); try { await operation.result; this._hasBackedUpAllKeys = true; @@ -113,13 +181,18 @@ export class KeyBackup { } log.catch(err); } - this.operationInProgress.set(undefined); + this._operationInProgress = undefined; + this.emit("change"); }); } } private _runFlushOperation(log: ILogItem): AbortableOperation, Progress> { return new AbortableOperation(async (setAbortable, setProgress) => { + const backupConfig = await this.backupConfigDeferred.promise; + if (!backupConfig) { + return; + } let total = 0; let amountFinished = 0; while (true) { @@ -138,8 +211,8 @@ export class KeyBackup { log.set("total", total); return; } - const payload = await this.encodeKeysForBackup(keysNeedingBackup); - const uploadRequest = this.hsApi.uploadRoomKeysToBackup(this.backupInfo.version, payload, {log}); + const payload = await this.encodeKeysForBackup(keysNeedingBackup, backupConfig.crypto); + const uploadRequest = this.hsApi.uploadRoomKeysToBackup(backupConfig.info.version, payload, {log}); setAbortable(uploadRequest); await uploadRequest.response(); await this.markKeysAsBackedUp(keysNeedingBackup, setAbortable); @@ -149,7 +222,7 @@ export class KeyBackup { }); } - private async encodeKeysForBackup(roomKeys: RoomKey[]): Promise { + private async encodeKeysForBackup(roomKeys: RoomKey[], crypto: Curve25519.BackupEncryption): Promise { const payload: KeyBackupPayload = { rooms: {} }; const payloadRooms = payload.rooms; for (const key of roomKeys) { @@ -157,7 +230,7 @@ export class KeyBackup { if (!roomPayload) { roomPayload = payloadRooms[key.roomId] = { sessions: {} }; } - roomPayload.sessions[key.sessionId] = await this.encodeRoomKey(key); + roomPayload.sessions[key.sessionId] = await this.encodeRoomKey(key, crypto); } return payload; } @@ -178,7 +251,7 @@ export class KeyBackup { await txn.complete(); } - private async encodeRoomKey(roomKey: RoomKey): Promise { + private async encodeRoomKey(roomKey: RoomKey, crypto: Curve25519.BackupEncryption): Promise { return await this.keyLoader.useKey(roomKey, session => { const firstMessageIndex = session.first_known_index(); const sessionKey = session.export_session(firstMessageIndex); @@ -186,27 +259,14 @@ export class KeyBackup { first_message_index: firstMessageIndex, forwarded_count: 0, is_verified: false, - session_data: this.crypto.encryptRoomKey(roomKey, sessionKey) + session_data: crypto.encryptRoomKey(roomKey, sessionKey) }; }); } dispose() { - this.crypto?.dispose(); - } - - static async fromSecretStorage(platform: Platform, olm: Olm, secretStorage: SecretStorage, hsApi: HomeServerApi, keyLoader: KeyLoader, storage: Storage, txn: Transaction): Promise { - const base64PrivateKey = await secretStorage.readSecret("m.megolm_backup.v1", txn); - if (base64PrivateKey) { - const privateKey = new Uint8Array(platform.encoding.base64.decode(base64PrivateKey)); - const backupInfo = await hsApi.roomKeysVersion().response() as BackupInfo; - if (backupInfo.algorithm === Curve25519.Algorithm) { - const crypto = Curve25519.BackupEncryption.fromAuthData(backupInfo.auth_data, privateKey, olm); - return new KeyBackup(backupInfo, privateKey, hsApi, keyLoader, storage, platform); - } else { - throw new Error(`Unknown backup algorithm: ${backupInfo.algorithm}`); - } - } + this.backupInfoRequest?.abort(); + this.backupConfigDeferred.value?.crypto?.dispose(); } } diff --git a/src/matrix/ssss/SecretStorage.ts b/src/matrix/ssss/SecretStorage.ts index ebdcd13a..093382a8 100644 --- a/src/matrix/ssss/SecretStorage.ts +++ b/src/matrix/ssss/SecretStorage.ts @@ -50,10 +50,20 @@ export class SecretStorage { const allAccountData = await txn.accountData.getAll(); for (const accountData of allAccountData) { try { + // TODO: fix this, using the webcrypto api closes the transaction + if (accountData.type === "m.megolm_backup.v1") { + return true; + } else { + continue; + } const secret = await this._decryptAccountData(accountData); return true; // decryption succeeded } catch (err) { - continue; + if (err instanceof DecryptionError && err.reason !== DecryptionFailure.NotEncryptedWithKey) { + throw err; + } else { + continue; + } } } return false; diff --git a/src/platform/web/ui/login/AccountSetupView.js b/src/platform/web/ui/login/AccountSetupView.js index e0d41693..cf2b544f 100644 --- a/src/platform/web/ui/login/AccountSetupView.js +++ b/src/platform/web/ui/login/AccountSetupView.js @@ -15,7 +15,7 @@ limitations under the License. */ import {TemplateView} from "../general/TemplateView"; -import {KeyBackupSettingsView} from "../session/settings/KeyBackupSettingsView.js"; +import {KeyBackupSettingsView} from "../session/settings/KeyBackupSettingsView"; export class AccountSetupView extends TemplateView { render(t, vm) { diff --git a/src/platform/web/ui/session/settings/KeyBackupSettingsView.js b/src/platform/web/ui/session/settings/KeyBackupSettingsView.ts similarity index 73% rename from src/platform/web/ui/session/settings/KeyBackupSettingsView.js rename to src/platform/web/ui/session/settings/KeyBackupSettingsView.ts index 6a886e3a..28c4febf 100644 --- a/src/platform/web/ui/session/settings/KeyBackupSettingsView.js +++ b/src/platform/web/ui/session/settings/KeyBackupSettingsView.ts @@ -14,32 +14,41 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView"; +import {TemplateView, Builder} from "../../general/TemplateView"; import {disableTargetCallback} from "../../general/utils"; +import {ViewNode} from "../../general/types"; +import {KeyBackupViewModel, Status, BackupWriteStatus} from "../../../../../domain/session/settings/KeyBackupViewModel"; +import {KeyType} from "../../../../../matrix/ssss/index"; -export class KeyBackupSettingsView extends TemplateView { - render(t, vm) { +export class KeyBackupSettingsView extends TemplateView { + render(t: Builder, vm: KeyBackupViewModel): ViewNode { return t.div([ t.map(vm => vm.status, (status, t, vm) => { switch (status) { - case "Enabled": return renderEnabled(t, vm); - case "NewVersionAvailable": return renderNewVersionAvailable(t, vm); - case "SetupKey": return renderEnableFromKey(t, vm); - case "SetupPhrase": return renderEnableFromPhrase(t, vm); - case "Pending": return t.p(vm.i18n`Waiting to go online…`); + case Status.Enabled: return renderEnabled(t, vm); + case Status.NewVersionAvailable: return renderNewVersionAvailable(t, vm); + case Status.Setup: { + if (vm.setupKeyType === KeyType.Passphrase) { + return renderEnableFromPhrase(t, vm); + } else { + return renderEnableFromKey(t, vm); + } + break; + } + case Status.Pending: return t.p(vm.i18n`Waiting to go online…`); } }), t.map(vm => vm.backupWriteStatus, (status, t, vm) => { switch (status) { - case "Writing": { + case BackupWriteStatus.Writing: { const progress = t.progress({ - min: 0, - max: 100, + min: 0+"", + max: 100+"", value: vm => vm.backupPercentage, }); return t.div([`Backup in progress `, progress, " ", vm => vm.backupInProgressLabel]); } - case "Stopped": { + case BackupWriteStatus.Stopped: { let label; const error = vm.backupError; if (error) { @@ -47,12 +56,12 @@ export class KeyBackupSettingsView extends TemplateView { } else { label = `Backup has stopped`; } - return t.p(label, " ", t.button({onClick: () => vm.startBackup()}, `Backup now`)); + return t.p([label, " ", t.button({onClick: () => vm.startBackup()}, `Backup now`)]); } - case "Done": + case BackupWriteStatus.Done: return t.p(`All keys are backed up.`); default: - return null; + return undefined; } }), t.if(vm => vm.isMasterKeyTrusted, t => { @@ -70,7 +79,7 @@ export class KeyBackupSettingsView extends TemplateView { } } -function renderEnabled(t, vm) { +function renderEnabled(t: Builder, vm: KeyBackupViewModel): ViewNode { const items = [ t.p([vm.i18n`Key backup is enabled, using backup version ${vm.backupVersion}. `, t.button({onClick: () => vm.disable()}, vm.i18n`Disable`)]) ]; @@ -80,14 +89,14 @@ function renderEnabled(t, vm) { return t.div(items); } -function renderNewVersionAvailable(t, vm) { +function renderNewVersionAvailable(t: Builder, vm: KeyBackupViewModel): ViewNode { const items = [ t.p([vm.i18n`A new backup version has been created from another device. Disable key backup and enable it again with the new key.`, t.button({onClick: () => vm.disable()}, vm.i18n`Disable`)]) ]; return t.div(items); } -function renderEnableFromKey(t, vm) { +function renderEnableFromKey(t: Builder, vm: KeyBackupViewModel): ViewNode { const useASecurityPhrase = t.button({className: "link", onClick: () => vm.showPhraseSetup()}, vm.i18n`use a security phrase`); return t.div([ t.p(vm.i18n`Enter your secret storage security key below to ${vm.purpose}, which will enable you to decrypt messages received before you logged into this session. The security key is a code of 12 groups of 4 characters separated by a space that Element created for you when setting up security.`), @@ -97,7 +106,7 @@ function renderEnableFromKey(t, vm) { ]); } -function renderEnableFromPhrase(t, vm) { +function renderEnableFromPhrase(t: Builder, vm: KeyBackupViewModel): ViewNode { const useASecurityKey = t.button({className: "link", onClick: () => vm.showKeySetup()}, vm.i18n`use your security key`); return t.div([ t.p(vm.i18n`Enter your secret storage security phrase below to ${vm.purpose}, which will enable you to decrypt messages received before you logged into this session. The security phrase is a freeform secret phrase you optionally chose when setting up security in Element. It is different from your password to login, unless you chose to set them to the same value.`), @@ -107,7 +116,7 @@ function renderEnableFromPhrase(t, vm) { ]); } -function renderEnableFieldRow(t, vm, label, callback) { +function renderEnableFieldRow(t, vm, label, callback): ViewNode { let setupDehydrationCheck; const eventHandler = () => callback(input.value, setupDehydrationCheck?.checked || false); const input = t.input({type: "password", disabled: vm => vm.isBusy, placeholder: label}); @@ -131,8 +140,8 @@ function renderEnableFieldRow(t, vm, label, callback) { ]); } -function renderError(t) { - return t.if(vm => vm.error, (t, vm) => { +function renderError(t: Builder): ViewNode { + return t.if(vm => vm.error !== undefined, (t, vm) => { return t.div([ t.p({className: "error"}, vm => vm.i18n`Could not enable key backup: ${vm.error}.`), t.p(vm.i18n`Try double checking that you did not mix up your security key, security phrase and login password as explained above.`) diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index aea1108a..4035281f 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -16,7 +16,7 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView"; import {disableTargetCallback} from "../../general/utils"; -import {KeyBackupSettingsView} from "./KeyBackupSettingsView.js" +import {KeyBackupSettingsView} from "./KeyBackupSettingsView" import {FeaturesView} from "./FeaturesView" export class SettingsView extends TemplateView { diff --git a/src/utils/AbortableOperation.ts b/src/utils/AbortableOperation.ts index e0afecd3..3592c951 100644 --- a/src/utils/AbortableOperation.ts +++ b/src/utils/AbortableOperation.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableValue, ObservableValue} from "../observable/value"; +import {EventEmitter} from "../utils/EventEmitter"; export interface IAbortable { abort(); @@ -24,25 +24,27 @@ export type SetAbortableFn = (a: IAbortable) => typeof a; export type SetProgressFn

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

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

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

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

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

{ + get progress(): P | undefined { return this._progress; } diff --git a/src/utils/Deferred.ts b/src/utils/Deferred.ts new file mode 100644 index 00000000..430fe996 --- /dev/null +++ b/src/utils/Deferred.ts @@ -0,0 +1,41 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + + +export class Deferred { + public readonly promise: Promise; + public readonly resolve: (value: T) => void; + public readonly reject: (err: Error) => void; + private _value?: T; + + constructor() { + let resolve; + let reject; + this.promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }) + this.resolve = (value: T) => { + this._value = value; + resolve(value); + }; + this.reject = reject; + } + + get value(): T | undefined { + return this._value; + } +}