From 44a26fd340d6379534692c391a3e5ece1f6ad4de Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 29 Oct 2021 15:48:28 +0200 Subject: [PATCH] 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), ]); }