forked from mystiq/hydrogen-web
Merge pull request #564 from vector-im/bwindels/dehydrated-device
Device dehydration support
This commit is contained in:
commit
014acbfaf5
24 changed files with 845 additions and 171 deletions
136
src/domain/AccountSetupViewModel.js
Normal file
136
src/domain/AccountSetupViewModel.js
Normal 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() {}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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; }
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
115
src/matrix/e2ee/Dehydration.js
Normal file
115
src/matrix/e2ee/Dehydration.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
41
src/platform/web/ui/login/AccountSetupView.js
Normal file
41
src/platform/web/ui/login/AccountSetupView.js
Normal 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`),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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`),
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
Loading…
Reference in a new issue