1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-11-28 05:03:59 +03:00

Merge branch 'develop' into travis/ts-mtxcli

This commit is contained in:
Travis Ralston
2021-06-03 18:49:08 -06:00
7 changed files with 775 additions and 453 deletions

View File

@@ -257,6 +257,9 @@ describe("MegolmDecryption", function() {
});
it("re-uses sessions for sequential messages", async function() {
mockCrypto._backupManager = {
backupGroupSession: () => {},
};
const mockStorage = new MockStorageApi();
const cryptoStore = new MemoryCryptoStore(mockStorage);

View File

@@ -28,6 +28,7 @@ import * as testUtils from "../../test-utils";
import { OlmDevice } from "../../../src/crypto/OlmDevice";
import { Crypto } from "../../../src/crypto";
import { resetCrossSigningKeys } from "./crypto-utils";
import { BackupManager } from "../../../src/crypto/backup";
const Olm = global.Olm;
@@ -73,7 +74,7 @@ const KEY_BACKUP_DATA = {
};
const BACKUP_INFO = {
algorithm: "m.megolm_backup.v1",
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
version: 1,
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
@@ -138,6 +139,7 @@ describe("MegolmBackup", function() {
let megolmDecryption;
beforeEach(async function() {
mockCrypto = testUtils.mock(Crypto, 'Crypto');
mockCrypto._backupManager = testUtils.mock(BackupManager, "BackupManager");
mockCrypto.backupKey = new Olm.PkEncryption();
mockCrypto.backupKey.set_recipient_key(
"hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
@@ -215,12 +217,14 @@ describe("MegolmBackup", function() {
};
mockCrypto.cancelRoomKeyRequest = function() {};
mockCrypto.backupGroupSession = jest.fn();
mockCrypto._backupManager = {
backupGroupSession: jest.fn(),
};
return event.attemptDecryption(mockCrypto).then(() => {
return megolmDecryption.onRoomKeyEvent(event);
}).then(() => {
expect(mockCrypto.backupGroupSession).toHaveBeenCalled();
expect(mockCrypto._backupManager.backupGroupSession).toHaveBeenCalled();
});
});
@@ -264,7 +268,7 @@ describe("MegolmBackup", function() {
})
.then(() => {
client.enableKeyBackup({
algorithm: "m.megolm_backup.v1",
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
version: 1,
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
@@ -292,12 +296,9 @@ describe("MegolmBackup", function() {
resolve();
return Promise.resolve({});
};
client.crypto.backupGroupSession(
"roomId",
client.crypto._backupManager.backupGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
[],
groupSession.session_id(),
groupSession.session_key(),
);
}).then(() => {
expect(numCalls).toBe(1);
@@ -335,17 +336,15 @@ describe("MegolmBackup", function() {
});
await resetCrossSigningKeys(client);
let numCalls = 0;
await new Promise((resolve, reject) => {
await Promise.all([
new Promise((resolve, reject) => {
let backupInfo;
client.http.authedRequest = function(
callback, method, path, queryParams, data, opts,
) {
++numCalls;
expect(numCalls).toBeLessThanOrEqual(1);
if (numCalls >= 2) {
// exit out of retry loop if there's something wrong
reject(new Error("authedRequest called too many timmes"));
return Promise.resolve({});
}
expect(numCalls).toBeLessThanOrEqual(2);
if (numCalls === 1) {
expect(method).toBe("POST");
expect(path).toBe("/room_keys/version");
try {
@@ -357,17 +356,28 @@ describe("MegolmBackup", function() {
reject(e);
return Promise.resolve({});
}
resolve();
backupInfo = data;
return Promise.resolve({});
} else if (numCalls === 2) {
expect(method).toBe("GET");
expect(path).toBe("/room_keys/version");
resolve();
return Promise.resolve(backupInfo);
} else {
// exit out of retry loop if there's something wrong
reject(new Error("authedRequest called too many times"));
return Promise.resolve({});
}
};
}),
client.createKeyBackupVersion({
algorithm: "m.megolm_backup.v1",
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
});
});
expect(numCalls).toBe(1);
}),
]);
expect(numCalls).toBe(2);
});
it('retries when a backup fails', function() {
@@ -434,7 +444,7 @@ describe("MegolmBackup", function() {
})
.then(() => {
client.enableKeyBackup({
algorithm: "foobar",
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
version: 1,
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
@@ -468,12 +478,9 @@ describe("MegolmBackup", function() {
);
}
};
client.crypto.backupGroupSession(
"roomId",
client.crypto._backupManager.backupGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
[],
groupSession.session_id(),
groupSession.session_key(),
);
}).then(() => {
expect(numCalls).toBe(2);
@@ -569,5 +576,21 @@ describe("MegolmBackup", function() {
const cachedKey = await client.crypto.getSessionBackupPrivateKey();
expect(cachedKey).not.toBeNull();
});
it("fails if an known algorithm is used", async function() {
const BAD_BACKUP_INFO = Object.assign({}, BACKUP_INFO, {
algorithm: "this.algorithm.does.not.exist",
});
client._http.authedRequest = function() {
return Promise.resolve(KEY_BACKUP_DATA);
};
await expect(client.restoreKeyBackupWithRecoveryKey(
"EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d",
ROOM_ID,
SESSION_ID,
BAD_BACKUP_INFO,
)).rejects.toThrow();
});
});
});

View File

@@ -376,7 +376,7 @@ describe("Secrets", function() {
]);
this.emit("accountData", event);
};
bob.crypto.checkKeyBackup = async () => {};
bob.crypto._backupManager.checkKeyBackup = async () => {};
const crossSigning = bob.crypto._crossSigningInfo;
const secretStorage = bob.crypto._secretStorage;

View File

@@ -413,9 +413,8 @@ MegolmEncryption.prototype._prepareNewSession = async function(sharedHistory) {
);
// don't wait for it to complete
this._crypto.backupGroupSession(
this._roomId, this._olmDevice.deviceCurve25519Key, [],
sessionId, key.key,
this._crypto._backupManager.backupGroupSession(
this._olmDevice.deviceCurve25519Key, sessionId,
);
return new OutboundSessionInfo(sessionId, sharedHistory);
@@ -1425,11 +1424,7 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) {
});
}).then(() => {
// don't wait for the keys to be backed up for the server
this._crypto.backupGroupSession(
content.room_id, senderKey, forwardingKeyChain,
content.session_id, content.session_key, keysClaimed,
exportFormat,
);
this._crypto._backupManager.backupGroupSession(senderKey, content.session_id);
}).catch((e) => {
logger.error(`Error handling m.room_key_event: ${e}`);
});
@@ -1645,14 +1640,8 @@ MegolmDecryption.prototype.importRoomKey = function(session, opts = {}) {
).then(() => {
if (opts.source !== "backup") {
// don't wait for it to complete
this._crypto.backupGroupSession(
session.room_id,
session.sender_key,
session.forwarding_curve25519_key_chain,
session.session_id,
session.session_key,
session.sender_claimed_keys,
true,
this._crypto._backupManager.backupGroupSession(
session.sender_key, session.session_id,
).catch((e) => {
// This throws if the upload failed, but this is fine
// since it will have written it to the db and will retry.

651
src/crypto/backup.ts Normal file
View File

@@ -0,0 +1,651 @@
/*
Copyright 2021 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.
*/
/**
* @module crypto/backup
*
* Classes for dealing with key backup.
*/
import { MatrixClient } from "../client";
import { logger } from "../logger";
import { MEGOLM_ALGORITHM, verifySignature } from "./olmlib";
import { DeviceInfo } from "./deviceinfo"
import { DeviceTrustLevel } from './CrossSigning';
import { keyFromPassphrase } from './key_passphrase';
import { sleep } from "../utils";
import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store';
import { encodeRecoveryKey } from './recoverykey';
const KEY_BACKUP_KEYS_PER_REQUEST = 200;
type AuthData = Record<string, any>;
type BackupInfo = {
algorithm: string,
auth_data: AuthData, // eslint-disable-line camelcase
[properties: string]: any,
};
type SigInfo = {
deviceId: string,
valid?: boolean | null, // true: valid, false: invalid, null: cannot attempt validation
device?: DeviceInfo | null,
crossSigningId?: boolean,
deviceTrust?: DeviceTrustLevel,
};
type TrustInfo = {
usable: boolean, // is the backup trusted, true iff there is a sig that is valid & from a trusted device
sigs: SigInfo[],
};
/** A function used to get the secret key for a backup.
*/
type GetKey = () => Promise<Uint8Array>;
interface BackupAlgorithmClass {
algorithmName: string;
// initialize from an existing backup
init(authData: AuthData, getKey: GetKey): Promise<BackupAlgorithm>;
// prepare a brand new backup
prepare(
key: string | Uint8Array | null,
): Promise<[Uint8Array, AuthData]>;
}
interface BackupAlgorithm {
encryptSession(data: Record<string, any>): Promise<any>;
decryptSessions(ciphertexts: Record<string, any>): Promise<Record<string, any>[]>;
authData: AuthData;
keyMatches(key: Uint8Array): Promise<boolean>;
free(): void;
}
/**
* Manages the key backup.
*/
export class BackupManager {
private algorithm: BackupAlgorithm | undefined;
private backupInfo: BackupInfo | undefined; // The info dict from /room_keys/version
public checkedForBackup: boolean; // Have we checked the server for a backup we can use?
private sendingBackups: boolean; // Are we currently sending backups?
constructor(private readonly baseApis, public readonly getKey: GetKey) {
this.checkedForBackup = false;
this.sendingBackups = false;
}
public get version(): string | undefined {
return this.backupInfo && this.backupInfo.version;
}
public static async makeAlgorithm(info: BackupInfo, getKey: GetKey): Promise<BackupAlgorithm> {
const Algorithm = algorithmsByName[info.algorithm];
if (!Algorithm) {
throw new Error("Unknown backup algorithm");
}
return await Algorithm.init(info.auth_data, getKey);
}
public async enableKeyBackup(info: BackupInfo): Promise<void> {
this.backupInfo = info;
if (this.algorithm) {
this.algorithm.free();
}
this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey);
this.baseApis.emit('crypto.keyBackupStatus', true);
// There may be keys left over from a partially completed backup, so
// schedule a send to check.
this.scheduleKeyBackupSend();
}
/**
* Disable backing up of keys.
*/
public disableKeyBackup(): void {
if (this.algorithm) {
this.algorithm.free();
}
this.algorithm = undefined;
this.backupInfo = undefined;
this.baseApis.emit('crypto.keyBackupStatus', false);
}
public getKeyBackupEnabled(): boolean | null {
if (!this.checkedForBackup) {
return null;
}
return Boolean(this.algorithm);
}
public async prepareKeyBackupVersion(
key?: string | Uint8Array | null,
algorithm?: string | undefined,
): Promise<BackupInfo> {
const Algorithm = algorithm ? algorithmsByName[algorithm] : DefaultAlgorithm;
if (!Algorithm) {
throw new Error("Unknown backup algorithm");
}
const [privateKey, authData] = await Algorithm.prepare(key);
const recoveryKey = encodeRecoveryKey(privateKey);
return {
algorithm: Algorithm.algorithmName,
auth_data: authData,
recovery_key: recoveryKey,
privateKey,
};
}
public async createKeyBackupVersion(info: BackupInfo): Promise<void> {
this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey);
}
/**
* Check the server for an active key backup and
* if one is present and has a valid signature from
* one of the user's verified devices, start backing up
* to it.
*/
public async checkAndStart(): Promise<{backupInfo: BackupInfo, trustInfo: TrustInfo}> {
logger.log("Checking key backup status...");
if (this.baseApis.isGuest()) {
logger.log("Skipping key backup check since user is guest");
this.checkedForBackup = true;
return null;
}
let backupInfo: BackupInfo;
try {
backupInfo = await this.baseApis.getKeyBackupVersion();
} catch (e) {
logger.log("Error checking for active key backup", e);
if (e.httpStatus === 404) {
// 404 is returned when the key backup does not exist, so that
// counts as successfully checking.
this.checkedForBackup = true;
}
return null;
}
this.checkedForBackup = true;
const trustInfo = await this.isKeyBackupTrusted(backupInfo);
if (trustInfo.usable && !this.backupInfo) {
logger.log(
"Found usable key backup v" + backupInfo.version +
": enabling key backups",
);
await this.enableKeyBackup(backupInfo);
} else if (!trustInfo.usable && this.backupInfo) {
logger.log("No usable key backup: disabling key backup");
this.disableKeyBackup();
} else if (!trustInfo.usable && !this.backupInfo) {
logger.log("No usable key backup: not enabling key backup");
} else if (trustInfo.usable && this.backupInfo) {
// may not be the same version: if not, we should switch
if (backupInfo.version !== this.backupInfo.version) {
logger.log(
"On backup version " + this.backupInfo.version + " but found " +
"version " + backupInfo.version + ": switching.",
);
this.disableKeyBackup();
await this.enableKeyBackup(backupInfo);
// We're now using a new backup, so schedule all the keys we have to be
// uploaded to the new backup. This is a bit of a workaround to upload
// keys to a new backup in *most* cases, but it won't cover all cases
// because we don't remember what backup version we uploaded keys to:
// see https://github.com/vector-im/element-web/issues/14833
await this.scheduleAllGroupSessionsForBackup();
} else {
logger.log("Backup version " + backupInfo.version + " still current");
}
}
return { backupInfo, trustInfo };
}
/**
* Forces a re-check of the key backup and enables/disables it
* as appropriate.
*
* @return {Object} Object with backup info (as returned by
* getKeyBackupVersion) in backupInfo and
* trust information (as returned by isKeyBackupTrusted)
* in trustInfo.
*/
public async checkKeyBackup(): Promise<{backupInfo: BackupInfo, trustInfo: TrustInfo}> {
this.checkedForBackup = false;
return this.checkAndStart();
}
/**
* Check if the given backup info is trusted.
*
* @param {object} backupInfo key backup info dict from /room_keys/version
* @return {object} {
* usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device
* sigs: [
* valid: [bool || null], // true: valid, false: invalid, null: cannot attempt validation
* deviceId: [string],
* device: [DeviceInfo || null],
* ]
* }
*/
public async isKeyBackupTrusted(backupInfo: BackupInfo): Promise<TrustInfo> {
const ret = {
usable: false,
trusted_locally: false,
sigs: [],
};
if (
!backupInfo ||
!backupInfo.algorithm ||
!backupInfo.auth_data ||
!backupInfo.auth_data.public_key ||
!backupInfo.auth_data.signatures
) {
logger.info("Key backup is absent or missing required data");
return ret;
}
const trustedPubkey = this.baseApis._crypto._sessionStore.getLocalTrustedBackupPubKey();
if (backupInfo.auth_data.public_key === trustedPubkey) {
logger.info("Backup public key " + trustedPubkey + " is trusted locally");
ret.trusted_locally = true;
}
const mySigs = backupInfo.auth_data.signatures[this.baseApis.getUserId()] || [];
for (const keyId of Object.keys(mySigs)) {
const keyIdParts = keyId.split(':');
if (keyIdParts[0] !== 'ed25519') {
logger.log("Ignoring unknown signature type: " + keyIdParts[0]);
continue;
}
// Could be a cross-signing master key, but just say this is the device
// ID for backwards compat
const sigInfo: SigInfo = { deviceId: keyIdParts[1] };
// first check to see if it's from our cross-signing key
const crossSigningId = this.baseApis._crypto._crossSigningInfo.getId();
if (crossSigningId === sigInfo.deviceId) {
sigInfo.crossSigningId = true;
try {
await verifySignature(
this.baseApis._crypto._olmDevice,
backupInfo.auth_data,
this.baseApis.getUserId(),
sigInfo.deviceId,
crossSigningId,
);
sigInfo.valid = true;
} catch (e) {
logger.warn(
"Bad signature from cross signing key " + crossSigningId, e,
);
sigInfo.valid = false;
}
ret.sigs.push(sigInfo);
continue;
}
// Now look for a sig from a device
// At some point this can probably go away and we'll just support
// it being signed by the cross-signing master key
const device = this.baseApis._crypto._deviceList.getStoredDevice(
this.baseApis.getUserId(), sigInfo.deviceId,
);
if (device) {
sigInfo.device = device;
sigInfo.deviceTrust = await this.baseApis.checkDeviceTrust(
this.baseApis.getUserId(), sigInfo.deviceId,
);
try {
await verifySignature(
this.baseApis._crypto._olmDevice,
backupInfo.auth_data,
this.baseApis.getUserId(),
device.deviceId,
device.getFingerprint(),
);
sigInfo.valid = true;
} catch (e) {
logger.info(
"Bad signature from key ID " + keyId + " userID " + this.baseApis.getUserId() +
" device ID " + device.deviceId + " fingerprint: " +
device.getFingerprint(), backupInfo.auth_data, e,
);
sigInfo.valid = false;
}
} else {
sigInfo.valid = null; // Can't determine validity because we don't have the signing device
logger.info("Ignoring signature from unknown key " + keyId);
}
ret.sigs.push(sigInfo);
}
ret.usable = ret.sigs.some((s) => {
return (
s.valid && (
(s.device && s.deviceTrust.isVerified()) ||
(s.crossSigningId)
)
);
});
ret.usable = ret.usable || ret.trusted_locally;
return ret;
}
/**
* Schedules sending all keys waiting to be sent to the backup, if not already
* scheduled. Retries if necessary.
*
* @param maxDelay Maximum delay to wait in ms. 0 means no delay.
*/
public async scheduleKeyBackupSend(maxDelay = 10000): Promise<void> {
if (this.sendingBackups) return;
this.sendingBackups = true;
try {
// wait between 0 and `maxDelay` seconds, to avoid backup
// requests from different clients hitting the server all at
// the same time when a new key is sent
const delay = Math.random() * maxDelay;
await sleep(delay, undefined);
let numFailures = 0; // number of consecutive failures
for (;;) {
if (!this.algorithm) {
return;
}
try {
const numBackedUp =
await this.backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST);
if (numBackedUp === 0) {
// no sessions left needing backup: we're done
return;
}
numFailures = 0;
} catch (err) {
numFailures++;
logger.log("Key backup request failed", err);
if (err.data) {
if (
err.data.errcode == 'M_NOT_FOUND' ||
err.data.errcode == 'M_WRONG_ROOM_KEYS_VERSION'
) {
// Re-check key backup status on error, so we can be
// sure to present the current situation when asked.
await this.checkKeyBackup();
// Backup version has changed or this backup version
// has been deleted
this.baseApis._crypto.emit("crypto.keyBackupFailed", err.data.errcode);
throw err;
}
}
}
if (numFailures) {
// exponential backoff if we have failures
await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)), undefined);
}
}
} finally {
this.sendingBackups = false;
}
}
/**
* Take some e2e keys waiting to be backed up and send them
* to the backup.
*
* @param {integer} limit Maximum number of keys to back up
* @returns {integer} Number of sessions backed up
*/
private async backupPendingKeys(limit: number): Promise<number> {
const sessions = await this.baseApis._crypto._cryptoStore.getSessionsNeedingBackup(limit);
if (!sessions.length) {
return 0;
}
let remaining = await this.baseApis._crypto._cryptoStore.countSessionsNeedingBackup();
this.baseApis._crypto.emit("crypto.keyBackupSessionsRemaining", remaining);
const data = {};
for (const session of sessions) {
const roomId = session.sessionData.room_id;
if (data[roomId] === undefined) {
data[roomId] = { sessions: {} };
}
const sessionData = await this.baseApis._crypto._olmDevice.exportInboundGroupSession(
session.senderKey, session.sessionId, session.sessionData,
);
sessionData.algorithm = MEGOLM_ALGORITHM;
const forwardedCount =
(sessionData.forwarding_curve25519_key_chain || []).length;
const userId = this.baseApis._crypto._deviceList.getUserByIdentityKey(
MEGOLM_ALGORITHM, session.senderKey,
);
const device = this.baseApis._crypto._deviceList.getDeviceByIdentityKey(
MEGOLM_ALGORITHM, session.senderKey,
);
const verified = this.baseApis._crypto._checkDeviceInfoTrust(userId, device).isVerified();
data[roomId]['sessions'][session.sessionId] = {
first_message_index: sessionData.first_known_index,
forwarded_count: forwardedCount,
is_verified: verified,
session_data: await this.algorithm.encryptSession(sessionData),
};
}
await this.baseApis.sendKeyBackup(
undefined, undefined, this.backupInfo.version,
{ rooms: data },
);
await this.baseApis._crypto._cryptoStore.unmarkSessionsNeedingBackup(sessions);
remaining = await this.baseApis._crypto._cryptoStore.countSessionsNeedingBackup();
this.baseApis._crypto.emit("crypto.keyBackupSessionsRemaining", remaining);
return sessions.length;
}
public async backupGroupSession(
senderKey: string, sessionId: string,
): Promise<void> {
await this.baseApis._crypto._cryptoStore.markSessionsNeedingBackup([{
senderKey: senderKey,
sessionId: sessionId,
}]);
if (this.backupInfo) {
// don't wait for this to complete: it will delay so
// happens in the background
this.scheduleKeyBackupSend();
}
// if this.backupInfo is not set, then the keys will be backed up when
// this.enableKeyBackup is called
}
/**
* Marks all group sessions as needing to be backed up and schedules them to
* upload in the background as soon as possible.
*/
public async scheduleAllGroupSessionsForBackup(): Promise<void> {
await this.flagAllGroupSessionsForBackup();
// Schedule keys to upload in the background as soon as possible.
this.scheduleKeyBackupSend(0 /* maxDelay */);
}
/**
* Marks all group sessions as needing to be backed up without scheduling
* them to upload in the background.
* @returns {Promise<int>} Resolves to the number of sessions now requiring a backup
* (which will be equal to the number of sessions in the store).
*/
public async flagAllGroupSessionsForBackup(): Promise<number> {
await this.baseApis._crypto._cryptoStore.doTxn(
'readwrite',
[
IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS,
IndexedDBCryptoStore.STORE_BACKUP,
],
(txn) => {
this.baseApis._crypto._cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => {
if (session !== null) {
this.baseApis._crypto._cryptoStore.markSessionsNeedingBackup([session], txn);
}
});
},
);
const remaining = await this.baseApis._crypto._cryptoStore.countSessionsNeedingBackup();
this.baseApis.emit("crypto.keyBackupSessionsRemaining", remaining);
return remaining;
}
/**
* Counts the number of end to end session keys that are waiting to be backed up
* @returns {Promise<int>} Resolves to the number of sessions requiring backup
*/
public countSessionsNeedingBackup(): Promise<number> {
return this.baseApis._crypto._cryptoStore.countSessionsNeedingBackup();
}
}
export class Curve25519 implements BackupAlgorithm {
public static algorithmName = "m.megolm_backup.v1.curve25519-aes-sha2";
constructor(
public authData: AuthData,
private publicKey: any, // FIXME: PkEncryption
private getKey: () => Promise<Uint8Array>,
) {}
public static async init(
authData: AuthData,
getKey: () => Promise<Uint8Array>,
): Promise<Curve25519> {
if (!authData || !authData.public_key) {
throw new Error("auth_data missing required information");
}
const publicKey = new global.Olm.PkEncryption();
publicKey.set_recipient_key(authData.public_key);
return new Curve25519(authData, publicKey, getKey);
}
public static async prepare(
key: string | Uint8Array | null,
): Promise<[Uint8Array, AuthData]> {
const decryption = new global.Olm.PkDecryption();
try {
const authData: AuthData = {};
if (!key) {
authData.public_key = decryption.generate_key();
} else if (key instanceof Uint8Array) {
authData.public_key = decryption.init_with_private_key(key);
} else {
const derivation = await keyFromPassphrase(key);
authData.private_key_salt = derivation.salt;
authData.private_key_iterations = derivation.iterations;
authData.public_key = decryption.init_with_private_key(derivation.key);
}
const publicKey = new global.Olm.PkEncryption();
publicKey.set_recipient_key(authData.public_key);
return [
decryption.get_private_key(),
authData,
]
} finally {
decryption.free();
}
}
public async encryptSession(data: Record<string, any>): Promise<any> {
const plainText: Record<string, any> = Object.assign({}, data);
delete plainText.session_id;
delete plainText.room_id;
delete plainText.first_known_index;
return this.publicKey.encrypt(JSON.stringify(plainText));
}
public async decryptSessions(sessions: Record<string, Record<string, any>>): Promise<Record<string, any>[]> {
const privKey = await this.getKey();
const decryption = new global.Olm.PkDecryption();
try {
const backupPubKey = decryption.init_with_private_key(privKey);
if (backupPubKey !== this.authData.public_key) {
// eslint-disable-next-line no-throw-literal
throw { errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY };
}
const keys = [];
for (const [sessionId, sessionData] of Object.entries(sessions)) {
try {
const decrypted = JSON.parse(decryption.decrypt(
sessionData.session_data.ephemeral,
sessionData.session_data.mac,
sessionData.session_data.ciphertext,
));
decrypted.session_id = sessionId;
keys.push(decrypted);
} catch (e) {
logger.log("Failed to decrypt megolm session from backup", e, sessionData);
}
}
return keys;
} finally {
decryption.free();
}
}
public async keyMatches(key: Uint8Array): Promise<boolean> {
const decryption = new global.Olm.PkDecryption();
let pubKey;
try {
pubKey = decryption.init_with_private_key(key);
} finally {
decryption.free();
}
return pubKey === this.authData.public_key;
}
public free(): void {
this.publicKey.free();
}
}
export const algorithmsByName: Record<string, BackupAlgorithmClass> = {
[Curve25519.algorithmName]: Curve25519,
};
export const DefaultAlgorithm: BackupAlgorithmClass = Curve25519;

View File

@@ -2,7 +2,7 @@
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018-2019 New Vector Ltd
Copyright 2019-2020 The Matrix.org Foundation C.I.C.
Copyright 2019-2021 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.
@@ -26,7 +26,6 @@ import { EventEmitter } from 'events';
import { ReEmitter } from '../ReEmitter';
import { logger } from '../logger';
import * as utils from "../utils";
import { sleep } from "../utils";
import { OlmDevice } from "./OlmDevice";
import * as olmlib from "./olmlib";
import { DeviceList } from "./DeviceList";
@@ -58,6 +57,7 @@ import { KeySignatureUploadError } from "../errors";
import { decryptAES, encryptAES } from './aes';
import { DehydrationManager } from './dehydration';
import { MatrixEvent } from "../models/event";
import { BackupManager } from "./backup";
const DeviceVerification = DeviceInfo.DeviceVerification;
@@ -85,7 +85,6 @@ export function isCryptoAvailable() {
}
const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000;
const KEY_BACKUP_KEYS_PER_REQUEST = 200;
/**
* Cryptography bits
@@ -154,13 +153,36 @@ export function Crypto(baseApis, sessionStore, userId, deviceId,
} else {
this._verificationMethods = defaultVerificationMethods;
}
// track whether this device's megolm keys are being backed up incrementally
// to the server or not.
// XXX: this should probably have a single source of truth from OlmAccount
this.backupInfo = null; // The info dict from /room_keys/version
this.backupKey = null; // The encryption key object
this._checkedForBackup = false; // Have we checked the server for a backup we can use?
this._sendingBackups = false; // Are we currently sending backups?
this._backupManager = new BackupManager(baseApis, async (algorithm) => {
// try to get key from cache
const cachedKey = await this.getSessionBackupPrivateKey();
if (cachedKey) {
return cachedKey;
}
// try to get key from secret storage
const storedKey = await this.getSecret("m.megolm_backup.v1");
if (storedKey) {
// ensure that the key is in the right format. If not, fix the key and
// store the fixed version
const fixedKey = fixBackupKey(storedKey);
if (fixedKey) {
const [keyId] = await this._crypto.getSecretStorageKey();
await this.storeSecret("m.megolm_backup.v1", fixedKey, [keyId]);
}
return olmlib.decodeBase64(fixedKey || storedKey);
}
// try to get key from app
if (this._baseApis._cryptoCallbacks && this._baseApis._cryptoCallbacks.getBackupKey) {
return await this._baseApis._cryptoCallbacks.getBackupKey(algorithm);
}
throw new Error("Unable to get private key");
});
this._olmDevice = new OlmDevice(cryptoStore);
this._deviceList = new DeviceList(
@@ -331,7 +353,7 @@ Crypto.prototype.init = async function(opts) {
this._deviceList.startTrackingDeviceList(this._userId);
logger.log("Crypto: checking for key backup...");
this._checkAndStartKeyBackup();
this._backupManager.checkAndStart();
};
/**
@@ -458,7 +480,7 @@ Crypto.prototype.isSecretStorageReady = async function() {
this._secretStorage,
);
const sessionBackupInStorage = (
!this._baseApis.getKeyBackupEnabled() ||
!this._backupManager.getKeyBackupEnabled() ||
this._baseApis.isKeyBackupKeyStored()
);
@@ -522,9 +544,11 @@ Crypto.prototype.bootstrapCrossSigning = async function({
builder.addKeySignature(this._userId, this._deviceId, deviceSignature);
// Sign message key backup with cross-signing master key
if (this.backupInfo) {
await crossSigningInfo.signObject(this.backupInfo.auth_data, "master");
builder.addSessionBackup(this.backupInfo);
if (this._backupManager.backupInfo) {
await crossSigningInfo.signObject(
this._backupManager.backupInfo.auth_data, "master",
);
builder.addSessionBackup(this._backupManager.backupInfo);
}
};
@@ -766,6 +790,7 @@ Crypto.prototype.bootstrapSecretStorage = async function({
keyBackupInfo.auth_data.private_key_salt &&
keyBackupInfo.auth_data.private_key_iterations
) {
// FIXME: ???
opts.passphrase = {
algorithm: "m.pbkdf2",
iterations: keyBackupInfo.auth_data.private_key_iterations,
@@ -1477,7 +1502,7 @@ Crypto.prototype.checkOwnCrossSigningTrust = async function({
}
// Now we may be able to trust our key backup
await this.checkKeyBackup();
await this._backupManager.checkKeyBackup();
// FIXME: if we previously trusted the backup, should we automatically sign
// the backup with the new key (if not already signed)?
};
@@ -1539,206 +1564,11 @@ Crypto.prototype._checkDeviceVerifications = async function(userId) {
logger.info(`Finished device verification upgrade for ${userId}`);
};
/**
* Check the server for an active key backup and
* if one is present and has a valid signature from
* one of the user's verified devices, start backing up
* to it.
*/
Crypto.prototype._checkAndStartKeyBackup = async function() {
logger.log("Checking key backup status...");
if (this._baseApis.isGuest()) {
logger.log("Skipping key backup check since user is guest");
this._checkedForBackup = true;
return null;
}
let backupInfo;
try {
backupInfo = await this._baseApis.getKeyBackupVersion();
} catch (e) {
logger.log("Error checking for active key backup", e);
if (e.httpStatus === 404) {
// 404 is returned when the key backup does not exist, so that
// counts as successfully checking.
this._checkedForBackup = true;
}
return null;
}
this._checkedForBackup = true;
const trustInfo = await this.isKeyBackupTrusted(backupInfo);
if (trustInfo.usable && !this.backupInfo) {
logger.log(
"Found usable key backup v" + backupInfo.version +
": enabling key backups",
);
this._baseApis.enableKeyBackup(backupInfo);
} else if (!trustInfo.usable && this.backupInfo) {
logger.log("No usable key backup: disabling key backup");
this._baseApis.disableKeyBackup();
} else if (!trustInfo.usable && !this.backupInfo) {
logger.log("No usable key backup: not enabling key backup");
} else if (trustInfo.usable && this.backupInfo) {
// may not be the same version: if not, we should switch
if (backupInfo.version !== this.backupInfo.version) {
logger.log(
"On backup version " + this.backupInfo.version + " but found " +
"version " + backupInfo.version + ": switching.",
);
this._baseApis.disableKeyBackup();
this._baseApis.enableKeyBackup(backupInfo);
// We're now using a new backup, so schedule all the keys we have to be
// uploaded to the new backup. This is a bit of a workaround to upload
// keys to a new backup in *most* cases, but it won't cover all cases
// because we don't remember what backup version we uploaded keys to:
// see https://github.com/vector-im/element-web/issues/14833
await this.scheduleAllGroupSessionsForBackup();
} else {
logger.log("Backup version " + backupInfo.version + " still current");
}
}
return { backupInfo, trustInfo };
};
Crypto.prototype.setTrustedBackupPubKey = async function(trustedPubKey) {
// This should be redundant post cross-signing is a thing, so just
// plonk it in localStorage for now.
this._sessionStore.setLocalTrustedBackupPubKey(trustedPubKey);
await this.checkKeyBackup();
};
/**
* Forces a re-check of the key backup and enables/disables it
* as appropriate.
*
* @return {Object} Object with backup info (as returned by
* getKeyBackupVersion) in backupInfo and
* trust information (as returned by isKeyBackupTrusted)
* in trustInfo.
*/
Crypto.prototype.checkKeyBackup = async function() {
this._checkedForBackup = false;
return this._checkAndStartKeyBackup();
};
/**
* @param {object} backupInfo key backup info dict from /room_keys/version
* @return {object} {
* usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device
* sigs: [
* valid: [bool || null], // true: valid, false: invalid, null: cannot attempt validation
* deviceId: [string],
* device: [DeviceInfo || null],
* ]
* }
*/
Crypto.prototype.isKeyBackupTrusted = async function(backupInfo) {
const ret = {
usable: false,
trusted_locally: false,
sigs: [],
};
if (
!backupInfo ||
!backupInfo.algorithm ||
!backupInfo.auth_data ||
!backupInfo.auth_data.public_key ||
!backupInfo.auth_data.signatures
) {
logger.info("Key backup is absent or missing required data");
return ret;
}
const trustedPubkey = this._sessionStore.getLocalTrustedBackupPubKey();
if (backupInfo.auth_data.public_key === trustedPubkey) {
logger.info("Backup public key " + trustedPubkey + " is trusted locally");
ret.trusted_locally = true;
}
const mySigs = backupInfo.auth_data.signatures[this._userId] || [];
for (const keyId of Object.keys(mySigs)) {
const keyIdParts = keyId.split(':');
if (keyIdParts[0] !== 'ed25519') {
logger.log("Ignoring unknown signature type: " + keyIdParts[0]);
continue;
}
// Could be a cross-signing master key, but just say this is the device
// ID for backwards compat
const sigInfo = { deviceId: keyIdParts[1] };
// first check to see if it's from our cross-signing key
const crossSigningId = this._crossSigningInfo.getId();
if (crossSigningId === sigInfo.deviceId) {
sigInfo.crossSigningId = true;
try {
await olmlib.verifySignature(
this._olmDevice,
backupInfo.auth_data,
this._userId,
sigInfo.deviceId,
crossSigningId,
);
sigInfo.valid = true;
} catch (e) {
logger.warning(
"Bad signature from cross signing key " + crossSigningId, e,
);
sigInfo.valid = false;
}
ret.sigs.push(sigInfo);
continue;
}
// Now look for a sig from a device
// At some point this can probably go away and we'll just support
// it being signed by the cross-signing master key
const device = this._deviceList.getStoredDevice(
this._userId, sigInfo.deviceId,
);
if (device) {
sigInfo.device = device;
sigInfo.deviceTrust = await this.checkDeviceTrust(
this._userId, sigInfo.deviceId,
);
try {
await olmlib.verifySignature(
this._olmDevice,
backupInfo.auth_data,
this._userId,
device.deviceId,
device.getFingerprint(),
);
sigInfo.valid = true;
} catch (e) {
logger.info(
"Bad signature from key ID " + keyId + " userID " + this._userId +
" device ID " + device.deviceId + " fingerprint: " +
device.getFingerprint(), backupInfo.auth_data, e,
);
sigInfo.valid = false;
}
} else {
sigInfo.valid = null; // Can't determine validity because we don't have the signing device
logger.info("Ignoring signature from unknown key " + keyId);
}
ret.sigs.push(sigInfo);
}
ret.usable = ret.sigs.some((s) => {
return (
s.valid && (
(s.device && s.deviceTrust.isVerified()) ||
(s.crossSigningId)
)
);
});
ret.usable |= ret.trusted_locally;
return ret;
await this._backupManager.checkKeyBackup();
};
/**
@@ -2785,191 +2615,12 @@ Crypto.prototype.importRoomKeys = function(keys, opts = {}) {
}));
};
/**
* Schedules sending all keys waiting to be sent to the backup, if not already
* scheduled. Retries if necessary.
*
* @param {number} maxDelay Maximum delay to wait in ms. 0 means no delay.
*/
Crypto.prototype.scheduleKeyBackupSend = async function(maxDelay = 10000) {
if (this._sendingBackups) return;
this._sendingBackups = true;
try {
// wait between 0 and `maxDelay` seconds, to avoid backup
// requests from different clients hitting the server all at
// the same time when a new key is sent
const delay = Math.random() * maxDelay;
await sleep(delay);
let numFailures = 0; // number of consecutive failures
while (1) {
if (!this.backupKey) {
return;
}
try {
const numBackedUp =
await this._backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST);
if (numBackedUp === 0) {
// no sessions left needing backup: we're done
return;
}
numFailures = 0;
} catch (err) {
numFailures++;
logger.log("Key backup request failed", err);
if (err.data) {
if (
err.data.errcode == 'M_NOT_FOUND' ||
err.data.errcode == 'M_WRONG_ROOM_KEYS_VERSION'
) {
// Re-check key backup status on error, so we can be
// sure to present the current situation when asked.
await this.checkKeyBackup();
// Backup version has changed or this backup version
// has been deleted
this.emit("crypto.keyBackupFailed", err.data.errcode);
throw err;
}
}
}
if (numFailures) {
// exponential backoff if we have failures
await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)));
}
}
} finally {
this._sendingBackups = false;
}
};
/**
* Take some e2e keys waiting to be backed up and send them
* to the backup.
*
* @param {integer} limit Maximum number of keys to back up
* @returns {integer} Number of sessions backed up
*/
Crypto.prototype._backupPendingKeys = async function(limit) {
const sessions = await this._cryptoStore.getSessionsNeedingBackup(limit);
if (!sessions.length) {
return 0;
}
let remaining = await this._cryptoStore.countSessionsNeedingBackup();
this.emit("crypto.keyBackupSessionsRemaining", remaining);
const data = {};
for (const session of sessions) {
const roomId = session.sessionData.room_id;
if (data[roomId] === undefined) {
data[roomId] = { sessions: {} };
}
const sessionData = await this._olmDevice.exportInboundGroupSession(
session.senderKey, session.sessionId, session.sessionData,
);
sessionData.algorithm = olmlib.MEGOLM_ALGORITHM;
delete sessionData.session_id;
delete sessionData.room_id;
const firstKnownIndex = sessionData.first_known_index;
delete sessionData.first_known_index;
const encrypted = this.backupKey.encrypt(JSON.stringify(sessionData));
const forwardedCount =
(sessionData.forwarding_curve25519_key_chain || []).length;
const userId = this._deviceList.getUserByIdentityKey(
olmlib.MEGOLM_ALGORITHM, session.senderKey,
);
const device = this._deviceList.getDeviceByIdentityKey(
olmlib.MEGOLM_ALGORITHM, session.senderKey,
);
const verified = this._checkDeviceInfoTrust(userId, device).isVerified();
data[roomId]['sessions'][session.sessionId] = {
first_message_index: firstKnownIndex,
forwarded_count: forwardedCount,
is_verified: verified,
session_data: encrypted,
};
}
await this._baseApis.sendKeyBackup(
undefined, undefined, this.backupInfo.version,
{ rooms: data },
);
await this._cryptoStore.unmarkSessionsNeedingBackup(sessions);
remaining = await this._cryptoStore.countSessionsNeedingBackup();
this.emit("crypto.keyBackupSessionsRemaining", remaining);
return sessions.length;
};
Crypto.prototype.backupGroupSession = async function(
roomId, senderKey, forwardingCurve25519KeyChain,
sessionId, sessionKey, keysClaimed,
exportFormat,
) {
await this._cryptoStore.markSessionsNeedingBackup([{
senderKey: senderKey,
sessionId: sessionId,
}]);
if (this.backupInfo) {
// don't wait for this to complete: it will delay so
// happens in the background
this.scheduleKeyBackupSend();
}
// if this.backupInfo is not set, then the keys will be backed up when
// client.enableKeyBackup is called
};
/**
* Marks all group sessions as needing to be backed up and schedules them to
* upload in the background as soon as possible.
*/
Crypto.prototype.scheduleAllGroupSessionsForBackup = async function() {
await this.flagAllGroupSessionsForBackup();
// Schedule keys to upload in the background as soon as possible.
this.scheduleKeyBackupSend(0 /* maxDelay */);
};
/**
* Marks all group sessions as needing to be backed up without scheduling
* them to upload in the background.
* @returns {Promise<int>} Resolves to the number of sessions now requiring a backup
* (which will be equal to the number of sessions in the store).
*/
Crypto.prototype.flagAllGroupSessionsForBackup = async function() {
await this._cryptoStore.doTxn(
'readwrite',
[
IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS,
IndexedDBCryptoStore.STORE_BACKUP,
],
(txn) => {
this._cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => {
if (session !== null) {
this._cryptoStore.markSessionsNeedingBackup([session], txn);
}
});
},
);
const remaining = await this._cryptoStore.countSessionsNeedingBackup();
this.emit("crypto.keyBackupSessionsRemaining", remaining);
return remaining;
};
/**
* Counts the number of end to end session keys that are waiting to be backed up
* @returns {Promise<int>} Resolves to the number of sessions requiring backup
*/
Crypto.prototype.countSessionsNeedingBackup = function() {
return this._cryptoStore.countSessionsNeedingBackup();
return this._backupManager.countSessionsNeedingBackup();
};
/**
@@ -3345,10 +2996,10 @@ Crypto.prototype._onRoomKeyEvent = function(event) {
return;
}
if (!this._checkedForBackup) {
if (!this._backupManager.checkedForBackup) {
// don't bother awaiting on this - the important thing is that we retry if we
// haven't managed to check before
this._checkAndStartKeyBackup();
this._backupManager.checkAndStart();
}
const alg = this._getRoomDecryptor(content.room_id, content.algorithm);

View File

@@ -349,6 +349,11 @@ RoomState.prototype.setStateEvents = function(stateEvents) {
self._updateMember(member);
self.emit("RoomState.members", event, self, member);
} else if (event.getType() === "m.room.power_levels") {
// events with unknown state keys should be ignored
// and should not aggregate onto members power levels
if (event.getStateKey() !== "") {
return;
}
const members = Object.values(self.members);
members.forEach(function(member) {
// We only propagate `RoomState.members` event if the