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";