mirror of
https://github.com/vector-im/hydrogen-web.git
synced 2025-02-02 07:31:38 +01:00
WIP2
This commit is contained in:
parent
780dfeb199
commit
dd59f37dce
@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import {ViewModel} from "./ViewModel";
|
||||
import {KeyType} from "../matrix/ssss/index";
|
||||
import {Status} from "./session/settings/KeyBackupViewModel.js";
|
||||
import {Status} from "./session/settings/KeyBackupViewModel";
|
||||
|
||||
export class AccountSetupViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
|
@ -1,223 +0,0 @@
|
||||
/*
|
||||
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 {ViewModel} from "../../ViewModel";
|
||||
import {KeyType} from "../../../matrix/ssss/index";
|
||||
import {createEnum} from "../../../utils/enum";
|
||||
import {FlatMapObservableValue} from "../../../observable/value";
|
||||
|
||||
export const Status = createEnum("Enabled", "SetupKey", "SetupPhrase", "Pending", "NewVersionAvailable");
|
||||
export const BackupWriteStatus = createEnum("Writing", "Stopped", "Done", "Pending");
|
||||
|
||||
export class KeyBackupViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._session = options.session;
|
||||
this._error = null;
|
||||
this._isBusy = false;
|
||||
this._dehydratedDeviceId = undefined;
|
||||
this._status = undefined;
|
||||
this._backupOperation = new FlatMapObservableValue(this._session.keyBackup, keyBackup => keyBackup.operationInProgress);
|
||||
this._progress = new FlatMapObservableValue(this._backupOperation, op => op.progress);
|
||||
this.track(this._backupOperation.subscribe(() => {
|
||||
// see if needsNewKey might be set
|
||||
this._reevaluateStatus();
|
||||
this.emitChange("isBackingUp");
|
||||
}));
|
||||
this.track(this._progress.subscribe(() => this.emitChange("backupPercentage")));
|
||||
this._reevaluateStatus();
|
||||
this.track(this._session.keyBackup.subscribe(() => {
|
||||
if (this._reevaluateStatus()) {
|
||||
this.emitChange("status");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
_reevaluateStatus() {
|
||||
if (this._isBusy) {
|
||||
return false;
|
||||
}
|
||||
let status;
|
||||
const keyBackup = this._session.keyBackup.get();
|
||||
if (keyBackup) {
|
||||
status = keyBackup.needsNewKey ? Status.NewVersionAvailable : Status.Enabled;
|
||||
} else if (keyBackup === null) {
|
||||
status = this.showPhraseSetup() ? Status.SetupPhrase : 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 key backup`;
|
||||
}
|
||||
|
||||
offerDehydratedDeviceSetup() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get dehydratedDeviceId() {
|
||||
return this._dehydratedDeviceId;
|
||||
}
|
||||
|
||||
get isBusy() {
|
||||
return this._isBusy;
|
||||
}
|
||||
|
||||
get backupVersion() {
|
||||
return this._session.keyBackup.get()?.version;
|
||||
}
|
||||
|
||||
get isMasterKeyTrusted() {
|
||||
return this._session.crossSigning?.isMasterKeyTrusted ?? false;
|
||||
}
|
||||
|
||||
get canSignOwnDevice() {
|
||||
return !!this._session.crossSigning;
|
||||
}
|
||||
|
||||
async signOwnDevice() {
|
||||
if (this._session.crossSigning) {
|
||||
await this.logger.run("KeyBackupViewModel.signOwnDevice", async log => {
|
||||
await this._session.crossSigning.signOwnDevice(log);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get backupWriteStatus() {
|
||||
const keyBackup = this._session.keyBackup.get();
|
||||
if (!keyBackup) {
|
||||
return BackupWriteStatus.Pending;
|
||||
} else if (keyBackup.hasStopped) {
|
||||
return BackupWriteStatus.Stopped;
|
||||
}
|
||||
const operation = keyBackup.operationInProgress.get();
|
||||
if (operation) {
|
||||
return BackupWriteStatus.Writing;
|
||||
} else if (keyBackup.hasBackedUpAllKeys) {
|
||||
return BackupWriteStatus.Done;
|
||||
} else {
|
||||
return BackupWriteStatus.Pending;
|
||||
}
|
||||
}
|
||||
|
||||
get backupError() {
|
||||
return this._session.keyBackup.get()?.error?.message;
|
||||
}
|
||||
|
||||
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, setupDehydratedDevice) {
|
||||
if (credential) {
|
||||
try {
|
||||
this._isBusy = true;
|
||||
this.emitChange("isBusy");
|
||||
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("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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("");
|
||||
}
|
||||
}
|
||||
|
||||
get isBackingUp() {
|
||||
return !!this._backupOperation.get();
|
||||
}
|
||||
|
||||
get backupPercentage() {
|
||||
const progress = this._progress.get();
|
||||
if (progress) {
|
||||
return Math.round((progress.finished / progress.total) * 100);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
get backupInProgressLabel() {
|
||||
const progress = this._progress.get();
|
||||
if (progress) {
|
||||
return this.i18n`${progress.finished} of ${progress.total}`;
|
||||
}
|
||||
return this.i18n`…`;
|
||||
}
|
||||
|
||||
cancelBackup() {
|
||||
this._backupOperation.get()?.abort();
|
||||
}
|
||||
|
||||
startBackup() {
|
||||
this._session.keyBackup.get()?.flush();
|
||||
}
|
||||
}
|
||||
|
270
src/domain/session/settings/KeyBackupViewModel.ts
Normal file
270
src/domain/session/settings/KeyBackupViewModel.ts
Normal file
@ -0,0 +1,270 @@
|
||||
/*
|
||||
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 {ViewModel} from "../../ViewModel";
|
||||
import {SegmentType} from "../../navigation/index";
|
||||
import {KeyType} from "../../../matrix/ssss/index";
|
||||
|
||||
import type {Options as BaseOptions} from "../../ViewModel";
|
||||
import type {Session} from "../../../matrix/Session";
|
||||
import type {Disposable} from "../../../utils/Disposables";
|
||||
import type {KeyBackup, Progress} from "../../../matrix/e2ee/megolm/keybackup/KeyBackup";
|
||||
import type {CrossSigning} from "../../../matrix/verification/CrossSigning";
|
||||
|
||||
export enum Status {
|
||||
Enabled,
|
||||
Setup,
|
||||
Pending,
|
||||
NewVersionAvailable
|
||||
};
|
||||
|
||||
export enum BackupWriteStatus {
|
||||
Writing,
|
||||
Stopped,
|
||||
Done,
|
||||
Pending
|
||||
};
|
||||
|
||||
type Options = {
|
||||
session: Session,
|
||||
} & BaseOptions;
|
||||
|
||||
export class KeyBackupViewModel extends ViewModel<SegmentType, Options> {
|
||||
private _error?: Error = undefined;
|
||||
private _isBusy = false;
|
||||
private _dehydratedDeviceId?: string = undefined;
|
||||
private _status = Status.Pending;
|
||||
private _backupOperationSubscription?: Disposable = undefined;
|
||||
private _keyBackupSubscription?: Disposable = undefined;
|
||||
private _progress?: Progress = undefined;
|
||||
private _setupKeyType = KeyType.RecoveryKey;
|
||||
|
||||
constructor(options) {
|
||||
super(options);
|
||||
const onKeyBackupSet = (keyBackup: KeyBackup | undefined) => {
|
||||
if (keyBackup && !this._keyBackupSubscription) {
|
||||
this._keyBackupSubscription = this.track(this._session.keyBackup.disposableOn("change", () => {
|
||||
this._onKeyBackupChange();
|
||||
}));
|
||||
} else if (!keyBackup && this._keyBackupSubscription) {
|
||||
this._keyBackupSubscription = this.disposeTracked(this._keyBackupSubscription);
|
||||
}
|
||||
this._onKeyBackupChange(); // update status
|
||||
};
|
||||
this.track(this._session.keyBackup.subscribe(onKeyBackupSet));
|
||||
onKeyBackupSet(this._keyBackup);
|
||||
}
|
||||
|
||||
private get _session(): Session {
|
||||
return this.getOption("session");
|
||||
}
|
||||
|
||||
private get _keyBackup(): KeyBackup | undefined {
|
||||
return this._session.keyBackup.get();
|
||||
}
|
||||
|
||||
private get _crossSigning(): CrossSigning | undefined {
|
||||
return this._session.crossSigning.get();
|
||||
}
|
||||
|
||||
private _onKeyBackupChange() {
|
||||
const keyBackup = this._keyBackup;
|
||||
if (keyBackup) {
|
||||
const {operationInProgress} = keyBackup;
|
||||
if (operationInProgress && !this._backupOperationSubscription) {
|
||||
this._backupOperationSubscription = this.track(operationInProgress.disposableOn("change", () => {
|
||||
this._progress = operationInProgress.progress;
|
||||
this.emitChange("backupPercentage");
|
||||
}));
|
||||
} else if (this._backupOperationSubscription && !operationInProgress) {
|
||||
this._backupOperationSubscription = this.disposeTracked(this._backupOperationSubscription);
|
||||
this._progress = undefined;
|
||||
}
|
||||
}
|
||||
this.emitChange("status");
|
||||
}
|
||||
|
||||
get status(): Status {
|
||||
const keyBackup = this._keyBackup;
|
||||
if (keyBackup) {
|
||||
if (keyBackup.needsNewKey) {
|
||||
return Status.NewVersionAvailable;
|
||||
} else if (keyBackup.version === undefined) {
|
||||
return Status.Pending;
|
||||
} else {
|
||||
return keyBackup.needsNewKey ? Status.NewVersionAvailable : Status.Enabled;
|
||||
}
|
||||
} else {
|
||||
return Status.Setup;
|
||||
}
|
||||
}
|
||||
|
||||
get decryptAction(): string {
|
||||
return this.i18n`Set up`;
|
||||
}
|
||||
|
||||
get purpose(): string {
|
||||
return this.i18n`set up key backup`;
|
||||
}
|
||||
|
||||
offerDehydratedDeviceSetup(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
get dehydratedDeviceId(): string | undefined {
|
||||
return this._dehydratedDeviceId;
|
||||
}
|
||||
|
||||
get isBusy(): boolean {
|
||||
return this._isBusy;
|
||||
}
|
||||
|
||||
get backupVersion(): string {
|
||||
return this._keyBackup?.version ?? "";
|
||||
}
|
||||
|
||||
get isMasterKeyTrusted(): boolean {
|
||||
return this._crossSigning?.isMasterKeyTrusted ?? false;
|
||||
}
|
||||
|
||||
get canSignOwnDevice(): boolean {
|
||||
return !!this._crossSigning;
|
||||
}
|
||||
|
||||
async signOwnDevice(): Promise<void> {
|
||||
const crossSigning = this._crossSigning;
|
||||
if (crossSigning) {
|
||||
await this.logger.run("KeyBackupViewModel.signOwnDevice", async log => {
|
||||
await crossSigning.signOwnDevice(log);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get backupWriteStatus(): BackupWriteStatus {
|
||||
const keyBackup = this._keyBackup;
|
||||
if (!keyBackup || keyBackup.version === undefined) {
|
||||
return BackupWriteStatus.Pending;
|
||||
} else if (keyBackup.hasStopped) {
|
||||
return BackupWriteStatus.Stopped;
|
||||
}
|
||||
const operation = keyBackup.operationInProgress;
|
||||
if (operation) {
|
||||
return BackupWriteStatus.Writing;
|
||||
} else if (keyBackup.hasBackedUpAllKeys) {
|
||||
return BackupWriteStatus.Done;
|
||||
} else {
|
||||
return BackupWriteStatus.Pending;
|
||||
}
|
||||
}
|
||||
|
||||
get backupError(): string | undefined {
|
||||
return this._keyBackup?.error?.message;
|
||||
}
|
||||
|
||||
get error(): string | undefined {
|
||||
return this._error?.message;
|
||||
}
|
||||
|
||||
showPhraseSetup(): void {
|
||||
if (this._status === Status.Setup) {
|
||||
this._setupKeyType = KeyType.Passphrase;
|
||||
this.emitChange("setupKeyType");
|
||||
}
|
||||
}
|
||||
|
||||
showKeySetup(): void {
|
||||
if (this._status === Status.Setup) {
|
||||
this._setupKeyType = KeyType.Passphrase;
|
||||
this.emitChange("setupKeyType");
|
||||
}
|
||||
}
|
||||
|
||||
get setupKeyType(): KeyType {
|
||||
return this._setupKeyType;
|
||||
}
|
||||
|
||||
private async _enterCredentials(keyType, credential, setupDehydratedDevice): Promise<void> {
|
||||
if (credential) {
|
||||
try {
|
||||
this._isBusy = true;
|
||||
this.emitChange("isBusy");
|
||||
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.emitChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enterSecurityPhrase(passphrase, setupDehydratedDevice): Promise<void> {
|
||||
return this._enterCredentials(KeyType.Passphrase, passphrase, setupDehydratedDevice);
|
||||
}
|
||||
|
||||
enterSecurityKey(securityKey, setupDehydratedDevice): Promise<void> {
|
||||
return this._enterCredentials(KeyType.RecoveryKey, securityKey, setupDehydratedDevice);
|
||||
}
|
||||
|
||||
async disable(): Promise<void> {
|
||||
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.emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
get isBackingUp(): boolean {
|
||||
return this._keyBackup?.operationInProgress !== undefined;
|
||||
}
|
||||
|
||||
get backupPercentage(): number {
|
||||
if (this._progress) {
|
||||
return Math.round((this._progress.finished / this._progress.total) * 100);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
get backupInProgressLabel(): string {
|
||||
if (this._progress) {
|
||||
return this.i18n`${this._progress.finished} of ${this._progress.total}`;
|
||||
}
|
||||
return this.i18n`…`;
|
||||
}
|
||||
|
||||
cancelBackup(): void {
|
||||
this._keyBackup?.operationInProgress?.abort();
|
||||
}
|
||||
|
||||
startBackup(): void {
|
||||
this.logger.run("KeyBackupViewModel.startBackup", log => {
|
||||
this._keyBackup?.flush(log);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
import {KeyBackupViewModel} from "./KeyBackupViewModel.js";
|
||||
import {KeyBackupViewModel} from "./KeyBackupViewModel";
|
||||
import {FeaturesViewModel} from "./FeaturesViewModel";
|
||||
import {submitLogsFromSessionToDefaultServer} from "../../../domain/rageshake";
|
||||
|
||||
|
@ -90,7 +90,7 @@ export class Session {
|
||||
this._getSyncToken = () => this.syncToken;
|
||||
this._olmWorker = olmWorker;
|
||||
this._keyBackup = new ObservableValue(undefined);
|
||||
this._crossSigning = undefined;
|
||||
this._crossSigning = new ObservableValue(undefined);
|
||||
this._observedRoomStatus = new Map();
|
||||
|
||||
if (olm) {
|
||||
@ -250,7 +250,7 @@ export class Session {
|
||||
}
|
||||
if (this._keyBackup.get()) {
|
||||
this._keyBackup.get().dispose();
|
||||
this._keyBackup.set(null);
|
||||
this._keyBackup.set(undefined);
|
||||
}
|
||||
// TODO: stop cross-signing
|
||||
const key = await ssssKeyFromCredential(type, credential, this._storage, this._platform, this._olm);
|
||||
@ -258,8 +258,8 @@ export class Session {
|
||||
// only after having read a secret, write the key
|
||||
// as we only find out if it was good if the MAC verification succeeds
|
||||
await this._writeSSSSKey(key, log);
|
||||
await this._keyBackup?.start(log);
|
||||
await this._crossSigning?.start(log);
|
||||
await this._keyBackup.get()?.start(log);
|
||||
await this._crossSigning.get()?.start(log);
|
||||
return key;
|
||||
} else {
|
||||
throw new Error("Could not read key backup with the given key");
|
||||
@ -331,29 +331,21 @@ export class Session {
|
||||
if (isValid) {
|
||||
await this._loadSecretStorageServices(secretStorage, txn, log);
|
||||
}
|
||||
if (!this._keyBackup.get()) {
|
||||
// null means key backup isn't configured yet
|
||||
// as opposed to undefined, which means we're still checking
|
||||
this._keyBackup.set(null);
|
||||
}
|
||||
return isValid;
|
||||
});
|
||||
}
|
||||
|
||||
_loadSecretStorageServices(secretStorage, txn, log) {
|
||||
async _loadSecretStorageServices(secretStorage, txn, log) {
|
||||
try {
|
||||
await log.wrap("enable key backup", async log => {
|
||||
// TODO: delay network request here until start()
|
||||
const keyBackup = await KeyBackup.fromSecretStorage(
|
||||
this._platform,
|
||||
this._olm,
|
||||
secretStorage,
|
||||
const keyBackup = new KeyBackup(
|
||||
this._hsApi,
|
||||
this._olm,
|
||||
this._keyLoader,
|
||||
this._storage,
|
||||
txn
|
||||
this._platform,
|
||||
);
|
||||
if (keyBackup) {
|
||||
if (await keyBackup.load(secretStorage, txn)) {
|
||||
for (const room of this._rooms.values()) {
|
||||
if (room.isEncrypted) {
|
||||
room.enableKeyBackup(keyBackup);
|
||||
@ -378,8 +370,8 @@ export class Session {
|
||||
ownUserId: this.userId,
|
||||
e2eeAccount: this._e2eeAccount
|
||||
});
|
||||
if (crossSigning.load(txn, log)) {
|
||||
this._crossSigning = crossSigning;
|
||||
if (await crossSigning.load(txn, log)) {
|
||||
this._crossSigning.set(crossSigning);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -585,8 +577,8 @@ export class Session {
|
||||
}
|
||||
});
|
||||
}
|
||||
this._keyBackup?.start(log);
|
||||
this._crossSigning?.start(log);
|
||||
this._keyBackup.get()?.start(log);
|
||||
this._crossSigning.get()?.start(log);
|
||||
|
||||
// restore unfinished operations, like sending out room keys
|
||||
const opsTxn = await this._storage.readWriteTxn([
|
||||
|
@ -20,6 +20,8 @@ import {MEGOLM_ALGORITHM} from "../../common";
|
||||
import * as Curve25519 from "./Curve25519";
|
||||
import {AbortableOperation} from "../../../../utils/AbortableOperation";
|
||||
import {ObservableValue} from "../../../../observable/value";
|
||||
import {Deferred} from "../../../../utils/Deferred";
|
||||
import {EventEmitter} from "../../../../utils/EventEmitter";
|
||||
|
||||
import {SetAbortableFn} from "../../../../utils/AbortableOperation";
|
||||
import type {BackupInfo, SessionData, SessionKeyInfo, SessionInfo, KeyBackupPayload} from "./types";
|
||||
@ -31,43 +33,69 @@ import type {Storage} from "../../../storage/idb/Storage";
|
||||
import type {ILogItem} from "../../../../logging/types";
|
||||
import type {Platform} from "../../../../platform/web/Platform";
|
||||
import type {Transaction} from "../../../storage/idb/Transaction";
|
||||
import type {IHomeServerRequest} from "../../../net/HomeServerRequest";
|
||||
import type * as OlmNamespace from "@matrix-org/olm";
|
||||
type Olm = typeof OlmNamespace;
|
||||
|
||||
const KEYS_PER_REQUEST = 200;
|
||||
|
||||
export class KeyBackup {
|
||||
public readonly operationInProgress = new ObservableValue<AbortableOperation<Promise<void>, Progress> | undefined>(undefined);
|
||||
// a set of fields we need to store once we've fetched
|
||||
// the backup info from the homeserver, which happens in start()
|
||||
class BackupConfig {
|
||||
constructor(
|
||||
public readonly info: BackupInfo,
|
||||
public readonly crypto: Curve25519.BackupEncryption
|
||||
) {}
|
||||
}
|
||||
|
||||
export class KeyBackup extends EventEmitter<{change: never}> {
|
||||
private _operationInProgress?: AbortableOperation<Promise<void>, Progress>;
|
||||
private _stopped = false;
|
||||
private _needsNewKey = false;
|
||||
private _hasBackedUpAllKeys = false;
|
||||
private _error?: Error;
|
||||
private crypto?: Curve25519.BackupEncryption;
|
||||
private backupInfo?: BackupInfo;
|
||||
private privateKey?: Uint8Array;
|
||||
private backupConfigDeferred: Deferred<BackupConfig | undefined> = new Deferred();
|
||||
private backupInfoRequest?: IHomeServerRequest;
|
||||
|
||||
constructor(
|
||||
private readonly backupInfo: BackupInfo,
|
||||
private readonly hsApi: HomeServerApi,
|
||||
private readonly olm: Olm,
|
||||
private readonly keyLoader: KeyLoader,
|
||||
private readonly storage: Storage,
|
||||
private readonly platform: Platform,
|
||||
private readonly maxDelay: number = 10000
|
||||
) {}
|
||||
) {
|
||||
super();
|
||||
// doing the network request for getting the backup info
|
||||
// and hence creating the crypto instance depending on the chose algorithm
|
||||
// is delayed until start() is called, but we want to already take requests
|
||||
// for fetching the room keys, so put the crypto and backupInfo in a deferred.
|
||||
this.backupConfigDeferred = new Deferred();
|
||||
}
|
||||
|
||||
get hasStopped(): boolean { return this._stopped; }
|
||||
get error(): Error | undefined { return this._error; }
|
||||
get version(): string { return this.backupInfo.version; }
|
||||
get version(): string | undefined { return this.backupConfigDeferred.value?.info?.version; }
|
||||
get needsNewKey(): boolean { return this._needsNewKey; }
|
||||
get hasBackedUpAllKeys(): boolean { return this._hasBackedUpAllKeys; }
|
||||
get operationInProgress(): AbortableOperation<Promise<void>, Progress> | undefined { return this._operationInProgress; }
|
||||
|
||||
async getRoomKey(roomId: string, sessionId: string, log: ILogItem): Promise<IncomingRoomKey | undefined> {
|
||||
if (this.needsNewKey || !this.crypto) {
|
||||
if (this.needsNewKey) {
|
||||
return;
|
||||
}
|
||||
const sessionResponse = await this.hsApi.roomKeyForRoomAndSession(this.backupInfo.version, roomId, sessionId, {log}).response();
|
||||
const backupConfig = await this.backupConfigDeferred.promise;
|
||||
if (!backupConfig) {
|
||||
return;
|
||||
}
|
||||
const sessionResponse = await this.hsApi.roomKeyForRoomAndSession(backupConfig.info.version, roomId, sessionId, {log}).response();
|
||||
if (!sessionResponse.session_data) {
|
||||
return;
|
||||
}
|
||||
const sessionKeyInfo = this.crypto.decryptRoomKey(sessionResponse.session_data as SessionData);
|
||||
const sessionKeyInfo = backupConfig.crypto.decryptRoomKey(sessionResponse.session_data as SessionData);
|
||||
if (sessionKeyInfo?.algorithm === MEGOLM_ALGORITHM) {
|
||||
return keyFromBackup(roomId, sessionId, sessionKeyInfo);
|
||||
} else if (sessionKeyInfo?.algorithm) {
|
||||
@ -79,14 +107,53 @@ export class KeyBackup {
|
||||
return txn.inboundGroupSessions.markAllAsNotBackedUp();
|
||||
}
|
||||
|
||||
start(log: ILogItem) {
|
||||
async load(secretStorage: SecretStorage, txn: Transaction) {
|
||||
// TODO: no exception here we should anticipate?
|
||||
const base64PrivateKey = await secretStorage.readSecret("m.megolm_backup.v1", txn);
|
||||
if (base64PrivateKey) {
|
||||
this.privateKey = new Uint8Array(this.platform.encoding.base64.decode(base64PrivateKey));
|
||||
return true;
|
||||
} else {
|
||||
this.backupConfigDeferred.resolve(undefined);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// fetch latest version
|
||||
this.flush(log);
|
||||
async start(log: ILogItem) {
|
||||
await log.wrap("KeyBackup.start", async log => {
|
||||
if (this.privateKey && !this.backupInfoRequest) {
|
||||
let backupInfo: BackupInfo;
|
||||
try {
|
||||
this.backupInfoRequest = this.hsApi.roomKeysVersion(undefined, {log});
|
||||
backupInfo = await this.backupInfoRequest.response() as BackupInfo;
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") {
|
||||
log.set("aborted", true);
|
||||
return;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
this.backupInfoRequest = undefined;
|
||||
}
|
||||
// TODO: what if backupInfo is undefined or we get 404 or something?
|
||||
if (backupInfo.algorithm === Curve25519.Algorithm) {
|
||||
const crypto = Curve25519.BackupEncryption.fromAuthData(backupInfo.auth_data, this.privateKey, this.olm);
|
||||
this.backupConfigDeferred.resolve(new BackupConfig(backupInfo, crypto));
|
||||
this.emit("change");
|
||||
} else {
|
||||
this.backupConfigDeferred.resolve(undefined);
|
||||
log.log({l: `Unknown backup algorithm`, algorithm: backupInfo.algorithm});
|
||||
}
|
||||
this.privateKey = undefined;
|
||||
}
|
||||
// fetch latest version
|
||||
this.flush(log);
|
||||
});
|
||||
}
|
||||
|
||||
flush(log: ILogItem): void {
|
||||
if (!this.operationInProgress.get()) {
|
||||
if (!this._operationInProgress) {
|
||||
log.wrapDetached("flush key backup", async log => {
|
||||
if (this._needsNewKey) {
|
||||
log.set("needsNewKey", this._needsNewKey);
|
||||
@ -96,7 +163,8 @@ export class KeyBackup {
|
||||
this._error = undefined;
|
||||
this._hasBackedUpAllKeys = false;
|
||||
const operation = this._runFlushOperation(log);
|
||||
this.operationInProgress.set(operation);
|
||||
this._operationInProgress = operation;
|
||||
this.emit("change");
|
||||
try {
|
||||
await operation.result;
|
||||
this._hasBackedUpAllKeys = true;
|
||||
@ -113,13 +181,18 @@ export class KeyBackup {
|
||||
}
|
||||
log.catch(err);
|
||||
}
|
||||
this.operationInProgress.set(undefined);
|
||||
this._operationInProgress = undefined;
|
||||
this.emit("change");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _runFlushOperation(log: ILogItem): AbortableOperation<Promise<void>, Progress> {
|
||||
return new AbortableOperation(async (setAbortable, setProgress) => {
|
||||
const backupConfig = await this.backupConfigDeferred.promise;
|
||||
if (!backupConfig) {
|
||||
return;
|
||||
}
|
||||
let total = 0;
|
||||
let amountFinished = 0;
|
||||
while (true) {
|
||||
@ -138,8 +211,8 @@ export class KeyBackup {
|
||||
log.set("total", total);
|
||||
return;
|
||||
}
|
||||
const payload = await this.encodeKeysForBackup(keysNeedingBackup);
|
||||
const uploadRequest = this.hsApi.uploadRoomKeysToBackup(this.backupInfo.version, payload, {log});
|
||||
const payload = await this.encodeKeysForBackup(keysNeedingBackup, backupConfig.crypto);
|
||||
const uploadRequest = this.hsApi.uploadRoomKeysToBackup(backupConfig.info.version, payload, {log});
|
||||
setAbortable(uploadRequest);
|
||||
await uploadRequest.response();
|
||||
await this.markKeysAsBackedUp(keysNeedingBackup, setAbortable);
|
||||
@ -149,7 +222,7 @@ export class KeyBackup {
|
||||
});
|
||||
}
|
||||
|
||||
private async encodeKeysForBackup(roomKeys: RoomKey[]): Promise<KeyBackupPayload> {
|
||||
private async encodeKeysForBackup(roomKeys: RoomKey[], crypto: Curve25519.BackupEncryption): Promise<KeyBackupPayload> {
|
||||
const payload: KeyBackupPayload = { rooms: {} };
|
||||
const payloadRooms = payload.rooms;
|
||||
for (const key of roomKeys) {
|
||||
@ -157,7 +230,7 @@ export class KeyBackup {
|
||||
if (!roomPayload) {
|
||||
roomPayload = payloadRooms[key.roomId] = { sessions: {} };
|
||||
}
|
||||
roomPayload.sessions[key.sessionId] = await this.encodeRoomKey(key);
|
||||
roomPayload.sessions[key.sessionId] = await this.encodeRoomKey(key, crypto);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
@ -178,7 +251,7 @@ export class KeyBackup {
|
||||
await txn.complete();
|
||||
}
|
||||
|
||||
private async encodeRoomKey(roomKey: RoomKey): Promise<SessionInfo> {
|
||||
private async encodeRoomKey(roomKey: RoomKey, crypto: Curve25519.BackupEncryption): Promise<SessionInfo> {
|
||||
return await this.keyLoader.useKey(roomKey, session => {
|
||||
const firstMessageIndex = session.first_known_index();
|
||||
const sessionKey = session.export_session(firstMessageIndex);
|
||||
@ -186,27 +259,14 @@ export class KeyBackup {
|
||||
first_message_index: firstMessageIndex,
|
||||
forwarded_count: 0,
|
||||
is_verified: false,
|
||||
session_data: this.crypto.encryptRoomKey(roomKey, sessionKey)
|
||||
session_data: crypto.encryptRoomKey(roomKey, sessionKey)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.crypto?.dispose();
|
||||
}
|
||||
|
||||
static async fromSecretStorage(platform: Platform, olm: Olm, secretStorage: SecretStorage, hsApi: HomeServerApi, keyLoader: KeyLoader, storage: Storage, txn: Transaction): Promise<KeyBackup | undefined> {
|
||||
const base64PrivateKey = await secretStorage.readSecret("m.megolm_backup.v1", txn);
|
||||
if (base64PrivateKey) {
|
||||
const privateKey = new Uint8Array(platform.encoding.base64.decode(base64PrivateKey));
|
||||
const backupInfo = await hsApi.roomKeysVersion().response() as BackupInfo;
|
||||
if (backupInfo.algorithm === Curve25519.Algorithm) {
|
||||
const crypto = Curve25519.BackupEncryption.fromAuthData(backupInfo.auth_data, privateKey, olm);
|
||||
return new KeyBackup(backupInfo, privateKey, hsApi, keyLoader, storage, platform);
|
||||
} else {
|
||||
throw new Error(`Unknown backup algorithm: ${backupInfo.algorithm}`);
|
||||
}
|
||||
}
|
||||
this.backupInfoRequest?.abort();
|
||||
this.backupConfigDeferred.value?.crypto?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -50,10 +50,20 @@ export class SecretStorage {
|
||||
const allAccountData = await txn.accountData.getAll();
|
||||
for (const accountData of allAccountData) {
|
||||
try {
|
||||
// TODO: fix this, using the webcrypto api closes the transaction
|
||||
if (accountData.type === "m.megolm_backup.v1") {
|
||||
return true;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
const secret = await this._decryptAccountData(accountData);
|
||||
return true; // decryption succeeded
|
||||
} catch (err) {
|
||||
continue;
|
||||
if (err instanceof DecryptionError && err.reason !== DecryptionFailure.NotEncryptedWithKey) {
|
||||
throw err;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
|
@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import {TemplateView} from "../general/TemplateView";
|
||||
import {KeyBackupSettingsView} from "../session/settings/KeyBackupSettingsView.js";
|
||||
import {KeyBackupSettingsView} from "../session/settings/KeyBackupSettingsView";
|
||||
|
||||
export class AccountSetupView extends TemplateView {
|
||||
render(t, vm) {
|
||||
|
@ -14,32 +14,41 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {TemplateView} from "../../general/TemplateView";
|
||||
import {TemplateView, Builder} from "../../general/TemplateView";
|
||||
import {disableTargetCallback} from "../../general/utils";
|
||||
import {ViewNode} from "../../general/types";
|
||||
import {KeyBackupViewModel, Status, BackupWriteStatus} from "../../../../../domain/session/settings/KeyBackupViewModel";
|
||||
import {KeyType} from "../../../../../matrix/ssss/index";
|
||||
|
||||
export class KeyBackupSettingsView extends TemplateView {
|
||||
render(t, vm) {
|
||||
export class KeyBackupSettingsView extends TemplateView<KeyBackupViewModel> {
|
||||
render(t: Builder<KeyBackupViewModel>, vm: KeyBackupViewModel): ViewNode {
|
||||
return t.div([
|
||||
t.map(vm => vm.status, (status, t, vm) => {
|
||||
switch (status) {
|
||||
case "Enabled": return renderEnabled(t, vm);
|
||||
case "NewVersionAvailable": return renderNewVersionAvailable(t, vm);
|
||||
case "SetupKey": return renderEnableFromKey(t, vm);
|
||||
case "SetupPhrase": return renderEnableFromPhrase(t, vm);
|
||||
case "Pending": return t.p(vm.i18n`Waiting to go online…`);
|
||||
case Status.Enabled: return renderEnabled(t, vm);
|
||||
case Status.NewVersionAvailable: return renderNewVersionAvailable(t, vm);
|
||||
case Status.Setup: {
|
||||
if (vm.setupKeyType === KeyType.Passphrase) {
|
||||
return renderEnableFromPhrase(t, vm);
|
||||
} else {
|
||||
return renderEnableFromKey(t, vm);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Status.Pending: return t.p(vm.i18n`Waiting to go online…`);
|
||||
}
|
||||
}),
|
||||
t.map(vm => vm.backupWriteStatus, (status, t, vm) => {
|
||||
switch (status) {
|
||||
case "Writing": {
|
||||
case BackupWriteStatus.Writing: {
|
||||
const progress = t.progress({
|
||||
min: 0,
|
||||
max: 100,
|
||||
min: 0+"",
|
||||
max: 100+"",
|
||||
value: vm => vm.backupPercentage,
|
||||
});
|
||||
return t.div([`Backup in progress `, progress, " ", vm => vm.backupInProgressLabel]);
|
||||
}
|
||||
case "Stopped": {
|
||||
case BackupWriteStatus.Stopped: {
|
||||
let label;
|
||||
const error = vm.backupError;
|
||||
if (error) {
|
||||
@ -47,12 +56,12 @@ export class KeyBackupSettingsView extends TemplateView {
|
||||
} else {
|
||||
label = `Backup has stopped`;
|
||||
}
|
||||
return t.p(label, " ", t.button({onClick: () => vm.startBackup()}, `Backup now`));
|
||||
return t.p([label, " ", t.button({onClick: () => vm.startBackup()}, `Backup now`)]);
|
||||
}
|
||||
case "Done":
|
||||
case BackupWriteStatus.Done:
|
||||
return t.p(`All keys are backed up.`);
|
||||
default:
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
t.if(vm => vm.isMasterKeyTrusted, t => {
|
||||
@ -70,7 +79,7 @@ export class KeyBackupSettingsView extends TemplateView {
|
||||
}
|
||||
}
|
||||
|
||||
function renderEnabled(t, vm) {
|
||||
function renderEnabled(t: Builder<KeyBackupViewModel>, vm: KeyBackupViewModel): ViewNode {
|
||||
const items = [
|
||||
t.p([vm.i18n`Key backup is enabled, using backup version ${vm.backupVersion}. `, t.button({onClick: () => vm.disable()}, vm.i18n`Disable`)])
|
||||
];
|
||||
@ -80,14 +89,14 @@ function renderEnabled(t, vm) {
|
||||
return t.div(items);
|
||||
}
|
||||
|
||||
function renderNewVersionAvailable(t, vm) {
|
||||
function renderNewVersionAvailable(t: Builder<KeyBackupViewModel>, vm: KeyBackupViewModel): ViewNode {
|
||||
const items = [
|
||||
t.p([vm.i18n`A new backup version has been created from another device. Disable key backup and enable it again with the new key.`, t.button({onClick: () => vm.disable()}, vm.i18n`Disable`)])
|
||||
];
|
||||
return t.div(items);
|
||||
}
|
||||
|
||||
function renderEnableFromKey(t, vm) {
|
||||
function renderEnableFromKey(t: Builder<KeyBackupViewModel>, vm: KeyBackupViewModel): ViewNode {
|
||||
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 ${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.`),
|
||||
@ -97,7 +106,7 @@ function renderEnableFromKey(t, vm) {
|
||||
]);
|
||||
}
|
||||
|
||||
function renderEnableFromPhrase(t, vm) {
|
||||
function renderEnableFromPhrase(t: Builder<KeyBackupViewModel>, vm: KeyBackupViewModel): ViewNode {
|
||||
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 ${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.`),
|
||||
@ -107,7 +116,7 @@ function renderEnableFromPhrase(t, vm) {
|
||||
]);
|
||||
}
|
||||
|
||||
function renderEnableFieldRow(t, vm, label, callback) {
|
||||
function renderEnableFieldRow(t, vm, label, callback): ViewNode {
|
||||
let setupDehydrationCheck;
|
||||
const eventHandler = () => callback(input.value, setupDehydrationCheck?.checked || false);
|
||||
const input = t.input({type: "password", disabled: vm => vm.isBusy, placeholder: label});
|
||||
@ -131,8 +140,8 @@ function renderEnableFieldRow(t, vm, label, callback) {
|
||||
]);
|
||||
}
|
||||
|
||||
function renderError(t) {
|
||||
return t.if(vm => vm.error, (t, vm) => {
|
||||
function renderError(t: Builder<KeyBackupViewModel>): ViewNode {
|
||||
return t.if(vm => vm.error !== undefined, (t, vm) => {
|
||||
return t.div([
|
||||
t.p({className: "error"}, vm => vm.i18n`Could not enable key backup: ${vm.error}.`),
|
||||
t.p(vm.i18n`Try double checking that you did not mix up your security key, security phrase and login password as explained above.`)
|
@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import {TemplateView} from "../../general/TemplateView";
|
||||
import {disableTargetCallback} from "../../general/utils";
|
||||
import {KeyBackupSettingsView} from "./KeyBackupSettingsView.js"
|
||||
import {KeyBackupSettingsView} from "./KeyBackupSettingsView"
|
||||
import {FeaturesView} from "./FeaturesView"
|
||||
|
||||
export class SettingsView extends TemplateView {
|
||||
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {BaseObservableValue, ObservableValue} from "../observable/value";
|
||||
import {EventEmitter} from "../utils/EventEmitter";
|
||||
|
||||
export interface IAbortable {
|
||||
abort();
|
||||
@ -24,25 +24,27 @@ export type SetAbortableFn = (a: IAbortable) => typeof a;
|
||||
export type SetProgressFn<P> = (progress: P) => void;
|
||||
type RunFn<T, P> = (setAbortable: SetAbortableFn, setProgress: SetProgressFn<P>) => T;
|
||||
|
||||
export class AbortableOperation<T, P = void> implements IAbortable {
|
||||
export class AbortableOperation<T, P = void> extends EventEmitter<{change: keyof AbortableOperation<T, P>}> implements IAbortable {
|
||||
public readonly result: T;
|
||||
private _abortable?: IAbortable;
|
||||
private _progress: ObservableValue<P | undefined>;
|
||||
private _progress?: P;
|
||||
|
||||
constructor(run: RunFn<T, P>) {
|
||||
super();
|
||||
this._abortable = undefined;
|
||||
const setAbortable: SetAbortableFn = abortable => {
|
||||
this._abortable = abortable;
|
||||
return abortable;
|
||||
};
|
||||
this._progress = new ObservableValue<P | undefined>(undefined);
|
||||
this._progress = undefined;
|
||||
const setProgress: SetProgressFn<P> = (progress: P) => {
|
||||
this._progress.set(progress);
|
||||
this._progress = progress;
|
||||
this.emit("change", "progress");
|
||||
};
|
||||
this.result = run(setAbortable, setProgress);
|
||||
}
|
||||
|
||||
get progress(): BaseObservableValue<P | undefined> {
|
||||
get progress(): P | undefined {
|
||||
return this._progress;
|
||||
}
|
||||
|
||||
|
41
src/utils/Deferred.ts
Normal file
41
src/utils/Deferred.ts
Normal file
@ -0,0 +1,41 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
export class Deferred<T> {
|
||||
public readonly promise: Promise<T>;
|
||||
public readonly resolve: (value: T) => void;
|
||||
public readonly reject: (err: Error) => void;
|
||||
private _value?: T;
|
||||
|
||||
constructor() {
|
||||
let resolve;
|
||||
let reject;
|
||||
this.promise = new Promise<T>((_resolve, _reject) => {
|
||||
resolve = _resolve;
|
||||
reject = _reject;
|
||||
})
|
||||
this.resolve = (value: T) => {
|
||||
this._value = value;
|
||||
resolve(value);
|
||||
};
|
||||
this.reject = reject;
|
||||
}
|
||||
|
||||
get value(): T | undefined {
|
||||
return this._value;
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user