You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-11-25 05:23:13 +03:00
881 lines
33 KiB
TypeScript
881 lines
33 KiB
TypeScript
/*
|
|
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.
|
|
*/
|
|
|
|
/**
|
|
* Classes for dealing with key backup.
|
|
*/
|
|
|
|
import type { IMegolmSessionData } from "../@types/crypto";
|
|
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 { encodeUri, safeSet, sleep } from "../utils";
|
|
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
|
|
import { encodeRecoveryKey } from "./recoverykey";
|
|
import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes";
|
|
import {
|
|
Curve25519SessionData,
|
|
IAes256AuthData,
|
|
ICurve25519AuthData,
|
|
IKeyBackupInfo,
|
|
IKeyBackupSession,
|
|
} from "./keybackup";
|
|
import { UnstableValue } from "../NamespacedValue";
|
|
import { CryptoEvent } from "./index";
|
|
import { crypto } from "./crypto";
|
|
import { ClientPrefix, HTTPError, MatrixError, Method } from "../http-api";
|
|
import { BackupTrustInfo } from "../crypto-api/keybackup";
|
|
|
|
const KEY_BACKUP_KEYS_PER_REQUEST = 200;
|
|
const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms
|
|
|
|
type AuthData = IKeyBackupInfo["auth_data"];
|
|
|
|
type SigInfo = {
|
|
deviceId: string;
|
|
valid?: boolean | null; // true: valid, false: invalid, null: cannot attempt validation
|
|
device?: DeviceInfo | null;
|
|
crossSigningId?: boolean;
|
|
deviceTrust?: DeviceTrustLevel;
|
|
};
|
|
|
|
/** @deprecated Prefer {@link BackupTrustInfo} */
|
|
export type TrustInfo = {
|
|
usable: boolean; // is the backup trusted, true iff there is a sig that is valid & from a trusted device
|
|
sigs: SigInfo[];
|
|
// eslint-disable-next-line camelcase
|
|
trusted_locally?: boolean;
|
|
};
|
|
|
|
export interface IKeyBackupCheck {
|
|
backupInfo?: IKeyBackupInfo;
|
|
trustInfo: TrustInfo;
|
|
}
|
|
|
|
/* eslint-disable camelcase */
|
|
export interface IPreparedKeyBackupVersion {
|
|
algorithm: string;
|
|
auth_data: AuthData;
|
|
recovery_key: string;
|
|
privateKey: Uint8Array;
|
|
}
|
|
/* eslint-enable camelcase */
|
|
|
|
/** A function used to get the secret key for a backup.
|
|
*/
|
|
type GetKey = () => Promise<ArrayLike<number>>;
|
|
|
|
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]>;
|
|
|
|
checkBackupVersion(info: IKeyBackupInfo): void;
|
|
}
|
|
|
|
interface BackupAlgorithm {
|
|
untrusted: boolean;
|
|
encryptSession(data: Record<string, any>): Promise<Curve25519SessionData | IEncryptedPayload>;
|
|
decryptSessions(ciphertexts: Record<string, IKeyBackupSession>): Promise<IMegolmSessionData[]>;
|
|
authData: AuthData;
|
|
keyMatches(key: ArrayLike<number>): Promise<boolean>;
|
|
free(): void;
|
|
}
|
|
|
|
export interface IKeyBackup {
|
|
rooms: {
|
|
[roomId: string]: {
|
|
sessions: {
|
|
[sessionId: string]: IKeyBackupSession;
|
|
};
|
|
};
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Manages the key backup.
|
|
*/
|
|
export class BackupManager {
|
|
private algorithm: BackupAlgorithm | undefined;
|
|
public backupInfo: IKeyBackupInfo | 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?
|
|
private sessionLastCheckAttemptedTime: Record<string, number> = {}; // When did we last try to check the server for a given session id?
|
|
// The backup manager will schedule backup of keys when active (`scheduleKeyBackupSend`), this allows cancel when client is stopped
|
|
private clientRunning = true;
|
|
|
|
public constructor(private readonly baseApis: MatrixClient, public readonly getKey: GetKey) {
|
|
this.checkedForBackup = false;
|
|
this.sendingBackups = false;
|
|
}
|
|
|
|
/**
|
|
* Stop the backup manager from backing up keys and allow a clean shutdown.
|
|
*/
|
|
public stop(): void {
|
|
this.clientRunning = false;
|
|
}
|
|
|
|
public get version(): string | undefined {
|
|
return this.backupInfo && this.backupInfo.version;
|
|
}
|
|
|
|
/**
|
|
* Performs a quick check to ensure that the backup info looks sane.
|
|
*
|
|
* Throws an error if a problem is detected.
|
|
*
|
|
* @param info - the key backup info
|
|
*/
|
|
public static checkBackupVersion(info: IKeyBackupInfo): void {
|
|
const Algorithm = algorithmsByName[info.algorithm];
|
|
if (!Algorithm) {
|
|
throw new Error("Unknown backup algorithm: " + info.algorithm);
|
|
}
|
|
if (typeof info.auth_data !== "object") {
|
|
throw new Error("Invalid backup data returned");
|
|
}
|
|
return Algorithm.checkBackupVersion(info);
|
|
}
|
|
|
|
public static makeAlgorithm(info: IKeyBackupInfo, getKey: GetKey): Promise<BackupAlgorithm> {
|
|
const Algorithm = algorithmsByName[info.algorithm];
|
|
if (!Algorithm) {
|
|
throw new Error("Unknown backup algorithm");
|
|
}
|
|
return Algorithm.init(info.auth_data, getKey);
|
|
}
|
|
|
|
public async enableKeyBackup(info: IKeyBackupInfo): Promise<void> {
|
|
this.backupInfo = info;
|
|
if (this.algorithm) {
|
|
this.algorithm.free();
|
|
}
|
|
|
|
this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey);
|
|
|
|
this.baseApis.emit(CryptoEvent.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(CryptoEvent.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<IPreparedKeyBackupVersion> {
|
|
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: IKeyBackupInfo): Promise<void> {
|
|
this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey);
|
|
}
|
|
|
|
/**
|
|
* Deletes all key backups.
|
|
*
|
|
* Will call the API to delete active backup until there is no more present.
|
|
*/
|
|
public async deleteAllKeyBackupVersions(): Promise<void> {
|
|
// there could be several backup versions, delete all to be safe.
|
|
let current = (await this.baseApis.getKeyBackupVersion())?.version ?? null;
|
|
while (current != null) {
|
|
await this.deleteKeyBackupVersion(current);
|
|
this.disableKeyBackup();
|
|
current = (await this.baseApis.getKeyBackupVersion())?.version ?? null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deletes the given key backup.
|
|
*
|
|
* @param version - The backup version to delete.
|
|
*/
|
|
public async deleteKeyBackupVersion(version: string): Promise<void> {
|
|
const path = encodeUri("/room_keys/version/$version", { $version: version });
|
|
await this.baseApis.http.authedRequest<void>(Method.Delete, path, undefined, undefined, {
|
|
prefix: ClientPrefix.V3,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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<IKeyBackupCheck | null> {
|
|
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: IKeyBackupInfo | undefined;
|
|
try {
|
|
backupInfo = (await this.baseApis.getKeyBackupVersion()) ?? undefined;
|
|
} catch (e) {
|
|
logger.log("Error checking for active key backup", e);
|
|
if ((<HTTPError>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.
|
|
*
|
|
* @returns Object with backup info (as returned by
|
|
* getKeyBackupVersion) in backupInfo and
|
|
* trust information (as returned by isKeyBackupTrusted)
|
|
* in trustInfo.
|
|
*/
|
|
public async checkKeyBackup(): Promise<IKeyBackupCheck | null> {
|
|
this.checkedForBackup = false;
|
|
return this.checkAndStart();
|
|
}
|
|
|
|
/**
|
|
* Attempts to retrieve a session from a key backup, if enough time
|
|
* has elapsed since the last check for this session id.
|
|
*/
|
|
public async queryKeyBackupRateLimited(
|
|
targetRoomId: string | undefined,
|
|
targetSessionId: string | undefined,
|
|
): Promise<void> {
|
|
if (!this.backupInfo) {
|
|
return;
|
|
}
|
|
|
|
const now = new Date().getTime();
|
|
if (
|
|
!this.sessionLastCheckAttemptedTime[targetSessionId!] ||
|
|
now - this.sessionLastCheckAttemptedTime[targetSessionId!] > KEY_BACKUP_CHECK_RATE_LIMIT
|
|
) {
|
|
this.sessionLastCheckAttemptedTime[targetSessionId!] = now;
|
|
await this.baseApis.restoreKeyBackupWithCache(targetRoomId!, targetSessionId!, this.backupInfo, {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the given backup info is trusted.
|
|
*
|
|
* @param backupInfo - key backup info dict from /room_keys/version
|
|
*/
|
|
public async isKeyBackupTrusted(backupInfo?: IKeyBackupInfo): Promise<TrustInfo> {
|
|
const ret = {
|
|
usable: false,
|
|
trusted_locally: false,
|
|
sigs: [] as SigInfo[],
|
|
};
|
|
|
|
if (!backupInfo || !backupInfo.algorithm || !backupInfo.auth_data || !backupInfo.auth_data.signatures) {
|
|
logger.info(`Key backup is absent or missing required data: ${JSON.stringify(backupInfo)}`);
|
|
return ret;
|
|
}
|
|
|
|
const userId = this.baseApis.getUserId()!;
|
|
const privKey = await this.baseApis.crypto!.getSessionBackupPrivateKey();
|
|
if (privKey) {
|
|
let algorithm: BackupAlgorithm | null = null;
|
|
try {
|
|
algorithm = await BackupManager.makeAlgorithm(backupInfo, async () => privKey);
|
|
|
|
if (await algorithm.keyMatches(privKey)) {
|
|
logger.info("Backup is trusted locally");
|
|
ret.trusted_locally = true;
|
|
}
|
|
} catch {
|
|
// do nothing -- if we have an error, then we don't mark it as
|
|
// locally trusted
|
|
} finally {
|
|
algorithm?.free();
|
|
}
|
|
}
|
|
|
|
const mySigs = backupInfo.auth_data.signatures[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: 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,
|
|
userId,
|
|
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(userId, sigInfo.deviceId);
|
|
if (device) {
|
|
sigInfo.device = device;
|
|
sigInfo.deviceTrust = this.baseApis.checkDeviceTrust(userId, sigInfo.deviceId);
|
|
try {
|
|
await verifySignature(
|
|
this.baseApis.crypto!.olmDevice,
|
|
backupInfo.auth_data,
|
|
userId,
|
|
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);
|
|
});
|
|
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> {
|
|
logger.debug(`Key backup: scheduleKeyBackupSend currentSending:${this.sendingBackups} delay:${maxDelay}`);
|
|
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);
|
|
if (!this.clientRunning) {
|
|
logger.debug("Key backup send aborted, client stopped");
|
|
this.sendingBackups = false;
|
|
return;
|
|
}
|
|
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
|
|
this.sendingBackups = false;
|
|
return;
|
|
}
|
|
numFailures = 0;
|
|
} catch (err) {
|
|
numFailures++;
|
|
logger.log("Key backup request failed", err);
|
|
if (err instanceof MatrixError) {
|
|
const errCode = err.data.errcode;
|
|
if (errCode == "M_NOT_FOUND" || errCode == "M_WRONG_ROOM_KEYS_VERSION") {
|
|
// Set to false now as `checkKeyBackup` might schedule a backupsend before this one ends.
|
|
this.sendingBackups = false;
|
|
// Backup version has changed or this backup version
|
|
// has been deleted
|
|
this.baseApis.crypto!.emit(CryptoEvent.KeyBackupFailed, errCode);
|
|
// Re-check key backup status on error, so we can be
|
|
// sure to present the current situation when asked.
|
|
// This call might restart the backup loop if new backup version is trusted
|
|
await this.checkKeyBackup();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
if (numFailures) {
|
|
// exponential backoff if we have failures
|
|
await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)));
|
|
}
|
|
|
|
if (!this.clientRunning) {
|
|
logger.debug("Key backup send loop aborted, client stopped");
|
|
this.sendingBackups = false;
|
|
return;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// No one actually checks errors on this promise, it's spawned internally.
|
|
// Just log, apps/client should use events to check status
|
|
logger.log(`Backup loop failed ${err}`);
|
|
this.sendingBackups = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Take some e2e keys waiting to be backed up and send them
|
|
* to the backup.
|
|
*
|
|
* @param limit - Maximum number of keys to back up
|
|
* @returns Number of sessions backed up
|
|
*/
|
|
public 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(CryptoEvent.KeyBackupSessionsRemaining, remaining);
|
|
|
|
const rooms: IKeyBackup["rooms"] = {};
|
|
for (const session of sessions) {
|
|
const roomId = session.sessionData!.room_id;
|
|
safeSet(rooms, roomId, rooms[roomId] || { sessions: {} });
|
|
|
|
const sessionData = 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) ??
|
|
undefined;
|
|
const verified = this.baseApis.crypto!.checkDeviceInfoTrust(userId!, device).isVerified();
|
|
|
|
safeSet(rooms[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 });
|
|
|
|
await this.baseApis.crypto!.cryptoStore.unmarkSessionsNeedingBackup(sessions);
|
|
remaining = await this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup();
|
|
this.baseApis.crypto!.emit(CryptoEvent.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 which 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(CryptoEvent.KeyBackupSessionsRemaining, remaining);
|
|
return remaining;
|
|
}
|
|
|
|
/**
|
|
* Counts the number of end to end session keys that are waiting to be backed up
|
|
* @returns Promise which 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";
|
|
|
|
public constructor(
|
|
public authData: ICurve25519AuthData,
|
|
private publicKey: any, // FIXME: PkEncryption
|
|
private getKey: () => Promise<Uint8Array>,
|
|
) {}
|
|
|
|
public static async init(authData: AuthData, getKey: () => Promise<Uint8Array>): Promise<Curve25519> {
|
|
if (!authData || !("public_key" in authData)) {
|
|
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 as ICurve25519AuthData, publicKey, getKey);
|
|
}
|
|
|
|
public static async prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]> {
|
|
const decryption = new global.Olm.PkDecryption();
|
|
try {
|
|
const authData: Partial<ICurve25519AuthData> = {};
|
|
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 as AuthData];
|
|
} finally {
|
|
decryption.free();
|
|
}
|
|
}
|
|
|
|
public static checkBackupVersion(info: IKeyBackupInfo): void {
|
|
if (!("public_key" in info.auth_data)) {
|
|
throw new Error("Invalid backup data returned");
|
|
}
|
|
}
|
|
|
|
public get untrusted(): boolean {
|
|
return true;
|
|
}
|
|
|
|
public async encryptSession(data: Record<string, any>): Promise<Curve25519SessionData> {
|
|
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, IKeyBackupSession<Curve25519SessionData>>,
|
|
): Promise<IMegolmSessionData[]> {
|
|
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) {
|
|
throw new MatrixError({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY });
|
|
}
|
|
|
|
const keys: IMegolmSessionData[] = [];
|
|
|
|
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: string;
|
|
try {
|
|
pubKey = decryption.init_with_private_key(key);
|
|
} finally {
|
|
decryption.free();
|
|
}
|
|
|
|
return pubKey === this.authData.public_key;
|
|
}
|
|
|
|
public free(): void {
|
|
this.publicKey.free();
|
|
}
|
|
}
|
|
|
|
function randomBytes(size: number): Uint8Array {
|
|
const buf = new Uint8Array(size);
|
|
crypto.getRandomValues(buf);
|
|
return buf;
|
|
}
|
|
|
|
const UNSTABLE_MSC3270_NAME = new UnstableValue(
|
|
"m.megolm_backup.v1.aes-hmac-sha2",
|
|
"org.matrix.msc3270.v1.aes-hmac-sha2",
|
|
);
|
|
|
|
export class Aes256 implements BackupAlgorithm {
|
|
public static algorithmName = UNSTABLE_MSC3270_NAME.name;
|
|
|
|
public constructor(public readonly authData: IAes256AuthData, private readonly key: Uint8Array) {}
|
|
|
|
public static async init(authData: IAes256AuthData, getKey: () => Promise<Uint8Array>): Promise<Aes256> {
|
|
if (!authData) {
|
|
throw new Error("auth_data missing");
|
|
}
|
|
const key = await getKey();
|
|
if (authData.mac) {
|
|
const { mac } = await calculateKeyCheck(key, authData.iv);
|
|
if (authData.mac.replace(/=+$/g, "") !== mac.replace(/=+/g, "")) {
|
|
throw new Error("Key does not match");
|
|
}
|
|
}
|
|
return new Aes256(authData, key);
|
|
}
|
|
|
|
public static async prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]> {
|
|
let outKey: Uint8Array;
|
|
const authData: Partial<IAes256AuthData> = {};
|
|
if (!key) {
|
|
outKey = randomBytes(32);
|
|
} else if (key instanceof Uint8Array) {
|
|
outKey = new Uint8Array(key);
|
|
} else {
|
|
const derivation = await keyFromPassphrase(key);
|
|
authData.private_key_salt = derivation.salt;
|
|
authData.private_key_iterations = derivation.iterations;
|
|
outKey = derivation.key;
|
|
}
|
|
|
|
const { iv, mac } = await calculateKeyCheck(outKey);
|
|
authData.iv = iv;
|
|
authData.mac = mac;
|
|
|
|
return [outKey, authData as AuthData];
|
|
}
|
|
|
|
public static checkBackupVersion(info: IKeyBackupInfo): void {
|
|
if (!("iv" in info.auth_data && "mac" in info.auth_data)) {
|
|
throw new Error("Invalid backup data returned");
|
|
}
|
|
}
|
|
|
|
public get untrusted(): boolean {
|
|
return false;
|
|
}
|
|
|
|
public encryptSession(data: Record<string, any>): Promise<IEncryptedPayload> {
|
|
const plainText: Record<string, any> = Object.assign({}, data);
|
|
delete plainText.session_id;
|
|
delete plainText.room_id;
|
|
delete plainText.first_known_index;
|
|
return encryptAES(JSON.stringify(plainText), this.key, data.session_id);
|
|
}
|
|
|
|
public async decryptSessions(
|
|
sessions: Record<string, IKeyBackupSession<IEncryptedPayload>>,
|
|
): Promise<IMegolmSessionData[]> {
|
|
const keys: IMegolmSessionData[] = [];
|
|
|
|
for (const [sessionId, sessionData] of Object.entries(sessions)) {
|
|
try {
|
|
const decrypted = JSON.parse(await decryptAES(sessionData.session_data, this.key, sessionId));
|
|
decrypted.session_id = sessionId;
|
|
keys.push(decrypted);
|
|
} catch (e) {
|
|
logger.log("Failed to decrypt megolm session from backup", e, sessionData);
|
|
}
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
public async keyMatches(key: Uint8Array): Promise<boolean> {
|
|
if (this.authData.mac) {
|
|
const { mac } = await calculateKeyCheck(key, this.authData.iv);
|
|
return this.authData.mac.replace(/=+$/g, "") === mac.replace(/=+/g, "");
|
|
} else {
|
|
// if we have no information, we have to assume the key is right
|
|
return true;
|
|
}
|
|
}
|
|
|
|
public free(): void {
|
|
this.key.fill(0);
|
|
}
|
|
}
|
|
|
|
export const algorithmsByName: Record<string, BackupAlgorithmClass> = {
|
|
[Curve25519.algorithmName]: Curve25519,
|
|
[Aes256.algorithmName]: Aes256,
|
|
};
|
|
|
|
export const DefaultAlgorithm: BackupAlgorithmClass = Curve25519;
|
|
|
|
/**
|
|
* Map a legacy {@link TrustInfo} into a new-style {@link BackupTrustInfo}.
|
|
*
|
|
* @param trustInfo - trustInfo to convert
|
|
*/
|
|
export function backupTrustInfoFromLegacyTrustInfo(trustInfo: TrustInfo): BackupTrustInfo {
|
|
return {
|
|
trusted: trustInfo.usable,
|
|
matchesDecryptionKey: trustInfo.trusted_locally ?? false,
|
|
};
|
|
}
|