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.
*/
import {AccountSetupViewModel} from "./AccountSetupViewModel.js";
import {LoadStatus} from "../matrix/SessionContainer.js";
import {SyncStatus} from "../matrix/Sync.js";
import {ViewModel} from "./ViewModel.js";
@ -29,6 +30,8 @@ export class SessionLoadViewModel extends ViewModel {
this._loading = false;
this._error = null;
this.backUrl = this.urlCreator.urlForSegment("session", true);
this._accountSetupViewModel = undefined;
}
async start() {
@ -39,6 +42,11 @@ export class SessionLoadViewModel extends ViewModel {
this._loading = true;
this.emitChange("loading");
this._waitHandle = this._sessionContainer.loadStatus.waitFor(s => {
if (s === LoadStatus.AccountSetup) {
this._accountSetupViewModel = new AccountSetupViewModel(this._sessionContainer.accountSetup);
} else {
this._accountSetupViewModel = undefined;
}
this.emitChange("loadLabel");
// wait for initial sync, but not catchup sync
const isCatchupSync = s === LoadStatus.FirstSync &&
@ -97,6 +105,10 @@ export class SessionLoadViewModel extends ViewModel {
// to show a spinner or not
get loading() {
const sc = this._sessionContainer;
if (sc && sc.loadStatus.get() === LoadStatus.AccountSetup) {
return false;
}
return this._loading;
}
@ -110,6 +122,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.AccountSetup:
return ""; // we'll show a header ing AccountSetupView
case LoadStatus.SessionSetup:
return `Setting up your encryption keys…`;
case LoadStatus.Loading:
@ -136,4 +152,13 @@ export class SessionLoadViewModel extends ViewModel {
const logExport = await this.logger.export();
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) {
this._setBusy(true);
this._sessionContainer.startWithLogin(loginMethod);
this._sessionContainer.startWithLogin(loginMethod, {inspectAccountSetup: true});
const loadStatus = this._sessionContainer.loadStatus;
const handle = loadStatus.waitFor(status => status !== LoadStatus.Login);
await handle.promise;

View file

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

View file

@ -15,18 +15,60 @@ limitations under the License.
*/
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 {
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`;
}
get purpose() {
return this.i18n`set up session backup`;
}
offerDehydratedDeviceSetup() {
return true;
}
get dehydratedDeviceId() {
return this._dehydratedDeviceId;
}
get isBusy() {
return this._isBusy;
@ -37,15 +79,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 +87,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("");
}
}
}

View file

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

View file

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

View file

@ -24,6 +24,7 @@ import { ObservableMap } from "../observable/index.js";
import {User} from "./User.js";
import {DeviceMessageHandler} from "./DeviceMessageHandler.js";
import {Account as E2EEAccount} from "./e2ee/Account.js";
import {uploadAccountAsDehydratedDevice} from "./e2ee/Dehydration.js";
import {Decryption as OlmDecryption} from "./e2ee/olm/Decryption.js";
import {Encryption as OlmEncryption} from "./e2ee/olm/Encryption.js";
import {Decryption as MegOlmDecryption} from "./e2ee/megolm/Decryption";
@ -39,6 +40,8 @@ import {
keyFromCredential as ssssKeyFromCredential,
readKey as ssssReadKey,
writeKey as ssssWriteKey,
removeKey as ssssRemoveKey,
keyFromDehydratedDeviceKey as createSSSSKeyFromDehydratedDeviceKey
} from "./ssss/index.js";
import {SecretStorage} from "./ssss/SecretStorage.js";
import {ObservableValue, RetainedObservableValue} from "../observable/ObservableValue";
@ -106,9 +109,9 @@ export class Session {
return this._sessionInfo.userId;
}
/** @internal call SessionContainer.logout instead */
async logout(log = undefined) {
const response = await this._hsApi.logout({log}).response();
console.log("logout", response);
await this._hsApi.logout({log}).response();
}
// called once this._e2eeAccount is assigned
@ -203,6 +206,12 @@ export class Session {
this._storage.storeNames.accountData,
]);
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
// as we only find out if it was good if the MAC verification succeeds
const writeTxn = await this._storage.readWriteTxn([
@ -215,7 +224,29 @@ export class Session {
throw err;
}
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) {
@ -248,23 +279,76 @@ 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 = await 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, 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 */
async load(log) {
const txn = await this._storage.readTxn([
@ -321,11 +405,17 @@ export class Session {
dispose() {
this._olmWorker?.dispose();
this._olmWorker = undefined;
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()) {
room.dispose();
}
this._rooms = undefined;
}
/**
@ -334,7 +424,7 @@ export class Session {
* and useful to store so we can later tell what capabilities
* our homeserver has.
*/
async start(lastVersionResponse, log) {
async start(lastVersionResponse, dehydratedDevice, log) {
if (lastVersionResponse) {
// store /versions response
const txn = await this._storage.readWriteTxn([
@ -346,6 +436,15 @@ export class Session {
}
// enable session backup, this requests the latest backup version
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([
this._storage.storeNames.session,
this._storage.storeNames.accountData,
@ -517,7 +616,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, false, log));
}
}
}

View file

@ -29,14 +29,17 @@ import {Session} from "./Session.js";
import {PasswordLoginMethod} from "./login/PasswordLoginMethod.js";
import {TokenLoginMethod} from "./login/TokenLoginMethod.js";
import {SSOLoginHelper} from "./login/SSOLoginHelper.js";
import {getDehydratedDevice} from "./e2ee/Dehydration.js";
export const LoadStatus = createEnum(
"NotLoading",
"Login",
"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",
"SessionSetup", // upload e2ee keys, ...
"Migrating", //not used atm, but would fit here
"Migrating", // not used atm, but would fit here
"FirstSync",
"Error",
"Ready",
@ -63,6 +66,7 @@ export class SessionContainer {
this._requestScheduler = null;
this._olmPromise = olmPromise;
this._workerPromise = workerPromise;
this._accountSetup = undefined;
}
createNewSessionId() {
@ -85,7 +89,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);
@ -127,7 +131,7 @@ export class SessionContainer {
});
}
async startWithLogin(loginMethod) {
async startWithLogin(loginMethod, {inspectAccountSetup} = {}) {
const currentStatus = this._status.get();
if (currentStatus !== LoadStatus.LoginFailed &&
currentStatus !== LoadStatus.NotLoading &&
@ -154,7 +158,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,21 +176,31 @@ export class SessionContainer {
}
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
// 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);
// free olm Account that might be contained
dehydratedDevice?.dispose();
this._error = err;
this._status.set(LoadStatus.Error);
}
});
}
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 +246,9 @@ export class SessionContainer {
platform: this._platform,
});
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);
await log.wrap("createIdentity", log => this._session.createIdentity(log));
}
@ -247,7 +262,9 @@ export class SessionContainer {
this._requestScheduler.start();
this._sync.start();
this._sessionStartedByReconnector = true;
await log.wrap("session start", log => this._session.start(this._reconnector.lastVersionsResponse, log));
const d = dehydratedDevice;
dehydratedDevice = undefined;
await log.wrap("session start", log => this._session.start(this._reconnector.lastVersionsResponse, d, log));
});
}
});
@ -266,8 +283,10 @@ export class SessionContainer {
if (this._isDisposed) {
return;
}
const d = dehydratedDevice;
dehydratedDevice = undefined;
// log as ref as we don't want to await it
await log.wrap("session start", log => this._session.start(lastVersionsResponse, log));
await log.wrap("session start", log => this._session.start(lastVersionsResponse, d, log));
}
}
@ -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() {
return this._status;
@ -331,6 +376,15 @@ export class SessionContainer {
return !this._reconnector;
}
logout() {
return this._platform.logger.run("logout", async log => {
try {
await this._session?.logout(log);
} catch (err) {}
await this.deleteSession(log);
});
}
dispose() {
if (this._reconnectSubscription) {
this._reconnectSubscription();
@ -339,12 +393,15 @@ export class SessionContainer {
this._reconnector = null;
if (this._requestScheduler) {
this._requestScheduler.stop();
this._requestScheduler = null;
}
if (this._sync) {
this._sync.stop();
this._sync = null;
}
if (this._session) {
this._session.dispose();
this._session = null;
}
if (this._waitForFirstSyncHandle) {
this._waitForFirstSyncHandle.dispose();
@ -378,3 +435,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();
}
}

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 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);
@ -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}) {
const account = new olm.Account();
if (olmWorker) {
@ -43,24 +76,13 @@ 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;
const serverOTKCount = 0;
if (storage) {
await initiallyStoreAccount(account, pickleKey, areDeviceKeysUploaded, serverOTKCount, storage);
}
await txn.complete();
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}) {
@ -80,7 +102,11 @@ export class Account {
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());
// only one algorithm supported by olm atm, so hardcode its name
const oneTimeKeysEntries = Object.entries(oneTimeKeys.curve25519);
@ -95,7 +121,8 @@ export class Account {
log.set("otks", true);
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;
log.set("serverOTKCount", this._serverOTKCount);
// 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 => {
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 +273,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 +304,13 @@ export class Account {
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) {
if (this._sessionBackup) {
if (this._sessionBackup && !!sessionBackup) {
return;
}
this._sessionBackup = sessionBackup;

View file

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

View file

@ -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 = "/_matrix/client/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 = CS_R0_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 = {}) {
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";

View file

@ -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);
});

View file

@ -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,30 @@ 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;
}
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;
}
withDescription(description) {
return new Key(description, this._binaryKey);
}
get description() {
return this._keyDescription;
}
get id() {
return this._keyDescription.id;
}
@ -51,3 +78,24 @@ export class Key {
return this._keyDescription.algorithm;
}
}
async function calculateKeyMac(key, ivStr, platform) {
const {crypto, encoding} = platform;
const {utf8, base64} = encoding;
const {derive, aes, hmac} = crypto;
const iv = base64.decode(ivStr);
// salt for HKDF, with 8 bytes of zeros
const zerosalt = new Uint8Array(8);
const ZERO_STR = "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";
const info = utf8.encode("");
const keybits = await derive.hkdf(key, zerosalt, info, "SHA-256", 512);
const aesKey = keybits.slice(0, 32);
const hmacKey = keybits.slice(32);
const ciphertext = await aes.encryptCTR({key: aesKey, iv, data: utf8.encode(ZERO_STR)});
const mac = await hmac.compute(hmacKey, ciphertext, "SHA-256");
return base64.encode(mac);
}

View file

@ -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,14 @@ 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);
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) {
@ -55,13 +65,24 @@ 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}`);
}
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;
}
.SessionLoadStatusView, .LoginView_query-spinner {
.SessionLoadStatusView > .status, .LoginView_query-spinner {
display: flex;
gap: 12px;
}
.SessionLoadStatusView> :not(:first-child), .LoginView_query-spinner> :not(:first-child) {
margin-left: 12px;
}
.SessionLoadStatusView p, .LoginView_query-spinner p {
.SessionLoadStatusView > .status p, .LoginView_query-spinner p {
flex: 1;
margin: 0;
}

View file

@ -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 {

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 {spinner} from "../common.js";
import {AccountSetupView} from "./AccountSetupView.js";
/** a view used both in the login view and the loading screen
to show the current state of loading the session.
@ -27,9 +28,19 @@ export class SessionLoadStatusView extends TemplateView {
onClick: () => vm.exportLogs()
}, vm.i18n`Export logs`);
});
const logoutButtonIfFailed = t.if(vm => vm.hasError, (t, vm) => {
return t.button({
onClick: () => vm.logout()
}, vm.i18n`Log out`);
});
return t.div({className: "SessionLoadStatusView"}, [
spinner(t, {hiddenWithLayout: vm => !vm.loading}),
t.p([vm => vm.loadLabel, exportLogsButtonIfFailed])
t.p({className: "status"}, [
spinner(t, {hidden: vm => !vm.loading}),
t.p(vm => vm.loadLabel),
exportLogsButtonIfFailed,
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 {SessionLoadStatusView} from "./SessionLoadStatusView.js";
function selectFileAsText(mimeType) {
const input = document.createElement("input");
input.setAttribute("type", "file");
if (mimeType) {
input.setAttribute("accept", mimeType);
}
const promise = new Promise((resolve, reject) => {
const checkFile = () => {
input.removeEventListener("change", checkFile, true);
const file = input.files[0];
if (file) {
resolve(file.text());
} else {
reject(new Error("No file selected"));
}
}
input.addEventListener("change", checkFile, true);
});
input.click();
return promise;
}
class SessionPickerItemView extends TemplateView {
_onDeleteClick() {
if (confirm("Are you sure?")) {
@ -81,10 +57,6 @@ export class SessionPickerView extends TemplateView {
t.h1(["Continue as …"]),
t.view(sessionList),
t.div({className: "button-row"}, [
t.button({
className: "button-action secondary",
onClick: async () => vm.import(await selectFileAsText("application/json"))
}, vm.i18n`Import a session`),
t.a({
className: "button-action primary",
href: vm.cancelUrl

View file

@ -21,25 +21,31 @@ 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) {
const useASecurityPhrase = t.button({className: "link", onClick: () => vm.showPhraseSetup()}, vm.i18n`use a security phrase`);
return t.div([
t.p(vm.i18n`Enter your secret storage security key below to set up session backup, which will enable you to decrypt messages received before you logged into this session. The security key is a code of 12 groups of 4 characters separated by a space that Element created for you when setting up security.`),
t.p(vm.i18n`Enter your secret storage security key below to ${vm.purpose}, which will enable you to decrypt messages received before you logged into this session. The security key is a code of 12 groups of 4 characters separated by a space that Element created for you when setting up security.`),
renderError(t),
renderEnableFieldRow(t, vm, vm.i18n`Security key`, key => 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.`]),
]);
}
@ -47,22 +53,34 @@ function renderEnableFromKey(t, vm) {
function renderEnableFromPhrase(t, vm) {
const useASecurityKey = t.button({className: "link", onClick: () => vm.showKeySetup()}, vm.i18n`use your security key`);
return t.div([
t.p(vm.i18n`Enter your secret storage security phrase below to set up session backup, which will enable you to decrypt messages received before you logged into this session. The security phrase is a freeform secret phrase you optionally chose when setting up security in Element. It is different from your password to login, unless you chose to set them to the same value.`),
t.p(vm.i18n`Enter your secret storage security phrase below to ${vm.purpose}, which will enable you to decrypt messages received before you logged into this session. The security phrase is a freeform secret phrase you optionally chose when setting up security in Element. It is different from your password to login, unless you chose to set them to the same value.`),
renderError(t),
renderEnableFieldRow(t, vm, vm.i18n`Security phrase`, phrase => 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),
]);
}

View file

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