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._onKeyBackupChange(); // update status
|
||||||
};
|
};
|
||||||
this.track(this._session.keyBackup.subscribe(onKeyBackupSet));
|
this.track(this._session.keyBackup.subscribe(onKeyBackupSet));
|
||||||
|
this.track(this._session.crossSigning.subscribe(() => {
|
||||||
|
this.emitChange("crossSigning");
|
||||||
|
}));
|
||||||
onKeyBackupSet(this._keyBackup);
|
onKeyBackupSet(this._keyBackup);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,7 +151,7 @@ export class KeyBackupViewModel extends ViewModel<SegmentType, Options> {
|
|||||||
return !!this._crossSigning;
|
return !!this._crossSigning;
|
||||||
}
|
}
|
||||||
|
|
||||||
async signOwnDevice(): Promise<void> {
|
private async _signOwnDevice(): Promise<void> {
|
||||||
const crossSigning = this._crossSigning;
|
const crossSigning = this._crossSigning;
|
||||||
if (crossSigning) {
|
if (crossSigning) {
|
||||||
await this.logger.run("KeyBackupViewModel.signOwnDevice", async log => {
|
await this.logger.run("KeyBackupViewModel.signOwnDevice", async log => {
|
||||||
@ -205,6 +208,7 @@ export class KeyBackupViewModel extends ViewModel<SegmentType, Options> {
|
|||||||
if (setupDehydratedDevice) {
|
if (setupDehydratedDevice) {
|
||||||
this._dehydratedDeviceId = await this._session.setupDehydratedDevice(key);
|
this._dehydratedDeviceId = await this._session.setupDehydratedDevice(key);
|
||||||
}
|
}
|
||||||
|
await this._signOwnDevice();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
this._error = err;
|
this._error = err;
|
||||||
|
@ -22,9 +22,12 @@ import {VerificationCancelledViewModel} from "./stages/VerificationCancelledView
|
|||||||
import {SelectMethodViewModel} from "./stages/SelectMethodViewModel";
|
import {SelectMethodViewModel} from "./stages/SelectMethodViewModel";
|
||||||
import {VerifyEmojisViewModel} from "./stages/VerifyEmojisViewModel";
|
import {VerifyEmojisViewModel} from "./stages/VerifyEmojisViewModel";
|
||||||
import {VerificationCompleteViewModel} from "./stages/VerificationCompleteViewModel";
|
import {VerificationCompleteViewModel} from "./stages/VerificationCompleteViewModel";
|
||||||
|
import {MissingKeysViewModel} from "./stages/MissingKeysViewModel";
|
||||||
import type {Session} from "../../../matrix/Session.js";
|
import type {Session} from "../../../matrix/Session.js";
|
||||||
import type {SASVerification} from "../../../matrix/verification/SAS/SASVerification";
|
import type {SASVerification} from "../../../matrix/verification/SAS/SASVerification";
|
||||||
import type {SASRequest} from "../../../matrix/verification/SAS/SASRequest";
|
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";
|
import type {Room} from "../../../matrix/room/Room.js";
|
||||||
|
|
||||||
type Options = BaseOptions & {
|
type Options = BaseOptions & {
|
||||||
@ -34,9 +37,16 @@ type Options = BaseOptions & {
|
|||||||
userId?: string;
|
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> {
|
export class DeviceVerificationViewModel extends ErrorReportViewModel<SegmentType, Options> {
|
||||||
private sas: SASVerification;
|
private sas: SASVerification;
|
||||||
private _currentStageViewModel: any;
|
private _currentStageViewModel: any;
|
||||||
|
private _needsToRequestSecret: boolean;
|
||||||
|
|
||||||
constructor(options: Readonly<Options>) {
|
constructor(options: Readonly<Options>) {
|
||||||
super(options);
|
super(options);
|
||||||
@ -54,12 +64,16 @@ export class DeviceVerificationViewModel extends ErrorReportViewModel<SegmentTyp
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async startVerification(requestOrUserId: SASRequest | string, room?: Room) {
|
private async startVerification(requestOrUserId: SASRequest | string, room?: Room) {
|
||||||
await this.logAndCatch("DeviceVerificationViewModel.start", (log) => {
|
await this.logAndCatch("DeviceVerificationViewModel.startVerification", async (log) => {
|
||||||
const crossSigning = this.getOption("session").crossSigning.get();
|
const crossSigningObservable = this.getOption("session").crossSigning;
|
||||||
|
const crossSigning = await crossSigningObservable.waitFor(c => !!c).promise;
|
||||||
this.sas = crossSigning.startVerification(requestOrUserId, room, log);
|
this.sas = crossSigning.startVerification(requestOrUserId, room, log);
|
||||||
if (!this.sas) {
|
if (!this.sas) {
|
||||||
throw new Error("CrossSigning.startVerification did not return a sas object!");
|
throw new Error("CrossSigning.startVerification did not return a sas object!");
|
||||||
}
|
}
|
||||||
|
if (!await this.performPreVerificationChecks(crossSigning, requestOrUserId, log)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.addEventListeners();
|
this.addEventListeners();
|
||||||
if (typeof requestOrUserId === "string") {
|
if (typeof requestOrUserId === "string") {
|
||||||
this.updateCurrentStageViewModel(new WaitingForOtherUserViewModel(this.childOptions({ sas: this.sas })));
|
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() {
|
private addEventListeners() {
|
||||||
this.track(this.sas.disposableOn("SelectVerificationStage", (stage) => {
|
this.track(this.sas.disposableOn("SelectVerificationStage", (stage) => {
|
||||||
this.updateCurrentStageViewModel(
|
this.updateCurrentStageViewModel(
|
||||||
@ -95,9 +142,24 @@ export class DeviceVerificationViewModel extends ErrorReportViewModel<SegmentTyp
|
|||||||
this.updateCurrentStageViewModel(
|
this.updateCurrentStageViewModel(
|
||||||
new VerificationCompleteViewModel(this.childOptions({ deviceId: deviceId!, sas: this.sas }))
|
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) {
|
private updateCurrentStageViewModel(vm) {
|
||||||
this._currentStageViewModel = this.disposeTracked(this._currentStageViewModel);
|
this._currentStageViewModel = this.disposeTracked(this._currentStageViewModel);
|
||||||
this._currentStageViewModel = this.track(vm);
|
this._currentStageViewModel = this.track(vm);
|
||||||
@ -105,8 +167,8 @@ export class DeviceVerificationViewModel extends ErrorReportViewModel<SegmentTyp
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
if (!this.sas.finished) {
|
if (this.sas && !this.sas.finished) {
|
||||||
this.sas.abort().catch(() => {/** ignore */});
|
this.sas.abort().catch((e) => { console.error(e); });
|
||||||
}
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@ -118,4 +180,8 @@ export class DeviceVerificationViewModel extends ErrorReportViewModel<SegmentTyp
|
|||||||
get type(): string {
|
get type(): string {
|
||||||
return "verification";
|
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 {Options as BaseOptions} from "../../../ViewModel";
|
||||||
import {DismissibleVerificationViewModel} from "./DismissibleVerificationViewModel";
|
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 {Session} from "../../../../matrix/Session.js";
|
||||||
import type {IChannel} from "../../../../matrix/verification/SAS/channel/IChannel";
|
import type {IChannel} from "../../../../matrix/verification/SAS/channel/IChannel";
|
||||||
import type {SASVerification} from "../../../../matrix/verification/SAS/SASVerification";
|
import type {SASVerification} from "../../../../matrix/verification/SAS/SASVerification";
|
||||||
@ -39,4 +39,74 @@ export class VerificationCancelledViewModel extends DismissibleVerificationViewM
|
|||||||
get kind(): string {
|
get kind(): string {
|
||||||
return "verification-cancelled";
|
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();
|
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 {
|
get kind(): string {
|
||||||
return "waiting-for-user";
|
return "waiting-for-user";
|
||||||
}
|
}
|
||||||
|
@ -77,7 +77,31 @@ export class DeviceMessageHandler extends EventEmitter{
|
|||||||
}
|
}
|
||||||
|
|
||||||
async afterSyncCompleted(decryptionResults, deviceTracker, hsApi, log) {
|
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 (this._callHandler) {
|
||||||
// if we don't have a device, we need to fetch the device keys the message claims
|
// 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
|
// 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 {
|
class SyncPreparation {
|
||||||
|
@ -43,9 +43,11 @@ import {
|
|||||||
readKey as ssssReadKey,
|
readKey as ssssReadKey,
|
||||||
writeKey as ssssWriteKey,
|
writeKey as ssssWriteKey,
|
||||||
removeKey as ssssRemoveKey,
|
removeKey as ssssRemoveKey,
|
||||||
keyFromDehydratedDeviceKey as createSSSSKeyFromDehydratedDeviceKey
|
keyFromDehydratedDeviceKey as createSSSSKeyFromDehydratedDeviceKey,
|
||||||
|
SecretStorage,
|
||||||
|
SecretSharing,
|
||||||
|
SecretFetcher
|
||||||
} from "./ssss/index";
|
} from "./ssss/index";
|
||||||
import {SecretStorage} from "./ssss/SecretStorage";
|
|
||||||
import {ObservableValue, RetainedObservableValue} from "../observable/value";
|
import {ObservableValue, RetainedObservableValue} from "../observable/value";
|
||||||
import {CallHandler} from "./calls/CallHandler";
|
import {CallHandler} from "./calls/CallHandler";
|
||||||
import {RoomStateHandlerSet} from "./room/state/RoomStateHandlerSet";
|
import {RoomStateHandlerSet} from "./room/state/RoomStateHandlerSet";
|
||||||
@ -109,6 +111,9 @@ export class Session {
|
|||||||
this._createRoomEncryption = this._createRoomEncryption.bind(this);
|
this._createRoomEncryption = this._createRoomEncryption.bind(this);
|
||||||
this._forgetArchivedRoom = this._forgetArchivedRoom.bind(this);
|
this._forgetArchivedRoom = this._forgetArchivedRoom.bind(this);
|
||||||
this.needsKeyBackup = new ObservableValue(false);
|
this.needsKeyBackup = new ObservableValue(false);
|
||||||
|
this._secretFetcher = new SecretFetcher();
|
||||||
|
this._secretSharing = null;
|
||||||
|
this._secretStorage = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get fingerprintKey() {
|
get fingerprintKey() {
|
||||||
@ -168,7 +173,7 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// called once this._e2eeAccount is assigned
|
// 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
|
// 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.
|
// and can create RoomEncryption objects and handle encrypted to_device messages and device list changes.
|
||||||
const senderKeyLock = new LockMap();
|
const senderKeyLock = new LockMap();
|
||||||
@ -202,6 +207,20 @@ export class Session {
|
|||||||
});
|
});
|
||||||
this._megolmDecryption = new MegOlmDecryption(this._keyLoader, this._olmWorker);
|
this._megolmDecryption = new MegOlmDecryption(this._keyLoader, this._olmWorker);
|
||||||
this._deviceMessageHandler.enableEncryption({olmDecryption, megolmDecryption: this._megolmDecryption});
|
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) {
|
_createRoomEncryption(room, encryptionParams) {
|
||||||
@ -255,11 +274,6 @@ export class Session {
|
|||||||
this._keyBackup.get().dispose();
|
this._keyBackup.get().dispose();
|
||||||
this._keyBackup.set(undefined);
|
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);
|
const key = await ssssKeyFromCredential(type, credential, this._storage, this._platform, this._olm);
|
||||||
if (await this._tryLoadSecretStorage(key, log)) {
|
if (await this._tryLoadSecretStorage(key, log)) {
|
||||||
// only after having read a secret, write the key
|
// only after having read a secret, write the key
|
||||||
@ -335,7 +349,9 @@ export class Session {
|
|||||||
const isValid = await secretStorage.hasValidKeyForAnyAccountData();
|
const isValid = await secretStorage.hasValidKeyForAnyAccountData();
|
||||||
log.set("isValid", isValid);
|
log.set("isValid", isValid);
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
|
this._secretStorage = secretStorage;
|
||||||
await this._loadSecretStorageServices(secretStorage, log);
|
await this._loadSecretStorageServices(secretStorage, log);
|
||||||
|
this._secretFetcher.setSecretStorage(secretStorage);
|
||||||
}
|
}
|
||||||
return isValid;
|
return isValid;
|
||||||
});
|
});
|
||||||
@ -363,29 +379,6 @@ export class Session {
|
|||||||
log.set("no_backup", true);
|
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) {
|
} catch (err) {
|
||||||
log.catch(err);
|
log.catch(err);
|
||||||
}
|
}
|
||||||
@ -404,6 +397,14 @@ export class Session {
|
|||||||
return this._crossSigning;
|
return this._crossSigning;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get secretSharing() {
|
||||||
|
return this._secretSharing;
|
||||||
|
}
|
||||||
|
|
||||||
|
get secretFetcher() {
|
||||||
|
return this._secretFetcher;
|
||||||
|
}
|
||||||
|
|
||||||
get hasIdentity() {
|
get hasIdentity() {
|
||||||
return !!this._e2eeAccount;
|
return !!this._e2eeAccount;
|
||||||
}
|
}
|
||||||
@ -414,10 +415,11 @@ export class Session {
|
|||||||
if (!this._e2eeAccount) {
|
if (!this._e2eeAccount) {
|
||||||
this._e2eeAccount = await this._createNewAccount(this._sessionInfo.deviceId, this._storage);
|
this._e2eeAccount = await this._createNewAccount(this._sessionInfo.deviceId, this._storage);
|
||||||
log.set("keys", this._e2eeAccount.identityKeys);
|
log.set("keys", this._e2eeAccount.identityKeys);
|
||||||
this._setupEncryption();
|
await this._setupEncryption();
|
||||||
}
|
}
|
||||||
await this._e2eeAccount.generateOTKsIfNeeded(this._storage, log);
|
await this._e2eeAccount.generateOTKsIfNeeded(this._storage, log);
|
||||||
await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, false, 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);
|
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() {
|
dispose() {
|
||||||
@ -745,6 +772,7 @@ export class Session {
|
|||||||
e2eeAccountChanges: null,
|
e2eeAccountChanges: null,
|
||||||
hasNewRoomKeys: false,
|
hasNewRoomKeys: false,
|
||||||
deviceMessageDecryptionResults: null,
|
deviceMessageDecryptionResults: null,
|
||||||
|
changedDevices: null,
|
||||||
};
|
};
|
||||||
const syncToken = syncResponse.next_batch;
|
const syncToken = syncResponse.next_batch;
|
||||||
if (syncToken !== this.syncToken) {
|
if (syncToken !== this.syncToken) {
|
||||||
@ -762,6 +790,7 @@ export class Session {
|
|||||||
const deviceLists = syncResponse.device_lists;
|
const deviceLists = syncResponse.device_lists;
|
||||||
if (this._deviceTracker && Array.isArray(deviceLists?.changed) && deviceLists.changed.length) {
|
if (this._deviceTracker && Array.isArray(deviceLists?.changed) && deviceLists.changed.length) {
|
||||||
await log.wrap("deviceLists", log => this._deviceTracker.writeDeviceChanges(deviceLists.changed, txn, log));
|
await log.wrap("deviceLists", log => this._deviceTracker.writeDeviceChanges(deviceLists.changed, txn, log));
|
||||||
|
changes.changedDevices = deviceLists.changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preparation) {
|
if (preparation) {
|
||||||
@ -811,6 +840,9 @@ export class Session {
|
|||||||
if (changes.deviceMessageDecryptionResults) {
|
if (changes.deviceMessageDecryptionResults) {
|
||||||
await this._deviceMessageHandler.afterSyncCompleted(changes.deviceMessageDecryptionResults, this._deviceTracker, this._hsApi, log);
|
await this._deviceMessageHandler.afterSyncCompleted(changes.deviceMessageDecryptionResults, this._deviceTracker, this._hsApi, log);
|
||||||
}
|
}
|
||||||
|
if (changes.changedDevices?.includes(this.userId)) {
|
||||||
|
this._secretSharing?.checkSecretValidity();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_tryReplaceRoomBeingCreated(roomId, log) {
|
_tryReplaceRoomBeingCreated(roomId, log) {
|
||||||
|
@ -36,7 +36,7 @@ type DecryptedEvent = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class DecryptionResult {
|
export class DecryptionResult {
|
||||||
private device?: DeviceKey;
|
public device?: DeviceKey;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly event: DecryptedEvent,
|
public readonly event: DecryptedEvent,
|
||||||
|
@ -527,7 +527,22 @@ export class DeviceTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Gets a single device */
|
/** 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([
|
const txn = await this._storage.readTxn([
|
||||||
this._storage.storeNames.deviceKeys,
|
this._storage.storeNames.deviceKeys,
|
||||||
]);
|
]);
|
||||||
@ -554,6 +569,9 @@ export class DeviceTracker {
|
|||||||
const txn = await this._storage.readWriteTxn([
|
const txn = await this._storage.readWriteTxn([
|
||||||
this._storage.storeNames.deviceKeys,
|
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.
|
// check again we don't have the device already.
|
||||||
// when updating all keys for a user we allow updating the
|
// when updating all keys for a user we allow updating the
|
||||||
// device when the key hasn't changed so the device display name
|
// device when the key hasn't changed so the device display name
|
||||||
@ -577,6 +595,22 @@ export class DeviceTracker {
|
|||||||
return deviceKey;
|
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.
|
* 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.
|
* 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
|
/** 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
|
* 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. */
|
* will have their keys fetched from the homeserver. */
|
||||||
async _devicesForUserIdentities(upToDateIdentities: UserIdentity[], outdatedUserIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise<DeviceKey[]> {
|
async _devicesForUserIdentities(upToDateIdentities: UserIdentity[], outdatedUserIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise<DeviceKey[]> {
|
||||||
log.set("uptodate", upToDateIdentities.length);
|
log.set("uptodate", upToDateIdentities.length);
|
||||||
@ -643,6 +677,10 @@ export class DeviceTracker {
|
|||||||
async getDeviceByCurve25519Key(curve25519Key, txn: Transaction): Promise<DeviceKey | undefined> {
|
async getDeviceByCurve25519Key(curve25519Key, txn: Transaction): Promise<DeviceKey | undefined> {
|
||||||
return await txn.deviceKeys.getByCurve25519Key(curve25519Key);
|
return await txn.deviceKeys.getByCurve25519Key(curve25519Key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get ownDeviceId(): string {
|
||||||
|
return this._ownDeviceId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import {createMockStorage} from "../../mocks/Storage";
|
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 {Platform} from "../../platform/web/Platform.js";
|
||||||
import type * as OlmNamespace from "@matrix-org/olm"
|
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;
|
type Olm = typeof OlmNamespace;
|
||||||
|
|
||||||
const SSSS_KEY = `${SESSION_E2EE_KEY_PREFIX}ssssKey`;
|
const SSSS_KEY = `${SESSION_E2EE_KEY_PREFIX}ssssKey`;
|
||||||
|
@ -34,7 +34,8 @@ export enum StoreNames {
|
|||||||
operations = "operations",
|
operations = "operations",
|
||||||
accountData = "accountData",
|
accountData = "accountData",
|
||||||
calls = "calls",
|
calls = "calls",
|
||||||
crossSigningKeys = "crossSigningKeys"
|
crossSigningKeys = "crossSigningKeys",
|
||||||
|
sharedSecrets = "sharedSecrets",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STORE_NAMES: Readonly<StoreNames[]> = Object.values(StoreNames);
|
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> {
|
count(keyRange?: IDBKeyRange): IDBRequest<number> {
|
||||||
try {
|
try {
|
||||||
return this._qt.count(keyRange);
|
return this._qt.count(keyRange);
|
||||||
@ -195,6 +205,11 @@ export class Store<T> extends QueryTarget<T> {
|
|||||||
this._prepareErrorLog(request, log, "delete", keyOrKeyRange, undefined);
|
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) {
|
private _prepareErrorLog(request: IDBRequest, log: ILogItem | undefined, operationName: string, key: IDBKey | undefined, value: T | undefined) {
|
||||||
if (log) {
|
if (log) {
|
||||||
log.ensureRefId();
|
log.ensureRefId();
|
||||||
|
@ -38,6 +38,7 @@ import {GroupSessionDecryptionStore} from "./stores/GroupSessionDecryptionStore"
|
|||||||
import {OperationStore} from "./stores/OperationStore";
|
import {OperationStore} from "./stores/OperationStore";
|
||||||
import {AccountDataStore} from "./stores/AccountDataStore";
|
import {AccountDataStore} from "./stores/AccountDataStore";
|
||||||
import {CallStore} from "./stores/CallStore";
|
import {CallStore} from "./stores/CallStore";
|
||||||
|
import {SharedSecretStore} from "./stores/SharedSecretStore";
|
||||||
import type {ILogger, ILogItem} from "../../../logging/types";
|
import type {ILogger, ILogItem} from "../../../logging/types";
|
||||||
|
|
||||||
export type IDBKey = IDBValidKey | IDBKeyRange;
|
export type IDBKey = IDBValidKey | IDBKeyRange;
|
||||||
@ -178,6 +179,10 @@ export class Transaction {
|
|||||||
return this._store(StoreNames.calls, idbStore => new CallStore(idbStore));
|
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> {
|
async complete(log?: ILogItem): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await txnAsPromise(this._txn);
|
await txnAsPromise(this._txn);
|
||||||
|
@ -37,7 +37,8 @@ export const schema: MigrationFunc[] = [
|
|||||||
addInboundSessionBackupIndex,
|
addInboundSessionBackupIndex,
|
||||||
migrateBackupStatus,
|
migrateBackupStatus,
|
||||||
createCallStore,
|
createCallStore,
|
||||||
applyCrossSigningChanges
|
applyCrossSigningChanges,
|
||||||
|
createSharedSecretStore,
|
||||||
];
|
];
|
||||||
// TODO: how to deal with git merge conflicts of this array?
|
// 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);
|
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 {VerificationEventType} from "./SAS/channel/types";
|
||||||
import {ObservableMap} from "../../observable/map";
|
import {ObservableMap} from "../../observable/map";
|
||||||
import {SASRequest} from "./SAS/SASRequest";
|
import {SASRequest} from "./SAS/SASRequest";
|
||||||
import type {SecretStorage} from "../ssss/SecretStorage";
|
import {SecretFetcher} from "../ssss";
|
||||||
import type {Storage} from "../storage/idb/Storage";
|
import type {Storage} from "../storage/idb/Storage";
|
||||||
import type {Platform} from "../../platform/web/Platform";
|
import type {Platform} from "../../platform/web/Platform";
|
||||||
import type {DeviceTracker} from "../e2ee/DeviceTracker";
|
import type {DeviceTracker} from "../e2ee/DeviceTracker";
|
||||||
@ -89,7 +89,7 @@ export interface IVerificationMethod {
|
|||||||
|
|
||||||
export class CrossSigning {
|
export class CrossSigning {
|
||||||
private readonly storage: Storage;
|
private readonly storage: Storage;
|
||||||
private readonly secretStorage: SecretStorage;
|
private readonly secretFetcher: SecretFetcher;
|
||||||
private readonly platform: Platform;
|
private readonly platform: Platform;
|
||||||
private readonly deviceTracker: DeviceTracker;
|
private readonly deviceTracker: DeviceTracker;
|
||||||
private readonly olm: Olm;
|
private readonly olm: Olm;
|
||||||
@ -106,7 +106,7 @@ export class CrossSigning {
|
|||||||
|
|
||||||
constructor(options: {
|
constructor(options: {
|
||||||
storage: Storage,
|
storage: Storage,
|
||||||
secretStorage: SecretStorage,
|
secretFetcher: SecretFetcher,
|
||||||
deviceTracker: DeviceTracker,
|
deviceTracker: DeviceTracker,
|
||||||
platform: Platform,
|
platform: Platform,
|
||||||
olm: Olm,
|
olm: Olm,
|
||||||
@ -118,7 +118,7 @@ export class CrossSigning {
|
|||||||
deviceMessageHandler: DeviceMessageHandler,
|
deviceMessageHandler: DeviceMessageHandler,
|
||||||
}) {
|
}) {
|
||||||
this.storage = options.storage;
|
this.storage = options.storage;
|
||||||
this.secretStorage = options.secretStorage;
|
this.secretFetcher = options.secretFetcher;
|
||||||
this.platform = options.platform;
|
this.platform = options.platform;
|
||||||
this.deviceTracker = options.deviceTracker;
|
this.deviceTracker = options.deviceTracker;
|
||||||
this.olm = options.olm;
|
this.olm = options.olm;
|
||||||
@ -231,33 +231,60 @@ export class CrossSigning {
|
|||||||
return this.sasVerificationInProgress;
|
return this.sasVerificationInProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleSASDeviceMessage({ unencrypted: event }) {
|
private async handleSASDeviceMessage({ unencrypted: event }) {
|
||||||
const txnId = event.content.transaction_id;
|
if (!event ||
|
||||||
/**
|
(event.type !== VerificationEventType.Request && event.type !== VerificationEventType.Start)
|
||||||
* If we receive an event for the current/previously finished
|
) {
|
||||||
* SAS verification, we should ignore it because the device channel
|
return;
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
|
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. */
|
/** 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> {
|
async signDevice(verification: IVerificationMethod, log: ILogItem): Promise<DeviceKey | undefined> {
|
||||||
return log.wrap("CrossSigning.signDevice", async log => {
|
return log.wrap("CrossSigning.signDevice", async log => {
|
||||||
if (!this._isMasterKeyTrusted) {
|
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);
|
log.set("mskNotTrusted", true);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const shouldSign = await verification.verify();
|
const shouldSign = await verification.verify() && this._isMasterKeyTrusted;
|
||||||
log.set("shouldSign", shouldSign);
|
log.set("shouldSign", shouldSign);
|
||||||
if (!shouldSign) {
|
if (!shouldSign) {
|
||||||
return;
|
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> {
|
getUserTrust(userId: string, log: ILogItem): Promise<UserTrust> {
|
||||||
return log.wrap("CrossSigning.getUserTrust", async log => {
|
return log.wrap("CrossSigning.getUserTrust", async log => {
|
||||||
log.set("id", userId);
|
log.set("id", userId);
|
||||||
@ -457,7 +512,7 @@ export class CrossSigning {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getSigningKey(usage: KeyUsage): Promise<Uint8Array | undefined> {
|
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) {
|
if (seedStr) {
|
||||||
return new Uint8Array(this.platform.encoding.base64.decode(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> {
|
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) {
|
if (this.isCancelled) {
|
||||||
throw new VerificationCancelledError();
|
throw new VerificationCancelledError();
|
||||||
}
|
}
|
||||||
@ -112,6 +112,15 @@ export class RoomChannel extends Disposables implements IChannel {
|
|||||||
await this.handleRequestEventSpecially(eventType, content, log);
|
await this.handleRequestEventSpecially(eventType, content, log);
|
||||||
return;
|
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));
|
Object.assign(content, createReference(this.id));
|
||||||
await this.room.sendEvent(eventType, content, undefined, log);
|
await this.room.sendEvent(eventType, content, undefined, log);
|
||||||
this.sentMessages.set(eventType, {content});
|
this.sentMessages.set(eventType, {content});
|
||||||
|
@ -70,8 +70,12 @@ export class ToDeviceChannel extends Disposables implements IChannel {
|
|||||||
this.track(
|
this.track(
|
||||||
this.deviceMessageHandler.disposableOn(
|
this.deviceMessageHandler.disposableOn(
|
||||||
"message",
|
"message",
|
||||||
async ({ unencrypted }) =>
|
async ({ unencrypted }) => {
|
||||||
await this.handleDeviceMessage(unencrypted)
|
if (!unencrypted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.handleDeviceMessage(unencrypted);
|
||||||
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
this.track(() => {
|
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 {KEY_AGREEMENT_LIST, HASHES_LIST, MAC_LIST, SAS_LIST} from "./constants";
|
||||||
import {SendAcceptVerificationStage} from "./SendAcceptVerificationStage";
|
import {SendAcceptVerificationStage} from "./SendAcceptVerificationStage";
|
||||||
import {SendKeyStage} from "./SendKeyStage";
|
import {SendKeyStage} from "./SendKeyStage";
|
||||||
|
import {Deferred} from "../../../../utils/Deferred";
|
||||||
import type {ILogItem} from "../../../../logging/types";
|
import type {ILogItem} from "../../../../logging/types";
|
||||||
|
|
||||||
export class SelectVerificationMethodStage extends BaseSASVerificationStage {
|
export class SelectVerificationMethodStage extends BaseSASVerificationStage {
|
||||||
private hasSentStartMessage = false;
|
private hasSentStartMessage?: Promise<void>;
|
||||||
private allowSelection = true;
|
private allowSelection = true;
|
||||||
public otherDeviceName: string;
|
public otherDeviceName: string;
|
||||||
|
|
||||||
@ -36,6 +37,7 @@ export class SelectVerificationMethodStage extends BaseSASVerificationStage {
|
|||||||
// We received the start message
|
// We received the start message
|
||||||
this.allowSelection = false;
|
this.allowSelection = false;
|
||||||
if (this.hasSentStartMessage) {
|
if (this.hasSentStartMessage) {
|
||||||
|
await this.hasSentStartMessage;
|
||||||
await this.resolveStartConflict(log);
|
await this.resolveStartConflict(log);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@ -96,6 +98,8 @@ export class SelectVerificationMethodStage extends BaseSASVerificationStage {
|
|||||||
|
|
||||||
async selectEmojiMethod(log: ILogItem) {
|
async selectEmojiMethod(log: ILogItem) {
|
||||||
if (!this.allowSelection) { return; }
|
if (!this.allowSelection) { return; }
|
||||||
|
const deferred = new Deferred<void>();
|
||||||
|
this.hasSentStartMessage = deferred.promise;
|
||||||
const content = {
|
const content = {
|
||||||
method: "m.sas.v1",
|
method: "m.sas.v1",
|
||||||
from_device: this.ourUserDeviceId,
|
from_device: this.ourUserDeviceId,
|
||||||
@ -110,6 +114,6 @@ export class SelectVerificationMethodStage extends BaseSASVerificationStage {
|
|||||||
* to the next stage (where we will send the key).
|
* to the next stage (where we will send the key).
|
||||||
*/
|
*/
|
||||||
await this.channel.send(VerificationEventType.Start, content, log);
|
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; }
|
.middle .close-middle { display: block !important; }
|
||||||
/* hide grid button */
|
/* hide grid button */
|
||||||
.LeftPanel .grid { display: none !important; }
|
.LeftPanel .grid { display: none !important; }
|
||||||
|
|
||||||
|
.VerificationReadyTileView {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.VerificationTileView__actions {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.LeftPanel {
|
.LeftPanel {
|
||||||
|
@ -1423,6 +1423,7 @@ button.RoomDetailsView_row::after {
|
|||||||
.VerificationCompleteView__heading,
|
.VerificationCompleteView__heading,
|
||||||
.VerifyEmojisView__heading,
|
.VerifyEmojisView__heading,
|
||||||
.SelectMethodView__heading,
|
.SelectMethodView__heading,
|
||||||
|
.MissingKeysView__heading,
|
||||||
.WaitingForOtherUserView__heading {
|
.WaitingForOtherUserView__heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -1432,6 +1433,10 @@ button.RoomDetailsView_row::after {
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.MissingKeysView__heading {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.VerificationCompleteView>*,
|
.VerificationCompleteView>*,
|
||||||
.SelectMethodView>*,
|
.SelectMethodView>*,
|
||||||
.VerifyEmojisView>*,
|
.VerifyEmojisView>*,
|
||||||
@ -1453,6 +1458,7 @@ button.RoomDetailsView_row::after {
|
|||||||
.SelectMethodView__title,
|
.SelectMethodView__title,
|
||||||
.WaitingForOtherUserView__title,
|
.WaitingForOtherUserView__title,
|
||||||
.VerificationCancelledView__description,
|
.VerificationCancelledView__description,
|
||||||
|
.MissingKeysView__description,
|
||||||
.VerificationCompleteView__description,
|
.VerificationCompleteView__description,
|
||||||
.VerifyEmojisView__description,
|
.VerifyEmojisView__description,
|
||||||
.SelectMethodView__description,
|
.SelectMethodView__description,
|
||||||
@ -1462,6 +1468,7 @@ button.RoomDetailsView_row::after {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.VerificationCancelledView__actions,
|
.VerificationCancelledView__actions,
|
||||||
|
.MissingKeysView__actions,
|
||||||
.SelectMethodView__actions,
|
.SelectMethodView__actions,
|
||||||
.VerifyEmojisView__actions,
|
.VerifyEmojisView__actions,
|
||||||
.WaitingForOtherUserView__actions {
|
.WaitingForOtherUserView__actions {
|
||||||
@ -1532,6 +1539,7 @@ button.RoomDetailsView_row::after {
|
|||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.VerificationInProgressTileView,
|
.VerificationInProgressTileView,
|
||||||
@ -1539,7 +1547,7 @@ button.RoomDetailsView_row::after {
|
|||||||
.VerificationCancelledTileView,
|
.VerificationCancelledTileView,
|
||||||
.VerificationReadyTileView {
|
.VerificationReadyTileView {
|
||||||
background: var(--background-color-primary--darker-5);
|
background: var(--background-color-primary--darker-5);
|
||||||
padding: 12px;
|
padding: 8px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
@ -1547,18 +1555,18 @@ button.RoomDetailsView_row::after {
|
|||||||
.VerificationTileView {
|
.VerificationTileView {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 16px;
|
padding: 5px 10%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.VerificationInProgressTileView .VerificationTileView__shield,
|
.VerificationInProgressTileView .VerificationTileView__shield,
|
||||||
.VerificationReadyTileView .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 {
|
.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 {
|
.VerificationTileView__shield {
|
||||||
|
@ -52,8 +52,8 @@ export class VerificationTileView extends TemplateView<VerificationTile> {
|
|||||||
class VerificationReadyTileView extends TemplateView<VerificationTile> {
|
class VerificationReadyTileView extends TemplateView<VerificationTile> {
|
||||||
render(t: Builder<VerificationTile>, vm: VerificationTile) {
|
render(t: Builder<VerificationTile>, vm: VerificationTile) {
|
||||||
return t.div({ className: "VerificationReadyTileView" }, [
|
return t.div({ className: "VerificationReadyTileView" }, [
|
||||||
|
t.div({ className: "VerificationTileView__shield" }),
|
||||||
t.div({ className: "VerificationTileView__description" }, [
|
t.div({ className: "VerificationTileView__description" }, [
|
||||||
t.div({ className: "VerificationTileView__shield" }),
|
|
||||||
t.div(vm.description)
|
t.div(vm.description)
|
||||||
]),
|
]),
|
||||||
t.div({ className: "VerificationTileView__actions" }, [
|
t.div({ className: "VerificationTileView__actions" }, [
|
||||||
|
@ -63,14 +63,6 @@ export class KeyBackupSettingsView extends TemplateView<KeyBackupViewModel> {
|
|||||||
}),
|
}),
|
||||||
t.if(vm => vm.canSignOwnDevice, t => {
|
t.if(vm => vm.canSignOwnDevice, t => {
|
||||||
return t.div([
|
return t.div([
|
||||||
t.button(
|
|
||||||
{
|
|
||||||
onClick: disableTargetCallback(async (evt) => {
|
|
||||||
await vm.signOwnDevice();
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Sign own device"
|
|
||||||
),
|
|
||||||
t.button(
|
t.button(
|
||||||
{
|
{
|
||||||
onClick: disableTargetCallback(async () => {
|
onClick: disableTargetCallback(async () => {
|
||||||
|
@ -14,19 +14,21 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
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 {DeviceVerificationViewModel} from "../../../../../domain/session/verification/DeviceVerificationViewModel";
|
||||||
import {WaitingForOtherUserView} from "./stages/WaitingForOtherUserView";
|
import {WaitingForOtherUserView} from "./stages/WaitingForOtherUserView";
|
||||||
import {VerificationCancelledView} from "./stages/VerificationCancelledView";
|
import {VerificationCancelledView} from "./stages/VerificationCancelledView";
|
||||||
import {SelectMethodView} from "./stages/SelectMethodView";
|
import {SelectMethodView} from "./stages/SelectMethodView";
|
||||||
import {VerifyEmojisView} from "./stages/VerifyEmojisView";
|
import {VerifyEmojisView} from "./stages/VerifyEmojisView";
|
||||||
import {VerificationCompleteView} from "./stages/VerificationCompleteView";
|
import {VerificationCompleteView} from "./stages/VerificationCompleteView";
|
||||||
|
import {MissingKeysView} from "./stages/MissingKeysView";
|
||||||
|
import {spinner} from "../../common.js";
|
||||||
|
|
||||||
export class DeviceVerificationView extends TemplateView<DeviceVerificationViewModel> {
|
export class DeviceVerificationView extends TemplateView<DeviceVerificationViewModel> {
|
||||||
render(t: Builder<DeviceVerificationViewModel>) {
|
render(t: Builder<DeviceVerificationViewModel>, vm: DeviceVerificationViewModel) {
|
||||||
return t.div({
|
return t.div({
|
||||||
className: {
|
className: {
|
||||||
"middle": true,
|
"middle": !vm.isHappeningInRoom,
|
||||||
"DeviceVerificationView": true,
|
"DeviceVerificationView": true,
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@ -37,7 +39,8 @@ export class DeviceVerificationView extends TemplateView<DeviceVerificationViewM
|
|||||||
case "select-method": return new SelectMethodView(vm);
|
case "select-method": return new SelectMethodView(vm);
|
||||||
case "verify-emojis": return new VerifyEmojisView(vm);
|
case "verify-emojis": return new VerifyEmojisView(vm);
|
||||||
case "verification-completed": return new VerificationCompleteView(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 {Builder, TemplateView} from "../../../general/TemplateView";
|
||||||
import {VerificationCancelledViewModel} from "../../../../../../domain/session/verification/stages/VerificationCancelledViewModel";
|
import {VerificationCancelledViewModel} from "../../../../../../domain/session/verification/stages/VerificationCancelledViewModel";
|
||||||
import {CancelReason} from "../../../../../../matrix/verification/SAS/channel/types";
|
|
||||||
|
|
||||||
export class VerificationCancelledView extends TemplateView<VerificationCancelledViewModel> {
|
export class VerificationCancelledView extends TemplateView<VerificationCancelledViewModel> {
|
||||||
render(t: Builder<VerificationCancelledViewModel>, vm: VerificationCancelledViewModel) {
|
render(t: Builder<VerificationCancelledViewModel>, vm: VerificationCancelledViewModel) {
|
||||||
const headerTextStart = vm.isCancelledByUs ? "You" : "The other device";
|
|
||||||
|
|
||||||
return t.div(
|
return t.div(
|
||||||
{
|
{
|
||||||
className: "VerificationCancelledView",
|
className: "VerificationCancelledView",
|
||||||
@ -29,11 +26,11 @@ export class VerificationCancelledView extends TemplateView<VerificationCancelle
|
|||||||
[
|
[
|
||||||
t.h2(
|
t.h2(
|
||||||
{ className: "VerificationCancelledView__title" },
|
{ className: "VerificationCancelledView__title" },
|
||||||
vm.i18n`${headerTextStart} cancelled the verification!`
|
vm.title,
|
||||||
),
|
),
|
||||||
t.p(
|
t.p(
|
||||||
{ className: "VerificationCancelledView__description" },
|
{ className: "VerificationCancelledView__description" },
|
||||||
vm.i18n`${this.getDescriptionFromCancellationCode(vm.cancelCode, vm.isCancelledByUs)}`
|
vm.description,
|
||||||
),
|
),
|
||||||
t.div({ className: "VerificationCancelledView__actions" }, [
|
t.div({ className: "VerificationCancelledView__actions" }, [
|
||||||
t.button({
|
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),
|
spinner(t),
|
||||||
t.h2(
|
t.h2(
|
||||||
{ className: "WaitingForOtherUserView__title" },
|
{ className: "WaitingForOtherUserView__title" },
|
||||||
vm.i18n`Waiting for any of your device to accept the verification request`
|
vm.title,
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
t.p({ className: "WaitingForOtherUserView__description" },
|
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.div({ className: "WaitingForOtherUserView__actions" },
|
||||||
t.button({
|
t.button({
|
||||||
|
Loading…
Reference in New Issue
Block a user