mirror of
https://github.com/vector-im/hydrogen-web.git
synced 2024-11-20 03:25:52 +01:00
Merge branch 'master' into sas-verification
This commit is contained in:
commit
3e7a4d95c3
@ -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) {
|
||||
|
@ -78,7 +78,7 @@ export class SessionLoadViewModel extends ViewModel {
|
||||
this._ready(client);
|
||||
}
|
||||
if (loadError) {
|
||||
console.error("session load error", loadError);
|
||||
console.error("session load error", loadError.stack);
|
||||
}
|
||||
} catch (err) {
|
||||
this._error = err;
|
||||
|
@ -17,6 +17,7 @@ limitations under the License.
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
import {RoomType} from "../../../matrix/room/common";
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
|
||||
import {UserTrust} from "../../../matrix/verification/CrossSigning";
|
||||
|
||||
export class MemberDetailsViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
@ -29,13 +30,60 @@ export class MemberDetailsViewModel extends ViewModel {
|
||||
this._session = options.session;
|
||||
this.track(this._powerLevelsObservable.subscribe(() => this._onPowerLevelsChange()));
|
||||
this.track(this._observableMember.subscribe( () => this._onMemberChange()));
|
||||
this.track(this._session.crossSigning.subscribe(() => {
|
||||
this.emitChange("trustShieldColor");
|
||||
}));
|
||||
this._userTrust = undefined;
|
||||
this.init(); // TODO: call this from parent view model and do something smart with error view model if it fails async?
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.features.crossSigning) {
|
||||
this._userTrust = await this.logger.run({l: "MemberDetailsViewModel.get user trust", id: this._member.userId}, log => {
|
||||
return this._session.crossSigning.get()?.getUserTrust(this._member.userId, log);
|
||||
});
|
||||
this.emitChange("trustShieldColor");
|
||||
}
|
||||
}
|
||||
|
||||
get name() { return this._member.name; }
|
||||
|
||||
get userId() { return this._member.userId; }
|
||||
|
||||
get trustDescription() {
|
||||
switch (this._userTrust) {
|
||||
case UserTrust.Trusted: return this.i18n`You have verified this user. This user has verified all of their sessions.`;
|
||||
case UserTrust.UserNotSigned: return this.i18n`You have not verified this user.`;
|
||||
case UserTrust.UserSignatureMismatch: return this.i18n`You appear to have signed this user, but the signature is invalid.`;
|
||||
case UserTrust.UserDeviceNotSigned: return this.i18n`You have verified this user, but they have one or more unverified sessions.`;
|
||||
case UserTrust.UserDeviceSignatureMismatch: return this.i18n`This user has a session signature that is invalid.`;
|
||||
case UserTrust.UserSetupError: return this.i18n`This user hasn't set up cross-signing correctly`;
|
||||
case UserTrust.OwnSetupError: return this.i18n`Cross-signing wasn't set up correctly on your side.`;
|
||||
default: return this.i18n`Pending…`;
|
||||
}
|
||||
}
|
||||
|
||||
get trustShieldColor() {
|
||||
if (!this._isEncrypted) {
|
||||
return undefined;
|
||||
}
|
||||
switch (this._userTrust) {
|
||||
case undefined:
|
||||
case UserTrust.OwnSetupError:
|
||||
return undefined;
|
||||
case UserTrust.Trusted:
|
||||
return "green";
|
||||
case UserTrust.UserNotSigned:
|
||||
return "black";
|
||||
default:
|
||||
return "red";
|
||||
}
|
||||
}
|
||||
|
||||
get type() { return "member-details"; }
|
||||
|
||||
get shouldShowBackButton() { return true; }
|
||||
|
||||
get previousSegmentName() { return "members"; }
|
||||
|
||||
get role() {
|
||||
@ -54,6 +102,14 @@ export class MemberDetailsViewModel extends ViewModel {
|
||||
this.emitChange("role");
|
||||
}
|
||||
|
||||
async signUser() {
|
||||
if (this._session.crossSigning) {
|
||||
await this.logger.run("MemberDetailsViewModel.signUser", async log => {
|
||||
await this._session.crossSigning.signUser(this.userId, log);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get avatarLetter() {
|
||||
return avatarInitials(this.name);
|
||||
}
|
||||
|
@ -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.get().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";
|
||||
|
||||
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {OLM_ALGORITHM} from "./e2ee/common.js";
|
||||
import {OLM_ALGORITHM} from "./e2ee/common";
|
||||
import {countBy, groupBy} from "../utils/groupBy";
|
||||
import {LRUCache} from "../utils/LRUCache";
|
||||
import {EventEmitter} from "../utils/EventEmitter";
|
||||
|
@ -33,9 +33,9 @@ import {KeyLoader as MegOlmKeyLoader} from "./e2ee/megolm/decryption/KeyLoader";
|
||||
import {KeyBackup} from "./e2ee/megolm/keybackup/KeyBackup";
|
||||
import {CrossSigning} from "./verification/CrossSigning";
|
||||
import {Encryption as MegOlmEncryption} from "./e2ee/megolm/Encryption.js";
|
||||
import {MEGOLM_ALGORITHM} from "./e2ee/common.js";
|
||||
import {MEGOLM_ALGORITHM} from "./e2ee/common";
|
||||
import {RoomEncryption} from "./e2ee/RoomEncryption.js";
|
||||
import {DeviceTracker} from "./e2ee/DeviceTracker.js";
|
||||
import {DeviceTracker} from "./e2ee/DeviceTracker";
|
||||
import {LockMap} from "../utils/LockMap";
|
||||
import {groupBy} from "../utils/groupBy";
|
||||
import {
|
||||
@ -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,18 +250,18 @@ export class Session {
|
||||
}
|
||||
if (this._keyBackup.get()) {
|
||||
this._keyBackup.get().dispose();
|
||||
this._keyBackup.set(null);
|
||||
this._keyBackup.set(undefined);
|
||||
}
|
||||
if (this._crossSigning.get()) {
|
||||
this._crossSigning.set(undefined);
|
||||
}
|
||||
const key = await ssssKeyFromCredential(type, credential, this._storage, this._platform, this._olm);
|
||||
// and create key backup, which needs to read from accountData
|
||||
const readTxn = await this._storage.readTxn([
|
||||
this._storage.storeNames.accountData,
|
||||
]);
|
||||
if (await this._createKeyBackup(key, readTxn, log)) {
|
||||
if (await this._tryLoadSecretStorage(key, log)) {
|
||||
// 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);
|
||||
this._keyBackup.get().flush(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");
|
||||
@ -315,42 +315,36 @@ export class Session {
|
||||
}
|
||||
}
|
||||
this._keyBackup.get().dispose();
|
||||
this._keyBackup.set(null);
|
||||
this._keyBackup.set(undefined);
|
||||
}
|
||||
if (this._crossSigning.get()) {
|
||||
this._crossSigning.set(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
_createKeyBackup(ssssKey, txn, log) {
|
||||
return log.wrap("enable key backup", async log => {
|
||||
try {
|
||||
const secretStorage = new SecretStorage({key: ssssKey, platform: this._platform});
|
||||
const keyBackup = await KeyBackup.fromSecretStorage(
|
||||
this._platform,
|
||||
this._olm,
|
||||
secretStorage,
|
||||
_tryLoadSecretStorage(ssssKey, log) {
|
||||
return log.wrap("enable secret storage", async log => {
|
||||
const secretStorage = new SecretStorage({key: ssssKey, platform: this._platform, storage: this._storage});
|
||||
const isValid = await secretStorage.hasValidKeyForAnyAccountData();
|
||||
log.set("isValid", isValid);
|
||||
if (isValid) {
|
||||
await this._loadSecretStorageServices(secretStorage, log);
|
||||
}
|
||||
return isValid;
|
||||
});
|
||||
}
|
||||
|
||||
async _loadSecretStorageServices(secretStorage, log) {
|
||||
try {
|
||||
await log.wrap("enable key backup", async log => {
|
||||
const keyBackup = new KeyBackup(
|
||||
this._hsApi,
|
||||
this._olm,
|
||||
this._keyLoader,
|
||||
this._storage,
|
||||
txn
|
||||
this._platform,
|
||||
);
|
||||
if (keyBackup) {
|
||||
if (this._features.crossSigning) {
|
||||
this._crossSigning = new CrossSigning({
|
||||
storage: this._storage,
|
||||
secretStorage,
|
||||
platform: this._platform,
|
||||
olm: this._olm,
|
||||
olmUtil: this._olmUtil,
|
||||
deviceTracker: this._deviceTracker,
|
||||
hsApi: this._hsApi,
|
||||
ownUserId: this.userId,
|
||||
deviceId: this.deviceId,
|
||||
e2eeAccount: this._e2eeAccount,
|
||||
deviceMessageHandler: this._deviceMessageHandler,
|
||||
});
|
||||
await log.wrap("enable cross-signing", log => {
|
||||
return this._crossSigning.init(log);
|
||||
});
|
||||
}
|
||||
if (await keyBackup.load(secretStorage, log)) {
|
||||
for (const room of this._rooms.values()) {
|
||||
if (room.isEncrypted) {
|
||||
room.enableKeyBackup(keyBackup);
|
||||
@ -361,11 +355,28 @@ export class Session {
|
||||
} else {
|
||||
log.set("no_backup", true);
|
||||
}
|
||||
} catch (err) {
|
||||
log.catch(err);
|
||||
});
|
||||
if (this._features.crossSigning) {
|
||||
await log.wrap("enable cross-signing", async log => {
|
||||
const crossSigning = new CrossSigning({
|
||||
storage: this._storage,
|
||||
secretStorage,
|
||||
platform: this._platform,
|
||||
olm: this._olm,
|
||||
olmUtil: this._olmUtil,
|
||||
deviceTracker: this._deviceTracker,
|
||||
hsApi: this._hsApi,
|
||||
ownUserId: this.userId,
|
||||
e2eeAccount: this._e2eeAccount
|
||||
});
|
||||
if (await crossSigning.load(log)) {
|
||||
this._crossSigning.set(crossSigning);
|
||||
}
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
} catch (err) {
|
||||
log.catch(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -469,6 +480,8 @@ export class Session {
|
||||
this._storage.storeNames.timelineEvents,
|
||||
this._storage.storeNames.timelineFragments,
|
||||
this._storage.storeNames.pendingEvents,
|
||||
this._storage.storeNames.accountData,
|
||||
this._storage.storeNames.crossSigningKeys,
|
||||
]);
|
||||
// restore session object
|
||||
this._syncInfo = await txn.session.get("sync");
|
||||
@ -483,10 +496,8 @@ export class Session {
|
||||
olmWorker: this._olmWorker,
|
||||
txn
|
||||
});
|
||||
if (this._e2eeAccount) {
|
||||
log.set("keys", this._e2eeAccount.identityKeys);
|
||||
this._setupEncryption();
|
||||
}
|
||||
log.set("keys", this._e2eeAccount.identityKeys);
|
||||
this._setupEncryption();
|
||||
}
|
||||
const pendingEventsByRoomId = await this._getPendingEventsByRoom(txn);
|
||||
// load invites
|
||||
@ -511,6 +522,14 @@ export class Session {
|
||||
room.setInvite(invite);
|
||||
}
|
||||
}
|
||||
if (this._olm && this._e2eeAccount) {
|
||||
// try set up session backup and cross-signing if we stored the ssss key
|
||||
const ssssKey = await ssssReadKey(txn);
|
||||
if (ssssKey) {
|
||||
// this will close the txn above, so we do it last
|
||||
await this._tryLoadSecretStorage(ssssKey, log);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
@ -546,35 +565,21 @@ export class Session {
|
||||
// TODO: what can we do if this throws?
|
||||
await txn.complete();
|
||||
}
|
||||
// enable session backup, this requests the latest backup version
|
||||
if (!this._keyBackup.get()) {
|
||||
if (dehydratedDevice) {
|
||||
await log.wrap("SSSSKeyFromDehydratedDeviceKey", async log => {
|
||||
const ssssKey = await createSSSSKeyFromDehydratedDeviceKey(dehydratedDevice.key, this._storage, this._platform);
|
||||
if (ssssKey) {
|
||||
// try if the key used to decrypt the dehydrated device also fits for secret storage
|
||||
if (dehydratedDevice) {
|
||||
await log.wrap("SSSSKeyFromDehydratedDeviceKey", async log => {
|
||||
const ssssKey = await createSSSSKeyFromDehydratedDeviceKey(dehydratedDevice.key, this._storage, this._platform);
|
||||
if (ssssKey) {
|
||||
if (await this._tryLoadSecretStorage(ssssKey, log)) {
|
||||
log.set("success", true);
|
||||
await this._writeSSSSKey(ssssKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
const txn = await this._storage.readTxn([
|
||||
this._storage.storeNames.session,
|
||||
this._storage.storeNames.accountData,
|
||||
]);
|
||||
// try set up session backup if we stored the ssss key
|
||||
const ssssKey = await ssssReadKey(txn);
|
||||
if (ssssKey) {
|
||||
// txn will end here as this does a network request
|
||||
if (await this._createKeyBackup(ssssKey, txn, log)) {
|
||||
this._keyBackup.get()?.flush(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);
|
||||
}
|
||||
});
|
||||
}
|
||||
await this._keyBackup.get()?.start(log);
|
||||
await this._crossSigning.get()?.start(log);
|
||||
|
||||
// restore unfinished operations, like sending out room keys
|
||||
const opsTxn = await this._storage.readWriteTxn([
|
||||
this._storage.storeNames.operations
|
||||
|
@ -218,7 +218,7 @@ export class Sync {
|
||||
_openPrepareSyncTxn() {
|
||||
const storeNames = this._storage.storeNames;
|
||||
return this._storage.readTxn([
|
||||
storeNames.deviceIdentities, // to read device from olm messages
|
||||
storeNames.deviceKeys, // to read device from olm messages
|
||||
storeNames.olmSessions,
|
||||
storeNames.inboundGroupSessions,
|
||||
// to read fragments when loading sync writer when rejoining archived room
|
||||
@ -329,7 +329,7 @@ export class Sync {
|
||||
storeNames.pendingEvents,
|
||||
storeNames.userIdentities,
|
||||
storeNames.groupSessionDecryptions,
|
||||
storeNames.deviceIdentities,
|
||||
storeNames.deviceKeys,
|
||||
// to discard outbound session when somebody leaves a room
|
||||
// and to create room key messages when somebody joins
|
||||
storeNames.outboundGroupSessions,
|
||||
|
@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import anotherjson from "another-json";
|
||||
import {SESSION_E2EE_KEY_PREFIX, OLM_ALGORITHM, MEGOLM_ALGORITHM} from "./common.js";
|
||||
import {SESSION_E2EE_KEY_PREFIX, OLM_ALGORITHM, MEGOLM_ALGORITHM} from "./common";
|
||||
|
||||
// use common prefix so it's easy to clear properties that are not e2ee related during session clear
|
||||
const ACCOUNT_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "olmAccount";
|
||||
@ -259,7 +259,7 @@ export class Account {
|
||||
return obj;
|
||||
}
|
||||
|
||||
getDeviceKeysToSignWithCrossSigning() {
|
||||
getUnsignedDeviceKey() {
|
||||
const identityKeys = JSON.parse(this._account.identity_keys());
|
||||
return this._keysAsSignableObject(identityKeys);
|
||||
}
|
||||
|
@ -26,7 +26,8 @@ limitations under the License.
|
||||
* see DeviceTracker
|
||||
*/
|
||||
|
||||
import type {DeviceIdentity} from "../storage/idb/stores/DeviceIdentityStore";
|
||||
import {getDeviceEd25519Key} from "./common";
|
||||
import type {DeviceKey} from "./common";
|
||||
import type {TimelineEvent} from "../storage/types";
|
||||
|
||||
type DecryptedEvent = {
|
||||
@ -35,7 +36,7 @@ type DecryptedEvent = {
|
||||
}
|
||||
|
||||
export class DecryptionResult {
|
||||
private device?: DeviceIdentity;
|
||||
private device?: DeviceKey;
|
||||
|
||||
constructor(
|
||||
public readonly event: DecryptedEvent,
|
||||
@ -44,13 +45,13 @@ export class DecryptionResult {
|
||||
public readonly encryptedEvent?: TimelineEvent
|
||||
) {}
|
||||
|
||||
setDevice(device: DeviceIdentity): void {
|
||||
setDevice(device: DeviceKey): void {
|
||||
this.device = device;
|
||||
}
|
||||
|
||||
get isVerified(): boolean {
|
||||
if (this.device) {
|
||||
const comesFromDevice = this.device.ed25519Key === this.claimedEd25519Key;
|
||||
const comesFromDevice = getDeviceEd25519Key(this.device) === this.claimedEd25519Key;
|
||||
return comesFromDevice;
|
||||
}
|
||||
return false;
|
||||
@ -65,11 +66,11 @@ export class DecryptionResult {
|
||||
}
|
||||
|
||||
get userId(): string | undefined {
|
||||
return this.device?.userId;
|
||||
return this.device?.user_id;
|
||||
}
|
||||
|
||||
get deviceId(): string | undefined {
|
||||
return this.device?.deviceId;
|
||||
return this.device?.device_id;
|
||||
}
|
||||
|
||||
get isVerificationUnknown(): boolean {
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MEGOLM_ALGORITHM, DecryptionSource} from "./common.js";
|
||||
import {MEGOLM_ALGORITHM, DecryptionSource} from "./common";
|
||||
import {groupEventsBySession} from "./megolm/decryption/utils";
|
||||
import {mergeMap} from "../../utils/mergeMap";
|
||||
import {groupBy} from "../../utils/groupBy";
|
||||
@ -235,7 +235,7 @@ export class RoomEncryption {
|
||||
// Use devicesForUsers rather than devicesForRoomMembers as the room might not be tracked yet
|
||||
await this._deviceTracker.devicesForUsers(sendersWithoutDevice, hsApi, log);
|
||||
// now that we've fetched the missing devices, try verifying the results again
|
||||
const txn = await this._storage.readTxn([this._storage.storeNames.deviceIdentities]);
|
||||
const txn = await this._storage.readTxn([this._storage.storeNames.deviceKeys]);
|
||||
await this._verifyDecryptionResults(resultsWithoutDevice, txn);
|
||||
const resultsWithFoundDevice = resultsWithoutDevice.filter(r => !r.isVerificationUnknown);
|
||||
const resultsToEventIdMap = resultsWithFoundDevice.reduce((map, r) => {
|
||||
|
@ -1,96 +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 anotherjson from "another-json";
|
||||
import {createEnum} from "../../utils/enum";
|
||||
|
||||
export const DecryptionSource = createEnum("Sync", "Timeline", "Retry");
|
||||
|
||||
// use common prefix so it's easy to clear properties that are not e2ee related during session clear
|
||||
export const SESSION_E2EE_KEY_PREFIX = "e2ee:";
|
||||
export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2";
|
||||
export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2";
|
||||
|
||||
export class DecryptionError extends Error {
|
||||
constructor(code, event, detailsObj = null) {
|
||||
super(`Decryption error ${code}${detailsObj ? ": "+JSON.stringify(detailsObj) : ""}`);
|
||||
this.code = code;
|
||||
this.event = event;
|
||||
this.details = detailsObj;
|
||||
}
|
||||
}
|
||||
|
||||
export const SIGNATURE_ALGORITHM = "ed25519";
|
||||
|
||||
export function verifyEd25519Signature(olmUtil, userId, deviceOrKeyId, ed25519Key, value, log = undefined) {
|
||||
const clone = Object.assign({}, value);
|
||||
delete clone.unsigned;
|
||||
delete clone.signatures;
|
||||
const canonicalJson = anotherjson.stringify(clone);
|
||||
const signature = value?.signatures?.[userId]?.[`${SIGNATURE_ALGORITHM}:${deviceOrKeyId}`];
|
||||
try {
|
||||
if (!signature) {
|
||||
throw new Error("no signature");
|
||||
}
|
||||
// throws when signature is invalid
|
||||
olmUtil.ed25519_verify(ed25519Key, canonicalJson, signature);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (log) {
|
||||
const logItem = log.log({l: "Invalid signature, ignoring.", ed25519Key, canonicalJson, signature});
|
||||
logItem.error = err;
|
||||
logItem.logLevel = log.level.Warn;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createRoomEncryptionEvent() {
|
||||
return {
|
||||
"type": "m.room.encryption",
|
||||
"state_key": "",
|
||||
"content": {
|
||||
"algorithm": MEGOLM_ALGORITHM,
|
||||
"rotation_period_ms": 604800000,
|
||||
"rotation_period_msgs": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Use enum when converting to TS
|
||||
export const HistoryVisibility = Object.freeze({
|
||||
Joined: "joined",
|
||||
Invited: "invited",
|
||||
WorldReadable: "world_readable",
|
||||
Shared: "shared",
|
||||
});
|
||||
|
||||
export function shouldShareKey(membership, historyVisibility) {
|
||||
switch (historyVisibility) {
|
||||
case HistoryVisibility.WorldReadable:
|
||||
return true;
|
||||
case HistoryVisibility.Shared:
|
||||
// was part of room at some time
|
||||
return membership !== undefined;
|
||||
case HistoryVisibility.Joined:
|
||||
return membership === "join";
|
||||
case HistoryVisibility.Invited:
|
||||
return membership === "invite" || membership === "join";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
134
src/matrix/e2ee/common.ts
Normal file
134
src/matrix/e2ee/common.ts
Normal file
@ -0,0 +1,134 @@
|
||||
/*
|
||||
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 anotherjson from "another-json";
|
||||
|
||||
import type {UnsentStateEvent} from "../room/common";
|
||||
import type {ILogItem} from "../../logging/types";
|
||||
import type * as OlmNamespace from "@matrix-org/olm";
|
||||
type Olm = typeof OlmNamespace;
|
||||
|
||||
export enum DecryptionSource {
|
||||
Sync, Timeline, Retry
|
||||
};
|
||||
|
||||
// use common prefix so it's easy to clear properties that are not e2ee related during session clear
|
||||
export const SESSION_E2EE_KEY_PREFIX = "e2ee:";
|
||||
export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2";
|
||||
export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2";
|
||||
|
||||
export class DecryptionError extends Error {
|
||||
constructor(private readonly code: string, private readonly event: object, private readonly detailsObj?: object) {
|
||||
super(`Decryption error ${code}${detailsObj ? ": "+JSON.stringify(detailsObj) : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const SIGNATURE_ALGORITHM = "ed25519";
|
||||
|
||||
export type SignedValue = {
|
||||
signatures?: {[userId: string]: {[keyId: string]: string}}
|
||||
unsigned?: object
|
||||
}
|
||||
|
||||
// we store device keys (and cross-signing) in the format we get them from the server
|
||||
// as that is what the signature is calculated on, so to verify and sign, we need
|
||||
// it in this format anyway.
|
||||
export type DeviceKey = SignedValue & {
|
||||
readonly user_id: string;
|
||||
readonly device_id: string;
|
||||
readonly algorithms: ReadonlyArray<string>;
|
||||
readonly keys: {[keyId: string]: string};
|
||||
readonly unsigned: {
|
||||
device_display_name?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function getDeviceEd25519Key(deviceKey: DeviceKey): string {
|
||||
return deviceKey.keys[`ed25519:${deviceKey.device_id}`];
|
||||
}
|
||||
|
||||
export function getDeviceCurve25519Key(deviceKey: DeviceKey): string {
|
||||
return deviceKey.keys[`curve25519:${deviceKey.device_id}`];
|
||||
}
|
||||
|
||||
export function getEd25519Signature(signedValue: SignedValue, userId: string, deviceOrKeyId: string): string | undefined {
|
||||
return signedValue?.signatures?.[userId]?.[`${SIGNATURE_ALGORITHM}:${deviceOrKeyId}`];
|
||||
}
|
||||
|
||||
export enum SignatureVerification {
|
||||
Valid,
|
||||
Invalid,
|
||||
NotSigned,
|
||||
}
|
||||
|
||||
export function verifyEd25519Signature(olmUtil: Olm.Utility, userId: string, deviceOrKeyId: string, ed25519Key: string, value: SignedValue, log?: ILogItem): SignatureVerification {
|
||||
const signature = getEd25519Signature(value, userId, deviceOrKeyId);
|
||||
if (!signature) {
|
||||
log?.set("no_signature", true);
|
||||
return SignatureVerification.NotSigned;
|
||||
}
|
||||
const clone = Object.assign({}, value) as object;
|
||||
delete clone["unsigned"];
|
||||
delete clone["signatures"];
|
||||
const canonicalJson = anotherjson.stringify(clone);
|
||||
try {
|
||||
// throws when signature is invalid
|
||||
olmUtil.ed25519_verify(ed25519Key, canonicalJson, signature);
|
||||
return SignatureVerification.Valid;
|
||||
} catch (err) {
|
||||
if (log) {
|
||||
const logItem = log.log({l: "Invalid signature, ignoring.", ed25519Key, canonicalJson, signature});
|
||||
logItem.error = err;
|
||||
logItem.logLevel = log.level.Warn;
|
||||
}
|
||||
return SignatureVerification.Invalid;
|
||||
}
|
||||
}
|
||||
|
||||
export function createRoomEncryptionEvent(): UnsentStateEvent {
|
||||
return {
|
||||
"type": "m.room.encryption",
|
||||
"state_key": "",
|
||||
"content": {
|
||||
"algorithm": MEGOLM_ALGORITHM,
|
||||
"rotation_period_ms": 604800000,
|
||||
"rotation_period_msgs": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export enum HistoryVisibility {
|
||||
Joined = "joined",
|
||||
Invited = "invited",
|
||||
WorldReadable = "world_readable",
|
||||
Shared = "shared",
|
||||
};
|
||||
|
||||
export function shouldShareKey(membership: string, historyVisibility: HistoryVisibility) {
|
||||
switch (historyVisibility) {
|
||||
case HistoryVisibility.WorldReadable:
|
||||
return true;
|
||||
case HistoryVisibility.Shared:
|
||||
// was part of room at some time
|
||||
return membership !== undefined;
|
||||
case HistoryVisibility.Joined:
|
||||
return membership === "join";
|
||||
case HistoryVisibility.Invited:
|
||||
return membership === "invite" || membership === "join";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
@ -14,10 +14,9 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {DecryptionError} from "../common.js";
|
||||
import {DecryptionPreparation} from "./decryption/DecryptionPreparation.js";
|
||||
import {SessionDecryption} from "./decryption/SessionDecryption";
|
||||
import {MEGOLM_ALGORITHM} from "../common.js";
|
||||
import {DecryptionError, MEGOLM_ALGORITHM} from "../common";
|
||||
import {validateEvent, groupEventsBySession} from "./decryption/utils";
|
||||
import {keyFromStorage, keyFromDeviceMessage, keyFromBackup} from "./decryption/RoomKey";
|
||||
import type {RoomKey, IncomingRoomKey} from "./decryption/RoomKey";
|
||||
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MEGOLM_ALGORITHM} from "../common.js";
|
||||
import {MEGOLM_ALGORITHM} from "../common";
|
||||
import {OutboundRoomKey} from "./decryption/RoomKey";
|
||||
|
||||
export class Encryption {
|
||||
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {DecryptionError} from "../../common.js";
|
||||
import {DecryptionError} from "../../common";
|
||||
|
||||
export class DecryptionChanges {
|
||||
constructor(roomId, results, errors, replayEntries) {
|
||||
|
@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import {DecryptionResult} from "../../DecryptionResult";
|
||||
import {DecryptionError} from "../../common.js";
|
||||
import {DecryptionError} from "../../common";
|
||||
import {ReplayDetectionEntry} from "./ReplayDetectionEntry";
|
||||
import type {RoomKey} from "./RoomKey";
|
||||
import type {KeyLoader, OlmDecryptionResult} from "./KeyLoader";
|
||||
|
@ -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,41 +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 crypto: Curve25519.BackupEncryption,
|
||||
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> {
|
||||
const sessionResponse = await this.hsApi.roomKeyForRoomAndSession(this.backupInfo.version, roomId, sessionId, {log}).response();
|
||||
if (this.needsNewKey) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
@ -77,8 +107,52 @@ export class KeyBackup {
|
||||
return txn.inboundGroupSessions.markAllAsNotBackedUp();
|
||||
}
|
||||
|
||||
async load(secretStorage: SecretStorage, log: ILogItem) {
|
||||
const base64PrivateKey = await secretStorage.readSecret("m.megolm_backup.v1");
|
||||
if (base64PrivateKey) {
|
||||
this.privateKey = new Uint8Array(this.platform.encoding.base64.decode(base64PrivateKey));
|
||||
return true;
|
||||
} else {
|
||||
this.backupConfigDeferred.resolve(undefined);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@ -88,7 +162,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;
|
||||
@ -105,13 +180,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) {
|
||||
@ -130,8 +210,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);
|
||||
@ -141,7 +221,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) {
|
||||
@ -149,7 +229,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;
|
||||
}
|
||||
@ -170,7 +250,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);
|
||||
@ -178,27 +258,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, crypto, hsApi, keyLoader, storage, platform);
|
||||
} else {
|
||||
throw new Error(`Unknown backup algorithm: ${backupInfo.algorithm}`);
|
||||
}
|
||||
}
|
||||
this.backupInfoRequest?.abort();
|
||||
this.backupConfigDeferred.value?.crypto?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -42,7 +42,7 @@ export type SessionInfo = {
|
||||
}
|
||||
|
||||
export type MegOlmSessionKeyInfo = {
|
||||
algorithm: MEGOLM_ALGORITHM,
|
||||
algorithm: typeof MEGOLM_ALGORITHM,
|
||||
sender_key: string,
|
||||
sender_claimed_keys: {[algorithm: string]: string},
|
||||
forwarding_curve25519_key_chain: string[],
|
||||
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {DecryptionError} from "../common.js";
|
||||
import {DecryptionError} from "../common";
|
||||
import {groupBy} from "../../../utils/groupBy";
|
||||
import {MultiLock, ILock} from "../../../utils/Lock";
|
||||
import {Session} from "./Session";
|
||||
|
@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import {groupByWithCreator} from "../../../utils/groupBy";
|
||||
import {verifyEd25519Signature, OLM_ALGORITHM} from "../common.js";
|
||||
import {verifyEd25519Signature, OLM_ALGORITHM, getDeviceCurve25519Key, getDeviceEd25519Key, SignatureVerification} from "../common";
|
||||
import {createSessionEntry} from "./Session";
|
||||
|
||||
import type {OlmMessage, OlmPayload, OlmEncryptedMessageContent} from "./types";
|
||||
@ -24,7 +24,7 @@ import type {LockMap} from "../../../utils/LockMap";
|
||||
import {Lock, MultiLock, ILock} from "../../../utils/Lock";
|
||||
import type {Storage} from "../../storage/idb/Storage";
|
||||
import type {Transaction} from "../../storage/idb/Transaction";
|
||||
import type {DeviceIdentity} from "../../storage/idb/stores/DeviceIdentityStore";
|
||||
import type {DeviceKey} from "../common";
|
||||
import type {HomeServerApi} from "../../net/HomeServerApi";
|
||||
import type {ILogItem} from "../../../logging/types";
|
||||
import type * as OlmNamespace from "@matrix-org/olm";
|
||||
@ -99,7 +99,7 @@ export class Encryption {
|
||||
return new MultiLock(locks);
|
||||
}
|
||||
|
||||
async encrypt(type: string, content: Record<string, any>, devices: DeviceIdentity[], hsApi: HomeServerApi, log: ILogItem): Promise<EncryptedMessage[]> {
|
||||
async encrypt(type: string, content: Record<string, any>, devices: DeviceKey[], hsApi: HomeServerApi, log: ILogItem): Promise<EncryptedMessage[]> {
|
||||
let messages: EncryptedMessage[] = [];
|
||||
for (let i = 0; i < devices.length ; i += MAX_BATCH_SIZE) {
|
||||
const batchDevices = devices.slice(i, i + MAX_BATCH_SIZE);
|
||||
@ -115,12 +115,12 @@ export class Encryption {
|
||||
return messages;
|
||||
}
|
||||
|
||||
async _encryptForMaxDevices(type: string, content: Record<string, any>, devices: DeviceIdentity[], hsApi: HomeServerApi, log: ILogItem): Promise<EncryptedMessage[]> {
|
||||
async _encryptForMaxDevices(type: string, content: Record<string, any>, devices: DeviceKey[], hsApi: HomeServerApi, log: ILogItem): Promise<EncryptedMessage[]> {
|
||||
// TODO: see if we can only hold some of the locks until after the /keys/claim call (if needed)
|
||||
// take a lock on all senderKeys so decryption and other calls to encrypt (should not happen)
|
||||
// don't modify the sessions at the same time
|
||||
const locks = await Promise.all(devices.map(device => {
|
||||
return this.senderKeyLock.takeLock(device.curve25519Key);
|
||||
return this.senderKeyLock.takeLock(getDeviceCurve25519Key(device));
|
||||
}));
|
||||
try {
|
||||
const {
|
||||
@ -158,10 +158,10 @@ export class Encryption {
|
||||
}
|
||||
}
|
||||
|
||||
async _findExistingSessions(devices: DeviceIdentity[]): Promise<{devicesWithoutSession: DeviceIdentity[], existingEncryptionTargets: EncryptionTarget[]}> {
|
||||
async _findExistingSessions(devices: DeviceKey[]): Promise<{devicesWithoutSession: DeviceKey[], existingEncryptionTargets: EncryptionTarget[]}> {
|
||||
const txn = await this.storage.readTxn([this.storage.storeNames.olmSessions]);
|
||||
const sessionIdsForDevice = await Promise.all(devices.map(async device => {
|
||||
return await txn.olmSessions.getSessionIds(device.curve25519Key);
|
||||
return await txn.olmSessions.getSessionIds(getDeviceCurve25519Key(device));
|
||||
}));
|
||||
const devicesWithoutSession = devices.filter((_, i) => {
|
||||
const sessionIds = sessionIdsForDevice[i];
|
||||
@ -184,36 +184,36 @@ export class Encryption {
|
||||
const plaintext = JSON.stringify(this._buildPlainTextMessageForDevice(type, content, device));
|
||||
const message = session!.encrypt(plaintext);
|
||||
const encryptedContent = {
|
||||
algorithm: OLM_ALGORITHM,
|
||||
algorithm: OLM_ALGORITHM as typeof OLM_ALGORITHM,
|
||||
sender_key: this.account.identityKeys.curve25519,
|
||||
ciphertext: {
|
||||
[device.curve25519Key]: message
|
||||
[getDeviceCurve25519Key(device)]: message
|
||||
}
|
||||
};
|
||||
return encryptedContent;
|
||||
}
|
||||
|
||||
_buildPlainTextMessageForDevice(type: string, content: Record<string, any>, device: DeviceIdentity): OlmPayload {
|
||||
_buildPlainTextMessageForDevice(type: string, content: Record<string, any>, device: DeviceKey): OlmPayload {
|
||||
return {
|
||||
keys: {
|
||||
"ed25519": this.account.identityKeys.ed25519
|
||||
},
|
||||
recipient_keys: {
|
||||
"ed25519": device.ed25519Key
|
||||
"ed25519": getDeviceEd25519Key(device)
|
||||
},
|
||||
recipient: device.userId,
|
||||
recipient: device.user_id,
|
||||
sender: this.ownUserId,
|
||||
content,
|
||||
type
|
||||
}
|
||||
}
|
||||
|
||||
async _createNewSessions(devicesWithoutSession: DeviceIdentity[], hsApi: HomeServerApi, timestamp: number, log: ILogItem): Promise<EncryptionTarget[]> {
|
||||
async _createNewSessions(devicesWithoutSession: DeviceKey[], hsApi: HomeServerApi, timestamp: number, log: ILogItem): Promise<EncryptionTarget[]> {
|
||||
const newEncryptionTargets = await log.wrap("claim", log => this._claimOneTimeKeys(hsApi, devicesWithoutSession, log));
|
||||
try {
|
||||
for (const target of newEncryptionTargets) {
|
||||
const {device, oneTimeKey} = target;
|
||||
target.session = await this.account.createOutboundOlmSession(device.curve25519Key, oneTimeKey);
|
||||
target.session = await this.account.createOutboundOlmSession(getDeviceCurve25519Key(device), oneTimeKey);
|
||||
}
|
||||
await this._storeSessions(newEncryptionTargets, timestamp);
|
||||
} catch (err) {
|
||||
@ -225,16 +225,16 @@ export class Encryption {
|
||||
return newEncryptionTargets;
|
||||
}
|
||||
|
||||
async _claimOneTimeKeys(hsApi: HomeServerApi, deviceIdentities: DeviceIdentity[], log: ILogItem): Promise<EncryptionTarget[]> {
|
||||
async _claimOneTimeKeys(hsApi: HomeServerApi, deviceIdentities: DeviceKey[], log: ILogItem): Promise<EncryptionTarget[]> {
|
||||
// create a Map<userId, Map<deviceId, deviceIdentity>>
|
||||
const devicesByUser = groupByWithCreator(deviceIdentities,
|
||||
(device: DeviceIdentity) => device.userId,
|
||||
(): Map<string, DeviceIdentity> => new Map(),
|
||||
(deviceMap: Map<string, DeviceIdentity>, device: DeviceIdentity) => deviceMap.set(device.deviceId, device)
|
||||
(device: DeviceKey) => device.user_id,
|
||||
(): Map<string, DeviceKey> => new Map(),
|
||||
(deviceMap: Map<string, DeviceKey>, device: DeviceKey) => deviceMap.set(device.device_id, device)
|
||||
);
|
||||
const oneTimeKeys = Array.from(devicesByUser.entries()).reduce((usersObj, [userId, deviceMap]) => {
|
||||
usersObj[userId] = Array.from(deviceMap.values()).reduce((devicesObj, device) => {
|
||||
devicesObj[device.deviceId] = OTK_ALGORITHM;
|
||||
devicesObj[device.device_id] = OTK_ALGORITHM;
|
||||
return devicesObj;
|
||||
}, {});
|
||||
return usersObj;
|
||||
@ -250,7 +250,7 @@ export class Encryption {
|
||||
return this._verifyAndCreateOTKTargets(userKeyMap, devicesByUser, log);
|
||||
}
|
||||
|
||||
_verifyAndCreateOTKTargets(userKeyMap: ClaimedOTKResponse, devicesByUser: Map<string, Map<string, DeviceIdentity>>, log: ILogItem): EncryptionTarget[] {
|
||||
_verifyAndCreateOTKTargets(userKeyMap: ClaimedOTKResponse, devicesByUser: Map<string, Map<string, DeviceKey>>, log: ILogItem): EncryptionTarget[] {
|
||||
const verifiedEncryptionTargets: EncryptionTarget[] = [];
|
||||
for (const [userId, userSection] of Object.entries(userKeyMap)) {
|
||||
for (const [deviceId, deviceSection] of Object.entries(userSection)) {
|
||||
@ -260,7 +260,7 @@ export class Encryption {
|
||||
const device = devicesByUser.get(userId)?.get(deviceId);
|
||||
if (device) {
|
||||
const isValidSignature = verifyEd25519Signature(
|
||||
this.olmUtil, userId, deviceId, device.ed25519Key, keySection, log);
|
||||
this.olmUtil, userId, deviceId, getDeviceEd25519Key(device), keySection, log) === SignatureVerification.Valid;
|
||||
if (isValidSignature) {
|
||||
const target = EncryptionTarget.fromOTK(device, keySection.key);
|
||||
verifiedEncryptionTargets.push(target);
|
||||
@ -281,7 +281,7 @@ export class Encryption {
|
||||
try {
|
||||
await Promise.all(encryptionTargets.map(async encryptionTarget => {
|
||||
const sessionEntry = await txn.olmSessions.get(
|
||||
encryptionTarget.device.curve25519Key, encryptionTarget.sessionId!);
|
||||
getDeviceCurve25519Key(encryptionTarget.device), encryptionTarget.sessionId!);
|
||||
if (sessionEntry && !failed) {
|
||||
const olmSession = new this.olm.Session();
|
||||
olmSession.unpickle(this.pickleKey, sessionEntry.session);
|
||||
@ -303,7 +303,7 @@ export class Encryption {
|
||||
try {
|
||||
for (const target of encryptionTargets) {
|
||||
const sessionEntry = createSessionEntry(
|
||||
target.session!, target.device.curve25519Key, timestamp, this.pickleKey);
|
||||
target.session!, getDeviceCurve25519Key(target.device), timestamp, this.pickleKey);
|
||||
txn.olmSessions.set(sessionEntry);
|
||||
}
|
||||
} catch (err) {
|
||||
@ -323,16 +323,16 @@ class EncryptionTarget {
|
||||
public session: Olm.Session | null = null;
|
||||
|
||||
constructor(
|
||||
public readonly device: DeviceIdentity,
|
||||
public readonly device: DeviceKey,
|
||||
public readonly oneTimeKey: string | null,
|
||||
public readonly sessionId: string | null
|
||||
) {}
|
||||
|
||||
static fromOTK(device: DeviceIdentity, oneTimeKey: string): EncryptionTarget {
|
||||
static fromOTK(device: DeviceKey, oneTimeKey: string): EncryptionTarget {
|
||||
return new EncryptionTarget(device, oneTimeKey, null);
|
||||
}
|
||||
|
||||
static fromSessionId(device: DeviceIdentity, sessionId: string): EncryptionTarget {
|
||||
static fromSessionId(device: DeviceKey, sessionId: string): EncryptionTarget {
|
||||
return new EncryptionTarget(device, null, sessionId);
|
||||
}
|
||||
|
||||
@ -346,6 +346,6 @@ class EncryptionTarget {
|
||||
export class EncryptedMessage {
|
||||
constructor(
|
||||
public readonly content: OlmEncryptedMessageContent,
|
||||
public readonly device: DeviceIdentity
|
||||
public readonly device: DeviceKey
|
||||
) {}
|
||||
}
|
||||
|
@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type {OLM_ALGORITHM} from "../common";
|
||||
|
||||
export const enum OlmPayloadType {
|
||||
PreKey = 0,
|
||||
Normal = 1
|
||||
@ -25,7 +27,7 @@ export type OlmMessage = {
|
||||
}
|
||||
|
||||
export type OlmEncryptedMessageContent = {
|
||||
algorithm?: "m.olm.v1.curve25519-aes-sha2"
|
||||
algorithm?: typeof OLM_ALGORITHM
|
||||
sender_key?: string,
|
||||
ciphertext?: {
|
||||
[deviceCurve25519Key: string]: OlmMessage
|
||||
|
@ -26,7 +26,7 @@ import {MemberList} from "./members/MemberList.js";
|
||||
import {Heroes} from "./members/Heroes.js";
|
||||
import {EventEntry} from "./timeline/entries/EventEntry.js";
|
||||
import {ObservedEventMap} from "./ObservedEventMap.js";
|
||||
import {DecryptionSource} from "../e2ee/common.js";
|
||||
import {DecryptionSource} from "../e2ee/common";
|
||||
import {ensureLogItem} from "../../logging/utils";
|
||||
import {PowerLevels} from "./PowerLevels.js";
|
||||
import {RetainedObservableValue} from "../../observable/value";
|
||||
@ -173,7 +173,7 @@ export class BaseRoom extends EventEmitter {
|
||||
const isTimelineOpen = this._isTimelineOpen;
|
||||
if (isTimelineOpen) {
|
||||
// read to fetch devices if timeline is open
|
||||
stores.push(this._storage.storeNames.deviceIdentities);
|
||||
stores.push(this._storage.storeNames.deviceKeys);
|
||||
}
|
||||
const writeTxn = await this._storage.readWriteTxn(stores);
|
||||
let decryption;
|
||||
|
@ -22,7 +22,7 @@ import {SendQueue} from "./sending/SendQueue.js";
|
||||
import {WrappedError} from "../error.js"
|
||||
import {Heroes} from "./members/Heroes.js";
|
||||
import {AttachmentUpload} from "./AttachmentUpload.js";
|
||||
import {DecryptionSource} from "../e2ee/common.js";
|
||||
import {DecryptionSource} from "../e2ee/common";
|
||||
import {iterateResponseStateEvents} from "./common";
|
||||
import {PowerLevels, EVENT_TYPE as POWERLEVELS_EVENT_TYPE } from "./PowerLevels.js";
|
||||
|
||||
|
@ -20,7 +20,7 @@ import {MediaRepository} from "../net/MediaRepository";
|
||||
import {EventEmitter} from "../../utils/EventEmitter";
|
||||
import {AttachmentUpload} from "./AttachmentUpload";
|
||||
import {loadProfiles, Profile, UserIdProfile} from "../profile";
|
||||
import {RoomType} from "./common";
|
||||
import {RoomType, UnsentStateEvent} from "./common";
|
||||
|
||||
import type {HomeServerApi} from "../net/HomeServerApi";
|
||||
import type {ILogItem} from "../../logging/types";
|
||||
@ -37,7 +37,7 @@ type CreateRoomPayload = {
|
||||
invite?: string[];
|
||||
room_alias_name?: string;
|
||||
creation_content?: {"m.federate": boolean};
|
||||
initial_state: { type: string; state_key: string; content: Record<string, any> }[];
|
||||
initial_state: UnsentStateEvent[];
|
||||
power_level_content_override?: Record<string, any>;
|
||||
}
|
||||
|
||||
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MEGOLM_ALGORITHM} from "../e2ee/common.js";
|
||||
import {MEGOLM_ALGORITHM} from "../e2ee/common";
|
||||
import {iterateResponseStateEvents} from "./common";
|
||||
|
||||
function applyTimelineEntries(data, timelineEntries, isInitialSync, canMarkUnread, ownUserId) {
|
||||
|
@ -28,6 +28,8 @@ export function isRedacted(event) {
|
||||
return !!event?.unsigned?.redacted_because;
|
||||
}
|
||||
|
||||
export type UnsentStateEvent = { type: string; state_key: string; content: Record<string, any> };
|
||||
|
||||
export enum RoomStatus {
|
||||
None = 1 << 0,
|
||||
BeingCreated = 1 << 1,
|
||||
|
@ -16,6 +16,8 @@ limitations under the License.
|
||||
import type {Key} from "./common";
|
||||
import type {Platform} from "../../platform/web/Platform.js";
|
||||
import type {Transaction} from "../storage/idb/Transaction";
|
||||
import type {Storage} from "../storage/idb/Storage";
|
||||
import type {AccountDataEntry} from "../storage/idb/stores/AccountDataStore";
|
||||
|
||||
type EncryptedData = {
|
||||
iv: string;
|
||||
@ -23,29 +25,72 @@ type EncryptedData = {
|
||||
mac: string;
|
||||
}
|
||||
|
||||
export enum DecryptionFailure {
|
||||
NotEncryptedWithKey,
|
||||
BadMAC,
|
||||
UnsupportedAlgorithm,
|
||||
}
|
||||
|
||||
class DecryptionError extends Error {
|
||||
constructor(msg: string, public readonly reason: DecryptionFailure) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
export class SecretStorage {
|
||||
private readonly _key: Key;
|
||||
private readonly _platform: Platform;
|
||||
private readonly _storage: Storage;
|
||||
|
||||
constructor({key, platform}: {key: Key, platform: Platform}) {
|
||||
constructor({key, platform, storage}: {key: Key, platform: Platform, storage: Storage}) {
|
||||
this._key = key;
|
||||
this._platform = platform;
|
||||
this._storage = storage;
|
||||
}
|
||||
|
||||
async readSecret(name: string, txn: Transaction): Promise<string | undefined> {
|
||||
/** this method will auto-commit any indexeddb transaction because of its use of the webcrypto api */
|
||||
async hasValidKeyForAnyAccountData() {
|
||||
const txn = await this._storage.readTxn([
|
||||
this._storage.storeNames.accountData,
|
||||
]);
|
||||
const allAccountData = await txn.accountData.getAll();
|
||||
for (const accountData of allAccountData) {
|
||||
try {
|
||||
const secret = await this._decryptAccountData(accountData);
|
||||
return true; // decryption succeeded
|
||||
} catch (err) {
|
||||
if (err instanceof DecryptionError && err.reason !== DecryptionFailure.NotEncryptedWithKey) {
|
||||
throw err;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** this method will auto-commit any indexeddb transaction because of its use of the webcrypto api */
|
||||
async readSecret(name: string): Promise<string | undefined> {
|
||||
const txn = await this._storage.readTxn([
|
||||
this._storage.storeNames.accountData,
|
||||
]);
|
||||
const accountData = await txn.accountData.get(name);
|
||||
if (!accountData) {
|
||||
return;
|
||||
}
|
||||
return await this._decryptAccountData(accountData);
|
||||
}
|
||||
|
||||
async _decryptAccountData(accountData: AccountDataEntry): Promise<string> {
|
||||
const encryptedData = accountData?.content?.encrypted?.[this._key.id] as EncryptedData;
|
||||
if (!encryptedData) {
|
||||
throw new Error(`Secret ${accountData.type} is not encrypted for key ${this._key.id}`);
|
||||
throw new DecryptionError(`Secret ${accountData.type} is not encrypted for key ${this._key.id}`, DecryptionFailure.NotEncryptedWithKey);
|
||||
}
|
||||
|
||||
if (this._key.algorithm === "m.secret_storage.v1.aes-hmac-sha2") {
|
||||
return await this._decryptAESSecret(accountData.type, encryptedData);
|
||||
} else {
|
||||
throw new Error(`Unsupported algorithm for key ${this._key.id}: ${this._key.algorithm}`);
|
||||
throw new DecryptionError(`Unsupported algorithm for key ${this._key.id}: ${this._key.algorithm}`, DecryptionFailure.UnsupportedAlgorithm);
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,7 +113,7 @@ export class SecretStorage {
|
||||
ciphertextBytes, "SHA-256");
|
||||
|
||||
if (!isVerified) {
|
||||
throw new Error("Bad MAC");
|
||||
throw new DecryptionError("Bad MAC", DecryptionFailure.BadMAC);
|
||||
}
|
||||
|
||||
const plaintextBytes = await this._platform.crypto.aes.decryptCTR({
|
||||
|
@ -17,7 +17,7 @@ limitations under the License.
|
||||
import {KeyDescription, Key} from "./common";
|
||||
import {keyFromPassphrase} from "./passphrase";
|
||||
import {keyFromRecoveryKey} from "./recoveryKey";
|
||||
import {SESSION_E2EE_KEY_PREFIX} from "../e2ee/common.js";
|
||||
import {SESSION_E2EE_KEY_PREFIX} from "../e2ee/common";
|
||||
import type {Storage} from "../storage/idb/Storage";
|
||||
import type {Transaction} from "../storage/idb/Transaction";
|
||||
import type {KeyDescriptionData} from "./common";
|
||||
|
@ -26,14 +26,15 @@ export enum StoreNames {
|
||||
timelineFragments = "timelineFragments",
|
||||
pendingEvents = "pendingEvents",
|
||||
userIdentities = "userIdentities",
|
||||
deviceIdentities = "deviceIdentities",
|
||||
deviceKeys = "deviceKeys",
|
||||
olmSessions = "olmSessions",
|
||||
inboundGroupSessions = "inboundGroupSessions",
|
||||
outboundGroupSessions = "outboundGroupSessions",
|
||||
groupSessionDecryptions = "groupSessionDecryptions",
|
||||
operations = "operations",
|
||||
accountData = "accountData",
|
||||
calls = "calls"
|
||||
calls = "calls",
|
||||
crossSigningKeys = "crossSigningKeys"
|
||||
}
|
||||
|
||||
export const STORE_NAMES: Readonly<StoreNames[]> = Object.values(StoreNames);
|
||||
|
@ -29,7 +29,8 @@ import {RoomMemberStore} from "./stores/RoomMemberStore";
|
||||
import {TimelineFragmentStore} from "./stores/TimelineFragmentStore";
|
||||
import {PendingEventStore} from "./stores/PendingEventStore";
|
||||
import {UserIdentityStore} from "./stores/UserIdentityStore";
|
||||
import {DeviceIdentityStore} from "./stores/DeviceIdentityStore";
|
||||
import {DeviceKeyStore} from "./stores/DeviceKeyStore";
|
||||
import {CrossSigningKeyStore} from "./stores/CrossSigningKeyStore";
|
||||
import {OlmSessionStore} from "./stores/OlmSessionStore";
|
||||
import {InboundGroupSessionStore} from "./stores/InboundGroupSessionStore";
|
||||
import {OutboundGroupSessionStore} from "./stores/OutboundGroupSessionStore";
|
||||
@ -141,8 +142,12 @@ export class Transaction {
|
||||
return this._store(StoreNames.userIdentities, idbStore => new UserIdentityStore(idbStore));
|
||||
}
|
||||
|
||||
get deviceIdentities(): DeviceIdentityStore {
|
||||
return this._store(StoreNames.deviceIdentities, idbStore => new DeviceIdentityStore(idbStore));
|
||||
get deviceKeys(): DeviceKeyStore {
|
||||
return this._store(StoreNames.deviceKeys, idbStore => new DeviceKeyStore(idbStore));
|
||||
}
|
||||
|
||||
get crossSigningKeys(): CrossSigningKeyStore {
|
||||
return this._store(StoreNames.crossSigningKeys, idbStore => new CrossSigningKeyStore(idbStore));
|
||||
}
|
||||
|
||||
get olmSessions(): OlmSessionStore {
|
||||
|
@ -2,7 +2,7 @@ import {IDOMStorage} from "./types";
|
||||
import {ITransaction} from "./QueryTarget";
|
||||
import {iterateCursor, NOT_DONE, reqAsPromise} from "./utils";
|
||||
import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/members/RoomMember.js";
|
||||
import {SESSION_E2EE_KEY_PREFIX} from "../../e2ee/common.js";
|
||||
import {SESSION_E2EE_KEY_PREFIX} from "../../e2ee/common";
|
||||
import {SummaryData} from "../../room/RoomSummary";
|
||||
import {RoomMemberStore, MemberData} from "./stores/RoomMemberStore";
|
||||
import {InboundGroupSessionStore, InboundGroupSessionEntry, BackupStatus, KeySource} from "./stores/InboundGroupSessionStore";
|
||||
@ -13,6 +13,8 @@ import {encodeScopeTypeKey} from "./stores/OperationStore";
|
||||
import {MAX_UNICODE} from "./stores/common";
|
||||
import {ILogItem} from "../../../logging/types";
|
||||
|
||||
import type {UserIdentity} from "../../e2ee/DeviceTracker";
|
||||
import {KeysTrackingStatus} from "../../e2ee/DeviceTracker";
|
||||
|
||||
export type MigrationFunc = (db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage, log: ILogItem) => Promise<void> | void;
|
||||
// FUNCTIONS SHOULD ONLY BE APPENDED!!
|
||||
@ -34,7 +36,8 @@ export const schema: MigrationFunc[] = [
|
||||
clearAllStores,
|
||||
addInboundSessionBackupIndex,
|
||||
migrateBackupStatus,
|
||||
createCallStore
|
||||
createCallStore,
|
||||
applyCrossSigningChanges
|
||||
];
|
||||
// TODO: how to deal with git merge conflicts of this array?
|
||||
|
||||
@ -275,3 +278,24 @@ async function migrateBackupStatus(db: IDBDatabase, txn: IDBTransaction, localSt
|
||||
function createCallStore(db: IDBDatabase) : void {
|
||||
db.createObjectStore("calls", {keyPath: "key"});
|
||||
}
|
||||
|
||||
//v18 add crossSigningKeys store, rename deviceIdentities to deviceKeys and empties userIdentities
|
||||
async function applyCrossSigningChanges(db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage, log: ILogItem) : Promise<void> {
|
||||
db.createObjectStore("crossSigningKeys", {keyPath: "key"});
|
||||
db.deleteObjectStore("deviceIdentities");
|
||||
const deviceKeys = db.createObjectStore("deviceKeys", {keyPath: "key"});
|
||||
deviceKeys.createIndex("byCurve25519Key", "curve25519Key", {unique: true});
|
||||
// mark all userIdentities as outdated as cross-signing keys won't be stored
|
||||
// also rename the deviceTrackingStatus field to keysTrackingStatus
|
||||
const userIdentities = txn.objectStore("userIdentities");
|
||||
let counter = 0;
|
||||
await iterateCursor<UserIdentity>(userIdentities.openCursor(), (value, key, cursor) => {
|
||||
delete value["deviceTrackingStatus"];
|
||||
delete value["crossSigningKeys"];
|
||||
value.keysTrackingStatus = KeysTrackingStatus.Outdated;
|
||||
cursor.update(value);
|
||||
counter += 1;
|
||||
return NOT_DONE;
|
||||
});
|
||||
log.set("marked_outdated", counter);
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ limitations under the License.
|
||||
import {Store} from "../Store";
|
||||
import {Content} from "../../types";
|
||||
|
||||
interface AccountDataEntry {
|
||||
export interface AccountDataEntry {
|
||||
type: string;
|
||||
content: Content;
|
||||
}
|
||||
@ -35,4 +35,8 @@ export class AccountDataStore {
|
||||
set(event: AccountDataEntry): void {
|
||||
this._store.put(event);
|
||||
}
|
||||
|
||||
async getAll(): Promise<ReadonlyArray<AccountDataEntry>> {
|
||||
return await this._store.selectAll();
|
||||
}
|
||||
}
|
||||
|
63
src/matrix/storage/idb/stores/CrossSigningKeyStore.ts
Normal file
63
src/matrix/storage/idb/stores/CrossSigningKeyStore.ts
Normal file
@ -0,0 +1,63 @@
|
||||
/*
|
||||
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 {MAX_UNICODE, MIN_UNICODE} from "./common";
|
||||
import {Store} from "../Store";
|
||||
import type {CrossSigningKey} from "../../../verification/CrossSigning";
|
||||
|
||||
type CrossSigningKeyEntry = {
|
||||
crossSigningKey: CrossSigningKey
|
||||
key: string; // key in storage, not a crypto key
|
||||
}
|
||||
|
||||
function encodeKey(userId: string, usage: string): string {
|
||||
return `${userId}|${usage}`;
|
||||
}
|
||||
|
||||
function decodeKey(key: string): { userId: string, usage: string } {
|
||||
const [userId, usage] = key.split("|");
|
||||
return {userId, usage};
|
||||
}
|
||||
|
||||
export class CrossSigningKeyStore {
|
||||
private _store: Store<CrossSigningKeyEntry>;
|
||||
|
||||
constructor(store: Store<CrossSigningKeyEntry>) {
|
||||
this._store = store;
|
||||
}
|
||||
|
||||
async get(userId: string, deviceId: string): Promise<CrossSigningKey | undefined> {
|
||||
return (await this._store.get(encodeKey(userId, deviceId)))?.crossSigningKey;
|
||||
}
|
||||
|
||||
set(crossSigningKey: CrossSigningKey): void {
|
||||
this._store.put({
|
||||
key:encodeKey(crossSigningKey["user_id"], crossSigningKey.usage[0]),
|
||||
crossSigningKey
|
||||
});
|
||||
}
|
||||
|
||||
remove(userId: string, usage: string): void {
|
||||
this._store.delete(encodeKey(userId, usage));
|
||||
}
|
||||
|
||||
removeAllForUser(userId: string): void {
|
||||
// exclude both keys as they are theoretical min and max,
|
||||
// but we should't have a match for just the room id, or room id with max
|
||||
const range = this._store.IDBKeyRange.bound(encodeKey(userId, MIN_UNICODE), encodeKey(userId, MAX_UNICODE), true, true);
|
||||
this._store.delete(range);
|
||||
}
|
||||
}
|
@ -16,15 +16,13 @@ limitations under the License.
|
||||
|
||||
import {MAX_UNICODE, MIN_UNICODE} from "./common";
|
||||
import {Store} from "../Store";
|
||||
import {getDeviceCurve25519Key} from "../../../e2ee/common";
|
||||
import type {DeviceKey} from "../../../e2ee/common";
|
||||
|
||||
export interface DeviceIdentity {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
ed25519Key: string;
|
||||
type DeviceKeyEntry = {
|
||||
key: string; // key in storage, not a crypto key
|
||||
curve25519Key: string;
|
||||
algorithms: string[];
|
||||
displayName: string;
|
||||
key: string;
|
||||
deviceKey: DeviceKey
|
||||
}
|
||||
|
||||
function encodeKey(userId: string, deviceId: string): string {
|
||||
@ -36,23 +34,24 @@ function decodeKey(key: string): { userId: string, deviceId: string } {
|
||||
return {userId, deviceId};
|
||||
}
|
||||
|
||||
export class DeviceIdentityStore {
|
||||
private _store: Store<DeviceIdentity>;
|
||||
export class DeviceKeyStore {
|
||||
private _store: Store<DeviceKeyEntry>;
|
||||
|
||||
constructor(store: Store<DeviceIdentity>) {
|
||||
constructor(store: Store<DeviceKeyEntry>) {
|
||||
this._store = store;
|
||||
}
|
||||
|
||||
getAllForUserId(userId: string): Promise<DeviceIdentity[]> {
|
||||
const range = this._store.IDBKeyRange.lowerBound(encodeKey(userId, ""));
|
||||
return this._store.selectWhile(range, device => {
|
||||
return device.userId === userId;
|
||||
async getAllForUserId(userId: string): Promise<DeviceKey[]> {
|
||||
const range = this._store.IDBKeyRange.lowerBound(encodeKey(userId, MIN_UNICODE));
|
||||
const entries = await this._store.selectWhile(range, device => {
|
||||
return device.deviceKey.user_id === userId;
|
||||
});
|
||||
return entries.map(e => e.deviceKey);
|
||||
}
|
||||
|
||||
async getAllDeviceIds(userId: string): Promise<string[]> {
|
||||
const deviceIds: string[] = [];
|
||||
const range = this._store.IDBKeyRange.lowerBound(encodeKey(userId, ""));
|
||||
const range = this._store.IDBKeyRange.lowerBound(encodeKey(userId, MIN_UNICODE));
|
||||
await this._store.iterateKeys(range, key => {
|
||||
const decodedKey = decodeKey(key as string);
|
||||
// prevent running into the next room
|
||||
@ -65,17 +64,21 @@ export class DeviceIdentityStore {
|
||||
return deviceIds;
|
||||
}
|
||||
|
||||
get(userId: string, deviceId: string): Promise<DeviceIdentity | undefined> {
|
||||
return this._store.get(encodeKey(userId, deviceId));
|
||||
async get(userId: string, deviceId: string): Promise<DeviceKey | undefined> {
|
||||
return (await this._store.get(encodeKey(userId, deviceId)))?.deviceKey;
|
||||
}
|
||||
|
||||
set(deviceIdentity: DeviceIdentity): void {
|
||||
deviceIdentity.key = encodeKey(deviceIdentity.userId, deviceIdentity.deviceId);
|
||||
this._store.put(deviceIdentity);
|
||||
set(deviceKey: DeviceKey): void {
|
||||
this._store.put({
|
||||
key: encodeKey(deviceKey.user_id, deviceKey.device_id),
|
||||
curve25519Key: getDeviceCurve25519Key(deviceKey)!,
|
||||
deviceKey
|
||||
});
|
||||
}
|
||||
|
||||
getByCurve25519Key(curve25519Key: string): Promise<DeviceIdentity | undefined> {
|
||||
return this._store.index("byCurve25519Key").get(curve25519Key);
|
||||
async getByCurve25519Key(curve25519Key: string): Promise<DeviceKey | undefined> {
|
||||
const entry = await this._store.index("byCurve25519Key").get(curve25519Key);
|
||||
return entry?.deviceKey;
|
||||
}
|
||||
|
||||
remove(userId: string, deviceId: string): void {
|
@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
import {Store} from "../Store";
|
||||
import {IDOMStorage} from "../types";
|
||||
import {SESSION_E2EE_KEY_PREFIX} from "../../../e2ee/common.js";
|
||||
import {SESSION_E2EE_KEY_PREFIX} from "../../../e2ee/common";
|
||||
import {parse, stringify} from "../../../../utils/typedJSON";
|
||||
import type {ILogItem} from "../../../../logging/types";
|
||||
|
||||
|
@ -14,12 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import {Store} from "../Store";
|
||||
|
||||
interface UserIdentity {
|
||||
userId: string;
|
||||
roomIds: string[];
|
||||
deviceTrackingStatus: number;
|
||||
}
|
||||
import type {UserIdentity} from "../../../e2ee/DeviceTracker";
|
||||
|
||||
export class UserIdentityStore {
|
||||
private _store: Store<UserIdentity>;
|
||||
|
@ -14,23 +14,61 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {verifyEd25519Signature, SignatureVerification} from "../e2ee/common";
|
||||
|
||||
import type {SecretStorage} from "../ssss/SecretStorage";
|
||||
import type {Storage} from "../storage/idb/Storage";
|
||||
import type {Platform} from "../../platform/web/Platform";
|
||||
import type {DeviceTracker} from "../e2ee/DeviceTracker";
|
||||
import type * as OlmNamespace from "@matrix-org/olm";
|
||||
import type {HomeServerApi} from "../net/HomeServerApi";
|
||||
import type {Account} from "../e2ee/Account";
|
||||
import {ILogItem} from "../../lib";
|
||||
import {pkSign} from "./common";
|
||||
import type {ISignatures} from "./common";
|
||||
import {SASVerification} from "./SAS/SASVerification";
|
||||
import {ToDeviceChannel} from "./SAS/channel/Channel";
|
||||
import type {DeviceMessageHandler} from "../DeviceMessageHandler.js";
|
||||
import {VerificationEventType} from "./SAS/channel/types";
|
||||
import type {SignedValue, DeviceKey} from "../e2ee/common";
|
||||
import type * as OlmNamespace from "@matrix-org/olm";
|
||||
|
||||
type Olm = typeof OlmNamespace;
|
||||
|
||||
// we store cross-signing (and device) keys in the format we get them from the server
|
||||
// as that is what the signature is calculated on, so to verify and sign, we need
|
||||
// it in this format anyway.
|
||||
export type CrossSigningKey = SignedValue & {
|
||||
readonly user_id: string;
|
||||
readonly usage: ReadonlyArray<string>;
|
||||
readonly keys: {[keyId: string]: string};
|
||||
}
|
||||
|
||||
export enum KeyUsage {
|
||||
Master = "master",
|
||||
SelfSigning = "self_signing",
|
||||
UserSigning = "user_signing"
|
||||
};
|
||||
|
||||
export enum UserTrust {
|
||||
/** We trust the user, the whole signature chain checks out from our MSK to all of their device keys. */
|
||||
Trusted = 1,
|
||||
/** We haven't signed this user's identity yet. Verify this user first to sign it. */
|
||||
UserNotSigned,
|
||||
/** We have signed the user already, but the signature isn't valid.
|
||||
One possible cause could be that an attacker is uploading signatures in our name. */
|
||||
UserSignatureMismatch,
|
||||
/** We trust the user, but they don't trust one of their devices. */
|
||||
UserDeviceNotSigned,
|
||||
/** We trust the user, but the signatures of one of their devices is invalid.
|
||||
* One possible cause could be that an attacker is uploading signatures in their name. */
|
||||
UserDeviceSignatureMismatch,
|
||||
/** The user doesn't have a valid signature for the SSK with their MSK, or the SSK is missing.
|
||||
* This likely means bootstrapping cross-signing on their end didn't finish correctly. */
|
||||
UserSetupError,
|
||||
/** We don't have a valid signature for our SSK with our MSK, the SSK is missing, or we don't trust our own MSK.
|
||||
* This likely means bootstrapping cross-signing on our end didn't finish correctly. */
|
||||
OwnSetupError
|
||||
}
|
||||
|
||||
export class CrossSigning {
|
||||
private readonly storage: Storage;
|
||||
private readonly secretStorage: SecretStorage;
|
||||
@ -90,53 +128,44 @@ export class CrossSigning {
|
||||
})
|
||||
}
|
||||
|
||||
async init(log: ILogItem) {
|
||||
log.wrap("CrossSigning.init", async log => {
|
||||
async load(log: ILogItem) {
|
||||
// try to verify the msk without accessing the network
|
||||
return await this.verifyMSKFrom4S(false, log);
|
||||
}
|
||||
|
||||
async start(log: ILogItem) {
|
||||
if (!this.isMasterKeyTrusted) {
|
||||
// try to verify the msk _with_ access to the network
|
||||
return await this.verifyMSKFrom4S(true, log);
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyMSKFrom4S(allowNetwork: boolean, log: ILogItem): Promise<boolean> {
|
||||
return await log.wrap("CrossSigning.verifyMSKFrom4S", async log => {
|
||||
// TODO: use errorboundary here
|
||||
const txn = await this.storage.readTxn([this.storage.storeNames.accountData]);
|
||||
|
||||
const mskSeed = await this.secretStorage.readSecret("m.cross_signing.master", txn);
|
||||
const privateMasterKey = await this.getSigningKey(KeyUsage.Master);
|
||||
if (!privateMasterKey) {
|
||||
return false;
|
||||
}
|
||||
const signing = new this.olm.PkSigning();
|
||||
let derivedPublicKey;
|
||||
try {
|
||||
const seed = new Uint8Array(this.platform.encoding.base64.decode(mskSeed));
|
||||
derivedPublicKey = signing.init_with_seed(seed);
|
||||
derivedPublicKey = signing.init_with_seed(privateMasterKey);
|
||||
} finally {
|
||||
signing.free();
|
||||
}
|
||||
const publishedKeys = await this.deviceTracker.getCrossSigningKeysForUser(this.ownUserId, this.hsApi, log);
|
||||
log.set({publishedMasterKey: publishedKeys.masterKey, derivedPublicKey});
|
||||
this._isMasterKeyTrusted = publishedKeys.masterKey === derivedPublicKey;
|
||||
log.set("isMasterKeyTrusted", this.isMasterKeyTrusted);
|
||||
});
|
||||
}
|
||||
|
||||
async signOwnDevice(log: ILogItem) {
|
||||
log.wrap("CrossSigning.signOwnDevice", async log => {
|
||||
if (!this._isMasterKeyTrusted) {
|
||||
log.set("mskNotTrusted", true);
|
||||
return;
|
||||
const publishedMasterKey = await this.deviceTracker.getCrossSigningKeyForUser(this.ownUserId, KeyUsage.Master, allowNetwork ? this.hsApi : undefined, log);
|
||||
if (!publishedMasterKey) {
|
||||
return false;
|
||||
}
|
||||
const deviceKey = this.e2eeAccount.getDeviceKeysToSignWithCrossSigning();
|
||||
const signedDeviceKey = await this.signDevice(deviceKey);
|
||||
const payload = {
|
||||
[signedDeviceKey["user_id"]]: {
|
||||
[signedDeviceKey["device_id"]]: signedDeviceKey
|
||||
}
|
||||
};
|
||||
const request = this.hsApi.uploadSignatures(payload, {log});
|
||||
await request.response();
|
||||
const publisedEd25519Key = publishedMasterKey && getKeyEd25519Key(publishedMasterKey);
|
||||
log.set({publishedMasterKey: publisedEd25519Key, derivedPublicKey});
|
||||
this._isMasterKeyTrusted = !!publisedEd25519Key && publisedEd25519Key === derivedPublicKey;
|
||||
log.set("isMasterKeyTrusted", this.isMasterKeyTrusted);
|
||||
return this.isMasterKeyTrusted;
|
||||
});
|
||||
}
|
||||
|
||||
private async signDevice<T extends object>(data: T): Promise<T & { signatures: ISignatures }> {
|
||||
const txn = await this.storage.readTxn([this.storage.storeNames.accountData]);
|
||||
const seedStr = await this.secretStorage.readSecret(`m.cross_signing.self_signing`, txn);
|
||||
const seed = new Uint8Array(this.platform.encoding.base64.decode(seedStr));
|
||||
pkSign(this.olm, data, seed, this.ownUserId, "");
|
||||
return data as T & { signatures: ISignatures };
|
||||
}
|
||||
|
||||
get isMasterKeyTrusted(): boolean {
|
||||
return this._isMasterKeyTrusted;
|
||||
}
|
||||
@ -170,6 +199,200 @@ export class CrossSigning {
|
||||
});
|
||||
return this.sasVerificationInProgress;
|
||||
}
|
||||
|
||||
/** returns our own device key signed by our self-signing key. Other signatures will be missing. */
|
||||
async signOwnDevice(log: ILogItem): Promise<DeviceKey | undefined> {
|
||||
return log.wrap("CrossSigning.signOwnDevice", async log => {
|
||||
if (!this._isMasterKeyTrusted) {
|
||||
log.set("mskNotTrusted", true);
|
||||
return;
|
||||
}
|
||||
const ownDeviceKey = this.e2eeAccount.getUnsignedDeviceKey() as DeviceKey;
|
||||
return this.signDeviceKey(ownDeviceKey, log);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return the signed device key for the given device id */
|
||||
async signDevice(deviceId: string, log: ILogItem): Promise<DeviceKey | undefined> {
|
||||
return log.wrap("CrossSigning.signDevice", async log => {
|
||||
log.set("id", deviceId);
|
||||
if (!this._isMasterKeyTrusted) {
|
||||
log.set("mskNotTrusted", true);
|
||||
return;
|
||||
}
|
||||
const keyToSign = await this.deviceTracker.deviceForId(this.ownUserId, deviceId, this.hsApi, log);
|
||||
if (!keyToSign) {
|
||||
return undefined;
|
||||
}
|
||||
delete keyToSign.signatures;
|
||||
return this.signDeviceKey(keyToSign, log);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return the signed MSK for the given user id */
|
||||
async signUser(userId: string, log: ILogItem): Promise<CrossSigningKey | undefined> {
|
||||
return log.wrap("CrossSigning.signUser", async log => {
|
||||
log.set("id", userId);
|
||||
if (!this._isMasterKeyTrusted) {
|
||||
log.set("mskNotTrusted", true);
|
||||
return;
|
||||
}
|
||||
// can't sign own user
|
||||
if (userId === this.ownUserId) {
|
||||
return;
|
||||
}
|
||||
const keyToSign = await this.deviceTracker.getCrossSigningKeyForUser(userId, KeyUsage.Master, this.hsApi, log);
|
||||
if (!keyToSign) {
|
||||
return;
|
||||
}
|
||||
const signingKey = await this.getSigningKey(KeyUsage.UserSigning);
|
||||
if (!signingKey) {
|
||||
return;
|
||||
}
|
||||
delete keyToSign.signatures;
|
||||
// add signature to keyToSign
|
||||
this.signKey(keyToSign, signingKey);
|
||||
const payload = {
|
||||
[keyToSign.user_id]: {
|
||||
[getKeyEd25519Key(keyToSign)!]: keyToSign
|
||||
}
|
||||
};
|
||||
const request = this.hsApi.uploadSignatures(payload, {log});
|
||||
await request.response();
|
||||
return keyToSign;
|
||||
});
|
||||
}
|
||||
|
||||
async getUserTrust(userId: string, log: ILogItem): Promise<UserTrust> {
|
||||
return log.wrap("getUserTrust", async log => {
|
||||
log.set("id", userId);
|
||||
if (!this.isMasterKeyTrusted) {
|
||||
return UserTrust.OwnSetupError;
|
||||
}
|
||||
const ourMSK = await log.wrap("get our msk", log => this.deviceTracker.getCrossSigningKeyForUser(this.ownUserId, KeyUsage.Master, this.hsApi, log));
|
||||
if (!ourMSK) {
|
||||
return UserTrust.OwnSetupError;
|
||||
}
|
||||
const ourUSK = await log.wrap("get our usk", log => this.deviceTracker.getCrossSigningKeyForUser(this.ownUserId, KeyUsage.UserSigning, this.hsApi, log));
|
||||
if (!ourUSK) {
|
||||
return UserTrust.OwnSetupError;
|
||||
}
|
||||
const ourUSKVerification = log.wrap("verify our usk", log => this.hasValidSignatureFrom(ourUSK, ourMSK, log));
|
||||
if (ourUSKVerification !== SignatureVerification.Valid) {
|
||||
return UserTrust.OwnSetupError;
|
||||
}
|
||||
const theirMSK = await log.wrap("get their msk", log => this.deviceTracker.getCrossSigningKeyForUser(userId, KeyUsage.Master, this.hsApi, log));
|
||||
if (!theirMSK) {
|
||||
/* assume that when they don't have an MSK, they've never enabled cross-signing on their client
|
||||
(or it's not supported) rather than assuming a setup error on their side.
|
||||
Later on, for their SSK, we _do_ assume it's a setup error as it doesn't make sense to have an MSK without a SSK */
|
||||
return UserTrust.UserNotSigned;
|
||||
}
|
||||
const theirMSKVerification = log.wrap("verify their msk", log => this.hasValidSignatureFrom(theirMSK, ourUSK, log));
|
||||
if (theirMSKVerification !== SignatureVerification.Valid) {
|
||||
if (theirMSKVerification === SignatureVerification.NotSigned) {
|
||||
return UserTrust.UserNotSigned;
|
||||
} else { /* SignatureVerification.Invalid */
|
||||
return UserTrust.UserSignatureMismatch;
|
||||
}
|
||||
}
|
||||
const theirSSK = await log.wrap("get their ssk", log => this.deviceTracker.getCrossSigningKeyForUser(userId, KeyUsage.SelfSigning, this.hsApi, log));
|
||||
if (!theirSSK) {
|
||||
return UserTrust.UserSetupError;
|
||||
}
|
||||
const theirSSKVerification = log.wrap("verify their ssk", log => this.hasValidSignatureFrom(theirSSK, theirMSK, log));
|
||||
if (theirSSKVerification !== SignatureVerification.Valid) {
|
||||
return UserTrust.UserSetupError;
|
||||
}
|
||||
const theirDeviceKeys = await log.wrap("get their devices", log => this.deviceTracker.devicesForUsers([userId], this.hsApi, log));
|
||||
const lowestDeviceVerification = theirDeviceKeys.reduce((lowest, dk) => log.wrap({l: "verify device", id: dk.device_id}, log => {
|
||||
const verification = this.hasValidSignatureFrom(dk, theirSSK, log);
|
||||
// first Invalid, then NotSigned, then Valid
|
||||
if (lowest === SignatureVerification.Invalid || verification === SignatureVerification.Invalid) {
|
||||
return SignatureVerification.Invalid;
|
||||
} else if (lowest === SignatureVerification.NotSigned || verification === SignatureVerification.NotSigned) {
|
||||
return SignatureVerification.NotSigned;
|
||||
} else if (lowest === SignatureVerification.Valid || verification === SignatureVerification.Valid) {
|
||||
return SignatureVerification.Valid;
|
||||
}
|
||||
// should never happen as we went over all the enum options
|
||||
return SignatureVerification.Invalid;
|
||||
}), SignatureVerification.Valid);
|
||||
if (lowestDeviceVerification !== SignatureVerification.Valid) {
|
||||
if (lowestDeviceVerification === SignatureVerification.NotSigned) {
|
||||
return UserTrust.UserDeviceNotSigned;
|
||||
} else { /* SignatureVerification.Invalid */
|
||||
return UserTrust.UserDeviceSignatureMismatch;
|
||||
}
|
||||
}
|
||||
return UserTrust.Trusted;
|
||||
});
|
||||
}
|
||||
|
||||
private async signDeviceKey(keyToSign: DeviceKey, log: ILogItem): Promise<DeviceKey | undefined> {
|
||||
const signingKey = await this.getSigningKey(KeyUsage.SelfSigning);
|
||||
if (!signingKey) {
|
||||
return undefined;
|
||||
}
|
||||
// add signature to keyToSign
|
||||
this.signKey(keyToSign, signingKey);
|
||||
// so the payload format of a signature is a map from userid to key id of the signed key
|
||||
// (without the algoritm prefix though according to example, e.g. just device id or base 64 public key)
|
||||
// to the complete signed key with the signature of the signing key in the signatures section.
|
||||
const payload = {
|
||||
[keyToSign.user_id]: {
|
||||
[keyToSign.device_id]: keyToSign
|
||||
}
|
||||
};
|
||||
const request = this.hsApi.uploadSignatures(payload, {log});
|
||||
await request.response();
|
||||
return keyToSign;
|
||||
}
|
||||
|
||||
private async getSigningKey(usage: KeyUsage): Promise<Uint8Array | undefined> {
|
||||
const seedStr = await this.secretStorage.readSecret(`m.cross_signing.${usage}`);
|
||||
if (seedStr) {
|
||||
return new Uint8Array(this.platform.encoding.base64.decode(seedStr));
|
||||
}
|
||||
}
|
||||
|
||||
private signKey(keyToSign: DeviceKey | CrossSigningKey, signingKey: Uint8Array) {
|
||||
pkSign(this.olm, keyToSign, signingKey, this.ownUserId, "");
|
||||
}
|
||||
|
||||
private hasValidSignatureFrom(key: DeviceKey | CrossSigningKey, signingKey: CrossSigningKey, log: ILogItem): SignatureVerification {
|
||||
const pubKey = getKeyEd25519Key(signingKey);
|
||||
if (!pubKey) {
|
||||
return SignatureVerification.NotSigned;
|
||||
}
|
||||
return verifyEd25519Signature(this.olmUtil, signingKey.user_id, pubKey, pubKey, key, log);
|
||||
}
|
||||
}
|
||||
|
||||
export function getKeyUsage(keyInfo: CrossSigningKey): KeyUsage | undefined {
|
||||
if (!Array.isArray(keyInfo.usage) || keyInfo.usage.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
const usage = keyInfo.usage[0];
|
||||
if (usage !== KeyUsage.Master && usage !== KeyUsage.SelfSigning && usage !== KeyUsage.UserSigning) {
|
||||
return undefined;
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
const algorithm = "ed25519";
|
||||
const prefix = `${algorithm}:`;
|
||||
|
||||
export function getKeyEd25519Key(keyInfo: CrossSigningKey): string | undefined {
|
||||
const ed25519KeyIds = Object.keys(keyInfo.keys).filter(keyId => keyId.startsWith(prefix));
|
||||
if (ed25519KeyIds.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
const keyId = ed25519KeyIds[0];
|
||||
const publicKey = keyInfo.keys[keyId];
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
export function getKeyUserId(keyInfo: CrossSigningKey): string | undefined {
|
||||
return keyInfo["user_id"];
|
||||
}
|
||||
|
@ -16,24 +16,10 @@ limitations under the License.
|
||||
|
||||
import { PkSigning } from "@matrix-org/olm";
|
||||
import anotherjson from "another-json";
|
||||
import type {SignedValue} from "../e2ee/common";
|
||||
import type * as OlmNamespace from "@matrix-org/olm";
|
||||
type Olm = typeof OlmNamespace;
|
||||
|
||||
export interface IObject {
|
||||
unsigned?: object;
|
||||
signatures?: ISignatures;
|
||||
}
|
||||
|
||||
export interface ISignatures {
|
||||
[entity: string]: {
|
||||
[keyId: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ISigned {
|
||||
signatures?: ISignatures;
|
||||
}
|
||||
|
||||
// from matrix-js-sdk
|
||||
/**
|
||||
* Sign a JSON object using public key cryptography
|
||||
@ -45,7 +31,7 @@ export interface ISigned {
|
||||
* @param pubKey - The public key (ignored if key is a seed)
|
||||
* @returns the signature for the object
|
||||
*/
|
||||
export function pkSign(olmUtil: Olm, obj: object & IObject, key: Uint8Array | PkSigning, userId: string, pubKey: string): string {
|
||||
export function pkSign(olmUtil: Olm, obj: SignedValue, key: Uint8Array | PkSigning, userId: string, pubKey: string): string {
|
||||
let createdKey = false;
|
||||
if (key instanceof Uint8Array) {
|
||||
const keyObj = new olmUtil.PkSigning();
|
||||
@ -69,4 +55,4 @@ export interface ISigned {
|
||||
key.free();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,37 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.MemberDetailsView_shield_container {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.MemberDetailsView_shield_red, .MemberDetailsView_shield_green, .MemberDetailsView_shield_black {
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.MemberDetailsView_shield_description {
|
||||
flex-grow: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.MemberDetailsView_shield_red {
|
||||
background-image: url("./icons/verification-error.svg?primary=error-color");
|
||||
}
|
||||
|
||||
.MemberDetailsView_shield_green {
|
||||
background-image: url("./icons/verified.svg?primary=accent-color");
|
||||
}
|
||||
|
||||
.MemberDetailsView_shield_black {
|
||||
background-image: url("./icons/encryption-status.svg?primary=text-color");
|
||||
}
|
||||
|
||||
.RoomDetailsView_label, .RoomDetailsView_row, .RoomDetailsView, .MemberDetailsView, .EncryptionIconView {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -0,0 +1,3 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 9.27V3.05L9 1L16 3.05V9.27C16 15.63 9 17 9 17C9 17 2 15.63 2 9.27ZM8.92011 4.39997C8.35011 4.43997 7.93011 4.93997 7.98011 5.50997L8.30011 9.50997C8.33011 9.85997 8.60011 10.13 8.95011 10.16H9.01011C9.38011 10.16 9.69011 9.87997 9.72011 9.50997L10.0401 5.50997V5.34997C9.98011 4.77997 9.48011 4.35997 8.92011 4.39997ZM9.88012 12.12C9.88012 12.606 9.48613 13 9.00012 13C8.51411 13 8.12012 12.606 8.12012 12.12C8.12012 11.634 8.51411 11.24 9.00012 11.24C9.48613 11.24 9.88012 11.634 9.88012 12.12Z" fill="#ff00ff"/>
|
||||
</svg>
|
After Width: | Height: | Size: 673 B |
@ -0,0 +1,3 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 3.05V9.27C2 15.63 9 17 9 17C9 17 16 15.63 16 9.27V3.05L9 1L2 3.05ZM11.9405 5.5196C12.1305 5.3396 12.4305 5.3496 12.6105 5.5396C12.7705 5.7196 12.7705 5.9896 12.6305 6.1696L8.41047 11.2796L8.38047 11.3196C8.10047 11.6596 7.59047 11.7096 7.25047 11.4296C7.22027 11.4145 7.19577 11.388 7.17266 11.363C7.16517 11.3549 7.15782 11.347 7.15047 11.3396L5.34047 9.2596C5.14047 9.0196 5.16047 8.6596 5.40047 8.4596C5.60047 8.2796 5.89047 8.2796 6.10047 8.4196L7.67047 9.5196L11.9405 5.5196Z" fill="#ff00ff"/>
|
||||
</svg>
|
After Width: | Height: | Size: 657 B |
@ -1182,6 +1182,7 @@ button.RoomDetailsView_row::after {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.LazyListParent {
|
||||
|
@ -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) {
|
||||
|
@ -19,15 +19,25 @@ import {TemplateView} from "../../general/TemplateView";
|
||||
|
||||
export class MemberDetailsView extends TemplateView {
|
||||
render(t, vm) {
|
||||
const securityNodes = [
|
||||
t.p(vm.isEncrypted ?
|
||||
vm.i18n`Messages in this room are end-to-end encrypted.` :
|
||||
vm.i18n`Messages in this room are not end-to-end encrypted.`),
|
||||
]
|
||||
|
||||
if (vm.features.crossSigning) {
|
||||
securityNodes.push(t.div({className: "MemberDetailsView_shield_container"}, [
|
||||
t.span({className: vm => `MemberDetailsView_shield_${vm.trustShieldColor}`}),
|
||||
t.p({className: "MemberDetailsView_shield_description"}, vm => vm.trustDescription)
|
||||
]));
|
||||
}
|
||||
|
||||
return t.div({className: "MemberDetailsView"},
|
||||
[ t.view(new AvatarView(vm, 128)),
|
||||
t.div({className: "MemberDetailsView_name"}, t.h2(vm => vm.name)),
|
||||
t.div({className: "MemberDetailsView_id"}, vm.userId),
|
||||
this._createSection(t, vm.i18n`Role`, vm => vm.role),
|
||||
this._createSection(t, vm.i18n`Security`, vm.isEncrypted ?
|
||||
vm.i18n`Messages in this room are end-to-end encrypted.` :
|
||||
vm.i18n`Messages in this room are not end-to-end encrypted.`
|
||||
),
|
||||
this._createSection(t, vm.i18n`Security`, securityNodes),
|
||||
this._createOptions(t, vm)
|
||||
]);
|
||||
}
|
||||
@ -41,14 +51,22 @@ export class MemberDetailsView extends TemplateView {
|
||||
}
|
||||
|
||||
_createOptions(t, vm) {
|
||||
const options = [
|
||||
t.a({href: vm.linkToUser, target: "_blank", rel: "noopener"}, vm.i18n`Open Link to User`),
|
||||
t.button({className: "text", onClick: () => vm.openDirectMessage()}, vm.i18n`Open direct message`)
|
||||
];
|
||||
if (vm.features.crossSigning) {
|
||||
const onClick = () => {
|
||||
if (confirm("You don't want to do this with any account but a test account. This will cross-sign this user without verifying their keys first. You won't be able to undo this apart from resetting your cross-signing keys.")) {
|
||||
vm.signUser();
|
||||
}
|
||||
};
|
||||
options.push(t.button({className: "text", onClick}, vm.i18n`Cross-sign user (DO NOT USE, TESTING ONLY)`))
|
||||
}
|
||||
return t.div({ className: "MemberDetailsView_section" },
|
||||
[
|
||||
t.div({className: "MemberDetailsView_label"}, vm.i18n`Options`),
|
||||
t.div({className: "MemberDetailsView_options"},
|
||||
[
|
||||
t.a({href: vm.linkToUser, target: "_blank", rel: "noopener"}, vm.i18n`Open Link to User`),
|
||||
t.button({className: "text", onClick: () => vm.openDirectMessage()}, vm.i18n`Open direct message`)
|
||||
])
|
||||
t.div({className: "MemberDetailsView_options"}, options)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
@ -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 => {
|
||||
@ -60,7 +69,7 @@ export class KeyBackupSettingsView extends TemplateView {
|
||||
}),
|
||||
t.if(vm => vm.canSignOwnDevice, t => {
|
||||
return t.button({
|
||||
onClick: disableTargetCallback(async evt => {
|
||||
onClick: disableTargetCallback(async () => {
|
||||
await vm.signOwnDevice();
|
||||
})
|
||||
}, "Sign own device");
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user