diff --git a/src/domain/AccountSetupViewModel.js b/src/domain/AccountSetupViewModel.js new file mode 100644 index 00000000..71fffe24 --- /dev/null +++ b/src/domain/AccountSetupViewModel.js @@ -0,0 +1,49 @@ +/* +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"; + +export class AccountSetupViewModel extends ViewModel { + constructor(accountSetup) { + super(); + this._accountSetup = accountSetup; + this._dehydratedDevice = undefined; + } + + get canDehydrateDevice() { + return !!this._accountSetup.encryptedDehydratedDevice; + } + + get deviceDecrypted() { + return !!this._dehydratedDevice; + } + + get dehydratedDeviceId() { + return this._accountSetup.encryptedDehydratedDevice.deviceId; + } + + tryDecryptDehydratedDevice(password) { + const {encryptedDehydratedDevice} = this._accountSetup; + if (encryptedDehydratedDevice) { + this._dehydratedDevice = encryptedDehydratedDevice.decrypt(password); + this.emitChange("deviceDecrypted"); + } + } + + finish() { + this._accountSetup.finish(this._dehydratedDevice); + } +} diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js index 240deeab..0064108f 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,7 +30,8 @@ export class SessionLoadViewModel extends ViewModel { this._loading = false; this._error = null; this.backUrl = this.urlCreator.urlForSegment("session", true); - this._dehydratedDevice = undefined; + this._accountSetupViewModel = undefined; + } async start() { @@ -40,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 && @@ -98,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; } @@ -113,8 +124,8 @@ export class SessionLoadViewModel extends ViewModel { switch (sc.loadStatus.get()) { case LoadStatus.QueryAccount: return `Querying account encryption setup…`; - case LoadStatus.SetupAccount: - return `Please enter your password to restore your encryption setup`; + case LoadStatus.AccountSetup: + return `Do you want to restore this dehydrated device?`; case LoadStatus.SessionSetup: return `Setting up your encryption keys…`; case LoadStatus.Loading: @@ -142,26 +153,7 @@ export class SessionLoadViewModel extends ViewModel { this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`); } - get canSetupAccount() { - return this._sessionContainer.loadStatus === LoadStatus.SetupAccount; - } - - get canDehydrateDevice() { - return this.canSetupAccount && !!this._sessionContainer.accountSetup.encryptedDehydratedDevice; - } - - tryDecryptDehydratedDevice(password) { - const {encryptedDehydratedDevice} = this._sessionContainer.accountSetup; - if (encryptedDehydratedDevice) { - this._dehydratedDevice = encryptedDehydratedDevice.decrypt(password); - return !!this._dehydratedDevice; - } - return false; - } - - finishAccountSetup() { - const dehydratedDevice = this._dehydratedDevice; - this._dehydratedDevice = undefined; - this._sessionContainer.accountSetup.finish(dehydratedDevice); + get accountSetupViewModel() { + return this._accountSetupViewModel; } } diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 9afd2888..04d4d18d 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -69,6 +69,10 @@ export class SettingsViewModel extends ViewModel { }); } + setupDehydratedDevice(key) { + return this._session.setupDehydratedDevice(key); + } + get isLoggingOut() { return this._isLoggingOut; } setSentImageSizeLimit(size) { 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 036edcec..c51ce63e 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -108,8 +108,7 @@ export class Session { } 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 @@ -249,7 +248,7 @@ export class Session { async createIdentity(log) { if (this._olm) { if (!this._e2eeAccount) { - this._e2eeAccount = this._createNewAccount(this._sessionInfo.deviceId, this._storage); + this._e2eeAccount = await this._createNewAccount(this._sessionInfo.deviceId, this._storage); log.set("keys", this._e2eeAccount.identityKeys); this._setupEncryption(); } @@ -259,39 +258,37 @@ export class Session { } /** @internal */ - dehydrateIdentity(dehydratedDevice, log = null) { - this._platform.logger.wrapOrRun(log, "dehydrateIdentity", async 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; + 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) { diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index 3b6d07d6..bc56905c 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -36,7 +36,7 @@ export const LoadStatus = createEnum( "Login", "LoginFailed", "QueryAccount", // check for dehydrated device after login - "SetupAccount", // asked to restore from dehydrated device if present, call sc.accountSetup.finish() to progress to the next stage + "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 @@ -66,6 +66,7 @@ export class SessionContainer { this._requestScheduler = null; this._olmPromise = olmPromise; this._workerPromise = workerPromise; + this._accountSetup = undefined; } createNewSessionId() { @@ -323,7 +324,7 @@ export class SessionContainer { let resolveStageFinish; const promiseStageFinish = new Promise(r => resolveStageFinish = r); this._accountSetup = new AccountSetup(encryptedDehydratedDevice, resolveStageFinish); - this._status.set(LoadStatus.SetupAccount); + this._status.set(LoadStatus.AccountSetup); await promiseStageFinish; const dehydratedDevice = this._accountSetup?._dehydratedDevice; this._accountSetup = null; diff --git a/src/matrix/e2ee/Account.js b/src/matrix/e2ee/Account.js index 91392232..35e55b16 100644 --- a/src/matrix/e2ee/Account.js +++ b/src/matrix/e2ee/Account.js @@ -111,7 +111,7 @@ export class Account { if (!this._areDeviceKeysUploaded) { log.set("identity", true); const identityKeys = JSON.parse(this._account.identity_keys()); - payload.device_keys = this._deviceKeysPayload(identityKeys); + payload.device_keys = this._deviceKeysPayload(identityKeys, dehydratedDeviceId || this._deviceId); } if (oneTimeKeysEntries.length) { log.set("otks", true); @@ -241,10 +241,10 @@ export class Account { } } - _deviceKeysPayload(identityKeys) { + _deviceKeysPayload(identityKeys, deviceId) { const obj = { user_id: this._userId, - device_id: this._deviceId, + device_id: deviceId, algorithms: [OLM_ALGORITHM, MEGOLM_ALGORITHM], keys: {} }; diff --git a/src/matrix/e2ee/Dehydration.js b/src/matrix/e2ee/Dehydration.js index 4ae7dd71..43ffed6e 100644 --- a/src/matrix/e2ee/Dehydration.js +++ b/src/matrix/e2ee/Dehydration.js @@ -54,13 +54,21 @@ class EncryptedDehydratedDevice { const account = new this._olm.Account(); try { const pickledAccount = this._dehydratedDevice.device_data.account; - account.unpickle(key, pickledAccount); + account.unpickle(new Uint8Array(key), pickledAccount); return new DehydratedDevice(this._dehydratedDevice, account); } catch (err) { account.free(); - return null; + if (err.message === "OLM.BAD_ACCOUNT_KEY") { + return undefined; + } else { + throw err; + } } } + + get deviceId() { + return this._dehydratedDevice.device_id; + } } class DehydratedDevice { diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index c8950635..308e71d9 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -19,7 +19,7 @@ import {encodeQueryParams, encodeBody} from "./common.js"; import {HomeServerRequest} from "./HomeServerRequest.js"; const CS_R0_PREFIX = "/_matrix/client/r0"; -const DEHYDRATION_PREFIX = "/unstable/org.matrix.msc2697.v2"; +const DEHYDRATION_PREFIX = "/_matrix/client/unstable/org.matrix.msc2697.v2"; export class HomeServerApi { constructor({homeserver, accessToken, request, reconnector}) { @@ -31,7 +31,7 @@ export class HomeServerApi { this._reconnector = reconnector; } - _url(csPath, prefix) { + _url(csPath, prefix = CS_R0_PREFIX) { return this._homeserver + prefix + csPath; } 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/login/AccountSetupView.js b/src/platform/web/ui/login/AccountSetupView.js new file mode 100644 index 00000000..2438a6df --- /dev/null +++ b/src/platform/web/ui/login/AccountSetupView.js @@ -0,0 +1,46 @@ +/* +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"; + +export class AccountSetupView extends TemplateView { + render(t, vm) { + const keyLabel = vm => `Dehydration key for device ${vm.dehydratedDeviceId}`; + const password = t.input({ + id: "dehydrated_device_key", + type: "password", + placeholder: keyLabel, + }); + const status = t.output({for: password.id}, vm => vm.deviceDecrypted ? "Key matches, good to go!" : ""); + return t.div({className: "form"}, [ + t.form({ + onSubmit: evt => { + evt.preventDefault(); + vm.tryDecryptDehydratedDevice(password.value); + } + }, [ + t.div({ className: "form-row" }, [t.label({ for: password.id }, keyLabel), password, status]), + ]), + t.div({ className: "button-row" }, [ + t.button({ + className: "button-action primary", + onClick: () => { vm.finish(); }, + type: "button", + }, vm.i18n`Continue`), + ]), + ]); + } +} diff --git a/src/platform/web/ui/login/SessionLoadStatusView.js b/src/platform/web/ui/login/SessionLoadStatusView.js index 3c22de9f..24feed52 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. @@ -28,8 +29,12 @@ export class SessionLoadStatusView extends TemplateView { }, vm.i18n`Export logs`); }); 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 + ]), + t.ifView(vm => vm.accountSetupViewModel, vm => new AccountSetupView(vm.accountSetupViewModel)), ]); } } diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index 482abeb7..e7ac06fd 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -48,7 +48,20 @@ export class SettingsView extends TemplateView { } }, disabled: vm => vm.isLoggingOut - }, vm.i18n`Log out`)) + }, vm.i18n`Log out`)), + row(t, "", t.button({ + onClick: async () => { + const key = prompt(vm.i18n`Enter the key to encrypt the dehydrated device`); + if (key) { + try { + const deviceId = await vm.setupDehydratedDevice(key); + alert(`Successfully set up dehydrated device with id ${deviceId}`); + } catch (err) { + alert(`Failed to set up dehydrated device: ${err.message}`); + } + } + }, + }, vm.i18n`Set up dehydrated device`)) ); settingNodes.push( t.h3("Session Backup"),