Merge pull request #564 from vector-im/bwindels/dehydrated-device

Device dehydration support
This commit is contained in:
Bruno Windels 2021-11-03 00:52:46 +01:00 committed by GitHub
commit 014acbfaf5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 845 additions and 171 deletions

View file

@ -0,0 +1,136 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {ViewModel} from "./ViewModel.js";
import {KeyType} from "../matrix/ssss/index.js";
import {Status} from "./session/settings/SessionBackupViewModel.js";
export class AccountSetupViewModel extends ViewModel {
constructor(accountSetup) {
super();
this._accountSetup = accountSetup;
this._dehydratedDevice = undefined;
this._decryptDehydratedDeviceViewModel = undefined;
if (this._accountSetup.encryptedDehydratedDevice) {
this._decryptDehydratedDeviceViewModel = new DecryptDehydratedDeviceViewModel(this, dehydratedDevice => {
this._dehydratedDevice = dehydratedDevice;
this._decryptDehydratedDeviceViewModel = undefined;
this.emitChange("deviceDecrypted");
});
}
}
get decryptDehydratedDeviceViewModel() {
return this._decryptDehydratedDeviceViewModel;
}
get deviceDecrypted() {
return !!this._dehydratedDevice;
}
get dehydratedDeviceId() {
return this._accountSetup.encryptedDehydratedDevice.deviceId;
}
finish() {
this._accountSetup.finish(this._dehydratedDevice);
}
}
// this vm adopts the same shape as SessionBackupViewModel so the same view can be reused.
class DecryptDehydratedDeviceViewModel extends ViewModel {
constructor(accountSetupViewModel, decryptedCallback) {
super();
this._accountSetupViewModel = accountSetupViewModel;
this._isBusy = false;
this._status = Status.SetupKey;
this._error = undefined;
this._decryptedCallback = decryptedCallback;
}
get decryptAction() {
return this.i18n`Restore`;
}
get purpose() {
return this.i18n`claim your dehydrated device`;
}
get offerDehydratedDeviceSetup() {
return false;
}
get dehydratedDeviceId() {
return this._accountSetupViewModel._dehydratedDevice?.deviceId;
}
get isBusy() {
return this._isBusy;
}
get backupVersion() { return 0; }
get status() {
return this._status;
}
get error() {
return this._error?.message;
}
showPhraseSetup() {
if (this._status === Status.SetupKey) {
this._status = Status.SetupPhrase;
this.emitChange("status");
}
}
showKeySetup() {
if (this._status === Status.SetupPhrase) {
this._status = Status.SetupKey;
this.emitChange("status");
}
}
async _enterCredentials(keyType, credential) {
if (credential) {
try {
this._isBusy = true;
this.emitChange("isBusy");
const {encryptedDehydratedDevice} = this._accountSetupViewModel._accountSetup;
const dehydratedDevice = await encryptedDehydratedDevice.decrypt(keyType, credential);
this._decryptedCallback(dehydratedDevice);
} catch (err) {
console.error(err);
this._error = err;
this.emitChange("error");
} finally {
this._isBusy = false;
this.emitChange("");
}
}
}
enterSecurityPhrase(passphrase) {
this._enterCredentials(KeyType.Passphrase, passphrase);
}
enterSecurityKey(securityKey) {
this._enterCredentials(KeyType.RecoveryKey, securityKey);
}
disable() {}
}

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {AccountSetupViewModel} from "./AccountSetupViewModel.js";
import {LoadStatus} from "../matrix/SessionContainer.js"; import {LoadStatus} from "../matrix/SessionContainer.js";
import {SyncStatus} from "../matrix/Sync.js"; import {SyncStatus} from "../matrix/Sync.js";
import {ViewModel} from "./ViewModel.js"; import {ViewModel} from "./ViewModel.js";
@ -29,6 +30,8 @@ export class SessionLoadViewModel extends ViewModel {
this._loading = false; this._loading = false;
this._error = null; this._error = null;
this.backUrl = this.urlCreator.urlForSegment("session", true); this.backUrl = this.urlCreator.urlForSegment("session", true);
this._accountSetupViewModel = undefined;
} }
async start() { async start() {
@ -39,6 +42,11 @@ export class SessionLoadViewModel extends ViewModel {
this._loading = true; this._loading = true;
this.emitChange("loading"); this.emitChange("loading");
this._waitHandle = this._sessionContainer.loadStatus.waitFor(s => { 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"); this.emitChange("loadLabel");
// wait for initial sync, but not catchup sync // wait for initial sync, but not catchup sync
const isCatchupSync = s === LoadStatus.FirstSync && const isCatchupSync = s === LoadStatus.FirstSync &&
@ -97,6 +105,10 @@ export class SessionLoadViewModel extends ViewModel {
// to show a spinner or not // to show a spinner or not
get loading() { get loading() {
const sc = this._sessionContainer;
if (sc && sc.loadStatus.get() === LoadStatus.AccountSetup) {
return false;
}
return this._loading; return this._loading;
} }
@ -110,6 +122,10 @@ export class SessionLoadViewModel extends ViewModel {
// Statuses related to login are handled by respective login view models // Statuses related to login are handled by respective login view models
if (sc) { if (sc) {
switch (sc.loadStatus.get()) { switch (sc.loadStatus.get()) {
case LoadStatus.QueryAccount:
return `Querying account encryption setup…`;
case LoadStatus.AccountSetup:
return ""; // we'll show a header ing AccountSetupView
case LoadStatus.SessionSetup: case LoadStatus.SessionSetup:
return `Setting up your encryption keys…`; return `Setting up your encryption keys…`;
case LoadStatus.Loading: case LoadStatus.Loading:
@ -136,4 +152,13 @@ export class SessionLoadViewModel extends ViewModel {
const logExport = await this.logger.export(); const logExport = await this.logger.export();
this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`); 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;
}
} }

View file

@ -107,7 +107,7 @@ export class LoginViewModel extends ViewModel {
async attemptLogin(loginMethod) { async attemptLogin(loginMethod) {
this._setBusy(true); this._setBusy(true);
this._sessionContainer.startWithLogin(loginMethod); this._sessionContainer.startWithLogin(loginMethod, {inspectAccountSetup: true});
const loadStatus = this._sessionContainer.loadStatus; const loadStatus = this._sessionContainer.loadStatus;
const handle = loadStatus.waitFor(status => status !== LoadStatus.Login); const handle = loadStatus.waitFor(status => status !== LoadStatus.Login);
await handle.promise; await handle.promise;

View file

@ -54,12 +54,10 @@ export class GapTile extends SimpleTile {
} }
updatePreviousSibling(prev) { updatePreviousSibling(prev) {
console.log("GapTile.updatePreviousSibling", prev);
super.updatePreviousSibling(prev); super.updatePreviousSibling(prev);
const isAtTop = !prev; const isAtTop = !prev;
if (this._isAtTop !== isAtTop) { if (this._isAtTop !== isAtTop) {
this._isAtTop = isAtTop; this._isAtTop = isAtTop;
console.log("isAtTop", this._isAtTop);
this.emitChange("isAtTop"); this.emitChange("isAtTop");
} }
} }

View file

@ -15,18 +15,60 @@ limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel.js";
import {KeyType} from "../../../matrix/ssss/index.js";
import {createEnum} from "../../../utils/enum.js";
export const Status = createEnum("Enabled", "SetupKey", "SetupPhrase", "Pending");
export class SessionBackupViewModel extends ViewModel { export class SessionBackupViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
this._session = options.session; this._session = options.session;
this._showKeySetup = true;
this._error = null; this._error = null;
this._isBusy = false; this._isBusy = false;
this._dehydratedDeviceId = undefined;
this._status = undefined;
this._reevaluateStatus();
this.track(this._session.hasSecretStorageKey.subscribe(() => { this.track(this._session.hasSecretStorageKey.subscribe(() => {
this.emitChange("status"); if (this._reevaluateStatus()) {
this.emitChange("status");
}
})); }));
} }
_reevaluateStatus() {
if (this._isBusy) {
return false;
}
let status;
const hasSecretStorageKey = this._session.hasSecretStorageKey.get();
if (hasSecretStorageKey === true) {
status = this._session.sessionBackup ? Status.Enabled : Status.SetupKey;
} else if (hasSecretStorageKey === false) {
status = Status.SetupKey;
} else {
status = Status.Pending;
}
const changed = status !== this._status;
this._status = status;
return changed;
}
get decryptAction() {
return this.i18n`Set up`;
}
get purpose() {
return this.i18n`set up session backup`;
}
offerDehydratedDeviceSetup() {
return true;
}
get dehydratedDeviceId() {
return this._dehydratedDeviceId;
}
get isBusy() { get isBusy() {
return this._isBusy; return this._isBusy;
@ -37,15 +79,7 @@ export class SessionBackupViewModel extends ViewModel {
} }
get status() { get status() {
if (this._session.sessionBackup) { return this._status;
return "enabled";
} else {
if (this._session.hasSecretStorageKey.get() === false) {
return this._showKeySetup ? "setupKey" : "setupPhrase";
} else {
return "pending";
}
}
} }
get error() { get error() {
@ -53,46 +87,61 @@ export class SessionBackupViewModel extends ViewModel {
} }
showPhraseSetup() { showPhraseSetup() {
this._showKeySetup = false; if (this._status === Status.SetupKey) {
this.emitChange("status"); this._status = Status.SetupPhrase;
this.emitChange("status");
}
} }
showKeySetup() { showKeySetup() {
this._showKeySetup = true; if (this._status === Status.SetupPhrase) {
this.emitChange("status"); this._status = Status.SetupKey;
this.emitChange("status");
}
} }
async enterSecurityPhrase(passphrase) { async _enterCredentials(keyType, credential, setupDehydratedDevice) {
if (passphrase) { if (credential) {
try { try {
this._isBusy = true; this._isBusy = true;
this.emitChange("isBusy"); 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) { } catch (err) {
console.error(err); console.error(err);
this._error = err; this._error = err;
this.emitChange("error"); this.emitChange("error");
} finally { } finally {
this._isBusy = false; this._isBusy = false;
this._reevaluateStatus();
this.emitChange(""); this.emitChange("");
} }
} }
} }
async enterSecurityKey(securityKey) { enterSecurityPhrase(passphrase, setupDehydratedDevice) {
if (securityKey) { this._enterCredentials(KeyType.Passphrase, passphrase, setupDehydratedDevice);
try { }
this._isBusy = true;
this.emitChange("isBusy"); enterSecurityKey(securityKey, setupDehydratedDevice) {
await this._session.enableSecretStorage("key", securityKey); this._enterCredentials(KeyType.RecoveryKey, securityKey, setupDehydratedDevice);
} catch (err) { }
console.error(err);
this._error = err; async disable() {
this.emitChange("error"); try {
} finally { this._isBusy = true;
this._isBusy = false; this.emitChange("isBusy");
this.emitChange(""); await this._session.disableSecretStorage();
} } catch (err) {
console.error(err);
this._error = err;
this.emitChange("error");
} finally {
this._isBusy = false;
this._reevaluateStatus();
this.emitChange("");
} }
} }
} }

View file

@ -57,16 +57,11 @@ export class SettingsViewModel extends ViewModel {
return this._sessionContainer.session; return this._sessionContainer.session;
} }
logout() { async logout() {
return this.logger.run("logout", async log => { this._isLoggingOut = true;
this._isLoggingOut = true; await this._sessionContainer.logout();
this.emitChange("isLoggingOut"); this.emitChange("isLoggingOut");
try { this.navigation.push("session", true);
await this._session.logout(log);
} catch (err) {}
await this._sessionContainer.deleteSession(log);
this.navigation.push("session", true);
});
} }
get isLoggingOut() { return this._isLoggingOut; } get isLoggingOut() { return this._isLoggingOut; }

View file

@ -21,7 +21,6 @@ export class ConsoleLogger extends BaseLogger {
} }
} }
const excludedKeysFromTable = ["l", "id"]; const excludedKeysFromTable = ["l", "id"];
function filterValues(values) { function filterValues(values) {
if (!values) { if (!values) {
@ -79,7 +78,7 @@ function itemCaption(item) {
} else if (item._values.l && item.error) { } else if (item._values.l && item.error) {
return `${item._values.l} failed`; return `${item._values.l} failed`;
} else if (typeof item._values.ref !== "undefined") { } else if (typeof item._values.ref !== "undefined") {
return `ref ${item._values.ref}` return `ref ${item._values.ref}`;
} else { } else {
return item._values.l || item._values.type; return item._values.l || item._values.type;
} }

View file

@ -24,6 +24,7 @@ import { ObservableMap } from "../observable/index.js";
import {User} from "./User.js"; import {User} from "./User.js";
import {DeviceMessageHandler} from "./DeviceMessageHandler.js"; import {DeviceMessageHandler} from "./DeviceMessageHandler.js";
import {Account as E2EEAccount} from "./e2ee/Account.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 {Decryption as OlmDecryption} from "./e2ee/olm/Decryption.js";
import {Encryption as OlmEncryption} from "./e2ee/olm/Encryption.js"; import {Encryption as OlmEncryption} from "./e2ee/olm/Encryption.js";
import {Decryption as MegOlmDecryption} from "./e2ee/megolm/Decryption"; import {Decryption as MegOlmDecryption} from "./e2ee/megolm/Decryption";
@ -39,6 +40,8 @@ import {
keyFromCredential as ssssKeyFromCredential, keyFromCredential as ssssKeyFromCredential,
readKey as ssssReadKey, readKey as ssssReadKey,
writeKey as ssssWriteKey, writeKey as ssssWriteKey,
removeKey as ssssRemoveKey,
keyFromDehydratedDeviceKey as createSSSSKeyFromDehydratedDeviceKey
} from "./ssss/index.js"; } from "./ssss/index.js";
import {SecretStorage} from "./ssss/SecretStorage.js"; import {SecretStorage} from "./ssss/SecretStorage.js";
import {ObservableValue, RetainedObservableValue} from "../observable/ObservableValue"; import {ObservableValue, RetainedObservableValue} from "../observable/ObservableValue";
@ -106,9 +109,9 @@ export class Session {
return this._sessionInfo.userId; return this._sessionInfo.userId;
} }
/** @internal call SessionContainer.logout instead */
async logout(log = undefined) { async logout(log = undefined) {
const response = await this._hsApi.logout({log}).response(); await this._hsApi.logout({log}).response();
console.log("logout", response);
} }
// called once this._e2eeAccount is assigned // called once this._e2eeAccount is assigned
@ -203,6 +206,12 @@ export class Session {
this._storage.storeNames.accountData, this._storage.storeNames.accountData,
]); ]);
await this._createSessionBackup(key, readTxn); await this._createSessionBackup(key, readTxn);
await this._writeSSSSKey(key);
this._hasSecretStorageKey.set(true);
return key;
}
async _writeSSSSKey(key) {
// only after having read a secret, write the key // only after having read a secret, write the key
// as we only find out if it was good if the MAC verification succeeds // as we only find out if it was good if the MAC verification succeeds
const writeTxn = await this._storage.readWriteTxn([ const writeTxn = await this._storage.readWriteTxn([
@ -215,7 +224,29 @@ export class Session {
throw err; throw err;
} }
await writeTxn.complete(); await writeTxn.complete();
this._hasSecretStorageKey.set(true); }
async disableSecretStorage() {
const writeTxn = await this._storage.readWriteTxn([
this._storage.storeNames.session,
]);
try {
ssssRemoveKey(writeTxn);
} catch (err) {
writeTxn.abort();
throw err;
}
await writeTxn.complete();
if (this._sessionBackup) {
for (const room of this._rooms.values()) {
if (room.isEncrypted) {
room.enableSessionBackup(undefined);
}
}
this._sessionBackup?.dispose();
this._sessionBackup = undefined;
}
this._hasSecretStorageKey.set(false);
} }
async _createSessionBackup(ssssKey, txn) { async _createSessionBackup(ssssKey, txn) {
@ -248,23 +279,76 @@ export class Session {
async createIdentity(log) { async createIdentity(log) {
if (this._olm) { if (this._olm) {
if (!this._e2eeAccount) { if (!this._e2eeAccount) {
this._e2eeAccount = await E2EEAccount.create({ this._e2eeAccount = await this._createNewAccount(this._sessionInfo.deviceId, this._storage);
hsApi: this._hsApi,
olm: this._olm,
pickleKey: PICKLE_KEY,
userId: this._sessionInfo.userId,
deviceId: this._sessionInfo.deviceId,
olmWorker: this._olmWorker,
storage: this._storage,
});
log.set("keys", this._e2eeAccount.identityKeys); log.set("keys", this._e2eeAccount.identityKeys);
this._setupEncryption(); this._setupEncryption();
} }
await this._e2eeAccount.generateOTKsIfNeeded(this._storage, log); await this._e2eeAccount.generateOTKsIfNeeded(this._storage, log);
await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, log)); await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, false, log));
} }
} }
/** @internal */
async dehydrateIdentity(dehydratedDevice, log) {
log.set("deviceId", dehydratedDevice.deviceId);
if (!this._olm) {
log.set("no_olm", true);
return false;
}
if (dehydratedDevice.deviceId !== this.deviceId) {
log.set("wrong_device", true);
return false;
}
if (this._e2eeAccount) {
log.set("account_already_setup", true);
return false;
}
if (!await dehydratedDevice.claim(this._hsApi, log)) {
log.set("already_claimed", true);
return false;
}
this._e2eeAccount = await E2EEAccount.adoptDehydratedDevice({
dehydratedDevice,
hsApi: this._hsApi,
olm: this._olm,
pickleKey: PICKLE_KEY,
userId: this._sessionInfo.userId,
olmWorker: this._olmWorker,
deviceId: this.deviceId,
storage: this._storage,
});
log.set("keys", this._e2eeAccount.identityKeys);
this._setupEncryption();
return true;
}
_createNewAccount(deviceId, storage = undefined) {
// storage is optional and if omitted the account won't be persisted (useful for dehydrating devices)
return E2EEAccount.create({
hsApi: this._hsApi,
olm: this._olm,
pickleKey: PICKLE_KEY,
userId: this._sessionInfo.userId,
olmWorker: this._olmWorker,
deviceId,
storage,
});
}
setupDehydratedDevice(key, log = null) {
return this._platform.logger.wrapOrRun(log, "setupDehydratedDevice", async log => {
const dehydrationAccount = await this._createNewAccount("temp-device-id");
try {
const deviceId = await uploadAccountAsDehydratedDevice(
dehydrationAccount, this._hsApi, key, "Dehydrated device", log);
log.set("deviceId", deviceId);
return deviceId;
} finally {
dehydrationAccount.dispose();
}
});
}
/** @internal */ /** @internal */
async load(log) { async load(log) {
const txn = await this._storage.readTxn([ const txn = await this._storage.readTxn([
@ -321,11 +405,17 @@ export class Session {
dispose() { dispose() {
this._olmWorker?.dispose(); this._olmWorker?.dispose();
this._olmWorker = undefined;
this._sessionBackup?.dispose(); this._sessionBackup?.dispose();
this._megolmDecryption.dispose(); this._sessionBackup = undefined;
this._megolmDecryption?.dispose();
this._megolmDecryption = undefined;
this._e2eeAccount?.dispose();
this._e2eeAccount = undefined;
for (const room of this._rooms.values()) { for (const room of this._rooms.values()) {
room.dispose(); room.dispose();
} }
this._rooms = undefined;
} }
/** /**
@ -334,7 +424,7 @@ export class Session {
* and useful to store so we can later tell what capabilities * and useful to store so we can later tell what capabilities
* our homeserver has. * our homeserver has.
*/ */
async start(lastVersionResponse, log) { async start(lastVersionResponse, dehydratedDevice, log) {
if (lastVersionResponse) { if (lastVersionResponse) {
// store /versions response // store /versions response
const txn = await this._storage.readWriteTxn([ const txn = await this._storage.readWriteTxn([
@ -346,6 +436,15 @@ export class Session {
} }
// enable session backup, this requests the latest backup version // enable session backup, this requests the latest backup version
if (!this._sessionBackup) { if (!this._sessionBackup) {
if (dehydratedDevice) {
await log.wrap("SSSSKeyFromDehydratedDeviceKey", async log => {
const ssssKey = await createSSSSKeyFromDehydratedDeviceKey(dehydratedDevice.key, this._storage, this._platform);
if (ssssKey) {
log.set("success", true);
await this._writeSSSSKey(ssssKey);
}
})
}
const txn = await this._storage.readTxn([ const txn = await this._storage.readTxn([
this._storage.storeNames.session, this._storage.storeNames.session,
this._storage.storeNames.accountData, this._storage.storeNames.accountData,
@ -517,7 +616,7 @@ export class Session {
if (!isCatchupSync) { if (!isCatchupSync) {
const needsToUploadOTKs = await this._e2eeAccount.generateOTKsIfNeeded(this._storage, log); const needsToUploadOTKs = await this._e2eeAccount.generateOTKsIfNeeded(this._storage, log);
if (needsToUploadOTKs) { if (needsToUploadOTKs) {
await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, log)); await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, false, log));
} }
} }
} }

View file

@ -29,14 +29,17 @@ import {Session} from "./Session.js";
import {PasswordLoginMethod} from "./login/PasswordLoginMethod.js"; import {PasswordLoginMethod} from "./login/PasswordLoginMethod.js";
import {TokenLoginMethod} from "./login/TokenLoginMethod.js"; import {TokenLoginMethod} from "./login/TokenLoginMethod.js";
import {SSOLoginHelper} from "./login/SSOLoginHelper.js"; import {SSOLoginHelper} from "./login/SSOLoginHelper.js";
import {getDehydratedDevice} from "./e2ee/Dehydration.js";
export const LoadStatus = createEnum( export const LoadStatus = createEnum(
"NotLoading", "NotLoading",
"Login", "Login",
"LoginFailed", "LoginFailed",
"QueryAccount", // check for dehydrated device after login
"AccountSetup", // asked to restore from dehydrated device if present, call sc.accountSetup.finish() to progress to the next stage
"Loading", "Loading",
"SessionSetup", // upload e2ee keys, ... "SessionSetup", // upload e2ee keys, ...
"Migrating", //not used atm, but would fit here "Migrating", // not used atm, but would fit here
"FirstSync", "FirstSync",
"Error", "Error",
"Ready", "Ready",
@ -63,6 +66,7 @@ export class SessionContainer {
this._requestScheduler = null; this._requestScheduler = null;
this._olmPromise = olmPromise; this._olmPromise = olmPromise;
this._workerPromise = workerPromise; this._workerPromise = workerPromise;
this._accountSetup = undefined;
} }
createNewSessionId() { createNewSessionId() {
@ -85,7 +89,7 @@ export class SessionContainer {
if (!sessionInfo) { if (!sessionInfo) {
throw new Error("Invalid session id: " + sessionId); 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()); log.set("status", this._status.get());
} catch (err) { } catch (err) {
log.catch(err); log.catch(err);
@ -127,7 +131,7 @@ export class SessionContainer {
}); });
} }
async startWithLogin(loginMethod) { async startWithLogin(loginMethod, {inspectAccountSetup} = {}) {
const currentStatus = this._status.get(); const currentStatus = this._status.get();
if (currentStatus !== LoadStatus.LoginFailed && if (currentStatus !== LoadStatus.LoginFailed &&
currentStatus !== LoadStatus.NotLoading && currentStatus !== LoadStatus.NotLoading &&
@ -154,7 +158,6 @@ export class SessionContainer {
lastUsed: clock.now() lastUsed: clock.now()
}; };
log.set("id", sessionId); log.set("id", sessionId);
await this._platform.sessionInfoStorage.add(sessionInfo);
} catch (err) { } catch (err) {
this._error = err; this._error = err;
if (err.name === "HomeServerError") { if (err.name === "HomeServerError") {
@ -173,21 +176,31 @@ export class SessionContainer {
} }
return; return;
} }
let dehydratedDevice;
if (inspectAccountSetup) {
dehydratedDevice = await this._inspectAccountAfterLogin(sessionInfo, log);
if (dehydratedDevice) {
sessionInfo.deviceId = dehydratedDevice.deviceId;
}
}
await this._platform.sessionInfoStorage.add(sessionInfo);
// loading the session can only lead to // loading the session can only lead to
// LoadStatus.Error in case of an error, // LoadStatus.Error in case of an error,
// so separate try/catch // so separate try/catch
try { try {
await this._loadSessionInfo(sessionInfo, true, log); await this._loadSessionInfo(sessionInfo, dehydratedDevice, log);
log.set("status", this._status.get()); log.set("status", this._status.get());
} catch (err) { } catch (err) {
log.catch(err); log.catch(err);
// free olm Account that might be contained
dehydratedDevice?.dispose();
this._error = err; this._error = err;
this._status.set(LoadStatus.Error); this._status.set(LoadStatus.Error);
} }
}); });
} }
async _loadSessionInfo(sessionInfo, isNewLogin, log) { async _loadSessionInfo(sessionInfo, dehydratedDevice, log) {
log.set("appVersion", this._platform.version); log.set("appVersion", this._platform.version);
const clock = this._platform.clock; const clock = this._platform.clock;
this._sessionStartedByReconnector = false; this._sessionStartedByReconnector = false;
@ -233,7 +246,9 @@ export class SessionContainer {
platform: this._platform, platform: this._platform,
}); });
await this._session.load(log); await this._session.load(log);
if (!this._session.hasIdentity) { if (dehydratedDevice) {
await log.wrap("dehydrateIdentity", log => this._session.dehydrateIdentity(dehydratedDevice, log));
} else if (!this._session.hasIdentity) {
this._status.set(LoadStatus.SessionSetup); this._status.set(LoadStatus.SessionSetup);
await log.wrap("createIdentity", log => this._session.createIdentity(log)); await log.wrap("createIdentity", log => this._session.createIdentity(log));
} }
@ -247,7 +262,9 @@ export class SessionContainer {
this._requestScheduler.start(); this._requestScheduler.start();
this._sync.start(); this._sync.start();
this._sessionStartedByReconnector = true; this._sessionStartedByReconnector = true;
await log.wrap("session start", log => this._session.start(this._reconnector.lastVersionsResponse, log)); const d = dehydratedDevice;
dehydratedDevice = undefined;
await log.wrap("session start", log => this._session.start(this._reconnector.lastVersionsResponse, d, log));
}); });
} }
}); });
@ -266,8 +283,10 @@ export class SessionContainer {
if (this._isDisposed) { if (this._isDisposed) {
return; return;
} }
const d = dehydratedDevice;
dehydratedDevice = undefined;
// log as ref as we don't want to await it // log as ref as we don't want to await it
await log.wrap("session start", log => this._session.start(lastVersionsResponse, log)); await log.wrap("session start", log => this._session.start(lastVersionsResponse, d, log));
} }
} }
@ -300,6 +319,32 @@ export class SessionContainer {
} }
} }
_inspectAccountAfterLogin(sessionInfo, log) {
return log.wrap("inspectAccount", async log => {
this._status.set(LoadStatus.QueryAccount);
const hsApi = new HomeServerApi({
homeserver: sessionInfo.homeServer,
accessToken: sessionInfo.accessToken,
request: this._platform.request,
});
const olm = await this._olmPromise;
const encryptedDehydratedDevice = await getDehydratedDevice(hsApi, olm, this._platform, log);
if (encryptedDehydratedDevice) {
let resolveStageFinish;
const promiseStageFinish = new Promise(r => resolveStageFinish = r);
this._accountSetup = new AccountSetup(encryptedDehydratedDevice, resolveStageFinish);
this._status.set(LoadStatus.AccountSetup);
await promiseStageFinish;
const dehydratedDevice = this._accountSetup?._dehydratedDevice;
this._accountSetup = null;
return dehydratedDevice;
}
});
}
get accountSetup() {
return this._accountSetup;
}
get loadStatus() { get loadStatus() {
return this._status; return this._status;
@ -331,6 +376,15 @@ export class SessionContainer {
return !this._reconnector; 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() { dispose() {
if (this._reconnectSubscription) { if (this._reconnectSubscription) {
this._reconnectSubscription(); this._reconnectSubscription();
@ -339,12 +393,15 @@ export class SessionContainer {
this._reconnector = null; this._reconnector = null;
if (this._requestScheduler) { if (this._requestScheduler) {
this._requestScheduler.stop(); this._requestScheduler.stop();
this._requestScheduler = null;
} }
if (this._sync) { if (this._sync) {
this._sync.stop(); this._sync.stop();
this._sync = null;
} }
if (this._session) { if (this._session) {
this._session.dispose(); this._session.dispose();
this._session = null;
} }
if (this._waitForFirstSyncHandle) { if (this._waitForFirstSyncHandle) {
this._waitForFirstSyncHandle.dispose(); this._waitForFirstSyncHandle.dispose();
@ -378,3 +435,20 @@ export class SessionContainer {
this._loginFailure = null; 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();
}
}

View file

@ -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 DEVICE_KEY_FLAG_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "areDeviceKeysUploaded";
const SERVER_OTK_COUNT_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "serverOTKCount"; 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 { export class Account {
static async load({olm, pickleKey, hsApi, userId, deviceId, olmWorker, txn}) { static async load({olm, pickleKey, hsApi, userId, deviceId, olmWorker, txn}) {
const pickledAccount = await txn.session.get(ACCOUNT_SESSION_KEY); const pickledAccount = await txn.session.get(ACCOUNT_SESSION_KEY);
@ -35,6 +53,21 @@ export class Account {
} }
} }
static async adoptDehydratedDevice({olm, dehydratedDevice, pickleKey, hsApi, userId, olmWorker, storage}) {
const account = dehydratedDevice.adoptUnpickledOlmAccount();
const oneTimeKeys = JSON.parse(account.one_time_keys());
// only one algorithm supported by olm atm, so hardcode its name
const oneTimeKeysEntries = Object.entries(oneTimeKeys.curve25519);
const serverOTKCount = oneTimeKeysEntries.length;
const areDeviceKeysUploaded = true;
await initiallyStoreAccount(account, pickleKey, areDeviceKeysUploaded, serverOTKCount, storage);
return new Account({
pickleKey, hsApi, account, userId,
deviceId: dehydratedDevice.deviceId,
areDeviceKeysUploaded, serverOTKCount, olm, olmWorker
});
}
static async create({olm, pickleKey, hsApi, userId, deviceId, olmWorker, storage}) { static async create({olm, pickleKey, hsApi, userId, deviceId, olmWorker, storage}) {
const account = new olm.Account(); const account = new olm.Account();
if (olmWorker) { if (olmWorker) {
@ -43,24 +76,13 @@ export class Account {
account.create(); account.create();
account.generate_one_time_keys(account.max_number_of_one_time_keys()); account.generate_one_time_keys(account.max_number_of_one_time_keys());
} }
const pickledAccount = account.pickle(pickleKey);
const areDeviceKeysUploaded = false; const areDeviceKeysUploaded = false;
const txn = await storage.readWriteTxn([ const serverOTKCount = 0;
storage.storeNames.session if (storage) {
]); await initiallyStoreAccount(account, pickleKey, areDeviceKeysUploaded, serverOTKCount, storage);
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;
} }
await txn.complete();
return new Account({pickleKey, hsApi, account, userId, 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}) { constructor({pickleKey, hsApi, account, userId, deviceId, areDeviceKeysUploaded, serverOTKCount, olm, olmWorker}) {
@ -80,7 +102,11 @@ export class Account {
return this._identityKeys; return this._identityKeys;
} }
async uploadKeys(storage, log) { setDeviceId(deviceId) {
this._deviceId = deviceId;
}
async uploadKeys(storage, isDehydratedDevice, log) {
const oneTimeKeys = JSON.parse(this._account.one_time_keys()); const oneTimeKeys = JSON.parse(this._account.one_time_keys());
// only one algorithm supported by olm atm, so hardcode its name // only one algorithm supported by olm atm, so hardcode its name
const oneTimeKeysEntries = Object.entries(oneTimeKeys.curve25519); const oneTimeKeysEntries = Object.entries(oneTimeKeys.curve25519);
@ -95,7 +121,8 @@ export class Account {
log.set("otks", true); log.set("otks", true);
payload.one_time_keys = this._oneTimeKeysPayload(oneTimeKeysEntries); payload.one_time_keys = this._oneTimeKeysPayload(oneTimeKeysEntries);
} }
const response = await this._hsApi.uploadKeys(payload, {log}).response(); const dehydratedDeviceId = isDehydratedDevice ? this._deviceId : undefined;
const response = await this._hsApi.uploadKeys(dehydratedDeviceId, payload, {log}).response();
this._serverOTKCount = response?.one_time_key_counts?.signed_curve25519; this._serverOTKCount = response?.one_time_key_counts?.signed_curve25519;
log.set("serverOTKCount", this._serverOTKCount); log.set("serverOTKCount", this._serverOTKCount);
// TODO: should we not modify this in the txn like we do elsewhere? // TODO: should we not modify this in the txn like we do elsewhere?
@ -105,12 +132,12 @@ export class Account {
await this._updateSessionStorage(storage, sessionStore => { await this._updateSessionStorage(storage, sessionStore => {
if (oneTimeKeysEntries.length) { if (oneTimeKeysEntries.length) {
this._account.mark_keys_as_published(); this._account.mark_keys_as_published();
sessionStore.set(ACCOUNT_SESSION_KEY, this._account.pickle(this._pickleKey)); sessionStore?.set(ACCOUNT_SESSION_KEY, this._account.pickle(this._pickleKey));
sessionStore.set(SERVER_OTK_COUNT_SESSION_KEY, this._serverOTKCount); sessionStore?.set(SERVER_OTK_COUNT_SESSION_KEY, this._serverOTKCount);
} }
if (!this._areDeviceKeysUploaded) { if (!this._areDeviceKeysUploaded) {
this._areDeviceKeysUploaded = true; this._areDeviceKeysUploaded = true;
sessionStore.set(DEVICE_KEY_FLAG_SESSION_KEY, this._areDeviceKeysUploaded); sessionStore?.set(DEVICE_KEY_FLAG_SESSION_KEY, this._areDeviceKeysUploaded);
} }
}); });
} }
@ -246,16 +273,20 @@ export class Account {
} }
async _updateSessionStorage(storage, callback) { async _updateSessionStorage(storage, callback) {
const txn = await storage.readWriteTxn([ if (storage) {
storage.storeNames.session const txn = await storage.readWriteTxn([
]); storage.storeNames.session
try { ]);
await callback(txn.session); try {
} catch (err) { await callback(txn.session);
txn.abort(); } catch (err) {
throw err; txn.abort();
throw err;
}
await txn.complete();
} else {
await callback(undefined);
} }
await txn.complete();
} }
signObject(obj) { signObject(obj) {
@ -273,4 +304,13 @@ export class Account {
obj.unsigned = unsigned; obj.unsigned = unsigned;
} }
} }
pickleWithKey(key) {
return this._account.pickle(key);
}
dispose() {
this._account.free();
this._account = undefined;
}
} }

View file

@ -0,0 +1,115 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const DEHYDRATION_LIBOLM_PICKLE_ALGORITHM = "org.matrix.msc2697.v1.olm.libolm_pickle";
import {KeyDescription} from "../ssss/common.js";
import {keyFromCredentialAndDescription} from "../ssss/index.js";
export async function getDehydratedDevice(hsApi, olm, platform, log) {
try {
const response = await hsApi.getDehydratedDevice({log}).response();
if (response.device_data.algorithm === DEHYDRATION_LIBOLM_PICKLE_ALGORITHM) {
return new EncryptedDehydratedDevice(response, olm, platform);
}
} catch (err) {
if (err.name !== "HomeServerError") {
log.error = err;
}
return undefined;
}
}
export async function uploadAccountAsDehydratedDevice(account, hsApi, key, deviceDisplayName, log) {
const response = await hsApi.createDehydratedDevice({
device_data: {
algorithm: DEHYDRATION_LIBOLM_PICKLE_ALGORITHM,
account: account.pickleWithKey(key.binaryKey),
passphrase: key.description?.passphraseParams || {},
},
initial_device_display_name: deviceDisplayName
}).response();
const deviceId = response.device_id;
account.setDeviceId(deviceId);
await account.uploadKeys(undefined, true, log);
return deviceId;
}
class EncryptedDehydratedDevice {
constructor(dehydratedDevice, olm, platform) {
this._dehydratedDevice = dehydratedDevice;
this._olm = olm;
this._platform = platform;
}
async decrypt(keyType, credential) {
const keyDescription = new KeyDescription("dehydrated_device", this._dehydratedDevice.device_data.passphrase);
const key = await keyFromCredentialAndDescription(keyType, credential, keyDescription, this._platform, this._olm);
const account = new this._olm.Account();
try {
const pickledAccount = this._dehydratedDevice.device_data.account;
account.unpickle(key.binaryKey.slice(), pickledAccount);
return new DehydratedDevice(this._dehydratedDevice, account, key);
} catch (err) {
account.free();
if (err.message === "OLM.BAD_ACCOUNT_KEY") {
return undefined;
} else {
throw err;
}
}
}
get deviceId() {
return this._dehydratedDevice.device_id;
}
}
class DehydratedDevice {
constructor(dehydratedDevice, account, key) {
this._dehydratedDevice = dehydratedDevice;
this._account = account;
this._key = key;
}
async claim(hsApi, log) {
try {
const response = await hsApi.claimDehydratedDevice(this.deviceId, {log}).response();
return response.success;
} catch (err) {
return false;
}
}
// make it clear that ownership is transfered upon calling this
adoptUnpickledOlmAccount() {
const account = this._account;
this._account = undefined;
return account;
}
get deviceId() {
return this._dehydratedDevice.device_id;
}
get key() {
return this._key;
}
dispose() {
this._account?.free();
this._account = undefined;
}
}

View file

@ -49,7 +49,7 @@ export class RoomEncryption {
} }
enableSessionBackup(sessionBackup) { enableSessionBackup(sessionBackup) {
if (this._sessionBackup) { if (this._sessionBackup && !!sessionBackup) {
return; return;
} }
this._sessionBackup = sessionBackup; this._sessionBackup = sessionBackup;

View file

@ -212,6 +212,7 @@ class KeyOperation {
dispose() { dispose() {
this.session.free(); this.session.free();
this.session = undefined as any;
} }
/** returns whether the key for this operation has been checked at some point against storage /** returns whether the key for this operation has been checked at some point against storage

View file

@ -18,6 +18,9 @@ limitations under the License.
import {encodeQueryParams, encodeBody} from "./common.js"; import {encodeQueryParams, encodeBody} from "./common.js";
import {HomeServerRequest} from "./HomeServerRequest.js"; import {HomeServerRequest} from "./HomeServerRequest.js";
const CS_R0_PREFIX = "/_matrix/client/r0";
const DEHYDRATION_PREFIX = "/_matrix/client/unstable/org.matrix.msc2697.v2";
export class HomeServerApi { export class HomeServerApi {
constructor({homeserver, accessToken, request, reconnector}) { constructor({homeserver, accessToken, request, reconnector}) {
// store these both in a closure somehow so it's harder to get at in case of XSS? // 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; this._reconnector = reconnector;
} }
_url(csPath) { _url(csPath, prefix = CS_R0_PREFIX) {
return `${this._homeserver}/_matrix/client/r0${csPath}`; return this._homeserver + prefix + csPath;
} }
_baseRequest(method, url, queryParams, body, options, accessToken) { _baseRequest(method, url, queryParams, body, options, accessToken) {
@ -92,15 +95,15 @@ export class HomeServerApi {
} }
_post(csPath, queryParams, body, options) { _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) { _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) { _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) { 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); return this._unauthedRequest("GET", `${this._homeserver}/_matrix/client/versions`, null, null, options);
} }
uploadKeys(payload, options = null) { uploadKeys(dehydratedDeviceId, payload, options = null) {
return this._post("/keys/upload", null, payload, options); let path = "/keys/upload";
if (dehydratedDeviceId) {
path = path + `/${encodeURIComponent(dehydratedDeviceId)}`;
}
return this._post(path, null, payload, options);
} }
queryKeys(queryRequest, options = null) { queryKeys(queryRequest, options = null) {
@ -229,6 +236,21 @@ export class HomeServerApi {
logout(options = null) { logout(options = null) {
return this._post(`/logout`, null, null, options); return this._post(`/logout`, null, null, options);
} }
getDehydratedDevice(options = {}) {
options.prefix = DEHYDRATION_PREFIX;
return this._get(`/dehydrated_device`, null, null, options);
}
createDehydratedDevice(payload, options = {}) {
options.prefix = DEHYDRATION_PREFIX;
return this._put(`/dehydrated_device`, null, payload, options);
}
claimDehydratedDevice(deviceId, options = {}) {
options.prefix = DEHYDRATION_PREFIX;
return this._post(`/dehydrated_device/claim`, null, {device_id: deviceId}, options);
}
} }
import {Request as MockRequest} from "../../mocks/Request.js"; import {Request as MockRequest} from "../../mocks/Request.js";

View file

@ -463,7 +463,7 @@ export class BaseRoom extends EventEmitter {
enableSessionBackup(sessionBackup) { enableSessionBackup(sessionBackup) {
this._roomEncryption?.enableSessionBackup(sessionBackup); this._roomEncryption?.enableSessionBackup(sessionBackup);
// TODO: do we really want to do this every time you open the app? // 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 => { this._platform.logger.run("enableSessionBackup", log => {
return this._roomEncryption.restoreMissingSessionsFromBackup(this._timeline.remoteEntries, log); return this._roomEncryption.restoreMissingSessionsFromBackup(this._timeline.remoteEntries, log);
}); });

View file

@ -15,9 +15,9 @@ limitations under the License.
*/ */
export class KeyDescription { export class KeyDescription {
constructor(id, keyAccountData) { constructor(id, keyDescription) {
this._id = id; this._id = id;
this._keyAccountData = keyAccountData; this._keyDescription = keyDescription;
} }
get id() { get id() {
@ -25,11 +25,30 @@ export class KeyDescription {
} }
get passphraseParams() { get passphraseParams() {
return this._keyAccountData?.content?.passphrase; return this._keyDescription?.passphrase;
} }
get algorithm() { get algorithm() {
return this._keyAccountData?.content?.algorithm; return this._keyDescription?.algorithm;
}
async isCompatible(key, platform) {
if (this.algorithm === "m.secret_storage.v1.aes-hmac-sha2") {
const kd = this._keyDescription;
if (kd.mac) {
const otherMac = await calculateKeyMac(key.binaryKey, kd.iv, platform);
return kd.mac === otherMac;
} else if (kd.passphrase) {
const kdOther = key.description._keyDescription;
if (!kdOther.passphrase) {
return false;
}
return kd.passphrase.algorithm === kdOther.passphrase.algorithm &&
kd.passphrase.iterations === kdOther.passphrase.iterations &&
kd.passphrase.salt === kdOther.passphrase.salt;
}
}
return false;
} }
} }
@ -39,6 +58,14 @@ export class Key {
this._binaryKey = binaryKey; this._binaryKey = binaryKey;
} }
withDescription(description) {
return new Key(description, this._binaryKey);
}
get description() {
return this._keyDescription;
}
get id() { get id() {
return this._keyDescription.id; return this._keyDescription.id;
} }
@ -51,3 +78,24 @@ export class Key {
return this._keyDescription.algorithm; 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);
}

View file

@ -18,9 +18,12 @@ import {KeyDescription, Key} from "./common.js";
import {keyFromPassphrase} from "./passphrase.js"; import {keyFromPassphrase} from "./passphrase.js";
import {keyFromRecoveryKey} from "./recoveryKey.js"; import {keyFromRecoveryKey} from "./recoveryKey.js";
import {SESSION_E2EE_KEY_PREFIX} from "../e2ee/common.js"; import {SESSION_E2EE_KEY_PREFIX} from "../e2ee/common.js";
import {createEnum} from "../../utils/enum.js";
const SSSS_KEY = `${SESSION_E2EE_KEY_PREFIX}ssssKey`; const SSSS_KEY = `${SESSION_E2EE_KEY_PREFIX}ssssKey`;
export const KeyType = createEnum("RecoveryKey", "Passphrase");
async function readDefaultKeyDescription(storage) { async function readDefaultKeyDescription(storage) {
const txn = await storage.readTxn([ const txn = await storage.readTxn([
storage.storeNames.accountData storage.storeNames.accountData
@ -34,7 +37,7 @@ async function readDefaultKeyDescription(storage) {
if (!keyAccountData) { if (!keyAccountData) {
return; return;
} }
return new KeyDescription(id, keyAccountData); return new KeyDescription(id, keyAccountData.content);
} }
export async function writeKey(key, txn) { export async function writeKey(key, txn) {
@ -47,7 +50,14 @@ export async function readKey(txn) {
return; return;
} }
const keyAccountData = await txn.accountData.get(`m.secret_storage.key.${keyData.id}`); const keyAccountData = await txn.accountData.get(`m.secret_storage.key.${keyData.id}`);
return new Key(new KeyDescription(keyData.id, keyAccountData), keyData.binaryKey); if (keyAccountData) {
return new Key(new KeyDescription(keyData.id, keyAccountData.content), keyData.binaryKey);
}
}
export async function removeKey(txn) {
await txn.session.remove(SSSS_KEY);
} }
export async function keyFromCredential(type, credential, storage, platform, olm) { export async function keyFromCredential(type, credential, storage, platform, olm) {
@ -55,13 +65,24 @@ export async function keyFromCredential(type, credential, storage, platform, olm
if (!keyDescription) { if (!keyDescription) {
throw new Error("Could not find a default secret storage key in account data"); 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; let key;
if (type === "phrase") { if (type === KeyType.Passphrase) {
key = await keyFromPassphrase(keyDescription, credential, platform); key = await keyFromPassphrase(keyDescription, credential, platform);
} else if (type === "key") { } else if (type === KeyType.RecoveryKey) {
key = keyFromRecoveryKey(keyDescription, credential, olm, platform); key = keyFromRecoveryKey(keyDescription, credential, olm, platform);
} else { } else {
throw new Error(`Invalid type: ${type}`); throw new Error(`Invalid type: ${type}`);
} }
return key; return key;
} }
export async function keyFromDehydratedDeviceKey(key, storage, platform) {
const keyDescription = await readDefaultKeyDescription(storage);
if (await keyDescription.isCompatible(key, platform)) {
return key.withDescription(keyDescription);
}
}

View file

@ -54,15 +54,12 @@ limitations under the License.
padding: 0 0.4em 0.4em; padding: 0 0.4em 0.4em;
} }
.SessionLoadStatusView, .LoginView_query-spinner { .SessionLoadStatusView > .status, .LoginView_query-spinner {
display: flex; display: flex;
gap: 12px;
} }
.SessionLoadStatusView> :not(:first-child), .LoginView_query-spinner> :not(:first-child) { .SessionLoadStatusView > .status p, .LoginView_query-spinner p {
margin-left: 12px;
}
.SessionLoadStatusView p, .LoginView_query-spinner p {
flex: 1; flex: 1;
margin: 0; margin: 0;
} }

View file

@ -629,6 +629,19 @@ a {
.Settings .row .label { .Settings .row .label {
flex: 0 0 200px; 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 { .error {

View file

@ -0,0 +1,41 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {TemplateView} from "../general/TemplateView";
import {SessionBackupSettingsView} from "../session/settings/SessionBackupSettingsView.js";
export class AccountSetupView extends TemplateView {
render(t, vm) {
return t.div({className: "Settings" /* hack for now to get the layout right*/}, [
t.h3(vm.i18n`Restore your encrypted history?`),
t.ifView(vm => vm.decryptDehydratedDeviceViewModel, vm => new SessionBackupSettingsView(vm.decryptDehydratedDeviceViewModel)),
t.map(vm => vm.deviceDecrypted, (decrypted, t) => {
if (decrypted) {
return t.p(vm.i18n`That worked out, you're good to go!`);
} else {
return t.p(vm.i18n`This will claim the dehydrated device ${vm.dehydratedDeviceId}, and will set up a new one.`);
}
}),
t.div({ className: "button-row" }, [
t.button({
className: "button-action primary",
onClick: () => { vm.finish(); },
type: "button",
}, vm => vm.deviceDecrypted ? vm.i18n`Continue` : vm.i18n`Continue without restoring`),
]),
]);
}
}

View file

@ -16,6 +16,7 @@ limitations under the License.
import {TemplateView} from "../general/TemplateView"; import {TemplateView} from "../general/TemplateView";
import {spinner} from "../common.js"; import {spinner} from "../common.js";
import {AccountSetupView} from "./AccountSetupView.js";
/** a view used both in the login view and the loading screen /** a view used both in the login view and the loading screen
to show the current state of loading the session. to show the current state of loading the session.
@ -27,9 +28,19 @@ export class SessionLoadStatusView extends TemplateView {
onClick: () => vm.exportLogs() onClick: () => vm.exportLogs()
}, vm.i18n`Export logs`); }, 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"}, [ return t.div({className: "SessionLoadStatusView"}, [
spinner(t, {hiddenWithLayout: vm => !vm.loading}), t.p({className: "status"}, [
t.p([vm => vm.loadLabel, exportLogsButtonIfFailed]) spinner(t, {hidden: vm => !vm.loading}),
t.p(vm => vm.loadLabel),
exportLogsButtonIfFailed,
logoutButtonIfFailed
]),
t.ifView(vm => vm.accountSetupViewModel, vm => new AccountSetupView(vm.accountSetupViewModel)),
]); ]);
} }
} }

View file

@ -19,30 +19,6 @@ import {TemplateView} from "../general/TemplateView";
import {hydrogenGithubLink} from "./common.js"; import {hydrogenGithubLink} from "./common.js";
import {SessionLoadStatusView} from "./SessionLoadStatusView.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 { class SessionPickerItemView extends TemplateView {
_onDeleteClick() { _onDeleteClick() {
if (confirm("Are you sure?")) { if (confirm("Are you sure?")) {
@ -81,10 +57,6 @@ export class SessionPickerView extends TemplateView {
t.h1(["Continue as …"]), t.h1(["Continue as …"]),
t.view(sessionList), t.view(sessionList),
t.div({className: "button-row"}, [ 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({ t.a({
className: "button-action primary", className: "button-action primary",
href: vm.cancelUrl href: vm.cancelUrl

View file

@ -21,25 +21,31 @@ export class SessionBackupSettingsView extends TemplateView {
render(t, vm) { render(t, vm) {
return t.mapView(vm => vm.status, status => { return t.mapView(vm => vm.status, status => {
switch (status) { switch (status) {
case "enabled": return new TemplateView(vm, renderEnabled) case "Enabled": return new TemplateView(vm, renderEnabled)
case "setupKey": return new TemplateView(vm, renderEnableFromKey) case "SetupKey": return new TemplateView(vm, renderEnableFromKey)
case "setupPhrase": return new TemplateView(vm, renderEnableFromPhrase) case "SetupPhrase": return new TemplateView(vm, renderEnableFromPhrase)
case "pending": return new StaticView(vm, t => t.p(vm.i18n`Waiting to go online…`)) case "Pending": return new StaticView(vm, t => t.p(vm.i18n`Waiting to go online…`))
} }
}); });
} }
} }
function renderEnabled(t, vm) { 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) { function renderEnableFromKey(t, vm) {
const useASecurityPhrase = t.button({className: "link", onClick: () => vm.showPhraseSetup()}, vm.i18n`use a security phrase`); const useASecurityPhrase = t.button({className: "link", onClick: () => vm.showPhraseSetup()}, vm.i18n`use a security phrase`);
return t.div([ 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), 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.`]), t.p([vm.i18n`Alternatively, you can `, useASecurityPhrase, vm.i18n` if you have one.`]),
]); ]);
} }
@ -47,22 +53,34 @@ function renderEnableFromKey(t, vm) {
function renderEnableFromPhrase(t, vm) { function renderEnableFromPhrase(t, vm) {
const useASecurityKey = t.button({className: "link", onClick: () => vm.showKeySetup()}, vm.i18n`use your security key`); const useASecurityKey = t.button({className: "link", onClick: () => vm.showKeySetup()}, vm.i18n`use your security key`);
return t.div([ 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), 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`.`]), t.p([vm.i18n`You can also `, useASecurityKey, vm.i18n`.`]),
]); ]);
} }
function renderEnableFieldRow(t, vm, label, callback) { function renderEnableFieldRow(t, vm, label, callback) {
const eventHandler = () => callback(input.value); let setupDehydrationCheck;
const input = t.input({type: "password", disabled: vm => vm.isBusy, placeholder: label, onChange: eventHandler}); 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`}, [ return t.div({className: `row`}, [
t.div({className: "label"}, label), t.div({className: "label"}, label),
t.div({className: "content"}, [ t.div({className: "content"}, children),
input,
t.button({disabled: vm => vm.isBusy, onClick: eventHandler}, vm.i18n`Set up`),
]),
]); ]);
} }

View file

@ -48,7 +48,7 @@ export class SettingsView extends TemplateView {
} }
}, },
disabled: vm => vm.isLoggingOut disabled: vm => vm.isLoggingOut
}, vm.i18n`Log out`)) }, vm.i18n`Log out`)),
); );
settingNodes.push( settingNodes.push(
t.h3("Session Backup"), t.h3("Session Backup"),
@ -91,7 +91,7 @@ export class SettingsView extends TemplateView {
}), }),
t.map(vm => vm.pushNotifications.serverError, (err, t) => { t.map(vm => vm.pushNotifications.serverError, (err, t) => {
if (err) { if (err) {
return t.p("Couln't not check on server: " + err.message); return t.p("Couldn't not check on server: " + err.message);
} }
}) })
]); ]);