Merge pull request #1112 from vector-im/implement-secret-sharing

Implement secret sharing
This commit is contained in:
R Midhun Suresh 2023-06-20 19:14:00 +05:30 committed by GitHub
commit 46add413f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1019 additions and 140 deletions

View File

@ -66,6 +66,9 @@ export class KeyBackupViewModel extends ViewModel<SegmentType, Options> {
this._onKeyBackupChange(); // update status
};
this.track(this._session.keyBackup.subscribe(onKeyBackupSet));
this.track(this._session.crossSigning.subscribe(() => {
this.emitChange("crossSigning");
}));
onKeyBackupSet(this._keyBackup);
}
@ -148,7 +151,7 @@ export class KeyBackupViewModel extends ViewModel<SegmentType, Options> {
return !!this._crossSigning;
}
async signOwnDevice(): Promise<void> {
private async _signOwnDevice(): Promise<void> {
const crossSigning = this._crossSigning;
if (crossSigning) {
await this.logger.run("KeyBackupViewModel.signOwnDevice", async log => {
@ -205,6 +208,7 @@ export class KeyBackupViewModel extends ViewModel<SegmentType, Options> {
if (setupDehydratedDevice) {
this._dehydratedDeviceId = await this._session.setupDehydratedDevice(key);
}
await this._signOwnDevice();
} catch (err) {
console.error(err);
this._error = err;

View File

@ -22,9 +22,12 @@ import {VerificationCancelledViewModel} from "./stages/VerificationCancelledView
import {SelectMethodViewModel} from "./stages/SelectMethodViewModel";
import {VerifyEmojisViewModel} from "./stages/VerifyEmojisViewModel";
import {VerificationCompleteViewModel} from "./stages/VerificationCompleteViewModel";
import {MissingKeysViewModel} from "./stages/MissingKeysViewModel";
import type {Session} from "../../../matrix/Session.js";
import type {SASVerification} from "../../../matrix/verification/SAS/SASVerification";
import type {SASRequest} from "../../../matrix/verification/SAS/SASRequest";
import type {CrossSigning} from "../../../matrix/verification/CrossSigning";
import type {ILogItem} from "../../../logging/types";
import type {Room} from "../../../matrix/room/Room.js";
type Options = BaseOptions & {
@ -34,9 +37,16 @@ type Options = BaseOptions & {
userId?: string;
};
const neededSecrets = [
"m.cross_signing.master",
"m.cross_signing.self_signing",
"m.cross_signing.user_signing",
];
export class DeviceVerificationViewModel extends ErrorReportViewModel<SegmentType, Options> {
private sas: SASVerification;
private _currentStageViewModel: any;
private _needsToRequestSecret: boolean;
constructor(options: Readonly<Options>) {
super(options);
@ -54,12 +64,16 @@ export class DeviceVerificationViewModel extends ErrorReportViewModel<SegmentTyp
}
private async startVerification(requestOrUserId: SASRequest | string, room?: Room) {
await this.logAndCatch("DeviceVerificationViewModel.start", (log) => {
const crossSigning = this.getOption("session").crossSigning.get();
await this.logAndCatch("DeviceVerificationViewModel.startVerification", async (log) => {
const crossSigningObservable = this.getOption("session").crossSigning;
const crossSigning = await crossSigningObservable.waitFor(c => !!c).promise;
this.sas = crossSigning.startVerification(requestOrUserId, room, log);
if (!this.sas) {
throw new Error("CrossSigning.startVerification did not return a sas object!");
}
if (!await this.performPreVerificationChecks(crossSigning, requestOrUserId, log)) {
return;
}
this.addEventListeners();
if (typeof requestOrUserId === "string") {
this.updateCurrentStageViewModel(new WaitingForOtherUserViewModel(this.childOptions({ sas: this.sas })));
@ -73,6 +87,39 @@ export class DeviceVerificationViewModel extends ErrorReportViewModel<SegmentTyp
});
}
private async performPreVerificationChecks(crossSigning: CrossSigning, requestOrUserId: SASRequest | string, log: ILogItem): Promise<boolean> {
return await log.wrap("DeviceVerificationViewModel.performPreVerificationChecks", async (_log) => {
const areWeVerified = await crossSigning.areWeVerified(log);
// If we're not verified, we'll need to ask the other device for secrets later
const otherUserId = typeof requestOrUserId === "string" ? requestOrUserId : requestOrUserId.sender;
const isDeviceVerification = otherUserId === this.getOption("session").userId;
this._needsToRequestSecret = isDeviceVerification && !areWeVerified;
if (this._needsToRequestSecret) {
return true;
}
/**
* It's possible that we are verified but don't have access
* to the private cross-signing keys. In this case we really
* can't verify the other device because we need these keys
* to sign their device.
*
* If this happens, we'll simply ask the user to enable key-backup
* (and secret storage) and try again later.
*/
const session = this.getOption("session");
const promises = neededSecrets.map(s => session.secretFetcher.getSecret(s));
const secrets = await Promise.all(promises)
for (const secret of secrets) {
if (!secret) {
// We really can't proceed!
this.updateCurrentStageViewModel(new MissingKeysViewModel(this.childOptions({})));
return false;
}
}
return true;
});
}
private addEventListeners() {
this.track(this.sas.disposableOn("SelectVerificationStage", (stage) => {
this.updateCurrentStageViewModel(
@ -95,9 +142,24 @@ export class DeviceVerificationViewModel extends ErrorReportViewModel<SegmentTyp
this.updateCurrentStageViewModel(
new VerificationCompleteViewModel(this.childOptions({ deviceId: deviceId!, sas: this.sas }))
);
this.requestSecrets();
}));
}
private async requestSecrets() {
await this.platform.logger.run("DeviceVerificationViewModel.requestSecrets", async (log) => {
if (this._needsToRequestSecret) {
const secretSharing = this.getOption("session").secretSharing;
const requestPromises = neededSecrets.map((secret) => secretSharing.requestSecret(secret, log));
const secretRequests = await Promise.all(requestPromises);
const receivedSecretPromises = secretRequests.map(r => r.waitForResponse());
await Promise.all(receivedSecretPromises);
const crossSigning = this.getOption("session").crossSigning.get();
crossSigning.start(log);
}
});
}
private updateCurrentStageViewModel(vm) {
this._currentStageViewModel = this.disposeTracked(this._currentStageViewModel);
this._currentStageViewModel = this.track(vm);
@ -105,8 +167,8 @@ export class DeviceVerificationViewModel extends ErrorReportViewModel<SegmentTyp
}
dispose(): void {
if (!this.sas.finished) {
this.sas.abort().catch(() => {/** ignore */});
if (this.sas && !this.sas.finished) {
this.sas.abort().catch((e) => { console.error(e); });
}
super.dispose();
}
@ -118,4 +180,8 @@ export class DeviceVerificationViewModel extends ErrorReportViewModel<SegmentTyp
get type(): string {
return "verification";
}
get isHappeningInRoom(): boolean {
return !!this.navigation.path.get("room");
}
}

View File

@ -0,0 +1,28 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {ViewModel, Options} from "../../../ViewModel";
import type {SegmentType} from "../../../navigation/index";
export class MissingKeysViewModel extends ViewModel<SegmentType, Options> {
gotoSettings() {
this.navigation.push("settings", true);
}
get kind(): string {
return "keys-missing";
}
}

View File

@ -16,7 +16,7 @@ limitations under the License.
import {Options as BaseOptions} from "../../../ViewModel";
import {DismissibleVerificationViewModel} from "./DismissibleVerificationViewModel";
import type {CancelReason} from "../../../../matrix/verification/SAS/channel/types";
import {CancelReason} from "../../../../matrix/verification/SAS/channel/types";
import type {Session} from "../../../../matrix/Session.js";
import type {IChannel} from "../../../../matrix/verification/SAS/channel/IChannel";
import type {SASVerification} from "../../../../matrix/verification/SAS/SASVerification";
@ -39,4 +39,74 @@ export class VerificationCancelledViewModel extends DismissibleVerificationViewM
get kind(): string {
return "verification-cancelled";
}
get title(): string {
if (this.isCancelledByUs) {
return this.i18n`You cancelled the verification!`;
}
if (this.getOption("sas").isCrossSigningAnotherUser) {
return this.i18n`The other user cancelled the verification!`;
}
else {
return this.i18n`The other device cancelled the verification!`;
}
}
get description(): string {
const descriptionsWhenWeCancelledForDeviceVerification = {
[CancelReason.InvalidMessage]: "Your other device sent an invalid message.",
[CancelReason.KeyMismatch]: "The key could not be verified.",
[CancelReason.TimedOut]: "The verification process timed out.",
[CancelReason.UnexpectedMessage]: "Your other device sent an unexpected message.",
[CancelReason.UnknownMethod]: "Your other device is using an unknown method for verification.",
[CancelReason.UnknownTransaction]: "Your other device sent a message with an unknown transaction id.",
[CancelReason.UserMismatch]: "The expected user did not match the user verified.",
[CancelReason.MismatchedCommitment]: "The hash commitment does not match.",
[CancelReason.MismatchedSAS]: "The emoji/decimal did not match.",
}
const descriptionsWhenTheyCancelledForDeviceVerification = {
[CancelReason.UserCancelled]: "Your other device cancelled the verification!",
[CancelReason.InvalidMessage]: "Invalid message sent to the other device.",
[CancelReason.KeyMismatch]: "The other device could not verify our keys",
[CancelReason.TimedOut]: "The verification process timed out.",
[CancelReason.UnexpectedMessage]: "Unexpected message sent to the other device.",
[CancelReason.UnknownMethod]: "Your other device does not understand the method you chose",
[CancelReason.UnknownTransaction]: "Your other device rejected our message.",
[CancelReason.UserMismatch]: "The expected user did not match the user verified.",
[CancelReason.MismatchedCommitment]: "Your other device was not able to verify the hash commitment",
[CancelReason.MismatchedSAS]: "The emoji/decimal did not match.",
}
const descriptionsWhenWeCancelledForCrossSigning = {
[CancelReason.InvalidMessage]: "The other user sent an invalid message.",
[CancelReason.KeyMismatch]: "The key could not be verified.",
[CancelReason.TimedOut]: "The verification process timed out.",
[CancelReason.UnexpectedMessage]: "The other user sent an unexpected message.",
[CancelReason.UnknownMethod]: "The other user is using an unknown method for verification.",
[CancelReason.UnknownTransaction]: "The other user sent a message with an unknown transaction id.",
[CancelReason.UserMismatch]: "The expected user did not match the user verified.",
[CancelReason.MismatchedCommitment]: "The hash commitment does not match.",
[CancelReason.MismatchedSAS]: "The emoji/decimal did not match.",
}
const descriptionsWhenTheyCancelledForCrossSigning = {
[CancelReason.UserCancelled]: "The other user cancelled the verification!",
[CancelReason.InvalidMessage]: "Invalid message sent to the other user.",
[CancelReason.KeyMismatch]: "The other user could not verify our keys",
[CancelReason.TimedOut]: "The verification process timed out.",
[CancelReason.UnexpectedMessage]: "Unexpected message sent to the other user.",
[CancelReason.UnknownMethod]: "The other user does not understand the method you chose",
[CancelReason.UnknownTransaction]: "The other user rejected our message.",
[CancelReason.UserMismatch]: "The expected user did not match the user verified.",
[CancelReason.MismatchedCommitment]: "The other user was not able to verify the hash commitment",
[CancelReason.MismatchedSAS]: "The emoji/decimal did not match.",
}
let map;
if (this.getOption("sas").isCrossSigningAnotherUser) {
map = this.isCancelledByUs ? descriptionsWhenWeCancelledForCrossSigning : descriptionsWhenTheyCancelledForCrossSigning;
} else {
map = this.isCancelledByUs ? descriptionsWhenWeCancelledForDeviceVerification : descriptionsWhenTheyCancelledForDeviceVerification;
}
const description = map[this.cancelCode] ?? ""
return this.i18n`${description}`;
}
}

View File

@ -27,6 +27,20 @@ export class WaitingForOtherUserViewModel extends ViewModel<SegmentType, Options
await this.options.sas.abort();
}
get title() {
const message = this.getOption("sas").isCrossSigningAnotherUser
? "Waiting for the other user to accept the verification request"
: "Waiting for any of your device to accept the verification request";
return this.i18n`${message}`;
}
get description() {
const message = this.getOption("sas").isCrossSigningAnotherUser
? "Ask the other user to accept the request from their client!"
: "Accept the request from the device you wish to verify!";
return this.i18n`${message}`;
}
get kind(): string {
return "waiting-for-user";
}

View File

@ -77,7 +77,31 @@ export class DeviceMessageHandler extends EventEmitter{
}
async afterSyncCompleted(decryptionResults, deviceTracker, hsApi, log) {
this._emitEncryptedEvents(decryptionResults);
await log.wrap("Verifying fingerprint of encrypted toDevice messages", async (log) => {
for (const result of decryptionResults) {
const sender = result.event.sender;
const device = await deviceTracker.deviceForCurveKey(
sender,
result.senderCurve25519Key,
hsApi,
log
);
result.setDevice(device);
if (result.isVerified) {
this.emit("message", { encrypted: result });
}
else {
log.log({
l: "could not verify olm fingerprint key matches, ignoring",
ed25519Key: result.device.ed25519Key,
claimedEd25519Key: result.claimedEd25519Key,
deviceId: device.deviceId,
userId: device.userId,
});
}
}
});
// todo: Refactor the following to use to device messages
if (this._callHandler) {
// if we don't have a device, we need to fetch the device keys the message claims
// and check the keys, and we should only do network requests during
@ -113,12 +137,6 @@ export class DeviceMessageHandler extends EventEmitter{
}
}
_emitEncryptedEvents(decryptionResults) {
// We don't emit for now as we're not verifying the identity of the sender
// for (const result of decryptionResults) {
// this.emit("message", { encrypted: result });
// }
}
}
class SyncPreparation {

View File

@ -43,9 +43,11 @@ import {
readKey as ssssReadKey,
writeKey as ssssWriteKey,
removeKey as ssssRemoveKey,
keyFromDehydratedDeviceKey as createSSSSKeyFromDehydratedDeviceKey
keyFromDehydratedDeviceKey as createSSSSKeyFromDehydratedDeviceKey,
SecretStorage,
SecretSharing,
SecretFetcher
} from "./ssss/index";
import {SecretStorage} from "./ssss/SecretStorage";
import {ObservableValue, RetainedObservableValue} from "../observable/value";
import {CallHandler} from "./calls/CallHandler";
import {RoomStateHandlerSet} from "./room/state/RoomStateHandlerSet";
@ -109,6 +111,9 @@ export class Session {
this._createRoomEncryption = this._createRoomEncryption.bind(this);
this._forgetArchivedRoom = this._forgetArchivedRoom.bind(this);
this.needsKeyBackup = new ObservableValue(false);
this._secretFetcher = new SecretFetcher();
this._secretSharing = null;
this._secretStorage = null;
}
get fingerprintKey() {
@ -168,7 +173,7 @@ export class Session {
}
// called once this._e2eeAccount is assigned
_setupEncryption() {
async _setupEncryption() {
// TODO: this should all go in a wrapper in e2ee/ that is bootstrapped by passing in the account
// and can create RoomEncryption objects and handle encrypted to_device messages and device list changes.
const senderKeyLock = new LockMap();
@ -202,6 +207,20 @@ export class Session {
});
this._megolmDecryption = new MegOlmDecryption(this._keyLoader, this._olmWorker);
this._deviceMessageHandler.enableEncryption({olmDecryption, megolmDecryption: this._megolmDecryption});
this._secretSharing = new SecretSharing({
hsApi: this._hsApi,
storage: this._storage,
deviceMessageHandler: this._deviceMessageHandler,
deviceTracker: this._deviceTracker,
ourUserId: this.userId,
olmEncryption: this._olmEncryption,
crypto: this._platform.crypto,
encoding: this._platform.encoding,
crossSigning: this._crossSigning,
logger: this._platform.logger,
});
await this._secretSharing.load();
this._secretFetcher.setSecretSharing(this._secretSharing);
}
_createRoomEncryption(room, encryptionParams) {
@ -255,11 +274,6 @@ export class Session {
this._keyBackup.get().dispose();
this._keyBackup.set(undefined);
}
const crossSigning = this._crossSigning.get();
if (crossSigning) {
crossSigning.dispose();
this._crossSigning.set(undefined);
}
const key = await ssssKeyFromCredential(type, credential, this._storage, this._platform, this._olm);
if (await this._tryLoadSecretStorage(key, log)) {
// only after having read a secret, write the key
@ -335,7 +349,9 @@ export class Session {
const isValid = await secretStorage.hasValidKeyForAnyAccountData();
log.set("isValid", isValid);
if (isValid) {
this._secretStorage = secretStorage;
await this._loadSecretStorageServices(secretStorage, log);
this._secretFetcher.setSecretStorage(secretStorage);
}
return isValid;
});
@ -363,29 +379,6 @@ export class Session {
log.set("no_backup", true);
}
});
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,
deviceMessageHandler: this._deviceMessageHandler,
hsApi: this._hsApi,
ownUserId: this.userId,
e2eeAccount: this._e2eeAccount,
deviceId: this.deviceId,
});
if (await crossSigning.load(log)) {
this._crossSigning.set(crossSigning);
}
else {
crossSigning.dispose();
}
});
}
} catch (err) {
log.catch(err);
}
@ -404,6 +397,14 @@ export class Session {
return this._crossSigning;
}
get secretSharing() {
return this._secretSharing;
}
get secretFetcher() {
return this._secretFetcher;
}
get hasIdentity() {
return !!this._e2eeAccount;
}
@ -414,10 +415,11 @@ export class Session {
if (!this._e2eeAccount) {
this._e2eeAccount = await this._createNewAccount(this._sessionInfo.deviceId, this._storage);
log.set("keys", this._e2eeAccount.identityKeys);
this._setupEncryption();
await this._setupEncryption();
}
await this._e2eeAccount.generateOTKsIfNeeded(this._storage, log);
await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, false, log));
await this._createCrossSigning();
}
}
@ -544,6 +546,31 @@ export class Session {
await this._tryLoadSecretStorage(ssssKey, log);
}
}
if (this._e2eeAccount) {
await this._createCrossSigning();
}
}
async _createCrossSigning() {
if (this._features.crossSigning) {
this._platform.logger.run("enable cross-signing", async log => {
const crossSigning = new CrossSigning({
storage: this._storage,
secretFetcher: this._secretFetcher,
platform: this._platform,
olm: this._olm,
olmUtil: this._olmUtil,
deviceTracker: this._deviceTracker,
deviceMessageHandler: this._deviceMessageHandler,
hsApi: this._hsApi,
ownUserId: this.userId,
e2eeAccount: this._e2eeAccount,
deviceId: this.deviceId,
});
await crossSigning.load(log);
this._crossSigning.set(crossSigning);
});
}
}
dispose() {
@ -745,6 +772,7 @@ export class Session {
e2eeAccountChanges: null,
hasNewRoomKeys: false,
deviceMessageDecryptionResults: null,
changedDevices: null,
};
const syncToken = syncResponse.next_batch;
if (syncToken !== this.syncToken) {
@ -762,6 +790,7 @@ export class Session {
const deviceLists = syncResponse.device_lists;
if (this._deviceTracker && Array.isArray(deviceLists?.changed) && deviceLists.changed.length) {
await log.wrap("deviceLists", log => this._deviceTracker.writeDeviceChanges(deviceLists.changed, txn, log));
changes.changedDevices = deviceLists.changed;
}
if (preparation) {
@ -811,6 +840,9 @@ export class Session {
if (changes.deviceMessageDecryptionResults) {
await this._deviceMessageHandler.afterSyncCompleted(changes.deviceMessageDecryptionResults, this._deviceTracker, this._hsApi, log);
}
if (changes.changedDevices?.includes(this.userId)) {
this._secretSharing?.checkSecretValidity();
}
}
_tryReplaceRoomBeingCreated(roomId, log) {

View File

@ -36,7 +36,7 @@ type DecryptedEvent = {
}
export class DecryptionResult {
private device?: DeviceKey;
public device?: DeviceKey;
constructor(
public readonly event: DecryptedEvent,

View File

@ -527,7 +527,22 @@ export class DeviceTracker {
}
/** Gets a single device */
async deviceForId(userId: string, deviceId: string, hsApi: HomeServerApi, log: ILogItem) {
async deviceForId(userId: string, deviceId: string, hsApi: HomeServerApi, log: ILogItem): Promise<DeviceKey | undefined> {
/**
* 1. If the device keys are outdated, we will fetch all the keys and update them.
*/
const userIdentityTxn = await this._storage.readTxn([this._storage.storeNames.userIdentities]);
const userIdentity = await userIdentityTxn.userIdentities.get(userId);
if (userIdentity?.keysTrackingStatus !== KeysTrackingStatus.UpToDate) {
const {deviceKeys} = await this._queryKeys([userId], hsApi, log);
const keyList = deviceKeys.get(userId);
const device = keyList!.find(device => device.device_id === deviceId);
return device;
}
/**
* 2. If keys are up to date, return from storage.
*/
const txn = await this._storage.readTxn([
this._storage.storeNames.deviceKeys,
]);
@ -554,6 +569,9 @@ export class DeviceTracker {
const txn = await this._storage.readWriteTxn([
this._storage.storeNames.deviceKeys,
]);
// todo: the following comment states what the code does
// but it fails to explain why it does what it does...
// check again we don't have the device already.
// when updating all keys for a user we allow updating the
// device when the key hasn't changed so the device display name
@ -577,6 +595,22 @@ export class DeviceTracker {
return deviceKey;
}
async deviceForCurveKey(userId: string, key: string, hsApi: HomeServerApi, log: ILogItem): Promise<DeviceKey | undefined> {
const txn = await this._storage.readTxn([
this._storage.storeNames.deviceKeys,
this._storage.storeNames.userIdentities,
]);
const userIdentity = await txn.userIdentities.get(userId);
if (userIdentity?.keysTrackingStatus !== KeysTrackingStatus.UpToDate) {
const {deviceKeys} = await this._queryKeys([userId], hsApi, log);
const keyList = deviceKeys.get(userId);
const device = keyList!.find(device => getDeviceCurve25519Key(device) === key);
return device;
}
const device = await txn.deviceKeys.getByCurve25519Key(key);
return device;
}
/**
* Gets all the device identities with which keys should be shared for a set of users in a tracked room.
* If any userIdentities are outdated, it will fetch them from the homeserver.
@ -611,7 +645,7 @@ export class DeviceTracker {
/** Gets the device identites for a set of user identities that
* are known to be up to date, and a set of userIds that are known
* to be absent from our store our outdated. The outdated user ids
* to be absent from our store or are outdated. The outdated user ids
* will have their keys fetched from the homeserver. */
async _devicesForUserIdentities(upToDateIdentities: UserIdentity[], outdatedUserIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise<DeviceKey[]> {
log.set("uptodate", upToDateIdentities.length);
@ -643,6 +677,10 @@ export class DeviceTracker {
async getDeviceByCurve25519Key(curve25519Key, txn: Transaction): Promise<DeviceKey | undefined> {
return await txn.deviceKeys.getByCurve25519Key(curve25519Key);
}
get ownDeviceId(): string {
return this._ownDeviceId;
}
}
import {createMockStorage} from "../../mocks/Storage";

View File

@ -0,0 +1,52 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type {SecretStorage} from "./SecretStorage";
import type {SecretSharing} from "./SecretSharing";
/**
* This is a wrapper around SecretStorage and SecretSharing so that
* you don't need to check both sources for a secret.
*/
export class SecretFetcher {
public secretStorage: SecretStorage;
public secretSharing: SecretSharing;
async getSecret(name: string): Promise<string | undefined> {
/**
* Note that we don't ask another device for secret here;
* that should be done explicitly since it can take arbitrary
* amounts of time to be fulfilled as the other devices may
* be offline etc...
*/
return await this.secretStorage?.readSecret(name) ??
await this.secretSharing?.getLocallyStoredSecret(name);
}
setSecretStorage(storage: SecretStorage) {
this.secretStorage = storage;
}
setSecretSharing(sharing: SecretSharing) {
this.secretSharing = sharing;
/**
* SecretSharing also needs to respond to secret requests
* from other devices, so it needs the secret fetcher as
* well
*/
this.secretSharing.setSecretFetcher(this);
}
}

View File

@ -0,0 +1,383 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type {HomeServerApi} from "../net/HomeServerApi";
import type {Storage} from "../storage/idb/Storage";
import type {DeviceMessageHandler} from "../DeviceMessageHandler.js"
import type {DeviceTracker} from "../e2ee/DeviceTracker";
import type {ILogger, ILogItem} from "../../logging/types";
import type {Encryption as OlmEncryption} from "../e2ee/olm/Encryption";
import type {Crypto} from "../../platform/web/dom/Crypto.js";
import type {Encoding} from "../../platform/web/utils/Encoding.js";
import type {CrossSigning} from "../verification/CrossSigning";
import type {SecretFetcher} from "./SecretFetcher";
import type {ObservableValue} from "../../observable/value";
import type {DecryptionResult} from "../e2ee/DecryptionResult";
import {makeTxnId, formatToDeviceMessagesPayload} from "../common.js";
import {Deferred} from "../../utils/Deferred";
import {StoreNames} from "../storage/common";
import {SESSION_E2EE_KEY_PREFIX} from "../e2ee/common";
type Options = {
hsApi: HomeServerApi;
storage: Storage;
deviceMessageHandler: DeviceMessageHandler;
deviceTracker: DeviceTracker;
ourUserId: string;
olmEncryption: OlmEncryption;
crypto: Crypto;
encoding: Encoding;
crossSigning: ObservableValue<CrossSigning | undefined>;
logger: ILogger;
};
const enum EVENT_TYPE {
REQUEST = "m.secret.request",
SEND = "m.secret.send",
}
const STORAGE_KEY = "secretRequestIds";
export class SecretSharing {
private readonly hsApi: HomeServerApi;
private readonly storage: Storage;
private readonly deviceMessageHandler: DeviceMessageHandler;
private readonly deviceTracker: DeviceTracker;
private readonly ourUserId: string;
private readonly olmEncryption: OlmEncryption;
private readonly waitMap: Map<string, { deferred: Deferred<any>, name: string }> = new Map();
private readonly encoding: Encoding;
private readonly aesEncryption: AESEncryption;
private readonly crossSigning: ObservableValue<CrossSigning | undefined>;
private readonly logger: ILogger;
private secretFetcher: SecretFetcher;
constructor(options: Options) {
this.hsApi = options.hsApi;
this.storage = options.storage;
this.deviceMessageHandler = options.deviceMessageHandler;
this.deviceTracker = options.deviceTracker;
this.ourUserId = options.ourUserId;
this.olmEncryption = options.olmEncryption;
this.encoding = options.encoding;
this.crossSigning = options.crossSigning;
this.logger = options.logger;
this.aesEncryption = new AESEncryption(this.storage, options.crypto, this.encoding);
}
async load(): Promise<void> {
this.deviceMessageHandler.on("message", async ({ encrypted }) => {
const type: EVENT_TYPE = encrypted?.event.type;
switch (type) {
case EVENT_TYPE.REQUEST: {
await this._respondToRequest(encrypted);
break;
}
case EVENT_TYPE.SEND: {
const {secret} = encrypted.event.content;
const name = await this.shouldAcceptSecret(encrypted);
if (name) {
this.writeSecretToStorage(name, secret);
}
break;
}
}
});
await this.aesEncryption.load();
}
private async _respondToRequest(request): Promise<void> {
await this.logger.run("SharedSecret.respondToRequest", async (log) => {
if (!await this.shouldRespondToRequest(request, log)) {
return;
}
const requestContent = request.event.content;
const id = requestContent.request_id;
const deviceId = requestContent.requesting_device_id;
const name = requestContent.name;
const secret = await this.secretFetcher.getSecret(name);
if (!secret) {
// Can't share a secret that we don't know about.
log.log({ l: "Secret not available to share" });
return;
}
const content = { secret, request_id: id };
const device = await this.deviceTracker.deviceForId(this.ourUserId, deviceId, this.hsApi, log);
if (!device) {
log.log({ l: "Cannot find device", deviceId });
return;
}
const messages = await log.wrap("olm encrypt", log => this.olmEncryption.encrypt(
EVENT_TYPE.SEND, content, [device], this.hsApi, log));
const payload = formatToDeviceMessagesPayload(messages);
await this.hsApi.sendToDevice("m.room.encrypted", payload, makeTxnId(), {log}).response();
});
}
private async shouldRespondToRequest(request: any, log: ILogItem): Promise<boolean> {
return log.wrap("SecretSharing.shouldRespondToRequest", async () => {
if (request.event.content.requesting_device_id === this.deviceTracker.ownDeviceId) {
// This is the request that we sent, so ignore
return false;
}
const crossSigning = this.crossSigning.get();
if (!crossSigning) {
// We're not in a position to respond to this request
log.log({ crossSigningNotAvailable: true });
return false;
}
const content = request.event.content;
if (
request.event.sender !== this.ourUserId ||
!(
content.name &&
content.action &&
content.requesting_device_id &&
content.request_id
) ||
content.action === "request_cancellation"
) {
// 1. Ensure that the message came from the same user as us
// 2. Validate message format
// 3. Check if this is a cancellation
return false;
}
// 3. Check that the device is verified
const deviceId = content.requesting_device_id;
const device = await this.deviceTracker.deviceForId(this.ourUserId, deviceId, this.hsApi, log);
if (!device) {
log.log({ l: "Device could not be acquired", deviceId });
return false;
}
if (!await crossSigning.isOurUserDeviceTrusted(device, log)) {
log.log({ l: "Device not trusted, returning" });
return false;
}
return true;
});
}
/**
* Returns name of the secret if we can accept the response.
* Returns undefined otherwise.
* @param decryptionResult Encrypted to-device event that contains the secret
*/
private async shouldAcceptSecret(decryptionResult: DecryptionResult): Promise<string | undefined> {
// 1. Check if we can trust this response
const crossSigning = this.crossSigning.get();
if (!crossSigning) {
return;
}
const device = decryptionResult.device;
if (!device) {
return;
}
if (!await crossSigning.isOurUserDeviceTrusted(device)) {
// We don't want to accept secrets from an untrusted device
console.log("received secret, but ignoring because not verified");
return;
}
const content = decryptionResult.event.content!;
const requestId = content.request_id;
// 2. Check if this request is in waitMap
const obj = this.waitMap.get(requestId);
if (obj) {
const { name, deferred } = obj;
deferred.resolve(decryptionResult);
this.waitMap.delete(requestId);
await this.removeStoredRequestId(requestId);
return name;
}
// 3. Check if we've persisted the request to storage
const txn = await this.storage.readTxn([this.storage.storeNames.session]);
const storedIds = await txn.session.get(STORAGE_KEY);
const name = storedIds?.[requestId];
if (name) {
await this.removeStoredRequestId(requestId);
return name;
}
}
private async removeStoredRequestId(requestId: string): Promise<void> {
const txn = await this.storage.readWriteTxn([this.storage.storeNames.session]);
const storedIds = await txn.session.get(STORAGE_KEY);
if (storedIds) {
delete storedIds[requestId];
txn.session.set(STORAGE_KEY, storedIds);
}
}
async checkSecretValidity(log: ILogItem): Promise<void> {
const crossSigning = this.crossSigning.get();
const needsDeleting = !await crossSigning?.areWeVerified(log);
if (needsDeleting) {
// User probably reset their cross-signing keys
// Can't trust the secrets anymore!
const txn = await this.storage.readWriteTxn([this.storage.storeNames.sharedSecrets]);
txn.sharedSecrets.deleteAllSecrets();
}
}
async getLocallyStoredSecret(name: string): Promise<any> {
const txn = await this.storage.readTxn([
this.storage.storeNames.sharedSecrets,
]);
const storedSecret = await txn.sharedSecrets.get(name);
if (storedSecret) {
const secret = await this.aesEncryption.decrypt(storedSecret.encrypted);
return secret;
}
}
// todo: this will break if two different pieces of code call this method
requestSecret(name: string, log: ILogItem): Promise<SecretRequest> {
return log.wrap("SharedSecret.requestSecret", async (_log) => {
const request_id = makeTxnId();
const promise = this.trackSecretRequest(request_id, name);
await this.sendRequestForSecret(name, request_id, _log);
await this.writeRequestIdToStorage(request_id, name);
const request = new SecretRequest(promise);
return request;
});
}
/**
* We will store the request-id of every secret request that we send.
* If a device responds to our secret request when we're offline and we receive
* it via sync when we come online at some later time, we can use this persisted
* request-id to determine if we should accept the secret.
*/
private async writeRequestIdToStorage(requestId: string, name: string): Promise<void> {
const txn = await this.storage.readWriteTxn([
this.storage.storeNames.session,
]);
const txnIds = await txn.session.get(STORAGE_KEY) ?? {};
txnIds[requestId] = name;
txn.session.set(STORAGE_KEY, txnIds)
}
private async writeSecretToStorage(name:string, secret: any): Promise<void> {
const encrypted = await this.aesEncryption.encrypt(secret);
const txn = await this.storage.readWriteTxn([StoreNames.sharedSecrets]);
txn.sharedSecrets.set(name, { encrypted });
}
private trackSecretRequest(request_id: string, name: string): Promise<any> {
const deferred = new Deferred();
this.waitMap.set(request_id, { deferred, name });
return deferred.promise;
}
private async sendRequestForSecret(name: string, request_id: string, log: ILogItem): Promise<void> {
const content = {
action: "request",
name,
request_id,
requesting_device_id: this.deviceTracker.ownDeviceId,
}
let devices = await this.deviceTracker.devicesForUsers([this.ourUserId], this.hsApi, log);
devices = devices.filter(d => d.device_id !== this.deviceTracker.ownDeviceId);
const messages = await log.wrap("olm encrypt", log => this.olmEncryption.encrypt(
EVENT_TYPE.REQUEST, content, devices, this.hsApi, log));
const payload = formatToDeviceMessagesPayload(messages);
await this.hsApi.sendToDevice("m.room.encrypted", payload, makeTxnId(), {log}).response();
}
setSecretFetcher(secretFetcher: SecretFetcher): void {
this.secretFetcher = secretFetcher;
}
}
class SecretRequest {
constructor(private receivedSecretPromise: Promise<any>) {
}
/**
* Wait for any of your device to respond to this secret request.
* If you're going to await this method, make sure you do that within a try catch block.
* @param timeout The max time (in seconds) that we will wait, after which the promise rejects
*/
async waitForResponse(timeout: number = 30): Promise<string> {
const timeoutPromise: Promise<string> = new Promise((_, reject) => {
setTimeout(reject, timeout * 1000);
});
const response = await Promise.race([this.receivedSecretPromise, timeoutPromise]);
return response.event.content.secret;
}
}
/**
* The idea is to encrypt the secret with AES before persisting to storage.
* The AES key is also in storage so this isn't really that much more secure.
* But it's a tiny bit better than storing the secret in plaintext.
*/
// todo: We could also encrypt the access-token using AES like element does
class AESEncryption {
private key: JsonWebKey;
private iv: Uint8Array;
constructor(private storage: Storage, private crypto: Crypto, private encoding: Encoding) { };
async load(): Promise<void> {
const storageKey = `${SESSION_E2EE_KEY_PREFIX}localAESKey`;
// 1. Check if we're already storing the AES key
const txn = await this.storage.readTxn([StoreNames.session]);
let { key, iv } = await txn.session.get(storageKey) ?? {};
// 2. If no key, create it and store in session store
if (!key) {
/**
* Element creates the key as "non-extractable", meaning that it cannot
* be exported through the crypto DOM API. But since it's going
* to end up in indexeddb anyway, it really doesn't matter.
*/
key = await this.crypto.aes.generateKey("jwk");
iv = await this.crypto.aes.generateIV();
const txn = await this.storage.readWriteTxn([StoreNames.session]);
txn.session.set(storageKey, { key, iv });
}
// 3. Set props
this.key = key;
this.iv = iv;
}
async encrypt(secret: string): Promise<Uint8Array> {
const data = this.encoding.utf8.encode(secret);
const encrypted = await this.crypto.aes.encryptCTR({
jwkKey: this.key,
iv: this.iv,
data,
});
return encrypted;
}
async decrypt(ciphertext: Uint8Array): Promise<string> {
const buffer = await this.crypto.aes.decryptCTR({
jwkKey: this.key,
iv: this.iv,
data: ciphertext,
});
const secret = this.encoding.utf8.decode(buffer);
return secret;
}
}

View File

@ -24,6 +24,11 @@ import type {KeyDescriptionData} from "./common";
import type {Platform} from "../../platform/web/Platform.js";
import type * as OlmNamespace from "@matrix-org/olm"
// Add exports for other classes
export {SecretFetcher} from "./SecretFetcher";
export {SecretSharing} from "./SecretSharing";
export {SecretStorage} from "./SecretStorage";
type Olm = typeof OlmNamespace;
const SSSS_KEY = `${SESSION_E2EE_KEY_PREFIX}ssssKey`;

View File

@ -34,7 +34,8 @@ export enum StoreNames {
operations = "operations",
accountData = "accountData",
calls = "calls",
crossSigningKeys = "crossSigningKeys"
crossSigningKeys = "crossSigningKeys",
sharedSecrets = "sharedSecrets",
}
export const STORE_NAMES: Readonly<StoreNames[]> = Object.values(StoreNames);

View File

@ -118,6 +118,16 @@ export class QueryTargetWrapper<T> {
}
}
clear(): IDBRequest<undefined> {
try {
LOG_REQUESTS && logRequest("clear", [], this._qt);
return this._qtStore.clear();
}
catch (err) {
throw new IDBRequestAttemptError("delete", this._qt, err, []);
}
}
count(keyRange?: IDBKeyRange): IDBRequest<number> {
try {
return this._qt.count(keyRange);
@ -195,6 +205,11 @@ export class Store<T> extends QueryTarget<T> {
this._prepareErrorLog(request, log, "delete", keyOrKeyRange, undefined);
}
clear(log?: ILogItem): void {
const request = this._idbStore.clear();
this._prepareErrorLog(request, log, "delete", undefined, undefined);
}
private _prepareErrorLog(request: IDBRequest, log: ILogItem | undefined, operationName: string, key: IDBKey | undefined, value: T | undefined) {
if (log) {
log.ensureRefId();

View File

@ -38,6 +38,7 @@ import {GroupSessionDecryptionStore} from "./stores/GroupSessionDecryptionStore"
import {OperationStore} from "./stores/OperationStore";
import {AccountDataStore} from "./stores/AccountDataStore";
import {CallStore} from "./stores/CallStore";
import {SharedSecretStore} from "./stores/SharedSecretStore";
import type {ILogger, ILogItem} from "../../../logging/types";
export type IDBKey = IDBValidKey | IDBKeyRange;
@ -178,6 +179,10 @@ export class Transaction {
return this._store(StoreNames.calls, idbStore => new CallStore(idbStore));
}
get sharedSecrets(): SharedSecretStore {
return this._store(StoreNames.sharedSecrets, idbStore => new SharedSecretStore(idbStore));
}
async complete(log?: ILogItem): Promise<void> {
try {
await txnAsPromise(this._txn);

View File

@ -37,7 +37,8 @@ export const schema: MigrationFunc[] = [
addInboundSessionBackupIndex,
migrateBackupStatus,
createCallStore,
applyCrossSigningChanges
applyCrossSigningChanges,
createSharedSecretStore,
];
// TODO: how to deal with git merge conflicts of this array?
@ -299,3 +300,8 @@ async function applyCrossSigningChanges(db: IDBDatabase, txn: IDBTransaction, lo
});
log.set("marked_outdated", counter);
}
//v19 create shared secrets store
function createSharedSecretStore(db: IDBDatabase) : void {
db.createObjectStore("sharedSecrets", {keyPath: "key"});
}

View File

@ -0,0 +1,43 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {Store} from "../Store";
type SharedSecret = any;
export class SharedSecretStore {
private _store: Store<SharedSecret>;
constructor(store: Store<SharedSecret>) {
this._store = store;
}
get(name: string): Promise<SharedSecret | undefined> {
return this._store.get(name);
}
set(name: string, secret: SharedSecret): void {
secret.key = name;
this._store.put(secret);
}
remove(name: string): void {
this._store.delete(name);
}
deleteAllSecrets(): void {
this._store.clear();
}
}

View File

@ -23,7 +23,7 @@ import {RoomChannel} from "./SAS/channel/RoomChannel";
import {VerificationEventType} from "./SAS/channel/types";
import {ObservableMap} from "../../observable/map";
import {SASRequest} from "./SAS/SASRequest";
import type {SecretStorage} from "../ssss/SecretStorage";
import {SecretFetcher} from "../ssss";
import type {Storage} from "../storage/idb/Storage";
import type {Platform} from "../../platform/web/Platform";
import type {DeviceTracker} from "../e2ee/DeviceTracker";
@ -89,7 +89,7 @@ export interface IVerificationMethod {
export class CrossSigning {
private readonly storage: Storage;
private readonly secretStorage: SecretStorage;
private readonly secretFetcher: SecretFetcher;
private readonly platform: Platform;
private readonly deviceTracker: DeviceTracker;
private readonly olm: Olm;
@ -106,7 +106,7 @@ export class CrossSigning {
constructor(options: {
storage: Storage,
secretStorage: SecretStorage,
secretFetcher: SecretFetcher,
deviceTracker: DeviceTracker,
platform: Platform,
olm: Olm,
@ -118,7 +118,7 @@ export class CrossSigning {
deviceMessageHandler: DeviceMessageHandler,
}) {
this.storage = options.storage;
this.secretStorage = options.secretStorage;
this.secretFetcher = options.secretFetcher;
this.platform = options.platform;
this.deviceTracker = options.deviceTracker;
this.olm = options.olm;
@ -231,33 +231,60 @@ export class CrossSigning {
return this.sasVerificationInProgress;
}
private handleSASDeviceMessage({ unencrypted: event }) {
const txnId = event.content.transaction_id;
/**
* If we receive an event for the current/previously finished
* SAS verification, we should ignore it because the device channel
* object (who also listens for to_device messages) will take care of it (if needed).
*/
const shouldIgnoreEvent = this.sasVerificationInProgress?.channel.id === txnId;
if (shouldIgnoreEvent) { return; }
/**
* 1. If we receive the cancel message, we need to update the requests map.
* 2. If we receive an starting message (viz request/start), we need to create the SASRequest from it.
*/
switch (event.type) {
case VerificationEventType.Cancel:
this.receivedSASVerifications.remove(txnId);
return;
case VerificationEventType.Request:
case VerificationEventType.Start:
this.platform.logger.run("Create SASRequest", () => {
this.receivedSASVerifications.set(txnId, new SASRequest(event));
});
return;
default:
// we don't care about this event!
return;
private async handleSASDeviceMessage({ unencrypted: event }) {
if (!event ||
(event.type !== VerificationEventType.Request && event.type !== VerificationEventType.Start)
) {
return;
}
await this.platform.logger.run("CrossSigning.handleSASDeviceMessage", async log => {
const txnId = event.content.transaction_id;
const fromDevice = event.content.from_device;
const fromUser = event.sender;
if (!fromDevice || fromUser !== this.ownUserId) {
/**
* SAS verification may be started with a request or a start message but
* both should contain a from_device.
*/
return;
}
if (!await this.areWeVerified(log)) {
/**
* If we're not verified, then the other device MUST be verified.
* We check this so that verification between two unverified devices
* never happen!
*/
const device = await this.deviceTracker.deviceForId(this.ownUserId, fromDevice, this.hsApi, log);
if (!device || !await this.isOurUserDeviceTrusted(device!, log)) {
return;
}
}
/**
* If we receive an event for the current/previously finished
* SAS verification, we should ignore it because the device channel
* object (who also listens for to_device messages) will take care of it (if needed).
*/
const shouldIgnoreEvent = this.sasVerificationInProgress?.channel.id === txnId;
if (shouldIgnoreEvent) { return; }
/**
* 1. If we receive the cancel message, we need to update the requests map.
* 2. If we receive an starting message (viz request/start), we need to create the SASRequest from it.
*/
switch (event.type) {
case VerificationEventType.Cancel:
this.receivedSASVerifications.remove(txnId);
return;
case VerificationEventType.Request:
case VerificationEventType.Start:
this.platform.logger.run("Create SASRequest", () => {
this.receivedSASVerifications.set(txnId, new SASRequest(event));
});
return;
default:
// we don't care about this event!
return;
}
});
}
/** returns our own device key signed by our self-signing key. Other signatures will be missing. */
@ -276,10 +303,17 @@ export class CrossSigning {
async signDevice(verification: IVerificationMethod, log: ILogItem): Promise<DeviceKey | undefined> {
return log.wrap("CrossSigning.signDevice", async log => {
if (!this._isMasterKeyTrusted) {
/**
* If we're the unverified device that is participating in
* the verification process, it is expected that we do not
* have access to the private part of MSK and thus
* cannot determine if the MSK is trusted. In this case, we
* do not need to sign anything because the other (verified)
* device will sign our device key with the SSK.
*/
log.set("mskNotTrusted", true);
return;
}
const shouldSign = await verification.verify();
const shouldSign = await verification.verify() && this._isMasterKeyTrusted;
log.set("shouldSign", shouldSign);
if (!shouldSign) {
return;
@ -340,6 +374,27 @@ export class CrossSigning {
});
}
async isOurUserDeviceTrusted(device: DeviceKey, log?: ILogItem): Promise<boolean> {
return await this.platform.logger.wrapOrRun(log, "CrossSigning.isOurUserDeviceTrusted", async (_log) => {
const ourSSK = await this.deviceTracker.getCrossSigningKeyForUser(this.ownUserId, KeyUsage.SelfSigning, this.hsApi, _log);
if (!ourSSK) {
return false;
}
const verification = this.hasValidSignatureFrom(device, ourSSK, _log);
if (verification === SignatureVerification.Valid) {
return true;
}
return false;
});
}
areWeVerified(log?: ILogItem): Promise<boolean> {
return this.platform.logger.wrapOrRun(log, "CrossSigning.areWeVerified", async (_log) => {
const device = await this.deviceTracker.deviceForId(this.ownUserId, this.deviceId, this.hsApi, _log);
return this.isOurUserDeviceTrusted(device!, log);
});
}
getUserTrust(userId: string, log: ILogItem): Promise<UserTrust> {
return log.wrap("CrossSigning.getUserTrust", async log => {
log.set("id", userId);
@ -457,7 +512,7 @@ export class CrossSigning {
}
private async getSigningKey(usage: KeyUsage): Promise<Uint8Array | undefined> {
const seedStr = await this.secretStorage.readSecret(`m.cross_signing.${usage}`);
const seedStr = await this.secretFetcher.getSecret(`m.cross_signing.${usage}`);
if (seedStr) {
return new Uint8Array(this.platform.encoding.base64.decode(seedStr));
}

View File

@ -103,7 +103,7 @@ export class RoomChannel extends Disposables implements IChannel {
}
async send(eventType: VerificationEventType, content: any, log: ILogItem): Promise<void> {
await log.wrap("RoomChannel.send", async () => {
await log.wrap("RoomChannel.send", async (_log) => {
if (this.isCancelled) {
throw new VerificationCancelledError();
}
@ -112,6 +112,15 @@ export class RoomChannel extends Disposables implements IChannel {
await this.handleRequestEventSpecially(eventType, content, log);
return;
}
if (!this.id) {
/**
* This might happen if the user cancelled the verification from the UI,
* but no verification messages were yet sent (maybe because the keys are
* missing etc..).
*/
return;
}
await this.room.ensureMessageKeyIsShared(_log);
Object.assign(content, createReference(this.id));
await this.room.sendEvent(eventType, content, undefined, log);
this.sentMessages.set(eventType, {content});

View File

@ -70,8 +70,12 @@ export class ToDeviceChannel extends Disposables implements IChannel {
this.track(
this.deviceMessageHandler.disposableOn(
"message",
async ({ unencrypted }) =>
await this.handleDeviceMessage(unencrypted)
async ({ unencrypted }) => {
if (!unencrypted) {
return;
}
await this.handleDeviceMessage(unencrypted);
}
)
);
this.track(() => {

View File

@ -18,10 +18,11 @@ import {CancelReason, VerificationEventType} from "../channel/types";
import {KEY_AGREEMENT_LIST, HASHES_LIST, MAC_LIST, SAS_LIST} from "./constants";
import {SendAcceptVerificationStage} from "./SendAcceptVerificationStage";
import {SendKeyStage} from "./SendKeyStage";
import {Deferred} from "../../../../utils/Deferred";
import type {ILogItem} from "../../../../logging/types";
export class SelectVerificationMethodStage extends BaseSASVerificationStage {
private hasSentStartMessage = false;
private hasSentStartMessage?: Promise<void>;
private allowSelection = true;
public otherDeviceName: string;
@ -36,6 +37,7 @@ export class SelectVerificationMethodStage extends BaseSASVerificationStage {
// We received the start message
this.allowSelection = false;
if (this.hasSentStartMessage) {
await this.hasSentStartMessage;
await this.resolveStartConflict(log);
}
else {
@ -96,6 +98,8 @@ export class SelectVerificationMethodStage extends BaseSASVerificationStage {
async selectEmojiMethod(log: ILogItem) {
if (!this.allowSelection) { return; }
const deferred = new Deferred<void>();
this.hasSentStartMessage = deferred.promise;
const content = {
method: "m.sas.v1",
from_device: this.ourUserDeviceId,
@ -110,6 +114,6 @@ export class SelectVerificationMethodStage extends BaseSASVerificationStage {
* to the next stage (where we will send the key).
*/
await this.channel.send(VerificationEventType.Start, content, log);
this.hasSentStartMessage = true;
deferred.resolve();
}
}

View File

@ -101,6 +101,14 @@ the layout viewport up without resizing it when the keyboard shows */
.middle .close-middle { display: block !important; }
/* hide grid button */
.LeftPanel .grid { display: none !important; }
.VerificationReadyTileView {
flex-direction: column;
}
.VerificationTileView__actions {
justify-content: center;
}
}
.LeftPanel {

View File

@ -1423,6 +1423,7 @@ button.RoomDetailsView_row::after {
.VerificationCompleteView__heading,
.VerifyEmojisView__heading,
.SelectMethodView__heading,
.MissingKeysView__heading,
.WaitingForOtherUserView__heading {
display: flex;
align-items: center;
@ -1432,6 +1433,10 @@ button.RoomDetailsView_row::after {
padding: 8px;
}
.MissingKeysView__heading {
text-align: center;
}
.VerificationCompleteView>*,
.SelectMethodView>*,
.VerifyEmojisView>*,
@ -1453,6 +1458,7 @@ button.RoomDetailsView_row::after {
.SelectMethodView__title,
.WaitingForOtherUserView__title,
.VerificationCancelledView__description,
.MissingKeysView__description,
.VerificationCompleteView__description,
.VerifyEmojisView__description,
.SelectMethodView__description,
@ -1462,6 +1468,7 @@ button.RoomDetailsView_row::after {
}
.VerificationCancelledView__actions,
.MissingKeysView__actions,
.SelectMethodView__actions,
.VerifyEmojisView__actions,
.WaitingForOtherUserView__actions {
@ -1532,6 +1539,7 @@ button.RoomDetailsView_row::after {
font-size: 1.4rem;
color: var(--text-color);
gap: 4px;
text-align: center;
}
.VerificationInProgressTileView,
@ -1539,7 +1547,7 @@ button.RoomDetailsView_row::after {
.VerificationCancelledTileView,
.VerificationReadyTileView {
background: var(--background-color-primary--darker-5);
padding: 12px;
padding: 8px;
box-sizing: border-box;
border-radius: 8px;
}
@ -1547,18 +1555,18 @@ button.RoomDetailsView_row::after {
.VerificationTileView {
display: flex;
justify-content: center;
padding: 16px;
padding: 5px 10%;
box-sizing: border-box;
}
.VerificationInProgressTileView .VerificationTileView__shield,
.VerificationReadyTileView .VerificationTileView__shield {
background: url("./icons/e2ee-normal.svg?primary=background-color-secondary--darker-40");
background: url("./icons/e2ee-normal.svg?primary=background-color-secondary--darker-40") no-repeat;
}
.VerificationCompletedTileView .VerificationTileView__shield {
background: url("./icons/e2ee-normal.svg?primary=accent-color");
background: url("./icons/e2ee-normal.svg?primary=accent-color") no-repeat;
}
.VerificationTileView__shield {

View File

@ -52,8 +52,8 @@ export class VerificationTileView extends TemplateView<VerificationTile> {
class VerificationReadyTileView extends TemplateView<VerificationTile> {
render(t: Builder<VerificationTile>, vm: VerificationTile) {
return t.div({ className: "VerificationReadyTileView" }, [
t.div({ className: "VerificationTileView__shield" }),
t.div({ className: "VerificationTileView__description" }, [
t.div({ className: "VerificationTileView__shield" }),
t.div(vm.description)
]),
t.div({ className: "VerificationTileView__actions" }, [

View File

@ -63,14 +63,6 @@ export class KeyBackupSettingsView extends TemplateView<KeyBackupViewModel> {
}),
t.if(vm => vm.canSignOwnDevice, t => {
return t.div([
t.button(
{
onClick: disableTargetCallback(async (evt) => {
await vm.signOwnDevice();
}),
},
"Sign own device"
),
t.button(
{
onClick: disableTargetCallback(async () => {

View File

@ -14,19 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {Builder, TemplateView} from "../../general/TemplateView";
import {Builder, InlineTemplateView, TemplateView} from "../../general/TemplateView";
import {DeviceVerificationViewModel} from "../../../../../domain/session/verification/DeviceVerificationViewModel";
import {WaitingForOtherUserView} from "./stages/WaitingForOtherUserView";
import {VerificationCancelledView} from "./stages/VerificationCancelledView";
import {SelectMethodView} from "./stages/SelectMethodView";
import {VerifyEmojisView} from "./stages/VerifyEmojisView";
import {VerificationCompleteView} from "./stages/VerificationCompleteView";
import {MissingKeysView} from "./stages/MissingKeysView";
import {spinner} from "../../common.js";
export class DeviceVerificationView extends TemplateView<DeviceVerificationViewModel> {
render(t: Builder<DeviceVerificationViewModel>) {
render(t: Builder<DeviceVerificationViewModel>, vm: DeviceVerificationViewModel) {
return t.div({
className: {
"middle": true,
"middle": !vm.isHappeningInRoom,
"DeviceVerificationView": true,
}
}, [
@ -37,7 +39,8 @@ export class DeviceVerificationView extends TemplateView<DeviceVerificationViewM
case "select-method": return new SelectMethodView(vm);
case "verify-emojis": return new VerifyEmojisView(vm);
case "verification-completed": return new VerificationCompleteView(vm);
default: return null;
case "keys-missing": return new MissingKeysView(vm);
default: return new InlineTemplateView(vm, () => spinner(t));
}
})
])

View File

@ -0,0 +1,47 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {Builder, TemplateView} from "../../../general/TemplateView";
import type {MissingKeysViewModel} from "../../../../../../domain/session/verification/stages/MissingKeysViewModel";
export class MissingKeysView extends TemplateView<MissingKeysViewModel> {
render(t: Builder<MissingKeysViewModel>, vm: MissingKeysViewModel) {
return t.div(
{
className: "MissingKeysView",
},
[
t.h2(
{ className: "MissingKeysView__heading" },
vm.i18n`Verification is currently not possible!`
),
t.p(
{ className: "MissingKeysView__description" },
vm.i18n`Some keys needed for verification are missing. You can fix this by enabling key backup in settings.`
),
t.div({ className: "MissingKeysView__actions" }, [
t.button({
className: {
"button-action": true,
"primary": true,
},
onclick: () => vm.gotoSettings(),
}, "Open Settings")
]),
]
);
}
}

View File

@ -16,12 +16,9 @@ limitations under the License.
import {Builder, TemplateView} from "../../../general/TemplateView";
import {VerificationCancelledViewModel} from "../../../../../../domain/session/verification/stages/VerificationCancelledViewModel";
import {CancelReason} from "../../../../../../matrix/verification/SAS/channel/types";
export class VerificationCancelledView extends TemplateView<VerificationCancelledViewModel> {
render(t: Builder<VerificationCancelledViewModel>, vm: VerificationCancelledViewModel) {
const headerTextStart = vm.isCancelledByUs ? "You" : "The other device";
return t.div(
{
className: "VerificationCancelledView",
@ -29,11 +26,11 @@ export class VerificationCancelledView extends TemplateView<VerificationCancelle
[
t.h2(
{ className: "VerificationCancelledView__title" },
vm.i18n`${headerTextStart} cancelled the verification!`
vm.title,
),
t.p(
{ className: "VerificationCancelledView__description" },
vm.i18n`${this.getDescriptionFromCancellationCode(vm.cancelCode, vm.isCancelledByUs)}`
vm.description,
),
t.div({ className: "VerificationCancelledView__actions" }, [
t.button({
@ -47,32 +44,4 @@ export class VerificationCancelledView extends TemplateView<VerificationCancelle
]
);
}
getDescriptionFromCancellationCode(code: CancelReason, isCancelledByUs: boolean): string {
const descriptionsWhenWeCancelled = {
[CancelReason.InvalidMessage]: "You other device sent an invalid message.",
[CancelReason.KeyMismatch]: "The key could not be verified.",
[CancelReason.TimedOut]: "The verification process timed out.",
[CancelReason.UnexpectedMessage]: "Your other device sent an unexpected message.",
[CancelReason.UnknownMethod]: "Your other device is using an unknown method for verification.",
[CancelReason.UnknownTransaction]: "Your other device sent a message with an unknown transaction id.",
[CancelReason.UserMismatch]: "The expected user did not match the user verified.",
[CancelReason.MismatchedCommitment]: "The hash commitment does not match.",
[CancelReason.MismatchedSAS]: "The emoji/decimal did not match.",
}
const descriptionsWhenTheyCancelled = {
[CancelReason.UserCancelled]: "Your other device cancelled the verification!",
[CancelReason.InvalidMessage]: "Invalid message sent to the other device.",
[CancelReason.KeyMismatch]: "The other device could not verify our keys",
[CancelReason.TimedOut]: "The verification process timed out.",
[CancelReason.UnexpectedMessage]: "Unexpected message sent to the other device.",
[CancelReason.UnknownMethod]: "Your other device does not understand the method you chose",
[CancelReason.UnknownTransaction]: "Your other device rejected our message.",
[CancelReason.UserMismatch]: "The expected user did not match the user verified.",
[CancelReason.MismatchedCommitment]: "Your other device was not able to verify the hash commitment",
[CancelReason.MismatchedSAS]: "The emoji/decimal did not match.",
}
const map = isCancelledByUs ? descriptionsWhenWeCancelled : descriptionsWhenTheyCancelled;
return map[code] ?? "";
}
}

View File

@ -25,11 +25,11 @@ export class WaitingForOtherUserView extends TemplateView<WaitingForOtherUserVie
spinner(t),
t.h2(
{ className: "WaitingForOtherUserView__title" },
vm.i18n`Waiting for any of your device to accept the verification request`
vm.title,
),
]),
t.p({ className: "WaitingForOtherUserView__description" },
vm.i18n`Accept the request from the device you wish to verify!`
vm.description,
),
t.div({ className: "WaitingForOtherUserView__actions" },
t.button({