diff --git a/src/domain/AccountSetupViewModel.js b/src/domain/AccountSetupViewModel.js new file mode 100644 index 00000000..7930b87d --- /dev/null +++ b/src/domain/AccountSetupViewModel.js @@ -0,0 +1,136 @@ +/* +Copyright 2021 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.js"; +import {KeyType} from "../matrix/ssss/index.js"; +import {Status} from "./session/settings/SessionBackupViewModel.js"; + +export class AccountSetupViewModel extends ViewModel { + constructor(accountSetup) { + super(); + this._accountSetup = accountSetup; + this._dehydratedDevice = undefined; + this._decryptDehydratedDeviceViewModel = undefined; + if (this._accountSetup.encryptedDehydratedDevice) { + this._decryptDehydratedDeviceViewModel = new DecryptDehydratedDeviceViewModel(this, dehydratedDevice => { + this._dehydratedDevice = dehydratedDevice; + this._decryptDehydratedDeviceViewModel = undefined; + this.emitChange("deviceDecrypted"); + }); + } + } + + get decryptDehydratedDeviceViewModel() { + return this._decryptDehydratedDeviceViewModel; + } + + get deviceDecrypted() { + return !!this._dehydratedDevice; + } + + get dehydratedDeviceId() { + return this._accountSetup.encryptedDehydratedDevice.deviceId; + } + + finish() { + this._accountSetup.finish(this._dehydratedDevice); + } +} + +// this vm adopts the same shape as SessionBackupViewModel so the same view can be reused. +class DecryptDehydratedDeviceViewModel extends ViewModel { + constructor(accountSetupViewModel, decryptedCallback) { + super(); + this._accountSetupViewModel = accountSetupViewModel; + this._isBusy = false; + this._status = Status.SetupKey; + this._error = undefined; + this._decryptedCallback = decryptedCallback; + } + + get decryptAction() { + return this.i18n`Restore`; + } + + get purpose() { + return this.i18n`claim your dehydrated device`; + } + + get offerDehydratedDeviceSetup() { + return false; + } + + get dehydratedDeviceId() { + return this._accountSetupViewModel._dehydratedDevice?.deviceId; + } + + get isBusy() { + return this._isBusy; + } + + get backupVersion() { return 0; } + + 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) { + if (credential) { + try { + this._isBusy = true; + this.emitChange("isBusy"); + const {encryptedDehydratedDevice} = this._accountSetupViewModel._accountSetup; + const dehydratedDevice = await encryptedDehydratedDevice.decrypt(keyType, credential); + this._decryptedCallback(dehydratedDevice); + } catch (err) { + console.error(err); + this._error = err; + this.emitChange("error"); + } finally { + this._isBusy = false; + this.emitChange(""); + } + } + } + + enterSecurityPhrase(passphrase) { + this._enterCredentials(KeyType.Passphrase, passphrase); + } + + enterSecurityKey(securityKey) { + this._enterCredentials(KeyType.RecoveryKey, securityKey); + } + + disable() {} +} diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js index f7cf8285..c798bc67 100644 --- a/src/domain/SessionLoadViewModel.js +++ b/src/domain/SessionLoadViewModel.js @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {AccountSetupViewModel} from "./AccountSetupViewModel.js"; import {LoadStatus} from "../matrix/SessionContainer.js"; import {SyncStatus} from "../matrix/Sync.js"; import {ViewModel} from "./ViewModel.js"; @@ -29,6 +30,8 @@ export class SessionLoadViewModel extends ViewModel { this._loading = false; this._error = null; this.backUrl = this.urlCreator.urlForSegment("session", true); + this._accountSetupViewModel = undefined; + } async start() { @@ -39,6 +42,11 @@ export class SessionLoadViewModel extends ViewModel { this._loading = true; this.emitChange("loading"); this._waitHandle = this._sessionContainer.loadStatus.waitFor(s => { + if (s === LoadStatus.AccountSetup) { + this._accountSetupViewModel = new AccountSetupViewModel(this._sessionContainer.accountSetup); + } else { + this._accountSetupViewModel = undefined; + } this.emitChange("loadLabel"); // wait for initial sync, but not catchup sync const isCatchupSync = s === LoadStatus.FirstSync && @@ -97,6 +105,10 @@ export class SessionLoadViewModel extends ViewModel { // to show a spinner or not get loading() { + const sc = this._sessionContainer; + if (sc && sc.loadStatus.get() === LoadStatus.AccountSetup) { + return false; + } return this._loading; } @@ -110,6 +122,10 @@ export class SessionLoadViewModel extends ViewModel { // Statuses related to login are handled by respective login view models if (sc) { switch (sc.loadStatus.get()) { + case LoadStatus.QueryAccount: + return `Querying account encryption setup…`; + case LoadStatus.AccountSetup: + return ""; // we'll show a header ing AccountSetupView case LoadStatus.SessionSetup: return `Setting up your encryption keys…`; case LoadStatus.Loading: @@ -136,4 +152,13 @@ export class SessionLoadViewModel extends ViewModel { const logExport = await this.logger.export(); this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`); } + + async logout() { + await this._sessionContainer.logout(); + this.navigation.push("session", true); + } + + get accountSetupViewModel() { + return this._accountSetupViewModel; + } } diff --git a/src/domain/login/LoginViewModel.js b/src/domain/login/LoginViewModel.js index 9cdf9290..6314f347 100644 --- a/src/domain/login/LoginViewModel.js +++ b/src/domain/login/LoginViewModel.js @@ -107,7 +107,7 @@ export class LoginViewModel extends ViewModel { async attemptLogin(loginMethod) { this._setBusy(true); - this._sessionContainer.startWithLogin(loginMethod); + this._sessionContainer.startWithLogin(loginMethod, {inspectAccountSetup: true}); const loadStatus = this._sessionContainer.loadStatus; const handle = loadStatus.waitFor(status => status !== LoadStatus.Login); await handle.promise; diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js index 5b4bcea4..1db2100e 100644 --- a/src/domain/session/room/timeline/tiles/GapTile.js +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -54,12 +54,10 @@ export class GapTile extends SimpleTile { } updatePreviousSibling(prev) { - console.log("GapTile.updatePreviousSibling", prev); super.updatePreviousSibling(prev); const isAtTop = !prev; if (this._isAtTop !== isAtTop) { this._isAtTop = isAtTop; - console.log("isAtTop", this._isAtTop); this.emitChange("isAtTop"); } } diff --git a/src/domain/session/settings/SessionBackupViewModel.js b/src/domain/session/settings/SessionBackupViewModel.js index d924fae6..533d0f6d 100644 --- a/src/domain/session/settings/SessionBackupViewModel.js +++ b/src/domain/session/settings/SessionBackupViewModel.js @@ -15,18 +15,60 @@ limitations under the License. */ import {ViewModel} from "../../ViewModel.js"; +import {KeyType} from "../../../matrix/ssss/index.js"; +import {createEnum} from "../../../utils/enum.js"; + +export const Status = createEnum("Enabled", "SetupKey", "SetupPhrase", "Pending"); export class SessionBackupViewModel extends ViewModel { constructor(options) { super(options); this._session = options.session; - this._showKeySetup = true; this._error = null; this._isBusy = false; + this._dehydratedDeviceId = undefined; + this._status = undefined; + this._reevaluateStatus(); this.track(this._session.hasSecretStorageKey.subscribe(() => { - this.emitChange("status"); + if (this._reevaluateStatus()) { + this.emitChange("status"); + } })); } + + _reevaluateStatus() { + if (this._isBusy) { + return false; + } + let status; + const hasSecretStorageKey = this._session.hasSecretStorageKey.get(); + if (hasSecretStorageKey === true) { + status = this._session.sessionBackup ? Status.Enabled : Status.SetupKey; + } else if (hasSecretStorageKey === false) { + status = 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 session backup`; + } + + offerDehydratedDeviceSetup() { + return true; + } + + get dehydratedDeviceId() { + return this._dehydratedDeviceId; + } get isBusy() { return this._isBusy; @@ -37,15 +79,7 @@ export class SessionBackupViewModel extends ViewModel { } get status() { - if (this._session.sessionBackup) { - return "enabled"; - } else { - if (this._session.hasSecretStorageKey.get() === false) { - return this._showKeySetup ? "setupKey" : "setupPhrase"; - } else { - return "pending"; - } - } + return this._status; } get error() { @@ -53,46 +87,61 @@ export class SessionBackupViewModel extends ViewModel { } showPhraseSetup() { - this._showKeySetup = false; - this.emitChange("status"); + if (this._status === Status.SetupKey) { + this._status = Status.SetupPhrase; + this.emitChange("status"); + } } showKeySetup() { - this._showKeySetup = true; - this.emitChange("status"); + if (this._status === Status.SetupPhrase) { + this._status = Status.SetupKey; + this.emitChange("status"); + } } - async enterSecurityPhrase(passphrase) { - if (passphrase) { + async _enterCredentials(keyType, credential, setupDehydratedDevice) { + if (credential) { try { this._isBusy = true; this.emitChange("isBusy"); - await this._session.enableSecretStorage("phrase", passphrase); + 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(""); } } } - async enterSecurityKey(securityKey) { - if (securityKey) { - try { - this._isBusy = true; - this.emitChange("isBusy"); - await this._session.enableSecretStorage("key", securityKey); - } catch (err) { - console.error(err); - this._error = err; - this.emitChange("error"); - } finally { - this._isBusy = false; - 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(""); } } } diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 9afd2888..9ce340f1 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -57,16 +57,11 @@ export class SettingsViewModel extends ViewModel { return this._sessionContainer.session; } - logout() { - return this.logger.run("logout", async log => { - this._isLoggingOut = true; - this.emitChange("isLoggingOut"); - try { - await this._session.logout(log); - } catch (err) {} - await this._sessionContainer.deleteSession(log); - this.navigation.push("session", true); - }); + async logout() { + this._isLoggingOut = true; + await this._sessionContainer.logout(); + this.emitChange("isLoggingOut"); + this.navigation.push("session", true); } get isLoggingOut() { return this._isLoggingOut; } diff --git a/src/logging/ConsoleLogger.js b/src/logging/ConsoleLogger.js index 4dcda826..9610795f 100644 --- a/src/logging/ConsoleLogger.js +++ b/src/logging/ConsoleLogger.js @@ -21,7 +21,6 @@ export class ConsoleLogger extends BaseLogger { } } - const excludedKeysFromTable = ["l", "id"]; function filterValues(values) { if (!values) { @@ -79,7 +78,7 @@ function itemCaption(item) { } else if (item._values.l && item.error) { return `${item._values.l} failed`; } else if (typeof item._values.ref !== "undefined") { - return `ref ${item._values.ref}` + return `ref ${item._values.ref}`; } else { return item._values.l || item._values.type; } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 235ff3b1..37fd90ad 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -24,6 +24,7 @@ import { ObservableMap } from "../observable/index.js"; import {User} from "./User.js"; import {DeviceMessageHandler} from "./DeviceMessageHandler.js"; import {Account as E2EEAccount} from "./e2ee/Account.js"; +import {uploadAccountAsDehydratedDevice} from "./e2ee/Dehydration.js"; import {Decryption as OlmDecryption} from "./e2ee/olm/Decryption.js"; import {Encryption as OlmEncryption} from "./e2ee/olm/Encryption.js"; import {Decryption as MegOlmDecryption} from "./e2ee/megolm/Decryption"; @@ -39,6 +40,8 @@ import { keyFromCredential as ssssKeyFromCredential, readKey as ssssReadKey, writeKey as ssssWriteKey, + removeKey as ssssRemoveKey, + keyFromDehydratedDeviceKey as createSSSSKeyFromDehydratedDeviceKey } from "./ssss/index.js"; import {SecretStorage} from "./ssss/SecretStorage.js"; import {ObservableValue, RetainedObservableValue} from "../observable/ObservableValue"; @@ -106,9 +109,9 @@ export class Session { return this._sessionInfo.userId; } + /** @internal call SessionContainer.logout instead */ async logout(log = undefined) { - const response = await this._hsApi.logout({log}).response(); - console.log("logout", response); + await this._hsApi.logout({log}).response(); } // called once this._e2eeAccount is assigned @@ -203,6 +206,12 @@ export class Session { this._storage.storeNames.accountData, ]); await this._createSessionBackup(key, readTxn); + await this._writeSSSSKey(key); + this._hasSecretStorageKey.set(true); + return key; + } + + async _writeSSSSKey(key) { // only after having read a secret, write the key // as we only find out if it was good if the MAC verification succeeds const writeTxn = await this._storage.readWriteTxn([ @@ -215,7 +224,29 @@ export class Session { throw err; } await writeTxn.complete(); - this._hasSecretStorageKey.set(true); + } + + async disableSecretStorage() { + const writeTxn = await this._storage.readWriteTxn([ + this._storage.storeNames.session, + ]); + try { + ssssRemoveKey(writeTxn); + } catch (err) { + writeTxn.abort(); + throw err; + } + await writeTxn.complete(); + if (this._sessionBackup) { + for (const room of this._rooms.values()) { + if (room.isEncrypted) { + room.enableSessionBackup(undefined); + } + } + this._sessionBackup?.dispose(); + this._sessionBackup = undefined; + } + this._hasSecretStorageKey.set(false); } async _createSessionBackup(ssssKey, txn) { @@ -248,23 +279,76 @@ export class Session { async createIdentity(log) { if (this._olm) { if (!this._e2eeAccount) { - this._e2eeAccount = await E2EEAccount.create({ - hsApi: this._hsApi, - olm: this._olm, - pickleKey: PICKLE_KEY, - userId: this._sessionInfo.userId, - deviceId: this._sessionInfo.deviceId, - olmWorker: this._olmWorker, - storage: this._storage, - }); + this._e2eeAccount = await this._createNewAccount(this._sessionInfo.deviceId, this._storage); log.set("keys", this._e2eeAccount.identityKeys); this._setupEncryption(); } await this._e2eeAccount.generateOTKsIfNeeded(this._storage, log); - await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, log)); + await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, false, log)); } } + /** @internal */ + async dehydrateIdentity(dehydratedDevice, log) { + log.set("deviceId", dehydratedDevice.deviceId); + if (!this._olm) { + log.set("no_olm", true); + return false; + } + if (dehydratedDevice.deviceId !== this.deviceId) { + log.set("wrong_device", true); + return false; + } + if (this._e2eeAccount) { + log.set("account_already_setup", true); + return false; + } + if (!await dehydratedDevice.claim(this._hsApi, log)) { + log.set("already_claimed", true); + return false; + } + this._e2eeAccount = await E2EEAccount.adoptDehydratedDevice({ + dehydratedDevice, + hsApi: this._hsApi, + olm: this._olm, + pickleKey: PICKLE_KEY, + userId: this._sessionInfo.userId, + olmWorker: this._olmWorker, + deviceId: this.deviceId, + storage: this._storage, + }); + log.set("keys", this._e2eeAccount.identityKeys); + this._setupEncryption(); + return true; + } + + _createNewAccount(deviceId, storage = undefined) { + // storage is optional and if omitted the account won't be persisted (useful for dehydrating devices) + return E2EEAccount.create({ + hsApi: this._hsApi, + olm: this._olm, + pickleKey: PICKLE_KEY, + userId: this._sessionInfo.userId, + olmWorker: this._olmWorker, + deviceId, + storage, + }); + } + + setupDehydratedDevice(key, log = null) { + return this._platform.logger.wrapOrRun(log, "setupDehydratedDevice", async log => { + const dehydrationAccount = await this._createNewAccount("temp-device-id"); + try { + const deviceId = await uploadAccountAsDehydratedDevice( + dehydrationAccount, this._hsApi, key, "Dehydrated device", log); + log.set("deviceId", deviceId); + return deviceId; + } finally { + dehydrationAccount.dispose(); + } + }); + } + /** @internal */ async load(log) { const txn = await this._storage.readTxn([ @@ -321,11 +405,17 @@ export class Session { dispose() { this._olmWorker?.dispose(); + this._olmWorker = undefined; this._sessionBackup?.dispose(); - this._megolmDecryption.dispose(); + this._sessionBackup = undefined; + this._megolmDecryption?.dispose(); + this._megolmDecryption = undefined; + this._e2eeAccount?.dispose(); + this._e2eeAccount = undefined; for (const room of this._rooms.values()) { room.dispose(); } + this._rooms = undefined; } /** @@ -334,7 +424,7 @@ export class Session { * and useful to store so we can later tell what capabilities * our homeserver has. */ - async start(lastVersionResponse, log) { + async start(lastVersionResponse, dehydratedDevice, log) { if (lastVersionResponse) { // store /versions response const txn = await this._storage.readWriteTxn([ @@ -346,6 +436,15 @@ export class Session { } // enable session backup, this requests the latest backup version if (!this._sessionBackup) { + if (dehydratedDevice) { + await log.wrap("SSSSKeyFromDehydratedDeviceKey", async log => { + const ssssKey = await createSSSSKeyFromDehydratedDeviceKey(dehydratedDevice.key, this._storage, this._platform); + if (ssssKey) { + log.set("success", true); + await this._writeSSSSKey(ssssKey); + } + }) + } const txn = await this._storage.readTxn([ this._storage.storeNames.session, this._storage.storeNames.accountData, @@ -517,7 +616,7 @@ export class Session { if (!isCatchupSync) { const needsToUploadOTKs = await this._e2eeAccount.generateOTKsIfNeeded(this._storage, log); if (needsToUploadOTKs) { - await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, log)); + await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, false, log)); } } } diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index ef76971a..8e3d147e 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -29,14 +29,17 @@ import {Session} from "./Session.js"; import {PasswordLoginMethod} from "./login/PasswordLoginMethod.js"; import {TokenLoginMethod} from "./login/TokenLoginMethod.js"; import {SSOLoginHelper} from "./login/SSOLoginHelper.js"; +import {getDehydratedDevice} from "./e2ee/Dehydration.js"; export const LoadStatus = createEnum( "NotLoading", "Login", "LoginFailed", + "QueryAccount", // check for dehydrated device after login + "AccountSetup", // asked to restore from dehydrated device if present, call sc.accountSetup.finish() to progress to the next stage "Loading", "SessionSetup", // upload e2ee keys, ... - "Migrating", //not used atm, but would fit here + "Migrating", // not used atm, but would fit here "FirstSync", "Error", "Ready", @@ -63,6 +66,7 @@ export class SessionContainer { this._requestScheduler = null; this._olmPromise = olmPromise; this._workerPromise = workerPromise; + this._accountSetup = undefined; } createNewSessionId() { @@ -85,7 +89,7 @@ export class SessionContainer { if (!sessionInfo) { throw new Error("Invalid session id: " + sessionId); } - await this._loadSessionInfo(sessionInfo, false, log); + await this._loadSessionInfo(sessionInfo, null, log); log.set("status", this._status.get()); } catch (err) { log.catch(err); @@ -127,7 +131,7 @@ export class SessionContainer { }); } - async startWithLogin(loginMethod) { + async startWithLogin(loginMethod, {inspectAccountSetup} = {}) { const currentStatus = this._status.get(); if (currentStatus !== LoadStatus.LoginFailed && currentStatus !== LoadStatus.NotLoading && @@ -154,7 +158,6 @@ export class SessionContainer { lastUsed: clock.now() }; log.set("id", sessionId); - await this._platform.sessionInfoStorage.add(sessionInfo); } catch (err) { this._error = err; if (err.name === "HomeServerError") { @@ -173,21 +176,31 @@ export class SessionContainer { } return; } + let dehydratedDevice; + if (inspectAccountSetup) { + dehydratedDevice = await this._inspectAccountAfterLogin(sessionInfo, log); + if (dehydratedDevice) { + sessionInfo.deviceId = dehydratedDevice.deviceId; + } + } + await this._platform.sessionInfoStorage.add(sessionInfo); // loading the session can only lead to // LoadStatus.Error in case of an error, // so separate try/catch try { - await this._loadSessionInfo(sessionInfo, true, log); + await this._loadSessionInfo(sessionInfo, dehydratedDevice, log); log.set("status", this._status.get()); } catch (err) { log.catch(err); + // free olm Account that might be contained + dehydratedDevice?.dispose(); this._error = err; this._status.set(LoadStatus.Error); } }); } - async _loadSessionInfo(sessionInfo, isNewLogin, log) { + async _loadSessionInfo(sessionInfo, dehydratedDevice, log) { log.set("appVersion", this._platform.version); const clock = this._platform.clock; this._sessionStartedByReconnector = false; @@ -233,7 +246,9 @@ export class SessionContainer { platform: this._platform, }); await this._session.load(log); - if (!this._session.hasIdentity) { + if (dehydratedDevice) { + await log.wrap("dehydrateIdentity", log => this._session.dehydrateIdentity(dehydratedDevice, log)); + } else if (!this._session.hasIdentity) { this._status.set(LoadStatus.SessionSetup); await log.wrap("createIdentity", log => this._session.createIdentity(log)); } @@ -247,7 +262,9 @@ export class SessionContainer { this._requestScheduler.start(); this._sync.start(); this._sessionStartedByReconnector = true; - await log.wrap("session start", log => this._session.start(this._reconnector.lastVersionsResponse, log)); + const d = dehydratedDevice; + dehydratedDevice = undefined; + await log.wrap("session start", log => this._session.start(this._reconnector.lastVersionsResponse, d, log)); }); } }); @@ -266,8 +283,10 @@ export class SessionContainer { if (this._isDisposed) { return; } + const d = dehydratedDevice; + dehydratedDevice = undefined; // log as ref as we don't want to await it - await log.wrap("session start", log => this._session.start(lastVersionsResponse, log)); + await log.wrap("session start", log => this._session.start(lastVersionsResponse, d, log)); } } @@ -300,6 +319,32 @@ export class SessionContainer { } } + _inspectAccountAfterLogin(sessionInfo, log) { + return log.wrap("inspectAccount", async log => { + this._status.set(LoadStatus.QueryAccount); + const hsApi = new HomeServerApi({ + homeserver: sessionInfo.homeServer, + accessToken: sessionInfo.accessToken, + request: this._platform.request, + }); + const olm = await this._olmPromise; + const encryptedDehydratedDevice = await getDehydratedDevice(hsApi, olm, this._platform, log); + if (encryptedDehydratedDevice) { + let resolveStageFinish; + const promiseStageFinish = new Promise(r => resolveStageFinish = r); + this._accountSetup = new AccountSetup(encryptedDehydratedDevice, resolveStageFinish); + this._status.set(LoadStatus.AccountSetup); + await promiseStageFinish; + const dehydratedDevice = this._accountSetup?._dehydratedDevice; + this._accountSetup = null; + return dehydratedDevice; + } + }); + } + + get accountSetup() { + return this._accountSetup; + } get loadStatus() { return this._status; @@ -331,6 +376,15 @@ export class SessionContainer { return !this._reconnector; } + logout() { + return this._platform.logger.run("logout", async log => { + try { + await this._session?.logout(log); + } catch (err) {} + await this.deleteSession(log); + }); + } + dispose() { if (this._reconnectSubscription) { this._reconnectSubscription(); @@ -339,12 +393,15 @@ export class SessionContainer { this._reconnector = null; if (this._requestScheduler) { this._requestScheduler.stop(); + this._requestScheduler = null; } if (this._sync) { this._sync.stop(); + this._sync = null; } if (this._session) { this._session.dispose(); + this._session = null; } if (this._waitForFirstSyncHandle) { this._waitForFirstSyncHandle.dispose(); @@ -378,3 +435,20 @@ export class SessionContainer { this._loginFailure = null; } } + +class AccountSetup { + constructor(encryptedDehydratedDevice, finishStage) { + this._encryptedDehydratedDevice = encryptedDehydratedDevice; + this._dehydratedDevice = undefined; + this._finishStage = finishStage; + } + + get encryptedDehydratedDevice() { + return this._encryptedDehydratedDevice; + } + + finish(dehydratedDevice) { + this._dehydratedDevice = dehydratedDevice; + this._finishStage(); + } +} diff --git a/src/matrix/e2ee/Account.js b/src/matrix/e2ee/Account.js index 1de43ccd..13792ddc 100644 --- a/src/matrix/e2ee/Account.js +++ b/src/matrix/e2ee/Account.js @@ -22,6 +22,24 @@ const ACCOUNT_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "olmAccount"; const DEVICE_KEY_FLAG_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "areDeviceKeysUploaded"; const SERVER_OTK_COUNT_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "serverOTKCount"; +async function initiallyStoreAccount(account, pickleKey, areDeviceKeysUploaded, serverOTKCount, storage) { + const pickledAccount = account.pickle(pickleKey); + const txn = await storage.readWriteTxn([ + storage.storeNames.session + ]); + try { + // add will throw if the key already exists + // we would not want to overwrite olmAccount here + txn.session.add(ACCOUNT_SESSION_KEY, pickledAccount); + txn.session.add(DEVICE_KEY_FLAG_SESSION_KEY, areDeviceKeysUploaded); + txn.session.add(SERVER_OTK_COUNT_SESSION_KEY, serverOTKCount); + } catch (err) { + txn.abort(); + throw err; + } + await txn.complete(); +} + export class Account { static async load({olm, pickleKey, hsApi, userId, deviceId, olmWorker, txn}) { const pickledAccount = await txn.session.get(ACCOUNT_SESSION_KEY); @@ -35,6 +53,21 @@ export class Account { } } + static async adoptDehydratedDevice({olm, dehydratedDevice, pickleKey, hsApi, userId, olmWorker, storage}) { + const account = dehydratedDevice.adoptUnpickledOlmAccount(); + const oneTimeKeys = JSON.parse(account.one_time_keys()); + // only one algorithm supported by olm atm, so hardcode its name + const oneTimeKeysEntries = Object.entries(oneTimeKeys.curve25519); + const serverOTKCount = oneTimeKeysEntries.length; + const areDeviceKeysUploaded = true; + await initiallyStoreAccount(account, pickleKey, areDeviceKeysUploaded, serverOTKCount, storage); + return new Account({ + pickleKey, hsApi, account, userId, + deviceId: dehydratedDevice.deviceId, + areDeviceKeysUploaded, serverOTKCount, olm, olmWorker + }); + } + static async create({olm, pickleKey, hsApi, userId, deviceId, olmWorker, storage}) { const account = new olm.Account(); if (olmWorker) { @@ -43,24 +76,13 @@ export class Account { account.create(); account.generate_one_time_keys(account.max_number_of_one_time_keys()); } - const pickledAccount = account.pickle(pickleKey); const areDeviceKeysUploaded = false; - const txn = await storage.readWriteTxn([ - storage.storeNames.session - ]); - try { - // add will throw if the key already exists - // we would not want to overwrite olmAccount here - txn.session.add(ACCOUNT_SESSION_KEY, pickledAccount); - txn.session.add(DEVICE_KEY_FLAG_SESSION_KEY, areDeviceKeysUploaded); - txn.session.add(SERVER_OTK_COUNT_SESSION_KEY, 0); - } catch (err) { - txn.abort(); - throw err; + const serverOTKCount = 0; + if (storage) { + await initiallyStoreAccount(account, pickleKey, areDeviceKeysUploaded, serverOTKCount, storage); } - await txn.complete(); return new Account({pickleKey, hsApi, account, userId, - deviceId, areDeviceKeysUploaded, serverOTKCount: 0, olm, olmWorker}); + deviceId, areDeviceKeysUploaded, serverOTKCount, olm, olmWorker}); } constructor({pickleKey, hsApi, account, userId, deviceId, areDeviceKeysUploaded, serverOTKCount, olm, olmWorker}) { @@ -80,7 +102,11 @@ export class Account { return this._identityKeys; } - async uploadKeys(storage, log) { + setDeviceId(deviceId) { + this._deviceId = deviceId; + } + + async uploadKeys(storage, isDehydratedDevice, log) { const oneTimeKeys = JSON.parse(this._account.one_time_keys()); // only one algorithm supported by olm atm, so hardcode its name const oneTimeKeysEntries = Object.entries(oneTimeKeys.curve25519); @@ -95,7 +121,8 @@ export class Account { log.set("otks", true); payload.one_time_keys = this._oneTimeKeysPayload(oneTimeKeysEntries); } - const response = await this._hsApi.uploadKeys(payload, {log}).response(); + const dehydratedDeviceId = isDehydratedDevice ? this._deviceId : undefined; + const response = await this._hsApi.uploadKeys(dehydratedDeviceId, payload, {log}).response(); this._serverOTKCount = response?.one_time_key_counts?.signed_curve25519; log.set("serverOTKCount", this._serverOTKCount); // TODO: should we not modify this in the txn like we do elsewhere? @@ -105,12 +132,12 @@ export class Account { await this._updateSessionStorage(storage, sessionStore => { if (oneTimeKeysEntries.length) { this._account.mark_keys_as_published(); - sessionStore.set(ACCOUNT_SESSION_KEY, this._account.pickle(this._pickleKey)); - sessionStore.set(SERVER_OTK_COUNT_SESSION_KEY, this._serverOTKCount); + sessionStore?.set(ACCOUNT_SESSION_KEY, this._account.pickle(this._pickleKey)); + sessionStore?.set(SERVER_OTK_COUNT_SESSION_KEY, this._serverOTKCount); } if (!this._areDeviceKeysUploaded) { this._areDeviceKeysUploaded = true; - sessionStore.set(DEVICE_KEY_FLAG_SESSION_KEY, this._areDeviceKeysUploaded); + sessionStore?.set(DEVICE_KEY_FLAG_SESSION_KEY, this._areDeviceKeysUploaded); } }); } @@ -246,16 +273,20 @@ export class Account { } async _updateSessionStorage(storage, callback) { - const txn = await storage.readWriteTxn([ - storage.storeNames.session - ]); - try { - await callback(txn.session); - } catch (err) { - txn.abort(); - throw err; + if (storage) { + const txn = await storage.readWriteTxn([ + storage.storeNames.session + ]); + try { + await callback(txn.session); + } catch (err) { + txn.abort(); + throw err; + } + await txn.complete(); + } else { + await callback(undefined); } - await txn.complete(); } signObject(obj) { @@ -273,4 +304,13 @@ export class Account { obj.unsigned = unsigned; } } + + pickleWithKey(key) { + return this._account.pickle(key); + } + + dispose() { + this._account.free(); + this._account = undefined; + } } diff --git a/src/matrix/e2ee/Dehydration.js b/src/matrix/e2ee/Dehydration.js new file mode 100644 index 00000000..65e2b90a --- /dev/null +++ b/src/matrix/e2ee/Dehydration.js @@ -0,0 +1,115 @@ +/* +Copyright 2021 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. +*/ + +const DEHYDRATION_LIBOLM_PICKLE_ALGORITHM = "org.matrix.msc2697.v1.olm.libolm_pickle"; +import {KeyDescription} from "../ssss/common.js"; +import {keyFromCredentialAndDescription} from "../ssss/index.js"; + +export async function getDehydratedDevice(hsApi, olm, platform, log) { + try { + const response = await hsApi.getDehydratedDevice({log}).response(); + if (response.device_data.algorithm === DEHYDRATION_LIBOLM_PICKLE_ALGORITHM) { + return new EncryptedDehydratedDevice(response, olm, platform); + } + } catch (err) { + if (err.name !== "HomeServerError") { + log.error = err; + } + return undefined; + } +} + +export async function uploadAccountAsDehydratedDevice(account, hsApi, key, deviceDisplayName, log) { + const response = await hsApi.createDehydratedDevice({ + device_data: { + algorithm: DEHYDRATION_LIBOLM_PICKLE_ALGORITHM, + account: account.pickleWithKey(key.binaryKey), + passphrase: key.description?.passphraseParams || {}, + }, + initial_device_display_name: deviceDisplayName + }).response(); + const deviceId = response.device_id; + account.setDeviceId(deviceId); + await account.uploadKeys(undefined, true, log); + return deviceId; +} + +class EncryptedDehydratedDevice { + constructor(dehydratedDevice, olm, platform) { + this._dehydratedDevice = dehydratedDevice; + this._olm = olm; + this._platform = platform; + } + + async decrypt(keyType, credential) { + const keyDescription = new KeyDescription("dehydrated_device", this._dehydratedDevice.device_data.passphrase); + const key = await keyFromCredentialAndDescription(keyType, credential, keyDescription, this._platform, this._olm); + const account = new this._olm.Account(); + try { + const pickledAccount = this._dehydratedDevice.device_data.account; + account.unpickle(key.binaryKey.slice(), pickledAccount); + return new DehydratedDevice(this._dehydratedDevice, account, key); + } catch (err) { + account.free(); + if (err.message === "OLM.BAD_ACCOUNT_KEY") { + return undefined; + } else { + throw err; + } + } + } + + get deviceId() { + return this._dehydratedDevice.device_id; + } +} + +class DehydratedDevice { + constructor(dehydratedDevice, account, key) { + this._dehydratedDevice = dehydratedDevice; + this._account = account; + this._key = key; + } + + async claim(hsApi, log) { + try { + const response = await hsApi.claimDehydratedDevice(this.deviceId, {log}).response(); + return response.success; + } catch (err) { + return false; + } + } + + // make it clear that ownership is transfered upon calling this + adoptUnpickledOlmAccount() { + const account = this._account; + this._account = undefined; + return account; + } + + get deviceId() { + return this._dehydratedDevice.device_id; + } + + get key() { + return this._key; + } + + dispose() { + this._account?.free(); + this._account = undefined; + } +} diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index 66b3366f..430d9af3 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -49,7 +49,7 @@ export class RoomEncryption { } enableSessionBackup(sessionBackup) { - if (this._sessionBackup) { + if (this._sessionBackup && !!sessionBackup) { return; } this._sessionBackup = sessionBackup; diff --git a/src/matrix/e2ee/megolm/decryption/KeyLoader.ts b/src/matrix/e2ee/megolm/decryption/KeyLoader.ts index 58f968c8..3aca957d 100644 --- a/src/matrix/e2ee/megolm/decryption/KeyLoader.ts +++ b/src/matrix/e2ee/megolm/decryption/KeyLoader.ts @@ -212,6 +212,7 @@ class KeyOperation { dispose() { this.session.free(); + this.session = undefined as any; } /** returns whether the key for this operation has been checked at some point against storage diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index c9beb00d..308e71d9 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -18,6 +18,9 @@ limitations under the License. import {encodeQueryParams, encodeBody} from "./common.js"; import {HomeServerRequest} from "./HomeServerRequest.js"; +const CS_R0_PREFIX = "/_matrix/client/r0"; +const DEHYDRATION_PREFIX = "/_matrix/client/unstable/org.matrix.msc2697.v2"; + export class HomeServerApi { constructor({homeserver, accessToken, request, reconnector}) { // store these both in a closure somehow so it's harder to get at in case of XSS? @@ -28,8 +31,8 @@ export class HomeServerApi { this._reconnector = reconnector; } - _url(csPath) { - return `${this._homeserver}/_matrix/client/r0${csPath}`; + _url(csPath, prefix = CS_R0_PREFIX) { + return this._homeserver + prefix + csPath; } _baseRequest(method, url, queryParams, body, options, accessToken) { @@ -92,15 +95,15 @@ export class HomeServerApi { } _post(csPath, queryParams, body, options) { - return this._authedRequest("POST", this._url(csPath), queryParams, body, options); + return this._authedRequest("POST", this._url(csPath, options?.prefix || CS_R0_PREFIX), queryParams, body, options); } _put(csPath, queryParams, body, options) { - return this._authedRequest("PUT", this._url(csPath), queryParams, body, options); + return this._authedRequest("PUT", this._url(csPath, options?.prefix || CS_R0_PREFIX), queryParams, body, options); } _get(csPath, queryParams, body, options) { - return this._authedRequest("GET", this._url(csPath), queryParams, body, options); + return this._authedRequest("GET", this._url(csPath, options?.prefix || CS_R0_PREFIX), queryParams, body, options); } sync(since, filter, timeout, options = null) { @@ -170,8 +173,12 @@ export class HomeServerApi { return this._unauthedRequest("GET", `${this._homeserver}/_matrix/client/versions`, null, null, options); } - uploadKeys(payload, options = null) { - return this._post("/keys/upload", null, payload, options); + uploadKeys(dehydratedDeviceId, payload, options = null) { + let path = "/keys/upload"; + if (dehydratedDeviceId) { + path = path + `/${encodeURIComponent(dehydratedDeviceId)}`; + } + return this._post(path, null, payload, options); } queryKeys(queryRequest, options = null) { @@ -229,6 +236,21 @@ export class HomeServerApi { logout(options = null) { return this._post(`/logout`, null, null, options); } + + getDehydratedDevice(options = {}) { + options.prefix = DEHYDRATION_PREFIX; + return this._get(`/dehydrated_device`, null, null, options); + } + + createDehydratedDevice(payload, options = {}) { + options.prefix = DEHYDRATION_PREFIX; + return this._put(`/dehydrated_device`, null, payload, options); + } + + claimDehydratedDevice(deviceId, options = {}) { + options.prefix = DEHYDRATION_PREFIX; + return this._post(`/dehydrated_device/claim`, null, {device_id: deviceId}, options); + } } import {Request as MockRequest} from "../../mocks/Request.js"; diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index d26d716d..1aa8cb18 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -463,7 +463,7 @@ export class BaseRoom extends EventEmitter { enableSessionBackup(sessionBackup) { this._roomEncryption?.enableSessionBackup(sessionBackup); // TODO: do we really want to do this every time you open the app? - if (this._timeline) { + if (this._timeline && sessionBackup) { this._platform.logger.run("enableSessionBackup", log => { return this._roomEncryption.restoreMissingSessionsFromBackup(this._timeline.remoteEntries, log); }); diff --git a/src/matrix/ssss/common.js b/src/matrix/ssss/common.js index 38a14790..406e8558 100644 --- a/src/matrix/ssss/common.js +++ b/src/matrix/ssss/common.js @@ -15,9 +15,9 @@ limitations under the License. */ export class KeyDescription { - constructor(id, keyAccountData) { + constructor(id, keyDescription) { this._id = id; - this._keyAccountData = keyAccountData; + this._keyDescription = keyDescription; } get id() { @@ -25,11 +25,30 @@ export class KeyDescription { } get passphraseParams() { - return this._keyAccountData?.content?.passphrase; + return this._keyDescription?.passphrase; } get algorithm() { - return this._keyAccountData?.content?.algorithm; + return this._keyDescription?.algorithm; + } + + async isCompatible(key, platform) { + if (this.algorithm === "m.secret_storage.v1.aes-hmac-sha2") { + const kd = this._keyDescription; + if (kd.mac) { + const otherMac = await calculateKeyMac(key.binaryKey, kd.iv, platform); + return kd.mac === otherMac; + } else if (kd.passphrase) { + const kdOther = key.description._keyDescription; + if (!kdOther.passphrase) { + return false; + } + return kd.passphrase.algorithm === kdOther.passphrase.algorithm && + kd.passphrase.iterations === kdOther.passphrase.iterations && + kd.passphrase.salt === kdOther.passphrase.salt; + } + } + return false; } } @@ -39,6 +58,14 @@ export class Key { this._binaryKey = binaryKey; } + withDescription(description) { + return new Key(description, this._binaryKey); + } + + get description() { + return this._keyDescription; + } + get id() { return this._keyDescription.id; } @@ -51,3 +78,24 @@ export class Key { return this._keyDescription.algorithm; } } + +async function calculateKeyMac(key, ivStr, platform) { + const {crypto, encoding} = platform; + const {utf8, base64} = encoding; + const {derive, aes, hmac} = crypto; + + const iv = base64.decode(ivStr); + + // salt for HKDF, with 8 bytes of zeros + const zerosalt = new Uint8Array(8); + const ZERO_STR = "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"; + + const info = utf8.encode(""); + const keybits = await derive.hkdf(key, zerosalt, info, "SHA-256", 512); + const aesKey = keybits.slice(0, 32); + const hmacKey = keybits.slice(32); + const ciphertext = await aes.encryptCTR({key: aesKey, iv, data: utf8.encode(ZERO_STR)}); + const mac = await hmac.compute(hmacKey, ciphertext, "SHA-256"); + + return base64.encode(mac); +} diff --git a/src/matrix/ssss/index.js b/src/matrix/ssss/index.js index e1baf9c9..cb795766 100644 --- a/src/matrix/ssss/index.js +++ b/src/matrix/ssss/index.js @@ -18,9 +18,12 @@ import {KeyDescription, Key} from "./common.js"; import {keyFromPassphrase} from "./passphrase.js"; import {keyFromRecoveryKey} from "./recoveryKey.js"; import {SESSION_E2EE_KEY_PREFIX} from "../e2ee/common.js"; +import {createEnum} from "../../utils/enum.js"; const SSSS_KEY = `${SESSION_E2EE_KEY_PREFIX}ssssKey`; +export const KeyType = createEnum("RecoveryKey", "Passphrase"); + async function readDefaultKeyDescription(storage) { const txn = await storage.readTxn([ storage.storeNames.accountData @@ -34,7 +37,7 @@ async function readDefaultKeyDescription(storage) { if (!keyAccountData) { return; } - return new KeyDescription(id, keyAccountData); + return new KeyDescription(id, keyAccountData.content); } export async function writeKey(key, txn) { @@ -47,7 +50,14 @@ export async function readKey(txn) { return; } const keyAccountData = await txn.accountData.get(`m.secret_storage.key.${keyData.id}`); - return new Key(new KeyDescription(keyData.id, keyAccountData), keyData.binaryKey); + if (keyAccountData) { + return new Key(new KeyDescription(keyData.id, keyAccountData.content), keyData.binaryKey); + } +} + + +export async function removeKey(txn) { + await txn.session.remove(SSSS_KEY); } export async function keyFromCredential(type, credential, storage, platform, olm) { @@ -55,13 +65,24 @@ export async function keyFromCredential(type, credential, storage, platform, olm if (!keyDescription) { throw new Error("Could not find a default secret storage key in account data"); } + return await keyFromCredentialAndDescription(type, credential, keyDescription, platform, olm); +} + +export async function keyFromCredentialAndDescription(type, credential, keyDescription, platform, olm) { let key; - if (type === "phrase") { + if (type === KeyType.Passphrase) { key = await keyFromPassphrase(keyDescription, credential, platform); - } else if (type === "key") { + } else if (type === KeyType.RecoveryKey) { key = keyFromRecoveryKey(keyDescription, credential, olm, platform); } else { throw new Error(`Invalid type: ${type}`); } return key; } + +export async function keyFromDehydratedDeviceKey(key, storage, platform) { + const keyDescription = await readDefaultKeyDescription(storage); + if (await keyDescription.isCompatible(key, platform)) { + return key.withDescription(keyDescription); + } +} diff --git a/src/platform/web/ui/css/login.css b/src/platform/web/ui/css/login.css index ca376dee..deb16b02 100644 --- a/src/platform/web/ui/css/login.css +++ b/src/platform/web/ui/css/login.css @@ -54,15 +54,12 @@ limitations under the License. padding: 0 0.4em 0.4em; } -.SessionLoadStatusView, .LoginView_query-spinner { +.SessionLoadStatusView > .status, .LoginView_query-spinner { display: flex; + gap: 12px; } -.SessionLoadStatusView> :not(:first-child), .LoginView_query-spinner> :not(:first-child) { - margin-left: 12px; -} - -.SessionLoadStatusView p, .LoginView_query-spinner p { +.SessionLoadStatusView > .status p, .LoginView_query-spinner p { flex: 1; margin: 0; } diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 1b0bc9e4..456c1b7b 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -629,6 +629,19 @@ a { .Settings .row .label { flex: 0 0 200px; + align-self: flex-start; +} + +.Settings .row .content p { + margin: 8px 0; +} + +.Settings .row .content p:first-child { + margin-top: 0; +} + +.Settings .row .content p:last-child { + margin-bottom: 0; } .error { diff --git a/src/platform/web/ui/login/AccountSetupView.js b/src/platform/web/ui/login/AccountSetupView.js new file mode 100644 index 00000000..32a4afb5 --- /dev/null +++ b/src/platform/web/ui/login/AccountSetupView.js @@ -0,0 +1,41 @@ +/* +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 {TemplateView} from "../general/TemplateView"; +import {SessionBackupSettingsView} from "../session/settings/SessionBackupSettingsView.js"; + +export class AccountSetupView extends TemplateView { + render(t, vm) { + return t.div({className: "Settings" /* hack for now to get the layout right*/}, [ + t.h3(vm.i18n`Restore your encrypted history?`), + t.ifView(vm => vm.decryptDehydratedDeviceViewModel, vm => new SessionBackupSettingsView(vm.decryptDehydratedDeviceViewModel)), + t.map(vm => vm.deviceDecrypted, (decrypted, t) => { + if (decrypted) { + return t.p(vm.i18n`That worked out, you're good to go!`); + } else { + return t.p(vm.i18n`This will claim the dehydrated device ${vm.dehydratedDeviceId}, and will set up a new one.`); + } + }), + t.div({ className: "button-row" }, [ + t.button({ + className: "button-action primary", + onClick: () => { vm.finish(); }, + type: "button", + }, vm => vm.deviceDecrypted ? vm.i18n`Continue` : vm.i18n`Continue without restoring`), + ]), + ]); + } +} diff --git a/src/platform/web/ui/login/SessionLoadStatusView.js b/src/platform/web/ui/login/SessionLoadStatusView.js index 3c22de9f..66ef5b27 100644 --- a/src/platform/web/ui/login/SessionLoadStatusView.js +++ b/src/platform/web/ui/login/SessionLoadStatusView.js @@ -16,6 +16,7 @@ limitations under the License. import {TemplateView} from "../general/TemplateView"; import {spinner} from "../common.js"; +import {AccountSetupView} from "./AccountSetupView.js"; /** a view used both in the login view and the loading screen to show the current state of loading the session. @@ -27,9 +28,19 @@ export class SessionLoadStatusView extends TemplateView { onClick: () => vm.exportLogs() }, vm.i18n`Export logs`); }); + const logoutButtonIfFailed = t.if(vm => vm.hasError, (t, vm) => { + return t.button({ + onClick: () => vm.logout() + }, vm.i18n`Log out`); + }); return t.div({className: "SessionLoadStatusView"}, [ - spinner(t, {hiddenWithLayout: vm => !vm.loading}), - t.p([vm => vm.loadLabel, exportLogsButtonIfFailed]) + t.p({className: "status"}, [ + spinner(t, {hidden: vm => !vm.loading}), + t.p(vm => vm.loadLabel), + exportLogsButtonIfFailed, + logoutButtonIfFailed + ]), + t.ifView(vm => vm.accountSetupViewModel, vm => new AccountSetupView(vm.accountSetupViewModel)), ]); } } diff --git a/src/platform/web/ui/login/SessionPickerView.js b/src/platform/web/ui/login/SessionPickerView.js index 4bde0189..1d28a291 100644 --- a/src/platform/web/ui/login/SessionPickerView.js +++ b/src/platform/web/ui/login/SessionPickerView.js @@ -19,30 +19,6 @@ import {TemplateView} from "../general/TemplateView"; import {hydrogenGithubLink} from "./common.js"; import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; -function selectFileAsText(mimeType) { - const input = document.createElement("input"); - input.setAttribute("type", "file"); - if (mimeType) { - input.setAttribute("accept", mimeType); - } - const promise = new Promise((resolve, reject) => { - const checkFile = () => { - input.removeEventListener("change", checkFile, true); - const file = input.files[0]; - if (file) { - resolve(file.text()); - } else { - reject(new Error("No file selected")); - } - } - input.addEventListener("change", checkFile, true); - }); - input.click(); - return promise; -} - - - class SessionPickerItemView extends TemplateView { _onDeleteClick() { if (confirm("Are you sure?")) { @@ -81,10 +57,6 @@ export class SessionPickerView extends TemplateView { t.h1(["Continue as …"]), t.view(sessionList), t.div({className: "button-row"}, [ - t.button({ - className: "button-action secondary", - onClick: async () => vm.import(await selectFileAsText("application/json")) - }, vm.i18n`Import a session`), t.a({ className: "button-action primary", href: vm.cancelUrl diff --git a/src/platform/web/ui/session/settings/SessionBackupSettingsView.js b/src/platform/web/ui/session/settings/SessionBackupSettingsView.js index b38517ab..e989f8ea 100644 --- a/src/platform/web/ui/session/settings/SessionBackupSettingsView.js +++ b/src/platform/web/ui/session/settings/SessionBackupSettingsView.js @@ -21,25 +21,31 @@ export class SessionBackupSettingsView extends TemplateView { render(t, vm) { return t.mapView(vm => vm.status, status => { switch (status) { - case "enabled": return new TemplateView(vm, renderEnabled) - case "setupKey": return new TemplateView(vm, renderEnableFromKey) - case "setupPhrase": return new TemplateView(vm, renderEnableFromPhrase) - case "pending": return new StaticView(vm, t => t.p(vm.i18n`Waiting to go online…`)) + case "Enabled": return new TemplateView(vm, renderEnabled) + case "SetupKey": return new TemplateView(vm, renderEnableFromKey) + case "SetupPhrase": return new TemplateView(vm, renderEnableFromPhrase) + case "Pending": return new StaticView(vm, t => t.p(vm.i18n`Waiting to go online…`)) } }); } } function renderEnabled(t, vm) { - return t.p(vm.i18n`Session backup is enabled, using backup version ${vm.backupVersion}.`); + const items = [ + t.p([vm.i18n`Session backup is enabled, using backup version ${vm.backupVersion}. `, t.button({onClick: () => vm.disable()}, vm.i18n`Disable`)]) + ]; + if (vm.dehydratedDeviceId) { + items.push(t.p(vm.i18n`A dehydrated device id was set up with id ${vm.dehydratedDeviceId} which you can use during your next login with your secret storage key.`)); + } + return t.div(items); } function renderEnableFromKey(t, vm) { 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 set up session backup, 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.`), + 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.`), renderError(t), - renderEnableFieldRow(t, vm, vm.i18n`Security key`, key => vm.enterSecurityKey(key)), + renderEnableFieldRow(t, vm, vm.i18n`Security key`, (key, setupDehydratedDevice) => vm.enterSecurityKey(key, setupDehydratedDevice)), t.p([vm.i18n`Alternatively, you can `, useASecurityPhrase, vm.i18n` if you have one.`]), ]); } @@ -47,22 +53,34 @@ function renderEnableFromKey(t, vm) { function renderEnableFromPhrase(t, vm) { 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 set up session backup, 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.`), + 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.`), renderError(t), - renderEnableFieldRow(t, vm, vm.i18n`Security phrase`, phrase => vm.enterSecurityPhrase(phrase)), + renderEnableFieldRow(t, vm, vm.i18n`Security phrase`, (phrase, setupDehydratedDevice) => vm.enterSecurityPhrase(phrase, setupDehydratedDevice)), t.p([vm.i18n`You can also `, useASecurityKey, vm.i18n`.`]), ]); } function renderEnableFieldRow(t, vm, label, callback) { - const eventHandler = () => callback(input.value); - const input = t.input({type: "password", disabled: vm => vm.isBusy, placeholder: label, onChange: eventHandler}); + let setupDehydrationCheck; + const eventHandler = () => callback(input.value, setupDehydrationCheck?.checked || false); + const input = t.input({type: "password", disabled: vm => vm.isBusy, placeholder: label}); + const children = [ + t.p([ + input, + t.button({disabled: vm => vm.isBusy, onClick: eventHandler}, vm.decryptAction), + ]), + ]; + if (vm.offerDehydratedDeviceSetup) { + setupDehydrationCheck = t.input({type: "checkbox", id:"enable-dehydrated-device"}); + const moreInfo = t.a({href: "https://github.com/uhoreg/matrix-doc/blob/dehydration/proposals/2697-device-dehydration.md", target: "_blank", rel: "noopener"}, "more info"); + children.push(t.p([ + setupDehydrationCheck, + t.label({for: setupDehydrationCheck.id}, [vm.i18n`Back up my device as well (`, moreInfo, ")"]) + ])); + } return t.div({className: `row`}, [ t.div({className: "label"}, label), - t.div({className: "content"}, [ - input, - t.button({disabled: vm => vm.isBusy, onClick: eventHandler}, vm.i18n`Set up`), - ]), + t.div({className: "content"}, children), ]); } diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index 482abeb7..e969ba40 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -48,7 +48,7 @@ export class SettingsView extends TemplateView { } }, disabled: vm => vm.isLoggingOut - }, vm.i18n`Log out`)) + }, vm.i18n`Log out`)), ); settingNodes.push( t.h3("Session Backup"), @@ -91,7 +91,7 @@ export class SettingsView extends TemplateView { }), t.map(vm => vm.pushNotifications.serverError, (err, t) => { if (err) { - return t.p("Couln't not check on server: " + err.message); + return t.p("Couldn't not check on server: " + err.message); } }) ]);