diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 2f4e0f1d..f3e85540 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -43,9 +43,11 @@ import { readKey as ssssReadKey, writeKey as ssssWriteKey, removeKey as ssssRemoveKey, - keyFromDehydratedDeviceKey as createSSSSKeyFromDehydratedDeviceKey + keyFromDehydratedDeviceKey as createSSSSKeyFromDehydratedDeviceKey, + SecretStorage, + SharedSecret, + SecretFetcher } from "./ssss/index"; -import {SecretStorage} from "./ssss/SecretStorage"; import {ObservableValue, RetainedObservableValue} from "../observable/value"; import {CallHandler} from "./calls/CallHandler"; import {RoomStateHandlerSet} from "./room/state/RoomStateHandlerSet"; @@ -109,6 +111,7 @@ export class Session { this._createRoomEncryption = this._createRoomEncryption.bind(this); this._forgetArchivedRoom = this._forgetArchivedRoom.bind(this); this.needsKeyBackup = new ObservableValue(false); + this.secretFetcher = new SecretFetcher(); } get fingerprintKey() { @@ -198,6 +201,20 @@ export class Session { }); this._megolmDecryption = new MegOlmDecryption(this._keyLoader, this._olmWorker); this._deviceMessageHandler.enableEncryption({olmDecryption, megolmDecryption: this._megolmDecryption}); + this._sharedSecret = new SharedSecret({ + 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, + }); + this.secretFetcher.setSecretSharing(this._sharedSecret); + } _createRoomEncryption(room, encryptionParams) { @@ -251,11 +268,11 @@ export class Session { this._keyBackup.get().dispose(); this._keyBackup.set(undefined); } - const crossSigning = this._crossSigning.get(); - if (crossSigning) { - crossSigning.dispose(); - this._crossSigning.set(undefined); - } + // const crossSigning = this._crossSigning.get(); + // if (crossSigning) { + // crossSigning.dispose(); + // this._crossSigning.set(undefined); + // } const key = await ssssKeyFromCredential(type, credential, this._storage, this._platform, this._olm); if (await this._tryLoadSecretStorage(key, log)) { // only after having read a secret, write the key @@ -331,7 +348,9 @@ export class Session { const isValid = await secretStorage.hasValidKeyForAnyAccountData(); log.set("isValid", isValid); if (isValid) { - await this._loadSecretStorageServices(secretStorage, log); + this._secretStorage = secretStorage; + await this._loadSecretStorageService(log); + this.secretFetcher.setSecretStorage(secretStorage); } return isValid; }); @@ -359,29 +378,6 @@ export class Session { log.set("no_backup", true); } }); - if (this._features.crossSigning) { - await log.wrap("enable cross-signing", async log => { - const crossSigning = new CrossSigning({ - storage: this._storage, - secretStorage, - platform: this._platform, - olm: this._olm, - olmUtil: this._olmUtil, - deviceTracker: this._deviceTracker, - deviceMessageHandler: this._deviceMessageHandler, - hsApi: this._hsApi, - ownUserId: this.userId, - e2eeAccount: this._e2eeAccount, - deviceId: this.deviceId, - }); - if (await crossSigning.load(log)) { - this._crossSigning.set(crossSigning); - } - else { - crossSigning.dispose(); - } - }); - } } catch (err) { log.catch(err); } @@ -588,6 +584,32 @@ export class Session { } }); } + + if (this._features.crossSigning) { + this._platform.logger.run("enable cross-signing", async log => { + const crossSigning = new CrossSigning({ + storage: this._storage, + // secretStorage: this._secretStorage, + 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, + }); + this._crossSigning.set(crossSigning); + // if (await crossSigning.load(log)) { + // this._crossSigning.set(crossSigning); + // } + // else { + // crossSigning.dispose(); + // } + }); + } await this._keyBackup.get()?.start(log); await this._crossSigning.get()?.start(log); diff --git a/src/matrix/ssss/SecretFetcher.ts b/src/matrix/ssss/SecretFetcher.ts new file mode 100644 index 00000000..9da0cc4a --- /dev/null +++ b/src/matrix/ssss/SecretFetcher.ts @@ -0,0 +1,46 @@ +/* +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 {SharedSecret} from "./SharedSecret"; + +/** + * This is a wrapper around SecretStorage and SecretSharing so that + * you don't need to always check both sources for something. + */ +export class SecretFetcher { + public secretStorage: SecretStorage; + public secretSharing: SharedSecret; + + async getSecret(name: string): Promise { + ; + return await this.secretStorage?.readSecret(name) ?? + await this.secretSharing?.getLocallyStoredSecret(name); + // 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... + } + + setSecretStorage(storage: SecretStorage) { + this.secretStorage = storage; + } + + setSecretSharing(sharing: SharedSecret) { + this.secretSharing = sharing; + this.secretSharing.setSecretFetcher(this); + } +} diff --git a/src/matrix/ssss/SharedSecret.ts b/src/matrix/ssss/SharedSecret.ts new file mode 100644 index 00000000..d6736521 --- /dev/null +++ b/src/matrix/ssss/SharedSecret.ts @@ -0,0 +1,277 @@ +/* +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 {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; + logger: ILogger; +}; + +const enum EVENT_TYPE { + REQUEST = "m.secret.request", + SEND = "m.secret.send", +} + +export class SharedSecret { + 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> = new Map(); + private readonly crypto: Crypto; + private readonly encoding: Encoding; + private readonly aesEncryption: AESEncryption; + private readonly crossSigning: ObservableValue; + 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.crypto = options.crypto; + this.encoding = options.encoding; + this.crossSigning = options.crossSigning; + this.logger = options.logger; + this.aesEncryption = new AESEncryption(this.storage, this.crypto, this.encoding); + (window as any).foo = this; + this.init(); + } + + private async init() { + this.deviceMessageHandler.on("message", ({ encrypted }) => { + const type: EVENT_TYPE = encrypted?.event.type; + switch (type) { + case EVENT_TYPE.REQUEST: { + this._respondToRequest(encrypted); + } + case EVENT_TYPE.SEND: { + const { request_id } = encrypted.event.content; + const deffered = this.waitMap.get(request_id); + deffered?.resolve(encrypted); + this.waitMap.delete(request_id); + break; + } + } + }); + await this.aesEncryption.load(); + } + + private async _respondToRequest(request) { + await this.logger.run("SharedSecret.respondToRequest", async (log) => { + if (!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)); + console.log("messages", messages); + const payload = formatToDeviceMessagesPayload(messages); + console.log("payload", payload); + await this.hsApi.sendToDevice("m.room.encrypted", payload, makeTxnId(), {log}).response(); + }); + } + + private async shouldRespondToRequest(request: any, log: ILogItem): Promise { + return log.wrap("SecretSharing.shouldRespondToRequest", async () => { + 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; + }) + + } + + async getLocallyStoredSecret(name: string): Promise { + 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 { + return log.wrap("SharedSecret.requestSecret", async (_log) => { + const request_id = makeTxnId(); + const promise = this.trackSecretRequest(request_id); + await this.sendRequestForSecret(name, request_id, _log); + const result = await promise; + const secret = result.event.content.secret; + await this.writeToStorage(name, secret); + return secret; + }); + } + + private async writeToStorage(name:string, secret: any) { + 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): Promise { + const deferred = new Deferred(); + this.waitMap.set(request_id, deferred); + return deferred.promise; + } + + private async sendRequestForSecret(name: string, request_id: string, log: ILogItem) { + 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)); + console.log("messages", messages); + const payload = formatToDeviceMessagesPayload(messages); + console.log("payload", payload); + await this.hsApi.sendToDevice("m.room.encrypted", payload, makeTxnId(), {log}).response(); + } + + setSecretFetcher(secretFetcher: SecretFetcher): void { + this.secretFetcher = secretFetcher; + } +} + +class AESEncryption { + private key: JsonWebKey; + private iv: Uint8Array; + + constructor(private storage: Storage, private crypto: Crypto, private encoding: Encoding) { }; + + async load() { + 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) { + 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 { + 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 { + const buffer = await this.crypto.aes.decryptCTR({ + jwkKey: this.key, + iv: this.iv, + data: ciphertext, + }); + const secret = this.encoding.utf8.decode(buffer); + return secret; + } +} diff --git a/src/matrix/verification/CrossSigning.ts b/src/matrix/verification/CrossSigning.ts index c23c2f54..a685a898 100644 --- a/src/matrix/verification/CrossSigning.ts +++ b/src/matrix/verification/CrossSigning.ts @@ -22,7 +22,7 @@ import {ToDeviceChannel} from "./SAS/channel/Channel"; import {VerificationEventType} from "./SAS/channel/types"; import {ObservableMap} from "../../observable/map"; import {SASRequest} from "./SAS/SASRequest"; -import type {SecretStorage} from "../ssss/SecretStorage"; +import {SecretFetcher} from "../ssss"; import type {Storage} from "../storage/idb/Storage"; import type {Platform} from "../../platform/web/Platform"; import type {DeviceTracker} from "../e2ee/DeviceTracker"; @@ -80,7 +80,7 @@ enum MSKVerification { export class CrossSigning { private readonly storage: Storage; - private readonly secretStorage: SecretStorage; + private readonly secretFetcher: SecretFetcher; private readonly platform: Platform; private readonly deviceTracker: DeviceTracker; private readonly olm: Olm; @@ -97,7 +97,7 @@ export class CrossSigning { constructor(options: { storage: Storage, - secretStorage: SecretStorage, + secretFetcher: SecretFetcher, deviceTracker: DeviceTracker, platform: Platform, olm: Olm, @@ -109,7 +109,7 @@ export class CrossSigning { deviceMessageHandler: DeviceMessageHandler, }) { this.storage = options.storage; - this.secretStorage = options.secretStorage; + this.secretFetcher = options.secretFetcher; this.platform = options.platform; this.deviceTracker = options.deviceTracker; this.olm = options.olm; @@ -208,6 +208,7 @@ export class CrossSigning { } private handleSASDeviceMessage({ unencrypted: event }) { + if (!event) { return; } const txnId = event.content.transaction_id; /** * If we receive an event for the current/previously finished @@ -304,6 +305,20 @@ export class CrossSigning { }); } + async isOurUserDeviceTrusted(device: DeviceKey, log: ILogItem): Promise { + return await log.wrap("CrossSigning.getDeviceTrust", async () => { + 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; + }); + } + getUserTrust(userId: string, log: ILogItem): Promise { return log.wrap("CrossSigning.getUserTrust", async log => { log.set("id", userId); @@ -421,7 +436,7 @@ export class CrossSigning { } private async getSigningKey(usage: KeyUsage): Promise { - const seedStr = await this.secretStorage.readSecret(`m.cross_signing.${usage}`); + const seedStr = await this.secretFetcher.getSecret(`m.cross_signing.${usage}`); if (seedStr) { return new Uint8Array(this.platform.encoding.base64.decode(seedStr)); }