From faf4ea6434a4d913183be57650b94df7edd0eb6d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 26 Oct 2021 18:47:46 +0200 Subject: [PATCH 01/22] WIP --- src/domain/SessionLoadViewModel.js | 28 ++++++++++ src/matrix/Session.js | 77 +++++++++++++++++++++---- src/matrix/SessionContainer.js | 59 ++++++++++++++++++-- src/matrix/e2ee/Account.js | 90 ++++++++++++++++++++---------- src/matrix/e2ee/Dehydration.js | 88 +++++++++++++++++++++++++++++ src/matrix/net/HomeServerApi.js | 36 +++++++++--- 6 files changed, 326 insertions(+), 52 deletions(-) create mode 100644 src/matrix/e2ee/Dehydration.js diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js index f7cf8285..240deeab 100644 --- a/src/domain/SessionLoadViewModel.js +++ b/src/domain/SessionLoadViewModel.js @@ -29,6 +29,7 @@ export class SessionLoadViewModel extends ViewModel { this._loading = false; this._error = null; this.backUrl = this.urlCreator.urlForSegment("session", true); + this._dehydratedDevice = undefined; } async start() { @@ -110,6 +111,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.SetupAccount: + return `Please enter your password to restore your encryption setup`; case LoadStatus.SessionSetup: return `Setting up your encryption keys…`; case LoadStatus.Loading: @@ -136,4 +141,27 @@ export class SessionLoadViewModel extends ViewModel { const logExport = await this.logger.export(); 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); + } } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 235ff3b1..18aa9d0a 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -248,23 +248,77 @@ 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 = 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, undefined, log)); } } + /** @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, + 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); + return deviceId; + } finally { + dehydrationAccount.dispose(); + } + }); + } + /** @internal */ async load(log) { const txn = await this._storage.readTxn([ @@ -323,6 +377,7 @@ export class Session { this._olmWorker?.dispose(); this._sessionBackup?.dispose(); this._megolmDecryption.dispose(); + this._e2eeAccount?.dispose(); for (const room of this._rooms.values()) { room.dispose(); } @@ -517,7 +572,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, undefined, log)); } } } diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index ef76971a..f74d2ea7 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -34,6 +34,8 @@ export const LoadStatus = createEnum( "NotLoading", "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 "Loading", "SessionSetup", // upload e2ee keys, ... "Migrating", //not used atm, but would fit here @@ -85,7 +87,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); @@ -154,7 +156,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,11 +174,16 @@ export class SessionContainer { } return; } + const dehydratedDevice = await this._inspectAccountAfterLogin(sessionInfo); + 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); @@ -187,7 +193,7 @@ export class SessionContainer { }); } - 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 +239,9 @@ export class SessionContainer { platform: this._platform, }); await this._session.load(log); - if (!this._session.hasIdentity) { + if (dehydratedDevice) { + await log.wrap("dehydrateIdentity", log => await this._session.dehydrateIdentity(dehydratedDevice, log)); + } else if (!this._session.hasIdentity) { this._status.set(LoadStatus.SessionSetup); await log.wrap("createIdentity", log => this._session.createIdentity(log)); } @@ -300,6 +308,30 @@ export class SessionContainer { } } + async _inspectAccountAfterLogin(sessionInfo) { + 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); + if (encryptedDehydratedDevice) { + let resolveStageFinish; + const promiseStageFinish = new Promise(r => resolveStageFinish = r); + this._accountSetup = new AccountSetup(encryptedDehydratedDevice, resolveStageFinish); + this._status.set(LoadStatus.SetupAccount); + await promiseStageFinish; + const dehydratedDevice = this._accountSetup?._dehydratedDevice; + this._accountSetup = null; + return dehydratedDevice; + } + } + + get accountSetup() { + return this._accountSetup; + } get loadStatus() { return this._status; @@ -378,3 +410,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..4a680ec6 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); @@ -34,6 +52,21 @@ export class Account { deviceId, areDeviceKeysUploaded, serverOTKCount, olm, olmWorker}); } } + + static async adoptDehydratedDevice({olm, dehydratedDevice, pickleKey, hsApi, userId, olmWorker, storage}) { + const account = dehydratedDevice.adoptUnpickledOlmAccount(); + const areDeviceKeysUploaded = true; + 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); + const serverOTKCount = oneTimeKeysEntries.length; + 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(); @@ -43,22 +76,9 @@ 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; + if (storage) { + await initiallyStoreAccount(account, pickleKey, false, 0, storage); } - await txn.complete(); return new Account({pickleKey, hsApi, account, userId, deviceId, areDeviceKeysUploaded, serverOTKCount: 0, olm, olmWorker}); } @@ -80,7 +100,7 @@ export class Account { return this._identityKeys; } - async uploadKeys(storage, log) { + async uploadKeys(storage, dehydratedDeviceId, 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 +115,7 @@ export class Account { log.set("otks", true); payload.one_time_keys = this._oneTimeKeysPayload(oneTimeKeysEntries); } - const response = await this._hsApi.uploadKeys(payload, {log}).response(); + 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 +125,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 +266,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 +297,12 @@ export class Account { obj.unsigned = unsigned; } } + + pickleWithKey(key) { + return this._account.pickle(key); + } + + dispose() { + this._account.free(); + } } diff --git a/src/matrix/e2ee/Dehydration.js b/src/matrix/e2ee/Dehydration.js new file mode 100644 index 00000000..f01a9423 --- /dev/null +++ b/src/matrix/e2ee/Dehydration.js @@ -0,0 +1,88 @@ +/* +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. +*/ + +export const DEHYDRATION_LIBOLM_PICKLE_ALGORITHM = "org.matrix.msc2697.v1.olm.libolm_pickle"; + +async function getDehydratedDevice(hsApi, olm) { + const response = await hsApi.getDehydratedDevice().response(); + if (response.device_data.algorithm === DEHYDRATION_LIBOLM_PICKLE_ALGORITHM) { + return new DehydratedDevice(response, olm); + } +} + +async function hasRemainingDevice(ownUserId, ownDeviceId, storage) { + +} + +async function uploadAccountAsDehydratedDevice(account, hsApi, key, deviceDisplayName, log) { + const response = await hsApi.createDehydratedDevice({ + device_data: { + algorithm: DEHYDRATION_LIBOLM_PICKLE_ALGORITHM, + account: account.pickleWithKey(new Uint8Array(key)), + passphrase: {} + }, + initial_device_display_name: deviceDisplayName + }).response(); + const deviceId = response.device_id; + await account.uploadKeys(undefined, deviceId, log); + return deviceId; +} + +class EncryptedDehydratedDevice { + constructor(dehydratedDevice, olm) { + this._dehydratedDevice = dehydratedDevice; + this._olm = olm; + } + + decrypt(key) { + const account = new this._olm.Account(); + try { + const pickledAccount = this._dehydratedDevice.device_data.account; + account.unpickle(key, pickledAccount); + return new DehydratedDevice(this._dehydratedDevice, account); + } catch (err) { + account.free(); + return null; + } + } +} + +class DehydratedDevice { + constructor(dehydratedDevice, account) { + this._dehydratedDevice = dehydratedDevice; + this._account = account; + } + + 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 = null; + return account; + } + + get deviceId() { + this._dehydratedDevice.device_id; + } +} diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index c9beb00d..d823abfd 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 = "/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) { + 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 = null) { + options.prefix = DEHYDRATION_PREFIX; + return this._put(`/dehydrated_device`, null, payload, options); + } + + claimDehydratedDevice(deviceId) { + options.prefix = DEHYDRATION_PREFIX; + return this._post(`/dehydrated_device/claim`, null, {device_id: deviceId}, options); + } } import {Request as MockRequest} from "../../mocks/Request.js"; From 718b410253045b408a93f1757568f4ae877737de Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 27 Oct 2021 10:26:36 +0200 Subject: [PATCH 02/22] WIP2 --- src/matrix/Session.js | 5 ++-- src/matrix/SessionContainer.js | 45 ++++++++++++++++++--------------- src/matrix/e2ee/Account.js | 12 +++++---- src/matrix/e2ee/Dehydration.js | 27 +++++++++++--------- src/matrix/net/HomeServerApi.js | 4 +-- 5 files changed, 51 insertions(+), 42 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 18aa9d0a..036edcec 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"; @@ -284,8 +285,8 @@ export class Session { pickleKey: PICKLE_KEY, userId: this._sessionInfo.userId, olmWorker: this._olmWorker, - deviceId, - storage, + deviceId: this.deviceId, + storage: this._storage, }); log.set("keys", this._e2eeAccount.identityKeys); this._setupEncryption(); diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index f74d2ea7..3b6d07d6 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -29,6 +29,7 @@ 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", @@ -38,7 +39,7 @@ export const LoadStatus = createEnum( "SetupAccount", // 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", @@ -174,7 +175,7 @@ export class SessionContainer { } return; } - const dehydratedDevice = await this._inspectAccountAfterLogin(sessionInfo); + const dehydratedDevice = await this._inspectAccountAfterLogin(sessionInfo, log); if (dehydratedDevice) { sessionInfo.deviceId = dehydratedDevice.deviceId; } @@ -240,7 +241,7 @@ export class SessionContainer { }); await this._session.load(log); if (dehydratedDevice) { - await log.wrap("dehydrateIdentity", log => await this._session.dehydrateIdentity(dehydratedDevice, log)); + 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)); @@ -308,25 +309,27 @@ export class SessionContainer { } } - async _inspectAccountAfterLogin(sessionInfo) { - this._status.set(LoadStatus.QueryAccount); - const hsApi = new HomeServerApi({ - homeserver: sessionInfo.homeServer, - accessToken: sessionInfo.accessToken, - request: this._platform.request, + _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, log); + if (encryptedDehydratedDevice) { + let resolveStageFinish; + const promiseStageFinish = new Promise(r => resolveStageFinish = r); + this._accountSetup = new AccountSetup(encryptedDehydratedDevice, resolveStageFinish); + this._status.set(LoadStatus.SetupAccount); + await promiseStageFinish; + const dehydratedDevice = this._accountSetup?._dehydratedDevice; + this._accountSetup = null; + return dehydratedDevice; + } }); - const olm = await this._olmPromise; - const encryptedDehydratedDevice = await getDehydratedDevice(hsApi, olm); - if (encryptedDehydratedDevice) { - let resolveStageFinish; - const promiseStageFinish = new Promise(r => resolveStageFinish = r); - this._accountSetup = new AccountSetup(encryptedDehydratedDevice, resolveStageFinish); - this._status.set(LoadStatus.SetupAccount); - await promiseStageFinish; - const dehydratedDevice = this._accountSetup?._dehydratedDevice; - this._accountSetup = null; - return dehydratedDevice; - } } get accountSetup() { diff --git a/src/matrix/e2ee/Account.js b/src/matrix/e2ee/Account.js index 4a680ec6..91392232 100644 --- a/src/matrix/e2ee/Account.js +++ b/src/matrix/e2ee/Account.js @@ -52,14 +52,14 @@ export class Account { deviceId, areDeviceKeysUploaded, serverOTKCount, olm, olmWorker}); } } - + static async adoptDehydratedDevice({olm, dehydratedDevice, pickleKey, hsApi, userId, olmWorker, storage}) { const account = dehydratedDevice.adoptUnpickledOlmAccount(); - const areDeviceKeysUploaded = true; - const oneTimeKeys = JSON.parse(this._account.one_time_keys()); + 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, @@ -76,11 +76,13 @@ export class Account { account.create(); account.generate_one_time_keys(account.max_number_of_one_time_keys()); } + const areDeviceKeysUploaded = false; + const serverOTKCount = 0; if (storage) { - await initiallyStoreAccount(account, pickleKey, false, 0, storage); + await initiallyStoreAccount(account, pickleKey, areDeviceKeysUploaded, serverOTKCount, storage); } 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}) { diff --git a/src/matrix/e2ee/Dehydration.js b/src/matrix/e2ee/Dehydration.js index f01a9423..4ae7dd71 100644 --- a/src/matrix/e2ee/Dehydration.js +++ b/src/matrix/e2ee/Dehydration.js @@ -14,20 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -export const DEHYDRATION_LIBOLM_PICKLE_ALGORITHM = "org.matrix.msc2697.v1.olm.libolm_pickle"; +const DEHYDRATION_LIBOLM_PICKLE_ALGORITHM = "org.matrix.msc2697.v1.olm.libolm_pickle"; -async function getDehydratedDevice(hsApi, olm) { - const response = await hsApi.getDehydratedDevice().response(); - if (response.device_data.algorithm === DEHYDRATION_LIBOLM_PICKLE_ALGORITHM) { - return new DehydratedDevice(response, olm); +export async function getDehydratedDevice(hsApi, olm, log) { + try { + const response = await hsApi.getDehydratedDevice({log}).response(); + if (response.device_data.algorithm === DEHYDRATION_LIBOLM_PICKLE_ALGORITHM) { + return new EncryptedDehydratedDevice(response, olm); + } + } catch (err) { + if (err.name !== "HomeServerError") { + log.error = err; + } + return undefined; } } -async function hasRemainingDevice(ownUserId, ownDeviceId, storage) { - -} - -async function uploadAccountAsDehydratedDevice(account, hsApi, key, deviceDisplayName, log) { +export async function uploadAccountAsDehydratedDevice(account, hsApi, key, deviceDisplayName, log) { const response = await hsApi.createDehydratedDevice({ device_data: { algorithm: DEHYDRATION_LIBOLM_PICKLE_ALGORITHM, @@ -66,7 +69,7 @@ class DehydratedDevice { this._account = account; } - claim(hsApi, log) { + async claim(hsApi, log) { try { const response = await hsApi.claimDehydratedDevice(this.deviceId, {log}).response(); return response.success; @@ -83,6 +86,6 @@ class DehydratedDevice { } get deviceId() { - this._dehydratedDevice.device_id; + return this._dehydratedDevice.device_id; } } diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index d823abfd..c8950635 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -242,12 +242,12 @@ export class HomeServerApi { return this._get(`/dehydrated_device`, null, null, options); } - createDehydratedDevice(payload, options = null) { + createDehydratedDevice(payload, options = {}) { options.prefix = DEHYDRATION_PREFIX; return this._put(`/dehydrated_device`, null, payload, options); } - claimDehydratedDevice(deviceId) { + claimDehydratedDevice(deviceId, options = {}) { options.prefix = DEHYDRATION_PREFIX; return this._post(`/dehydrated_device/claim`, null, {device_id: deviceId}, options); } From c89e414bb5e86b2aa7bf6e31c1e22aab730313d8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 27 Oct 2021 15:08:53 +0200 Subject: [PATCH 03/22] WIP3 --- src/domain/AccountSetupViewModel.js | 49 ++++++++++++++ src/domain/SessionLoadViewModel.js | 40 +++++------ .../session/settings/SettingsViewModel.js | 4 ++ src/logging/ConsoleLogger.js | 3 +- src/matrix/Session.js | 67 +++++++++---------- src/matrix/SessionContainer.js | 5 +- src/matrix/e2ee/Account.js | 6 +- src/matrix/e2ee/Dehydration.js | 12 +++- src/matrix/net/HomeServerApi.js | 4 +- src/platform/web/ui/css/login.css | 9 +-- src/platform/web/ui/login/AccountSetupView.js | 46 +++++++++++++ .../web/ui/login/SessionLoadStatusView.js | 9 ++- .../web/ui/session/settings/SettingsView.js | 15 ++++- 13 files changed, 190 insertions(+), 79 deletions(-) create mode 100644 src/domain/AccountSetupViewModel.js create mode 100644 src/platform/web/ui/login/AccountSetupView.js 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"), From e3378d5636848036329924d7c4e3da6d895e078d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 27 Oct 2021 18:08:50 +0200 Subject: [PATCH 04/22] use correct device_id in signatures for dehydrating device completely replace device id for dehydrating device so we don't have to pass it down the stack --- src/matrix/Session.js | 4 ++-- src/matrix/e2ee/Account.js | 13 +++++++++---- src/matrix/e2ee/Dehydration.js | 3 ++- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index c51ce63e..ede2ece9 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -253,7 +253,7 @@ export class Session { this._setupEncryption(); } await this._e2eeAccount.generateOTKsIfNeeded(this._storage, log); - await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, undefined, log)); + await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, false, log)); } } @@ -570,7 +570,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, undefined, log)); + await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, false, log)); } } } diff --git a/src/matrix/e2ee/Account.js b/src/matrix/e2ee/Account.js index 35e55b16..81725dcc 100644 --- a/src/matrix/e2ee/Account.js +++ b/src/matrix/e2ee/Account.js @@ -102,7 +102,11 @@ export class Account { return this._identityKeys; } - async uploadKeys(storage, dehydratedDeviceId, 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); @@ -111,12 +115,13 @@ export class Account { if (!this._areDeviceKeysUploaded) { log.set("identity", true); const identityKeys = JSON.parse(this._account.identity_keys()); - payload.device_keys = this._deviceKeysPayload(identityKeys, dehydratedDeviceId || this._deviceId); + payload.device_keys = this._deviceKeysPayload(identityKeys); } if (oneTimeKeysEntries.length) { log.set("otks", true); payload.one_time_keys = this._oneTimeKeysPayload(oneTimeKeysEntries); } + 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); @@ -241,10 +246,10 @@ export class Account { } } - _deviceKeysPayload(identityKeys, deviceId) { + _deviceKeysPayload(identityKeys) { const obj = { user_id: this._userId, - device_id: deviceId, + device_id: this._deviceId, algorithms: [OLM_ALGORITHM, MEGOLM_ALGORITHM], keys: {} }; diff --git a/src/matrix/e2ee/Dehydration.js b/src/matrix/e2ee/Dehydration.js index 43ffed6e..4fa75d81 100644 --- a/src/matrix/e2ee/Dehydration.js +++ b/src/matrix/e2ee/Dehydration.js @@ -40,7 +40,8 @@ export async function uploadAccountAsDehydratedDevice(account, hsApi, key, devic initial_device_display_name: deviceDisplayName }).response(); const deviceId = response.device_id; - await account.uploadKeys(undefined, deviceId, log); + account.setDeviceId(deviceId); + await account.uploadKeys(undefined, true, log); return deviceId; } From cbccca20d0b2ec070d8fc4031f3ac0168063a913 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 27 Oct 2021 18:09:31 +0200 Subject: [PATCH 05/22] remove leftover logging --- src/domain/session/room/timeline/tiles/GapTile.js | 2 -- 1 file changed, 2 deletions(-) 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"); } } From 68a6113c26109a21f6b7c252d4321da5dd0968e1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 28 Oct 2021 11:47:31 +0200 Subject: [PATCH 06/22] add logout button to session load error screen --- src/domain/SessionLoadViewModel.js | 5 +++++ src/domain/session/settings/SettingsViewModel.js | 15 +++++---------- src/matrix/Session.js | 1 + src/matrix/SessionContainer.js | 9 +++++++++ .../web/ui/login/SessionLoadStatusView.js | 8 +++++++- 5 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js index 0064108f..eec7c2fb 100644 --- a/src/domain/SessionLoadViewModel.js +++ b/src/domain/SessionLoadViewModel.js @@ -153,6 +153,11 @@ export class SessionLoadViewModel extends ViewModel { 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/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 04d4d18d..eaffeb12 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); } setupDehydratedDevice(key) { diff --git a/src/matrix/Session.js b/src/matrix/Session.js index ede2ece9..90f383bb 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -107,6 +107,7 @@ export class Session { return this._sessionInfo.userId; } + /** @internal call SessionContainer.logout instead */ async logout(log = undefined) { await this._hsApi.logout({log}).response(); } diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index bc56905c..84d79bcb 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -367,6 +367,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(); diff --git a/src/platform/web/ui/login/SessionLoadStatusView.js b/src/platform/web/ui/login/SessionLoadStatusView.js index 24feed52..66ef5b27 100644 --- a/src/platform/web/ui/login/SessionLoadStatusView.js +++ b/src/platform/web/ui/login/SessionLoadStatusView.js @@ -28,11 +28,17 @@ 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"}, [ t.p({className: "status"}, [ spinner(t, {hidden: vm => !vm.loading}), t.p(vm => vm.loadLabel), - exportLogsButtonIfFailed + exportLogsButtonIfFailed, + logoutButtonIfFailed ]), t.ifView(vm => vm.accountSetupViewModel, vm => new AccountSetupView(vm.accountSetupViewModel)), ]); From 3ef37c15c75f1490e2e3a0a73ffd092c96f5cfd3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 28 Oct 2021 11:47:54 +0200 Subject: [PATCH 07/22] remove import session button as it's not supported anymore --- .../web/ui/login/SessionPickerView.js | 28 ------------------- 1 file changed, 28 deletions(-) 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 From bef12c7a8fdbbf16a65e657c5e0c7f6afefbc722 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 28 Oct 2021 11:48:25 +0200 Subject: [PATCH 08/22] prevent double free on olm account when logging out by ensuring we only dispose the e2ee/Account once, as well as the Session and other classes --- src/matrix/Session.js | 5 +++++ src/matrix/SessionContainer.js | 3 +++ src/matrix/e2ee/Account.js | 1 + src/matrix/e2ee/megolm/decryption/KeyLoader.ts | 1 + 4 files changed, 10 insertions(+) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 90f383bb..13dbc128 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -374,12 +374,17 @@ export class Session { dispose() { this._olmWorker?.dispose(); + this._olmWorker = undefined; this._sessionBackup?.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; } /** diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index 84d79bcb..9794e93a 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -384,12 +384,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(); diff --git a/src/matrix/e2ee/Account.js b/src/matrix/e2ee/Account.js index 81725dcc..13792ddc 100644 --- a/src/matrix/e2ee/Account.js +++ b/src/matrix/e2ee/Account.js @@ -311,5 +311,6 @@ export class Account { dispose() { this._account.free(); + this._account = undefined; } } 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 From 544019f67db0172e07d3c4f0f8a51f14fa424a34 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 28 Oct 2021 11:52:32 +0200 Subject: [PATCH 09/22] ensure olm Account in dehydrated device is freed on error --- src/matrix/SessionContainer.js | 2 ++ src/matrix/e2ee/Dehydration.js | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index 9794e93a..6b194c9b 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -189,6 +189,8 @@ export class SessionContainer { 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); } diff --git a/src/matrix/e2ee/Dehydration.js b/src/matrix/e2ee/Dehydration.js index 4fa75d81..22d6082b 100644 --- a/src/matrix/e2ee/Dehydration.js +++ b/src/matrix/e2ee/Dehydration.js @@ -90,11 +90,16 @@ class DehydratedDevice { // make it clear that ownership is transfered upon calling this adoptUnpickledOlmAccount() { const account = this._account; - this._account = null; + this._account = undefined; return account; } get deviceId() { return this._dehydratedDevice.device_id; } + + dispose() { + this._account?.free(); + this._account = undefined; + } } From c3dfdde6267f3397f61a4e9b2e854638a93c5c2e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 28 Oct 2021 12:04:42 +0200 Subject: [PATCH 10/22] be forgiving when dispose has already been called --- src/matrix/Session.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 13dbc128..87c7481c 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -377,7 +377,7 @@ export class Session { this._olmWorker = undefined; this._sessionBackup?.dispose(); this._sessionBackup = undefined; - this._megolmDecryption.dispose(); + this._megolmDecryption?.dispose(); this._megolmDecryption = undefined; this._e2eeAccount?.dispose(); this._e2eeAccount = undefined; From 6273d723f18466bc854997af4b2714c9947b09ad Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 29 Oct 2021 15:24:24 +0200 Subject: [PATCH 11/22] fix typo --- src/platform/web/ui/session/settings/SettingsView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index e7ac06fd..87789811 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -104,7 +104,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); } }) ]); From 3b3751c8279d0a8891c17aa41bd14f863d495faf Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 29 Oct 2021 15:47:14 +0200 Subject: [PATCH 12/22] remove dehydrated device test button from settings --- src/domain/session/settings/SettingsViewModel.js | 4 ---- .../web/ui/session/settings/SettingsView.js | 13 ------------- 2 files changed, 17 deletions(-) diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index eaffeb12..9ce340f1 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -64,10 +64,6 @@ export class SettingsViewModel extends ViewModel { this.navigation.push("session", true); } - setupDehydratedDevice(key) { - return this._session.setupDehydratedDevice(key); - } - get isLoggingOut() { return this._isLoggingOut; } setSentImageSizeLimit(size) { diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index 87789811..e969ba40 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -49,19 +49,6 @@ export class SettingsView extends TemplateView { }, disabled: vm => vm.isLoggingOut }, 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"), From 44a26fd340d6379534692c391a3e5ece1f6ad4de Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 29 Oct 2021 15:48:28 +0200 Subject: [PATCH 13/22] key backup: add disable button,and enabling add dehydrated device option --- src/domain/AccountSetupViewModel.js | 5 +- .../settings/SessionBackupViewModel.js | 109 +++++++++++++----- src/matrix/Session.js | 26 +++++ src/matrix/SessionContainer.js | 2 +- src/matrix/e2ee/Dehydration.js | 33 ++++-- src/matrix/e2ee/RoomEncryption.js | 2 +- src/matrix/room/BaseRoom.js | 2 +- src/matrix/ssss/common.js | 12 +- src/matrix/ssss/index.js | 20 +++- .../web/ui/css/themes/element/theme.css | 13 +++ .../settings/SessionBackupSettingsView.js | 44 ++++--- 11 files changed, 201 insertions(+), 67 deletions(-) diff --git a/src/domain/AccountSetupViewModel.js b/src/domain/AccountSetupViewModel.js index 71fffe24..c1f9d41b 100644 --- a/src/domain/AccountSetupViewModel.js +++ b/src/domain/AccountSetupViewModel.js @@ -15,6 +15,7 @@ limitations under the License. */ import {ViewModel} from "./ViewModel.js"; +import {KeyType} from "../matrix/ssss/index.js"; export class AccountSetupViewModel extends ViewModel { constructor(accountSetup) { @@ -35,10 +36,10 @@ export class AccountSetupViewModel extends ViewModel { return this._accountSetup.encryptedDehydratedDevice.deviceId; } - tryDecryptDehydratedDevice(password) { + async tryDecryptDehydratedDevice(password) { const {encryptedDehydratedDevice} = this._accountSetup; if (encryptedDehydratedDevice) { - this._dehydratedDevice = encryptedDehydratedDevice.decrypt(password); + this._dehydratedDevice = await encryptedDehydratedDevice.decrypt(KeyType.RecoveryKey, password); this.emitChange("deviceDecrypted"); } } diff --git a/src/domain/session/settings/SessionBackupViewModel.js b/src/domain/session/settings/SessionBackupViewModel.js index d924fae6..014fc99f 100644 --- a/src/domain/session/settings/SessionBackupViewModel.js +++ b/src/domain/session/settings/SessionBackupViewModel.js @@ -15,18 +15,56 @@ limitations under the License. */ import {ViewModel} from "../../ViewModel.js"; +import {KeyType} from "../../../matrix/ssss/index.js"; +import {createEnum} from "../../../utils/enum.js"; + +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`; + } + + offerDehydratedDeviceSetup() { + return true; + } + + get dehydratedDeviceId() { + return this._dehydratedDeviceId; + } get isBusy() { return this._isBusy; @@ -37,15 +75,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 +83,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/matrix/Session.js b/src/matrix/Session.js index 87c7481c..d6810969 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -40,6 +40,7 @@ import { keyFromCredential as ssssKeyFromCredential, readKey as ssssReadKey, writeKey as ssssWriteKey, + removeKey as ssssRemoveKey } from "./ssss/index.js"; import {SecretStorage} from "./ssss/SecretStorage.js"; import {ObservableValue, RetainedObservableValue} from "../observable/ObservableValue"; @@ -217,6 +218,30 @@ export class Session { } await writeTxn.complete(); this._hasSecretStorageKey.set(true); + return key; + } + + 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) { @@ -311,6 +336,7 @@ export class Session { try { const deviceId = await uploadAccountAsDehydratedDevice( dehydrationAccount, this._hsApi, key, "Dehydrated device", log); + log.set("deviceId", deviceId); return deviceId; } finally { dehydrationAccount.dispose(); diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index 6b194c9b..ae2ddece 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -321,7 +321,7 @@ export class SessionContainer { request: this._platform.request, }); const olm = await this._olmPromise; - const encryptedDehydratedDevice = await getDehydratedDevice(hsApi, olm, log); + const encryptedDehydratedDevice = await getDehydratedDevice(hsApi, olm, this._platform, log); if (encryptedDehydratedDevice) { let resolveStageFinish; const promiseStageFinish = new Promise(r => resolveStageFinish = r); diff --git a/src/matrix/e2ee/Dehydration.js b/src/matrix/e2ee/Dehydration.js index 22d6082b..ac2d275d 100644 --- a/src/matrix/e2ee/Dehydration.js +++ b/src/matrix/e2ee/Dehydration.js @@ -15,12 +15,14 @@ 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, log) { +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); + return new EncryptedDehydratedDevice(response, olm, platform); } } catch (err) { if (err.name !== "HomeServerError") { @@ -34,8 +36,8 @@ export async function uploadAccountAsDehydratedDevice(account, hsApi, key, devic const response = await hsApi.createDehydratedDevice({ device_data: { algorithm: DEHYDRATION_LIBOLM_PICKLE_ALGORITHM, - account: account.pickleWithKey(new Uint8Array(key)), - passphrase: {} + account: account.pickleWithKey(key.binaryKey), + passphrase: key.description?.passphraseParams || {}, }, initial_device_display_name: deviceDisplayName }).response(); @@ -46,17 +48,20 @@ export async function uploadAccountAsDehydratedDevice(account, hsApi, key, devic } class EncryptedDehydratedDevice { - constructor(dehydratedDevice, olm) { + constructor(dehydratedDevice, olm, platform) { this._dehydratedDevice = dehydratedDevice; this._olm = olm; + this._platform = platform; } - decrypt(key) { + 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(new Uint8Array(key), pickledAccount); - return new DehydratedDevice(this._dehydratedDevice, account); + account.unpickle(key.binaryKey, pickledAccount); + return new DehydratedDevice(this._dehydratedDevice, account, keyType, key); } catch (err) { account.free(); if (err.message === "OLM.BAD_ACCOUNT_KEY") { @@ -73,9 +78,11 @@ class EncryptedDehydratedDevice { } class DehydratedDevice { - constructor(dehydratedDevice, account) { + constructor(dehydratedDevice, account, keyType, key) { this._dehydratedDevice = dehydratedDevice; this._account = account; + this._keyType = keyType; + this._key = key; } async claim(hsApi, log) { @@ -98,6 +105,14 @@ class DehydratedDevice { return this._dehydratedDevice.device_id; } + get key() { + return this._key; + } + + get keyType() { + return this._keyType; + } + 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/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..579e38b3 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,11 @@ 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; } } @@ -39,6 +39,10 @@ export class Key { this._binaryKey = binaryKey; } + get description() { + return this._keyDescription; + } + get id() { return this._keyDescription.id; } diff --git a/src/matrix/ssss/index.js b/src/matrix/ssss/index.js index e1baf9c9..c104cac2 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,12 @@ 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); + 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,10 +63,14 @@ 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}`); 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/session/settings/SessionBackupSettingsView.js b/src/platform/web/ui/session/settings/SessionBackupSettingsView.js index b38517ab..25d8b944 100644 --- a/src/platform/web/ui/session/settings/SessionBackupSettingsView.js +++ b/src/platform/web/ui/session/settings/SessionBackupSettingsView.js @@ -21,17 +21,23 @@ 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) { @@ -39,7 +45,7 @@ function renderEnableFromKey(t, vm) { 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.`), 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.`]), ]); } @@ -49,20 +55,32 @@ function renderEnableFromPhrase(t, vm) { 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.`), 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), ]); } From 6d9d8797fea87dd3af54e306fc9d59bfe5950db3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 29 Oct 2021 16:40:35 +0200 Subject: [PATCH 14/22] use same UI as in settings to pick between recovery key and passphrase --- src/domain/AccountSetupViewModel.js | 106 ++++++++++++++++-- src/domain/SessionLoadViewModel.js | 2 +- .../settings/SessionBackupViewModel.js | 6 +- src/platform/web/ui/login/AccountSetupView.js | 20 ++-- .../settings/SessionBackupSettingsView.js | 4 +- 5 files changed, 115 insertions(+), 23 deletions(-) diff --git a/src/domain/AccountSetupViewModel.js b/src/domain/AccountSetupViewModel.js index c1f9d41b..7930b87d 100644 --- a/src/domain/AccountSetupViewModel.js +++ b/src/domain/AccountSetupViewModel.js @@ -16,16 +16,25 @@ 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 canDehydrateDevice() { - return !!this._accountSetup.encryptedDehydratedDevice; + get decryptDehydratedDeviceViewModel() { + return this._decryptDehydratedDeviceViewModel; } get deviceDecrypted() { @@ -36,15 +45,92 @@ export class AccountSetupViewModel extends ViewModel { return this._accountSetup.encryptedDehydratedDevice.deviceId; } - async tryDecryptDehydratedDevice(password) { - const {encryptedDehydratedDevice} = this._accountSetup; - if (encryptedDehydratedDevice) { - this._dehydratedDevice = await encryptedDehydratedDevice.decrypt(KeyType.RecoveryKey, password); - this.emitChange("deviceDecrypted"); - } - } - 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 eec7c2fb..c798bc67 100644 --- a/src/domain/SessionLoadViewModel.js +++ b/src/domain/SessionLoadViewModel.js @@ -125,7 +125,7 @@ export class SessionLoadViewModel extends ViewModel { case LoadStatus.QueryAccount: return `Querying account encryption setup…`; case LoadStatus.AccountSetup: - return `Do you want to restore this dehydrated device?`; + return ""; // we'll show a header ing AccountSetupView case LoadStatus.SessionSetup: return `Setting up your encryption keys…`; case LoadStatus.Loading: diff --git a/src/domain/session/settings/SessionBackupViewModel.js b/src/domain/session/settings/SessionBackupViewModel.js index 014fc99f..533d0f6d 100644 --- a/src/domain/session/settings/SessionBackupViewModel.js +++ b/src/domain/session/settings/SessionBackupViewModel.js @@ -18,7 +18,7 @@ import {ViewModel} from "../../ViewModel.js"; import {KeyType} from "../../../matrix/ssss/index.js"; import {createEnum} from "../../../utils/enum.js"; -const Status = createEnum("Enabled", "SetupKey", "SetupPhrase", "Pending"); +export const Status = createEnum("Enabled", "SetupKey", "SetupPhrase", "Pending"); export class SessionBackupViewModel extends ViewModel { constructor(options) { @@ -58,6 +58,10 @@ export class SessionBackupViewModel extends ViewModel { return this.i18n`Set up`; } + get purpose() { + return this.i18n`set up session backup`; + } + offerDehydratedDeviceSetup() { return true; } diff --git a/src/platform/web/ui/login/AccountSetupView.js b/src/platform/web/ui/login/AccountSetupView.js index 2438a6df..1855e82f 100644 --- a/src/platform/web/ui/login/AccountSetupView.js +++ b/src/platform/web/ui/login/AccountSetupView.js @@ -15,6 +15,7 @@ limitations under the License. */ import {TemplateView} from "../general/TemplateView"; +import {SessionBackupSettingsView} from "../session/settings/SessionBackupSettingsView.js"; export class AccountSetupView extends TemplateView { render(t, vm) { @@ -25,21 +26,22 @@ export class AccountSetupView extends TemplateView { 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); + 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: "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`), + }, vm => vm.deviceDecrypted ? vm.i18n`Continue` : vm.i18n`Continue without restoring`), ]), ]); } diff --git a/src/platform/web/ui/session/settings/SessionBackupSettingsView.js b/src/platform/web/ui/session/settings/SessionBackupSettingsView.js index 25d8b944..e989f8ea 100644 --- a/src/platform/web/ui/session/settings/SessionBackupSettingsView.js +++ b/src/platform/web/ui/session/settings/SessionBackupSettingsView.js @@ -43,7 +43,7 @@ function renderEnabled(t, vm) { 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, setupDehydratedDevice) => vm.enterSecurityKey(key, setupDehydratedDevice)), t.p([vm.i18n`Alternatively, you can `, useASecurityPhrase, vm.i18n` if you have one.`]), @@ -53,7 +53,7 @@ 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, setupDehydratedDevice) => vm.enterSecurityPhrase(phrase, setupDehydratedDevice)), t.p([vm.i18n`You can also `, useASecurityKey, vm.i18n`.`]), From 567cdd5510204b3e4bdfec27fd31278684a4f072 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 29 Oct 2021 19:17:31 +0200 Subject: [PATCH 15/22] WIP for enabling session backup from dehydration key --- src/matrix/Session.js | 19 +++++++++++++++---- src/matrix/SessionContainer.js | 8 ++++++-- src/matrix/e2ee/Dehydration.js | 9 ++------- src/matrix/ssss/common.js | 26 ++++++++++++++++++++++++++ src/matrix/ssss/index.js | 11 ++++++++++- 5 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index d6810969..bbeb6d1e 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -40,7 +40,8 @@ import { keyFromCredential as ssssKeyFromCredential, readKey as ssssReadKey, writeKey as ssssWriteKey, - removeKey as ssssRemoveKey + removeKey as ssssRemoveKey, + keyFromDehydratedDeviceKey as createSSSSKeyFromDehydratedDeviceKey } from "./ssss/index.js"; import {SecretStorage} from "./ssss/SecretStorage.js"; import {ObservableValue, RetainedObservableValue} from "../observable/ObservableValue"; @@ -205,6 +206,12 @@ export class Session { this._storage.storeNames.accountData, ]); await this._createSessionBackup(key, readTxn); + 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([ @@ -217,8 +224,6 @@ export class Session { throw err; } await writeTxn.complete(); - this._hasSecretStorageKey.set(true); - return key; } async disableSecretStorage() { @@ -419,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([ @@ -431,6 +436,12 @@ export class Session { } // enable session backup, this requests the latest backup version if (!this._sessionBackup) { + if (dehydratedDevice) { + const ssssKey = await createSSSSKeyFromDehydratedDeviceKey(dehydratedDevice.key, this._storage); + if (ssssKey) { + this._writeSSSSKey(ssssKey); + } + } const txn = await this._storage.readTxn([ this._storage.storeNames.session, this._storage.storeNames.accountData, diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index ae2ddece..10e5acfc 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -259,7 +259,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)); }); } }); @@ -278,8 +280,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)); } } diff --git a/src/matrix/e2ee/Dehydration.js b/src/matrix/e2ee/Dehydration.js index ac2d275d..87cf0121 100644 --- a/src/matrix/e2ee/Dehydration.js +++ b/src/matrix/e2ee/Dehydration.js @@ -61,7 +61,7 @@ class EncryptedDehydratedDevice { try { const pickledAccount = this._dehydratedDevice.device_data.account; account.unpickle(key.binaryKey, pickledAccount); - return new DehydratedDevice(this._dehydratedDevice, account, keyType, key); + return new DehydratedDevice(this._dehydratedDevice, account, key); } catch (err) { account.free(); if (err.message === "OLM.BAD_ACCOUNT_KEY") { @@ -78,10 +78,9 @@ class EncryptedDehydratedDevice { } class DehydratedDevice { - constructor(dehydratedDevice, account, keyType, key) { + constructor(dehydratedDevice, account, key) { this._dehydratedDevice = dehydratedDevice; this._account = account; - this._keyType = keyType; this._key = key; } @@ -109,10 +108,6 @@ class DehydratedDevice { return this._key; } - get keyType() { - return this._keyType; - } - dispose() { this._account?.free(); this._account = undefined; diff --git a/src/matrix/ssss/common.js b/src/matrix/ssss/common.js index 579e38b3..45bdfc2b 100644 --- a/src/matrix/ssss/common.js +++ b/src/matrix/ssss/common.js @@ -31,6 +31,28 @@ export class KeyDescription { get algorithm() { return this._keyDescription?.algorithm; } + + isCompatible(d) { + const kd = this._keyDescription; + const kdOther = d._keyDescription; + if (kd.algorithm === "m.secret_storage.v1.aes-hmac-sha2") { + if (kdOther.algorithm !== kd.algorithm) { + return false; + } + if (kd.passphrase) { + if (!kdOther.passphrase) { + return false; + } + return kd.passphrase.algorithm === kdOther.passphrase.algorithm && + kd.passphrase.iterations === kdOther.passphrase.iterations && + kd.passphrase.salt === kdOther.passphrase.salt; + } else { + return !!kd.iv && kd.iv === kdOther.iv && + !!kd.mac && kd.mac === kdOther.mac; + } + } + return false; + } } export class Key { @@ -39,6 +61,10 @@ export class Key { this._binaryKey = binaryKey; } + withDescription(description) { + return new Key(description, this._binaryKey); + } + get description() { return this._keyDescription; } diff --git a/src/matrix/ssss/index.js b/src/matrix/ssss/index.js index c104cac2..efa2cb0a 100644 --- a/src/matrix/ssss/index.js +++ b/src/matrix/ssss/index.js @@ -50,7 +50,9 @@ 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.content), keyData.binaryKey); + if (keyAccountData) { + return new Key(new KeyDescription(keyData.id, keyAccountData.content), keyData.binaryKey); + } } @@ -77,3 +79,10 @@ export async function keyFromCredentialAndDescription(type, credential, keyDescr } return key; } + +export async function keyFromDehydratedDeviceKey(key, storage) { + const keyDescription = await readDefaultKeyDescription(storage); + if (key.description.isCompatible(keyDescription)) { + return key.withDescription(keyDescription); + } +} From 8a36eb453238979243e46088e1f02250540a7525 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 3 Nov 2021 02:07:57 +0100 Subject: [PATCH 16/22] check mac of dehydrated key to match default 4s key mac before adopting --- src/matrix/Session.js | 2 +- src/matrix/ssss/common.js | 40 ++++++++++++++++++++++++++++----------- src/matrix/ssss/index.js | 4 ++-- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index bbeb6d1e..9ee458bd 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -437,7 +437,7 @@ export class Session { // enable session backup, this requests the latest backup version if (!this._sessionBackup) { if (dehydratedDevice) { - const ssssKey = await createSSSSKeyFromDehydratedDeviceKey(dehydratedDevice.key, this._storage); + const ssssKey = await createSSSSKeyFromDehydratedDeviceKey(dehydratedDevice.key, this._storage, this._platform); if (ssssKey) { this._writeSSSSKey(ssssKey); } diff --git a/src/matrix/ssss/common.js b/src/matrix/ssss/common.js index 45bdfc2b..406e8558 100644 --- a/src/matrix/ssss/common.js +++ b/src/matrix/ssss/common.js @@ -32,23 +32,20 @@ export class KeyDescription { return this._keyDescription?.algorithm; } - isCompatible(d) { - const kd = this._keyDescription; - const kdOther = d._keyDescription; - if (kd.algorithm === "m.secret_storage.v1.aes-hmac-sha2") { - if (kdOther.algorithm !== kd.algorithm) { - return false; - } - if (kd.passphrase) { + 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; - } else { - return !!kd.iv && kd.iv === kdOther.iv && - !!kd.mac && kd.mac === kdOther.mac; } } return false; @@ -81,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 efa2cb0a..cb795766 100644 --- a/src/matrix/ssss/index.js +++ b/src/matrix/ssss/index.js @@ -80,9 +80,9 @@ export async function keyFromCredentialAndDescription(type, credential, keyDescr return key; } -export async function keyFromDehydratedDeviceKey(key, storage) { +export async function keyFromDehydratedDeviceKey(key, storage, platform) { const keyDescription = await readDefaultKeyDescription(storage); - if (key.description.isCompatible(keyDescription)) { + if (await keyDescription.isCompatible(key, platform)) { return key.withDescription(keyDescription); } } From 80a98f04c7f5669868ba4292de1dded1dec4a489 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 3 Nov 2021 02:08:27 +0100 Subject: [PATCH 17/22] pickle clears the key, so slice it before calling so we can reuse for 4s --- src/matrix/e2ee/Dehydration.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/e2ee/Dehydration.js b/src/matrix/e2ee/Dehydration.js index 87cf0121..65e2b90a 100644 --- a/src/matrix/e2ee/Dehydration.js +++ b/src/matrix/e2ee/Dehydration.js @@ -60,7 +60,7 @@ class EncryptedDehydratedDevice { const account = new this._olm.Account(); try { const pickledAccount = this._dehydratedDevice.device_data.account; - account.unpickle(key.binaryKey, pickledAccount); + account.unpickle(key.binaryKey.slice(), pickledAccount); return new DehydratedDevice(this._dehydratedDevice, account, key); } catch (err) { account.free(); From a8022077f6353ce011b016e4737639283d068836 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 3 Nov 2021 02:20:11 +0100 Subject: [PATCH 18/22] add minimal logging --- src/matrix/Session.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 9ee458bd..04e2bd62 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -437,10 +437,13 @@ export class Session { // enable session backup, this requests the latest backup version if (!this._sessionBackup) { if (dehydratedDevice) { - const ssssKey = await createSSSSKeyFromDehydratedDeviceKey(dehydratedDevice.key, this._storage, this._platform); - if (ssssKey) { - this._writeSSSSKey(ssssKey); - } + await log.wrap("SSSSKeyFromDehydratedDeviceKey", async log => { + const ssssKey = await createSSSSKeyFromDehydratedDeviceKey(dehydratedDevice.key, this._storage, this._platform); + if (ssssKey) { + log.set("success", true); + this._writeSSSSKey(ssssKey); + } + }) } const txn = await this._storage.readTxn([ this._storage.storeNames.session, From bffe34fe0a4e4b02629cc758426c855813caf62f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 3 Nov 2021 02:20:27 +0100 Subject: [PATCH 19/22] await write key --- src/matrix/Session.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 04e2bd62..37fd90ad 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -206,7 +206,7 @@ export class Session { this._storage.storeNames.accountData, ]); await this._createSessionBackup(key, readTxn); - this._writeSSSSKey(key); + await this._writeSSSSKey(key); this._hasSecretStorageKey.set(true); return key; } @@ -441,7 +441,7 @@ export class Session { const ssssKey = await createSSSSKeyFromDehydratedDeviceKey(dehydratedDevice.key, this._storage, this._platform); if (ssssKey) { log.set("success", true); - this._writeSSSSKey(ssssKey); + await this._writeSSSSKey(ssssKey); } }) } From 790b9cbc13b8614ca58ae16668ccfcc7d9238df0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 3 Nov 2021 02:28:01 +0100 Subject: [PATCH 20/22] require a flag to enable account setup during login as it blocks login from progressing, any sdk usage should enable to indicate they are listening for the AccountSetup status. --- src/domain/login/LoginViewModel.js | 2 +- src/matrix/SessionContainer.js | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) 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/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index 10e5acfc..8e3d147e 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -131,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 && @@ -176,9 +176,12 @@ export class SessionContainer { } return; } - const dehydratedDevice = await this._inspectAccountAfterLogin(sessionInfo, log); - if (dehydratedDevice) { - sessionInfo.deviceId = dehydratedDevice.deviceId; + 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 From b2d8f5f02351c819681cd49488fdac67e3a114e9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 3 Nov 2021 02:35:22 +0100 Subject: [PATCH 21/22] fix lint --- src/platform/web/ui/login/AccountSetupView.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/platform/web/ui/login/AccountSetupView.js b/src/platform/web/ui/login/AccountSetupView.js index 1855e82f..48f1df3f 100644 --- a/src/platform/web/ui/login/AccountSetupView.js +++ b/src/platform/web/ui/login/AccountSetupView.js @@ -25,7 +25,6 @@ export class AccountSetupView extends TemplateView { type: "password", placeholder: keyLabel, }); - const status = t.output({for: password.id}, vm => vm.deviceDecrypted ? "Key matches, good to go!" : ""); 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)), From ee9c9b33ca101951ee823acd1966bd1399e64350 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 3 Nov 2021 02:36:53 +0100 Subject: [PATCH 22/22] fix lint again --- src/platform/web/ui/login/AccountSetupView.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/platform/web/ui/login/AccountSetupView.js b/src/platform/web/ui/login/AccountSetupView.js index 48f1df3f..32a4afb5 100644 --- a/src/platform/web/ui/login/AccountSetupView.js +++ b/src/platform/web/ui/login/AccountSetupView.js @@ -19,12 +19,6 @@ import {SessionBackupSettingsView} from "../session/settings/SessionBackupSettin 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, - }); 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)),