mirror of
https://github.com/vector-im/hydrogen-web.git
synced 2024-12-22 19:14:52 +01:00
Merge pull request #1112 from vector-im/implement-secret-sharing
Implement secret sharing
This commit is contained in:
commit
46add413f6
@ -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;
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
@ -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";
|
||||
}
|
||||
}
|
@ -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}`;
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -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";
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
@ -36,7 +36,7 @@ type DecryptedEvent = {
|
||||
}
|
||||
|
||||
export class DecryptionResult {
|
||||
private device?: DeviceKey;
|
||||
public device?: DeviceKey;
|
||||
|
||||
constructor(
|
||||
public readonly event: DecryptedEvent,
|
||||
|
@ -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";
|
||||
|
52
src/matrix/ssss/SecretFetcher.ts
Normal file
52
src/matrix/ssss/SecretFetcher.ts
Normal 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);
|
||||
}
|
||||
}
|
383
src/matrix/ssss/SecretSharing.ts
Normal file
383
src/matrix/ssss/SecretSharing.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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`;
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
|
@ -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"});
|
||||
}
|
||||
|
43
src/matrix/storage/idb/stores/SharedSecretStore.ts
Normal file
43
src/matrix/storage/idb/stores/SharedSecretStore.ts
Normal 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();
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
|
@ -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});
|
||||
|
@ -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(() => {
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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" }, [
|
||||
|
@ -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 () => {
|
||||
|
@ -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));
|
||||
}
|
||||
})
|
||||
])
|
||||
|
@ -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")
|
||||
]),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
@ -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] ?? "";
|
||||
}
|
||||
}
|
||||
|
@ -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({
|
||||
|
Loading…
Reference in New Issue
Block a user