This commit is contained in:
Bruno Windels 2023-03-21 18:24:46 +01:00
parent 780dfeb199
commit dd59f37dce
12 changed files with 473 additions and 312 deletions

View File

@ -16,7 +16,7 @@ limitations under the License.
import {ViewModel} from "./ViewModel"; import {ViewModel} from "./ViewModel";
import {KeyType} from "../matrix/ssss/index"; import {KeyType} from "../matrix/ssss/index";
import {Status} from "./session/settings/KeyBackupViewModel.js"; import {Status} from "./session/settings/KeyBackupViewModel";
export class AccountSetupViewModel extends ViewModel { export class AccountSetupViewModel extends ViewModel {
constructor(options) { constructor(options) {

View File

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

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

View File

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel"; import {ViewModel} from "../../ViewModel";
import {KeyBackupViewModel} from "./KeyBackupViewModel.js"; import {KeyBackupViewModel} from "./KeyBackupViewModel";
import {FeaturesViewModel} from "./FeaturesViewModel"; import {FeaturesViewModel} from "./FeaturesViewModel";
import {submitLogsFromSessionToDefaultServer} from "../../../domain/rageshake"; import {submitLogsFromSessionToDefaultServer} from "../../../domain/rageshake";

View File

@ -90,7 +90,7 @@ export class Session {
this._getSyncToken = () => this.syncToken; this._getSyncToken = () => this.syncToken;
this._olmWorker = olmWorker; this._olmWorker = olmWorker;
this._keyBackup = new ObservableValue(undefined); this._keyBackup = new ObservableValue(undefined);
this._crossSigning = undefined; this._crossSigning = new ObservableValue(undefined);
this._observedRoomStatus = new Map(); this._observedRoomStatus = new Map();
if (olm) { if (olm) {
@ -250,7 +250,7 @@ export class Session {
} }
if (this._keyBackup.get()) { if (this._keyBackup.get()) {
this._keyBackup.get().dispose(); this._keyBackup.get().dispose();
this._keyBackup.set(null); this._keyBackup.set(undefined);
} }
// TODO: stop cross-signing // TODO: stop cross-signing
const key = await ssssKeyFromCredential(type, credential, this._storage, this._platform, this._olm); 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 // 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
await this._writeSSSSKey(key, log); await this._writeSSSSKey(key, log);
await this._keyBackup?.start(log); await this._keyBackup.get()?.start(log);
await this._crossSigning?.start(log); await this._crossSigning.get()?.start(log);
return key; return key;
} else { } else {
throw new Error("Could not read key backup with the given key"); throw new Error("Could not read key backup with the given key");
@ -331,29 +331,21 @@ export class Session {
if (isValid) { if (isValid) {
await this._loadSecretStorageServices(secretStorage, txn, log); 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; return isValid;
}); });
} }
_loadSecretStorageServices(secretStorage, txn, log) { async _loadSecretStorageServices(secretStorage, txn, log) {
try { try {
await log.wrap("enable key backup", async log => { await log.wrap("enable key backup", async log => {
// TODO: delay network request here until start() const keyBackup = new KeyBackup(
const keyBackup = await KeyBackup.fromSecretStorage(
this._platform,
this._olm,
secretStorage,
this._hsApi, this._hsApi,
this._olm,
this._keyLoader, this._keyLoader,
this._storage, this._storage,
txn this._platform,
); );
if (keyBackup) { if (await keyBackup.load(secretStorage, txn)) {
for (const room of this._rooms.values()) { for (const room of this._rooms.values()) {
if (room.isEncrypted) { if (room.isEncrypted) {
room.enableKeyBackup(keyBackup); room.enableKeyBackup(keyBackup);
@ -378,8 +370,8 @@ export class Session {
ownUserId: this.userId, ownUserId: this.userId,
e2eeAccount: this._e2eeAccount e2eeAccount: this._e2eeAccount
}); });
if (crossSigning.load(txn, log)) { if (await crossSigning.load(txn, log)) {
this._crossSigning = crossSigning; this._crossSigning.set(crossSigning);
} }
}); });
} }
@ -585,8 +577,8 @@ export class Session {
} }
}); });
} }
this._keyBackup?.start(log); this._keyBackup.get()?.start(log);
this._crossSigning?.start(log); this._crossSigning.get()?.start(log);
// restore unfinished operations, like sending out room keys // restore unfinished operations, like sending out room keys
const opsTxn = await this._storage.readWriteTxn([ const opsTxn = await this._storage.readWriteTxn([

View File

@ -20,6 +20,8 @@ import {MEGOLM_ALGORITHM} from "../../common";
import * as Curve25519 from "./Curve25519"; import * as Curve25519 from "./Curve25519";
import {AbortableOperation} from "../../../../utils/AbortableOperation"; import {AbortableOperation} from "../../../../utils/AbortableOperation";
import {ObservableValue} from "../../../../observable/value"; import {ObservableValue} from "../../../../observable/value";
import {Deferred} from "../../../../utils/Deferred";
import {EventEmitter} from "../../../../utils/EventEmitter";
import {SetAbortableFn} from "../../../../utils/AbortableOperation"; import {SetAbortableFn} from "../../../../utils/AbortableOperation";
import type {BackupInfo, SessionData, SessionKeyInfo, SessionInfo, KeyBackupPayload} from "./types"; 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 {ILogItem} from "../../../../logging/types";
import type {Platform} from "../../../../platform/web/Platform"; import type {Platform} from "../../../../platform/web/Platform";
import type {Transaction} from "../../../storage/idb/Transaction"; import type {Transaction} from "../../../storage/idb/Transaction";
import type {IHomeServerRequest} from "../../../net/HomeServerRequest";
import type * as OlmNamespace from "@matrix-org/olm"; import type * as OlmNamespace from "@matrix-org/olm";
type Olm = typeof OlmNamespace; type Olm = typeof OlmNamespace;
const KEYS_PER_REQUEST = 200; const KEYS_PER_REQUEST = 200;
export class KeyBackup { // a set of fields we need to store once we've fetched
public readonly operationInProgress = new ObservableValue<AbortableOperation<Promise<void>, Progress> | undefined>(undefined); // 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 _stopped = false;
private _needsNewKey = false; private _needsNewKey = false;
private _hasBackedUpAllKeys = false; private _hasBackedUpAllKeys = false;
private _error?: Error; private _error?: Error;
private crypto?: Curve25519.BackupEncryption; private crypto?: Curve25519.BackupEncryption;
private backupInfo?: BackupInfo;
private privateKey?: Uint8Array;
private backupConfigDeferred: Deferred<BackupConfig | undefined> = new Deferred();
private backupInfoRequest?: IHomeServerRequest;
constructor( constructor(
private readonly backupInfo: BackupInfo,
private readonly hsApi: HomeServerApi, private readonly hsApi: HomeServerApi,
private readonly olm: Olm,
private readonly keyLoader: KeyLoader, private readonly keyLoader: KeyLoader,
private readonly storage: Storage, private readonly storage: Storage,
private readonly platform: Platform, private readonly platform: Platform,
private readonly maxDelay: number = 10000 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 hasStopped(): boolean { return this._stopped; }
get error(): Error | undefined { return this._error; } 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 needsNewKey(): boolean { return this._needsNewKey; }
get hasBackedUpAllKeys(): boolean { return this._hasBackedUpAllKeys; } 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> { async getRoomKey(roomId: string, sessionId: string, log: ILogItem): Promise<IncomingRoomKey | undefined> {
if (this.needsNewKey || !this.crypto) { if (this.needsNewKey) {
return; 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) { if (!sessionResponse.session_data) {
return; 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) { if (sessionKeyInfo?.algorithm === MEGOLM_ALGORITHM) {
return keyFromBackup(roomId, sessionId, sessionKeyInfo); return keyFromBackup(roomId, sessionId, sessionKeyInfo);
} else if (sessionKeyInfo?.algorithm) { } else if (sessionKeyInfo?.algorithm) {
@ -79,14 +107,53 @@ export class KeyBackup {
return txn.inboundGroupSessions.markAllAsNotBackedUp(); 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 async start(log: ILogItem) {
this.flush(log); 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 { flush(log: ILogItem): void {
if (!this.operationInProgress.get()) { if (!this._operationInProgress) {
log.wrapDetached("flush key backup", async log => { log.wrapDetached("flush key backup", async log => {
if (this._needsNewKey) { if (this._needsNewKey) {
log.set("needsNewKey", this._needsNewKey); log.set("needsNewKey", this._needsNewKey);
@ -96,7 +163,8 @@ export class KeyBackup {
this._error = undefined; this._error = undefined;
this._hasBackedUpAllKeys = false; this._hasBackedUpAllKeys = false;
const operation = this._runFlushOperation(log); const operation = this._runFlushOperation(log);
this.operationInProgress.set(operation); this._operationInProgress = operation;
this.emit("change");
try { try {
await operation.result; await operation.result;
this._hasBackedUpAllKeys = true; this._hasBackedUpAllKeys = true;
@ -113,13 +181,18 @@ export class KeyBackup {
} }
log.catch(err); log.catch(err);
} }
this.operationInProgress.set(undefined); this._operationInProgress = undefined;
this.emit("change");
}); });
} }
} }
private _runFlushOperation(log: ILogItem): AbortableOperation<Promise<void>, Progress> { private _runFlushOperation(log: ILogItem): AbortableOperation<Promise<void>, Progress> {
return new AbortableOperation(async (setAbortable, setProgress) => { return new AbortableOperation(async (setAbortable, setProgress) => {
const backupConfig = await this.backupConfigDeferred.promise;
if (!backupConfig) {
return;
}
let total = 0; let total = 0;
let amountFinished = 0; let amountFinished = 0;
while (true) { while (true) {
@ -138,8 +211,8 @@ export class KeyBackup {
log.set("total", total); log.set("total", total);
return; return;
} }
const payload = await this.encodeKeysForBackup(keysNeedingBackup); const payload = await this.encodeKeysForBackup(keysNeedingBackup, backupConfig.crypto);
const uploadRequest = this.hsApi.uploadRoomKeysToBackup(this.backupInfo.version, payload, {log}); const uploadRequest = this.hsApi.uploadRoomKeysToBackup(backupConfig.info.version, payload, {log});
setAbortable(uploadRequest); setAbortable(uploadRequest);
await uploadRequest.response(); await uploadRequest.response();
await this.markKeysAsBackedUp(keysNeedingBackup, setAbortable); 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 payload: KeyBackupPayload = { rooms: {} };
const payloadRooms = payload.rooms; const payloadRooms = payload.rooms;
for (const key of roomKeys) { for (const key of roomKeys) {
@ -157,7 +230,7 @@ export class KeyBackup {
if (!roomPayload) { if (!roomPayload) {
roomPayload = payloadRooms[key.roomId] = { sessions: {} }; roomPayload = payloadRooms[key.roomId] = { sessions: {} };
} }
roomPayload.sessions[key.sessionId] = await this.encodeRoomKey(key); roomPayload.sessions[key.sessionId] = await this.encodeRoomKey(key, crypto);
} }
return payload; return payload;
} }
@ -178,7 +251,7 @@ export class KeyBackup {
await txn.complete(); 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 => { return await this.keyLoader.useKey(roomKey, session => {
const firstMessageIndex = session.first_known_index(); const firstMessageIndex = session.first_known_index();
const sessionKey = session.export_session(firstMessageIndex); const sessionKey = session.export_session(firstMessageIndex);
@ -186,27 +259,14 @@ export class KeyBackup {
first_message_index: firstMessageIndex, first_message_index: firstMessageIndex,
forwarded_count: 0, forwarded_count: 0,
is_verified: false, is_verified: false,
session_data: this.crypto.encryptRoomKey(roomKey, sessionKey) session_data: crypto.encryptRoomKey(roomKey, sessionKey)
}; };
}); });
} }
dispose() { dispose() {
this.crypto?.dispose(); this.backupInfoRequest?.abort();
} this.backupConfigDeferred.value?.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}`);
}
}
} }
} }

View File

@ -50,10 +50,20 @@ export class SecretStorage {
const allAccountData = await txn.accountData.getAll(); const allAccountData = await txn.accountData.getAll();
for (const accountData of allAccountData) { for (const accountData of allAccountData) {
try { 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); const secret = await this._decryptAccountData(accountData);
return true; // decryption succeeded return true; // decryption succeeded
} catch (err) { } catch (err) {
continue; if (err instanceof DecryptionError && err.reason !== DecryptionFailure.NotEncryptedWithKey) {
throw err;
} else {
continue;
}
} }
} }
return false; return false;

View File

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import {TemplateView} from "../general/TemplateView"; import {TemplateView} from "../general/TemplateView";
import {KeyBackupSettingsView} from "../session/settings/KeyBackupSettingsView.js"; import {KeyBackupSettingsView} from "../session/settings/KeyBackupSettingsView";
export class AccountSetupView extends TemplateView { export class AccountSetupView extends TemplateView {
render(t, vm) { render(t, vm) {

View File

@ -14,32 +14,41 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "../../general/TemplateView"; import {TemplateView, Builder} from "../../general/TemplateView";
import {disableTargetCallback} from "../../general/utils"; 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 { export class KeyBackupSettingsView extends TemplateView<KeyBackupViewModel> {
render(t, vm) { render(t: Builder<KeyBackupViewModel>, vm: KeyBackupViewModel): ViewNode {
return t.div([ return t.div([
t.map(vm => vm.status, (status, t, vm) => { t.map(vm => vm.status, (status, t, vm) => {
switch (status) { switch (status) {
case "Enabled": return renderEnabled(t, vm); case Status.Enabled: return renderEnabled(t, vm);
case "NewVersionAvailable": return renderNewVersionAvailable(t, vm); case Status.NewVersionAvailable: return renderNewVersionAvailable(t, vm);
case "SetupKey": return renderEnableFromKey(t, vm); case Status.Setup: {
case "SetupPhrase": return renderEnableFromPhrase(t, vm); if (vm.setupKeyType === KeyType.Passphrase) {
case "Pending": return t.p(vm.i18n`Waiting to go online…`); 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) => { t.map(vm => vm.backupWriteStatus, (status, t, vm) => {
switch (status) { switch (status) {
case "Writing": { case BackupWriteStatus.Writing: {
const progress = t.progress({ const progress = t.progress({
min: 0, min: 0+"",
max: 100, max: 100+"",
value: vm => vm.backupPercentage, value: vm => vm.backupPercentage,
}); });
return t.div([`Backup in progress `, progress, " ", vm => vm.backupInProgressLabel]); return t.div([`Backup in progress `, progress, " ", vm => vm.backupInProgressLabel]);
} }
case "Stopped": { case BackupWriteStatus.Stopped: {
let label; let label;
const error = vm.backupError; const error = vm.backupError;
if (error) { if (error) {
@ -47,12 +56,12 @@ export class KeyBackupSettingsView extends TemplateView {
} else { } else {
label = `Backup has stopped`; 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.`); return t.p(`All keys are backed up.`);
default: default:
return null; return undefined;
} }
}), }),
t.if(vm => vm.isMasterKeyTrusted, t => { 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 = [ const items = [
t.p([vm.i18n`Key backup is enabled, using backup version ${vm.backupVersion}. `, t.button({onClick: () => vm.disable()}, vm.i18n`Disable`)]) 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); return t.div(items);
} }
function renderNewVersionAvailable(t, vm) { function renderNewVersionAvailable(t: Builder<KeyBackupViewModel>, vm: KeyBackupViewModel): ViewNode {
const items = [ 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`)]) 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); 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`); 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 ${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.`), 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`); 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 ${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.`), 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; let setupDehydrationCheck;
const eventHandler = () => callback(input.value, setupDehydrationCheck?.checked || false); const eventHandler = () => callback(input.value, setupDehydrationCheck?.checked || false);
const input = t.input({type: "password", disabled: vm => vm.isBusy, placeholder: label}); 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) { function renderError(t: Builder<KeyBackupViewModel>): ViewNode {
return t.if(vm => vm.error, (t, vm) => { return t.if(vm => vm.error !== undefined, (t, vm) => {
return t.div([ return t.div([
t.p({className: "error"}, vm => vm.i18n`Could not enable key backup: ${vm.error}.`), 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.`) t.p(vm.i18n`Try double checking that you did not mix up your security key, security phrase and login password as explained above.`)

View File

@ -16,7 +16,7 @@ limitations under the License.
import {TemplateView} from "../../general/TemplateView"; import {TemplateView} from "../../general/TemplateView";
import {disableTargetCallback} from "../../general/utils"; import {disableTargetCallback} from "../../general/utils";
import {KeyBackupSettingsView} from "./KeyBackupSettingsView.js" import {KeyBackupSettingsView} from "./KeyBackupSettingsView"
import {FeaturesView} from "./FeaturesView" import {FeaturesView} from "./FeaturesView"
export class SettingsView extends TemplateView { export class SettingsView extends TemplateView {

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {BaseObservableValue, ObservableValue} from "../observable/value"; import {EventEmitter} from "../utils/EventEmitter";
export interface IAbortable { export interface IAbortable {
abort(); abort();
@ -24,25 +24,27 @@ export type SetAbortableFn = (a: IAbortable) => typeof a;
export type SetProgressFn<P> = (progress: P) => void; export type SetProgressFn<P> = (progress: P) => void;
type RunFn<T, P> = (setAbortable: SetAbortableFn, setProgress: SetProgressFn<P>) => T; 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; public readonly result: T;
private _abortable?: IAbortable; private _abortable?: IAbortable;
private _progress: ObservableValue<P | undefined>; private _progress?: P;
constructor(run: RunFn<T, P>) { constructor(run: RunFn<T, P>) {
super();
this._abortable = undefined; this._abortable = undefined;
const setAbortable: SetAbortableFn = abortable => { const setAbortable: SetAbortableFn = abortable => {
this._abortable = abortable; this._abortable = abortable;
return abortable; return abortable;
}; };
this._progress = new ObservableValue<P | undefined>(undefined); this._progress = undefined;
const setProgress: SetProgressFn<P> = (progress: P) => { const setProgress: SetProgressFn<P> = (progress: P) => {
this._progress.set(progress); this._progress = progress;
this.emit("change", "progress");
}; };
this.result = run(setAbortable, setProgress); this.result = run(setAbortable, setProgress);
} }
get progress(): BaseObservableValue<P | undefined> { get progress(): P | undefined {
return this._progress; return this._progress;
} }

41
src/utils/Deferred.ts Normal file
View 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;
}
}