feat: complete Ancestor quote bot with production-ready Docker support

This commit is contained in:
unfunny
2026-09-13 14:14:38 -04:00
parent f2016da05a
commit fe7351a7dc
1145 changed files with 30890 additions and 97684 deletions

View File

@@ -14,67 +14,66 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Logger } from "../../logger.ts";
import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts";
import { type AESEncryptedSecretStoragePayload } from "../../@types/AESEncryptedSecretStoragePayload.ts";
import { type ISignatures } from "../../@types/signed.ts";
import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index";
import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager";
import { ICrossSigningKey } from "../../client";
import { IOlmDevice } from "../algorithms/megolm";
import { TrackingStatus } from "../DeviceList";
import { IRoomEncryption } from "../RoomList";
import { IDevice } from "../deviceinfo";
import { ICrossSigningInfo } from "../CrossSigning";
import { PrefixedLogger } from "../../logger";
import { InboundGroupSessionData } from "../OlmDevice";
import { MatrixEvent } from "../../models/event";
import { DehydrationManager } from "../dehydration";
import { IEncryptedPayload } from "../aes";
/**
* Internal module. Definitions for storage for the crypto module
*/
export interface SecretStorePrivateKeys {
"m.megolm_backup.v1": AESEncryptedSecretStoragePayload;
"dehydration": {
keyInfo: DehydrationManager["keyInfo"];
key: IEncryptedPayload;
deviceDisplayName: string;
time: number;
} | null;
"m.megolm_backup.v1": IEncryptedPayload;
}
/**
* Abstraction of things that can store data required for end-to-end encryption
*/
export interface CryptoStore {
/**
* Returns true if this CryptoStore has ever been initialised (ie, it might contain data).
*
* Unlike the rest of the methods in this interface, can be called before {@link CryptoStore#startup}.
*
* @internal
*/
containsData(): Promise<boolean>;
/**
* Initialise this crypto store.
*
* Typically, this involves provisioning storage, and migrating any existing data to the current version of the
* storage schema where appropriate.
*
* Must be called before any of the rest of the methods in this interface.
*/
startup(): Promise<CryptoStore>;
deleteAllData(): Promise<void>;
/**
* Get data on how much of the libolm to Rust Crypto migration has been done.
*
* @internal
*/
getMigrationState(): Promise<MigrationState>;
/**
* Set data on how much of the libolm to Rust Crypto migration has been done.
*
* @internal
*/
setMigrationState(migrationState: MigrationState): Promise<void>;
getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest>;
getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null>;
getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null>;
getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]>;
getOutgoingRoomKeyRequestsByTarget(
userId: string,
deviceId: string,
wantedStates: number[],
): Promise<OutgoingRoomKeyRequest[]>;
updateOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
updates: Partial<OutgoingRoomKeyRequest>,
): Promise<OutgoingRoomKeyRequest | null>;
deleteOutgoingRoomKeyRequest(requestId: string, expectedState: number): Promise<OutgoingRoomKeyRequest | null>;
// Olm Account
getAccount(txn: unknown, func: (accountPickle: string | null) => void): void;
storeAccount(txn: unknown, accountPickle: string): void;
getCrossSigningKeys(txn: unknown, func: (keys: Record<string, CrossSigningKeyInfo> | null) => void): void;
getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void;
getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
txn: unknown,
func: (key: SecretStorePrivateKeys[K] | null) => void,
type: K,
): void;
storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void;
storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
txn: unknown,
type: K,
@@ -94,25 +93,11 @@ export interface CryptoStore {
txn: unknown,
func: (sessions: { [sessionId: string]: ISessionInfo }) => void,
): void;
getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo | null) => void): void;
storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void;
/**
* Get a batch of end-to-end sessions from the database.
*
* @returns A batch of Olm Sessions, or `null` if no sessions are left.
* @internal
*/
getEndToEndSessionsBatch(): Promise<ISessionInfo[] | null>;
/**
* Delete a batch of end-to-end sessions from the database.
*
* Any sessions in the list which are not found are silently ignored.
*
* @internal
*/
deleteEndToEndSessionsBatch(sessions: { deviceKey?: string; sessionId?: string }[]): Promise<void>;
storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void>;
getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null>;
filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]>;
// Inbound Group Sessions
getEndToEndInboundGroupSession(
@@ -121,60 +106,55 @@ export interface CryptoStore {
txn: unknown,
func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void,
): void;
getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void;
addEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: unknown,
): void;
storeEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: unknown,
): void;
/**
* Count the number of Megolm sessions in the database.
*
* @internal
*/
countEndToEndInboundGroupSessions(): Promise<number>;
/**
* Get a batch of Megolm sessions from the database.
*
* @returns A batch of Megolm Sessions, or `null` if no sessions are left.
* @internal
*/
getEndToEndInboundGroupSessionsBatch(): Promise<SessionExtended[] | null>;
/**
* Delete a batch of Megolm sessions from the database.
*
* Any sessions in the list which are not found are silently ignored.
*
* @internal
*/
deleteEndToEndInboundGroupSessionsBatch(sessions: { senderKey: string; sessionId: string }[]): Promise<void>;
storeEndToEndInboundGroupSessionWithheld(
senderCurve25519Key: string,
sessionId: string,
sessionData: IWithheld,
txn: unknown,
): void;
// Device Data
getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void;
storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void;
storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void;
getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void;
getSessionsNeedingBackup(limit: number): Promise<ISession[]>;
countSessionsNeedingBackup(txn?: unknown): Promise<number>;
unmarkSessionsNeedingBackup(sessions: ISession[], txn?: unknown): Promise<void>;
markSessionsNeedingBackup(sessions: ISession[], txn?: unknown): Promise<void>;
addSharedHistoryInboundGroupSession(roomId: string, senderKey: string, sessionId: string, txn?: unknown): void;
getSharedHistoryInboundGroupSessions(
roomId: string,
txn?: unknown,
): Promise<[senderKey: string, sessionId: string][]>;
addParkedSharedHistory(roomId: string, data: ParkedSharedHistory, txn?: unknown): void;
takeParkedSharedHistory(roomId: string, txn?: unknown): Promise<ParkedSharedHistory[]>;
// Session key backups
doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T, log?: Logger): Promise<T>;
doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T, log?: PrefixedLogger): Promise<T>;
}
export type Mode = "readonly" | "readwrite";
/** Data on a Megolm session */
export interface ISession {
senderKey: string;
sessionId: string;
sessionData?: InboundGroupSessionData;
}
/** Extended data on a Megolm session */
export interface SessionExtended extends ISession {
needsBackup: boolean;
}
/** Data on an Olm session */
export interface ISessionInfo {
deviceKey?: string;
sessionId?: string;
@@ -195,7 +175,14 @@ export interface IDeviceData {
syncToken?: string;
}
export interface IProblem {
type: string;
fixed: boolean;
time: number;
}
export interface IWithheld {
// eslint-disable-next-line camelcase
room_id: string;
code: string;
reason: string;
@@ -229,153 +216,11 @@ export interface OutgoingRoomKeyRequest {
state: RoomKeyRequestState;
}
/**
* Keys for the `account` object store to store the migration state.
* Values are defined in `MigrationState`.
* @internal
*/
export const ACCOUNT_OBJECT_KEY_MIGRATION_STATE = "migrationState";
/**
* A record of which steps have been completed in the libolm to Rust Crypto migration.
*
* Used by {@link CryptoStore#getMigrationState} and {@link CryptoStore#setMigrationState}.
*
* @internal
*/
export enum MigrationState {
/** No migration steps have yet been completed. */
NOT_STARTED,
/** We have migrated the account data, cross-signing keys, etc. */
INITIAL_DATA_MIGRATED,
/** INITIAL_DATA_MIGRATED, and in addition, we have migrated all the Olm sessions. */
OLM_SESSIONS_MIGRATED,
/** OLM_SESSIONS_MIGRATED, and in addition, we have migrated all the Megolm sessions. */
MEGOLM_SESSIONS_MIGRATED,
/** MEGOLM_SESSIONS_MIGRATED, and in addition, we have migrated all the room settings. */
ROOM_SETTINGS_MIGRATED,
/** ROOM_SETTINGS_MIGRATED, and in addition, we have done the first own keys query in order to
* load the public part of the keys that have been migrated */
INITIAL_OWN_KEY_QUERY_DONE,
}
/**
* The size of batches to be returned by {@link CryptoStore#getEndToEndSessionsBatch} and
* {@link CryptoStore#getEndToEndInboundGroupSessionsBatch}.
*/
export const SESSION_BATCH_SIZE = 50;
export interface InboundGroupSessionData {
room_id: string;
/** pickled Olm.InboundGroupSession */
session: string;
keysClaimed?: Record<string, string>;
/** Devices involved in forwarding this session to us (normally empty). */
export interface ParkedSharedHistory {
senderId: string;
senderKey: string;
sessionId: string;
sessionKey: string;
keysClaimed: ReturnType<MatrixEvent["getKeysClaimed"]>; // XXX: Less type dependence on MatrixEvent
forwardingCurve25519KeyChain: string[];
/** whether this session is untrusted. */
untrusted?: boolean;
/** whether this session exists during the room being set to shared history. */
sharedHistory?: boolean;
}
export interface ICrossSigningInfo {
keys: Record<string, CrossSigningKeyInfo>;
firstUse: boolean;
crossSigningVerifiedBefore: boolean;
}
export interface IRoomEncryption {
algorithm: string;
rotation_period_ms?: number;
rotation_period_msgs?: number;
}
export enum TrackingStatus {
NotTracked,
PendingDownload,
DownloadInProgress,
UpToDate,
}
/**
* possible states for a room key request
*
* The state machine looks like:
* ```
*
* | (cancellation sent)
* | .-------------------------------------------------.
* | | |
* V V (cancellation requested) |
* UNSENT -----------------------------+ |
* | | |
* | | |
* | (send successful) | CANCELLATION_PENDING_AND_WILL_RESEND
* V | Λ
* SENT | |
* |-------------------------------- | --------------'
* | | (cancellation requested with intent
* | | to resend the original request)
* | |
* | (cancellation requested) |
* V |
* CANCELLATION_PENDING |
* | |
* | (cancellation sent) |
* V |
* (deleted) <---------------------------+
* ```
*/
export enum RoomKeyRequestState {
/** request not yet sent */
Unsent,
/** request sent, awaiting reply */
Sent,
/** reply received, cancellation not yet sent */
CancellationPending,
/**
* Cancellation not yet sent and will transition to UNSENT instead of
* being deleted once the cancellation has been sent.
*/
CancellationPendingAndWillResend,
}
interface IRoomKey {
room_id: string;
algorithm: string;
}
/**
* The parameters of a room key request. The details of the request may
* vary with the crypto algorithm, but the management and storage layers for
* outgoing requests expect it to have 'room_id' and 'session_id' properties.
*/
export interface IRoomKeyRequestBody extends IRoomKey {
session_id: string;
sender_key: string;
}
export interface IRoomKeyRequestRecipient {
userId: string;
deviceId: string;
}
interface IDevice {
keys: Record<string, string>;
algorithms: string[];
verified: DeviceVerification;
known: boolean;
unsigned?: Record<string, any>;
signatures?: ISignatures;
}
/** State of the verification of the device. */
export enum DeviceVerification {
Blocked = -1,
Unverified = 0,
Verified = 1,
}

View File

@@ -14,24 +14,25 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Logger, logger } from "../../logger.ts";
import { logger, PrefixedLogger } from "../../logger";
import { deepCompare } from "../../utils";
import {
type CryptoStore,
type IDeviceData,
type ISession,
type SessionExtended,
type ISessionInfo,
type IWithheld,
MigrationState,
type Mode,
type SecretStorePrivateKeys,
SESSION_BATCH_SIZE,
ACCOUNT_OBJECT_KEY_MIGRATION_STATE,
type InboundGroupSessionData,
type IRoomEncryption,
} from "./base.ts";
import { IndexedDBCryptoStore } from "./indexeddb-crypto-store.ts";
import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts";
CryptoStore,
IDeviceData,
IProblem,
ISession,
ISessionInfo,
IWithheld,
Mode,
OutgoingRoomKeyRequest,
ParkedSharedHistory,
SecretStorePrivateKeys,
} from "./base";
import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index";
import { ICrossSigningKey } from "../../client";
import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { InboundGroupSessionData } from "../OlmDevice";
const PROFILE_TRANSACTIONS = false;
@@ -39,8 +40,6 @@ const PROFILE_TRANSACTIONS = false;
* Implementation of a CryptoStore which is backed by an existing
* IndexedDB connection. Generally you want IndexedDBCryptoStore
* which connects to the database and defers to one of these.
*
* @internal
*/
export class Backend implements CryptoStore {
private nextTxnId = 0;
@@ -57,49 +56,306 @@ export class Backend implements CryptoStore {
};
}
public async containsData(): Promise<boolean> {
throw Error("Not implemented for Backend");
}
public async startup(): Promise<CryptoStore> {
// No work to do, as the startup is done by the caller (e.g IndexedDBCryptoStore)
// by passing us a ready IDBDatabase instance
return this;
}
public async deleteAllData(): Promise<void> {
throw Error("This is not implemented, call IDBFactory::deleteDatabase(dbName) instead.");
}
/**
* Get data on how much of the libolm to Rust Crypto migration has been done.
* Look for an existing outgoing room key request, and if none is found,
* add a new one
*
* Implementation of {@link CryptoStore.getMigrationState}.
*
* @returns resolves to
* {@link OutgoingRoomKeyRequest}: either the
* same instance as passed in, or the existing one.
*/
public async getMigrationState(): Promise<MigrationState> {
let migrationState = MigrationState.NOT_STARTED;
await this.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_ACCOUNT);
const getReq = objectStore.get(ACCOUNT_OBJECT_KEY_MIGRATION_STATE);
getReq.onsuccess = (): void => {
migrationState = getReq.result ?? MigrationState.NOT_STARTED;
};
public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> {
const requestBody = request.requestBody;
return new Promise((resolve, reject) => {
const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite");
txn.onerror = reject;
// first see if we already have an entry for this request.
this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => {
if (existing) {
// this entry matches the request - return it.
logger.log(
`already have key request outstanding for ` +
`${requestBody.room_id} / ${requestBody.session_id}: ` +
`not sending another`,
);
resolve(existing);
return;
}
// we got to the end of the list without finding a match
// - add the new request.
logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id);
txn.oncomplete = (): void => {
resolve(request);
};
const store = txn.objectStore("outgoingRoomKeyRequests");
store.add(request);
});
});
return migrationState;
}
/**
* Set data on how much of the libolm to Rust Crypto migration has been done.
* Look for an existing room key request
*
* Implementation of {@link CryptoStore.setMigrationState}.
* @param requestBody - existing request to look for
*
* @returns resolves to the matching
* {@link OutgoingRoomKeyRequest}, or null if
* not found
*/
public async setMigrationState(migrationState: MigrationState): Promise<void> {
await this.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_ACCOUNT);
objectStore.put(migrationState, ACCOUNT_OBJECT_KEY_MIGRATION_STATE);
public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null> {
return new Promise((resolve, reject) => {
const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly");
txn.onerror = reject;
this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => {
resolve(existing);
});
});
}
/**
* look for an existing room key request in the db
*
* @internal
* @param txn - database transaction
* @param requestBody - existing request to look for
* @param callback - function to call with the results of the
* search. Either passed a matching
* {@link OutgoingRoomKeyRequest}, or null if
* not found.
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
private _getOutgoingRoomKeyRequest(
txn: IDBTransaction,
requestBody: IRoomKeyRequestBody,
callback: (req: OutgoingRoomKeyRequest | null) => void,
): void {
const store = txn.objectStore("outgoingRoomKeyRequests");
const idx = store.index("session");
const cursorReq = idx.openCursor([requestBody.room_id, requestBody.session_id]);
cursorReq.onsuccess = (): void => {
const cursor = cursorReq.result;
if (!cursor) {
// no match found
callback(null);
return;
}
const existing = cursor.value;
if (deepCompare(existing.requestBody, requestBody)) {
// got a match
callback(existing);
return;
}
// look at the next entry in the index
cursor.continue();
};
}
/**
* Look for room key requests by state
*
* @param wantedStates - list of acceptable states
*
* @returns resolves to the a
* {@link OutgoingRoomKeyRequest}, or null if
* there are no pending requests in those states. If there are multiple
* requests in those states, an arbitrary one is chosen.
*/
public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null> {
if (wantedStates.length === 0) {
return Promise.resolve(null);
}
// this is a bit tortuous because we need to make sure we do the lookup
// in a single transaction, to avoid having a race with the insertion
// code.
// index into the wantedStates array
let stateIndex = 0;
let result: OutgoingRoomKeyRequest;
function onsuccess(this: IDBRequest<IDBCursorWithValue | null>): void {
const cursor = this.result;
if (cursor) {
// got a match
result = cursor.value;
return;
}
// try the next state in the list
stateIndex++;
if (stateIndex >= wantedStates.length) {
// no matches
return;
}
const wantedState = wantedStates[stateIndex];
const cursorReq = (this.source as IDBIndex).openCursor(wantedState);
cursorReq.onsuccess = onsuccess;
}
const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly");
const store = txn.objectStore("outgoingRoomKeyRequests");
const wantedState = wantedStates[stateIndex];
const cursorReq = store.index("state").openCursor(wantedState);
cursorReq.onsuccess = onsuccess;
return promiseifyTxn(txn).then(() => result);
}
/**
*
* @returns All elements in a given state
*/
public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]> {
return new Promise((resolve, reject) => {
const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly");
const store = txn.objectStore("outgoingRoomKeyRequests");
const index = store.index("state");
const request = index.getAll(wantedState);
request.onsuccess = (): void => resolve(request.result);
request.onerror = (): void => reject(request.error);
});
}
public getOutgoingRoomKeyRequestsByTarget(
userId: string,
deviceId: string,
wantedStates: number[],
): Promise<OutgoingRoomKeyRequest[]> {
let stateIndex = 0;
const results: OutgoingRoomKeyRequest[] = [];
function onsuccess(this: IDBRequest<IDBCursorWithValue | null>): void {
const cursor = this.result;
if (cursor) {
const keyReq = cursor.value;
if (
keyReq.recipients.some(
(recipient: IRoomKeyRequestRecipient) =>
recipient.userId === userId && recipient.deviceId === deviceId,
)
) {
results.push(keyReq);
}
cursor.continue();
} else {
// try the next state in the list
stateIndex++;
if (stateIndex >= wantedStates.length) {
// no matches
return;
}
const wantedState = wantedStates[stateIndex];
const cursorReq = (this.source as IDBIndex).openCursor(wantedState);
cursorReq.onsuccess = onsuccess;
}
}
const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly");
const store = txn.objectStore("outgoingRoomKeyRequests");
const wantedState = wantedStates[stateIndex];
const cursorReq = store.index("state").openCursor(wantedState);
cursorReq.onsuccess = onsuccess;
return promiseifyTxn(txn).then(() => results);
}
/**
* Look for an existing room key request by id and state, and update it if
* found
*
* @param requestId - ID of request to update
* @param expectedState - state we expect to find the request in
* @param updates - name/value map of updates to apply
*
* @returns resolves to
* {@link OutgoingRoomKeyRequest}
* updated request, or null if no matching row was found
*/
public updateOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
updates: Partial<OutgoingRoomKeyRequest>,
): Promise<OutgoingRoomKeyRequest | null> {
let result: OutgoingRoomKeyRequest | null = null;
function onsuccess(this: IDBRequest<IDBCursorWithValue | null>): void {
const cursor = this.result;
if (!cursor) {
return;
}
const data = cursor.value;
if (data.state != expectedState) {
logger.warn(
`Cannot update room key request from ${expectedState} ` +
`as it was already updated to ${data.state}`,
);
return;
}
Object.assign(data, updates);
cursor.update(data);
result = data;
}
const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite");
const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId);
cursorReq.onsuccess = onsuccess;
return promiseifyTxn(txn).then(() => result);
}
/**
* Look for an existing room key request by id and state, and delete it if
* found
*
* @param requestId - ID of request to update
* @param expectedState - state we expect to find the request in
*
* @returns resolves once the operation is completed
*/
public deleteOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
): Promise<OutgoingRoomKeyRequest | null> {
const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite");
const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId);
cursorReq.onsuccess = (): void => {
const cursor = cursorReq.result;
if (!cursor) {
return;
}
const data = cursor.value;
if (data.state != expectedState) {
logger.warn(`Cannot delete room key request in state ${data.state} ` + `(expected ${expectedState})`);
return;
}
cursor.delete();
};
return promiseifyTxn<OutgoingRoomKeyRequest | null>(txn);
}
// Olm Account
public getAccount(txn: IDBTransaction, func: (accountPickle: string | null) => void): void {
@@ -121,7 +377,7 @@ export class Backend implements CryptoStore {
public getCrossSigningKeys(
txn: IDBTransaction,
func: (keys: Record<string, CrossSigningKeyInfo> | null) => void,
func: (keys: Record<string, ICrossSigningKey> | null) => void,
): void {
const objectStore = txn.objectStore("account");
const getReq = objectStore.get("crossSigningKeys");
@@ -150,6 +406,11 @@ export class Backend implements CryptoStore {
};
}
public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, ICrossSigningKey>): void {
const objectStore = txn.objectStore("account");
objectStore.put(keys, "crossSigningKeys");
}
public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
txn: IDBTransaction,
type: K,
@@ -224,6 +485,24 @@ export class Backend implements CryptoStore {
};
}
public getAllEndToEndSessions(txn: IDBTransaction, func: (session: ISessionInfo | null) => void): void {
const objectStore = txn.objectStore("sessions");
const getReq = objectStore.openCursor();
getReq.onsuccess = function (): void {
try {
const cursor = getReq.result;
if (cursor) {
func(cursor.value);
cursor.continue();
} else {
func(null);
}
} catch (e) {
abortWithException(txn, <Error>e);
}
};
}
public storeEndToEndSession(
deviceKey: string,
sessionId: string,
@@ -239,60 +518,74 @@ export class Backend implements CryptoStore {
});
}
/**
* Fetch a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndSessionsBatch}.
*/
public async getEndToEndSessionsBatch(): Promise<null | ISessionInfo[]> {
const result: ISessionInfo[] = [];
await this.doTxn("readonly", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => {
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_SESSIONS);
const getReq = objectStore.openCursor();
getReq.onsuccess = function (): void {
try {
const cursor = getReq.result;
if (cursor) {
result.push(cursor.value);
if (result.length < SESSION_BATCH_SIZE) {
cursor.continue();
}
}
} catch (e) {
abortWithException(txn, <Error>e);
}
};
public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
const txn = this.db.transaction("session_problems", "readwrite");
const objectStore = txn.objectStore("session_problems");
objectStore.put({
deviceKey,
type,
fixed,
time: Date.now(),
});
await promiseifyTxn(txn);
}
if (result.length === 0) {
// No sessions left.
return null;
}
public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> {
let result: IProblem | null = null;
const txn = this.db.transaction("session_problems", "readwrite");
const objectStore = txn.objectStore("session_problems");
const index = objectStore.index("deviceKey");
const req = index.getAll(deviceKey);
req.onsuccess = (): void => {
const problems = req.result;
if (!problems.length) {
result = null;
return;
}
problems.sort((a, b) => {
return a.time - b.time;
});
const lastProblem = problems[problems.length - 1];
for (const problem of problems) {
if (problem.time > timestamp) {
result = Object.assign({}, problem, { fixed: lastProblem.fixed });
return;
}
}
if (lastProblem.fixed) {
result = null;
} else {
result = lastProblem;
}
};
await promiseifyTxn(txn);
return result;
}
/**
* Delete a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndSessionsBatch}.
*
* @internal
*/
public async deleteEndToEndSessionsBatch(sessions: { deviceKey: string; sessionId: string }[]): Promise<void> {
await this.doTxn("readwrite", [IndexedDBCryptoStore.STORE_SESSIONS], async (txn) => {
try {
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_SESSIONS);
for (const { deviceKey, sessionId } of sessions) {
const req = objectStore.delete([deviceKey, sessionId]);
await new Promise((resolve) => {
req.onsuccess = resolve;
});
}
} catch (e) {
abortWithException(txn, <Error>e);
}
});
// FIXME: we should probably prune this when devices get deleted
public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
const txn = this.db.transaction("notified_error_devices", "readwrite");
const objectStore = txn.objectStore("notified_error_devices");
const ret: IOlmDevice[] = [];
await Promise.all(
devices.map((device) => {
return new Promise<void>((resolve) => {
const { userId, deviceInfo } = device;
const getReq = objectStore.get([userId, deviceInfo.deviceId]);
getReq.onsuccess = function (): void {
if (!getReq.result) {
objectStore.put({ userId, deviceId: deviceInfo.deviceId });
ret.push(device);
}
resolve();
};
});
}),
);
return ret;
}
// Inbound group sessions
@@ -340,6 +633,57 @@ export class Backend implements CryptoStore {
};
}
public getAllEndToEndInboundGroupSessions(txn: IDBTransaction, func: (session: ISession | null) => void): void {
const objectStore = txn.objectStore("inbound_group_sessions");
const getReq = objectStore.openCursor();
getReq.onsuccess = function (): void {
const cursor = getReq.result;
if (cursor) {
try {
func({
senderKey: cursor.value.senderCurve25519Key,
sessionId: cursor.value.sessionId,
sessionData: cursor.value.session,
});
} catch (e) {
abortWithException(txn, <Error>e);
}
cursor.continue();
} else {
try {
func(null);
} catch (e) {
abortWithException(txn, <Error>e);
}
}
};
}
public addEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: IDBTransaction,
): void {
const objectStore = txn.objectStore("inbound_group_sessions");
const addReq = objectStore.add({
senderCurve25519Key,
sessionId,
session: sessionData,
});
addReq.onerror = (ev): void => {
if (addReq.error?.name === "ConstraintError") {
// This stops the error from triggering the txn's onerror
ev.stopPropagation();
// ...and this stops it from aborting the transaction
ev.preventDefault();
logger.log("Ignoring duplicate inbound group session: " + senderCurve25519Key + " / " + sessionId);
} else {
abortWithException(txn, new Error("Failed to add inbound group session: " + addReq.error));
}
};
}
public storeEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
@@ -354,94 +698,17 @@ export class Backend implements CryptoStore {
});
}
/**
* Count the number of Megolm sessions in the database.
*
* Implementation of {@link CryptoStore.countEndToEndInboundGroupSessions}.
*
* @internal
*/
public async countEndToEndInboundGroupSessions(): Promise<number> {
let result = 0;
await this.doTxn("readonly", [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => {
const sessionStore = txn.objectStore(IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS);
const countReq = sessionStore.count();
countReq.onsuccess = (): void => {
result = countReq.result;
};
});
return result;
}
/**
* Fetch a batch of Megolm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndInboundGroupSessionsBatch}.
*/
public async getEndToEndInboundGroupSessionsBatch(): Promise<null | SessionExtended[]> {
const result: SessionExtended[] = [];
await this.doTxn(
"readonly",
[IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_BACKUP],
(txn) => {
const sessionStore = txn.objectStore(IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS);
const backupStore = txn.objectStore(IndexedDBCryptoStore.STORE_BACKUP);
const getReq = sessionStore.openCursor();
getReq.onsuccess = function (): void {
try {
const cursor = getReq.result;
if (cursor) {
const backupGetReq = backupStore.get(cursor.key);
backupGetReq.onsuccess = (): void => {
result.push({
senderKey: cursor.value.senderCurve25519Key,
sessionId: cursor.value.sessionId,
sessionData: cursor.value.session,
needsBackup: backupGetReq.result !== undefined,
});
if (result.length < SESSION_BATCH_SIZE) {
cursor.continue();
}
};
}
} catch (e) {
abortWithException(txn, <Error>e);
}
};
},
);
if (result.length === 0) {
// No sessions left.
return null;
}
return result;
}
/**
* Delete a batch of Megolm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndInboundGroupSessionsBatch}.
*
* @internal
*/
public async deleteEndToEndInboundGroupSessionsBatch(
sessions: { senderKey: string; sessionId: string }[],
): Promise<void> {
await this.doTxn("readwrite", [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], async (txn) => {
try {
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS);
for (const { senderKey, sessionId } of sessions) {
const req = objectStore.delete([senderKey, sessionId]);
await new Promise((resolve) => {
req.onsuccess = resolve;
});
}
} catch (e) {
abortWithException(txn, <Error>e);
}
public storeEndToEndInboundGroupSessionWithheld(
senderCurve25519Key: string,
sessionId: string,
sessionData: IWithheld,
txn: IDBTransaction,
): void {
const objectStore = txn.objectStore("inbound_group_sessions_withheld");
objectStore.put({
senderCurve25519Key,
sessionId,
session: sessionData,
});
}
@@ -457,6 +724,16 @@ export class Backend implements CryptoStore {
};
}
public storeEndToEndDeviceData(deviceData: IDeviceData, txn: IDBTransaction): void {
const objectStore = txn.objectStore("device_data");
objectStore.put(deviceData, "-");
}
public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: IDBTransaction): void {
const objectStore = txn.objectStore("rooms");
objectStore.put(roomInfo, roomId);
}
public getEndToEndRooms(txn: IDBTransaction, func: (rooms: Record<string, IRoomEncryption>) => void): void {
const rooms: Parameters<Parameters<Backend["getEndToEndRooms"]>[1]>[0] = {};
const objectStore = txn.objectStore("rooms");
@@ -476,6 +753,67 @@ export class Backend implements CryptoStore {
};
}
// session backups
public getSessionsNeedingBackup(limit: number): Promise<ISession[]> {
return new Promise((resolve, reject) => {
const sessions: ISession[] = [];
const txn = this.db.transaction(["sessions_needing_backup", "inbound_group_sessions"], "readonly");
txn.onerror = reject;
txn.oncomplete = function (): void {
resolve(sessions);
};
const objectStore = txn.objectStore("sessions_needing_backup");
const sessionStore = txn.objectStore("inbound_group_sessions");
const getReq = objectStore.openCursor();
getReq.onsuccess = function (): void {
const cursor = getReq.result;
if (cursor) {
const sessionGetReq = sessionStore.get(cursor.key);
sessionGetReq.onsuccess = function (): void {
sessions.push({
senderKey: sessionGetReq.result.senderCurve25519Key,
sessionId: sessionGetReq.result.sessionId,
sessionData: sessionGetReq.result.session,
});
};
if (!limit || sessions.length < limit) {
cursor.continue();
}
}
};
});
}
public countSessionsNeedingBackup(txn?: IDBTransaction): Promise<number> {
if (!txn) {
txn = this.db.transaction("sessions_needing_backup", "readonly");
}
const objectStore = txn.objectStore("sessions_needing_backup");
return new Promise((resolve, reject) => {
const req = objectStore.count();
req.onerror = reject;
req.onsuccess = (): void => resolve(req.result);
});
}
public async unmarkSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> {
if (!txn) {
txn = this.db.transaction("sessions_needing_backup", "readwrite");
}
const objectStore = txn.objectStore("sessions_needing_backup");
await Promise.all(
sessions.map((session) => {
return new Promise((resolve, reject) => {
const req = objectStore.delete([session.senderKey, session.sessionId]);
req.onsuccess = resolve;
req.onerror = reject;
});
}),
);
}
public async markSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> {
if (!txn) {
txn = this.db.transaction("sessions_needing_backup", "readwrite");
@@ -495,11 +833,80 @@ export class Backend implements CryptoStore {
);
}
public addSharedHistoryInboundGroupSession(
roomId: string,
senderKey: string,
sessionId: string,
txn?: IDBTransaction,
): void {
if (!txn) {
txn = this.db.transaction("shared_history_inbound_group_sessions", "readwrite");
}
const objectStore = txn.objectStore("shared_history_inbound_group_sessions");
const req = objectStore.get([roomId]);
req.onsuccess = (): void => {
const { sessions } = req.result || { sessions: [] };
sessions.push([senderKey, sessionId]);
objectStore.put({ roomId, sessions });
};
}
public getSharedHistoryInboundGroupSessions(
roomId: string,
txn?: IDBTransaction,
): Promise<[senderKey: string, sessionId: string][]> {
if (!txn) {
txn = this.db.transaction("shared_history_inbound_group_sessions", "readonly");
}
const objectStore = txn.objectStore("shared_history_inbound_group_sessions");
const req = objectStore.get([roomId]);
return new Promise((resolve, reject) => {
req.onsuccess = (): void => {
const { sessions } = req.result || { sessions: [] };
resolve(sessions);
};
req.onerror = reject;
});
}
public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory, txn?: IDBTransaction): void {
if (!txn) {
txn = this.db.transaction("parked_shared_history", "readwrite");
}
const objectStore = txn.objectStore("parked_shared_history");
const req = objectStore.get([roomId]);
req.onsuccess = (): void => {
const { parked } = req.result || { parked: [] };
parked.push(parkedData);
objectStore.put({ roomId, parked });
};
}
public takeParkedSharedHistory(roomId: string, txn?: IDBTransaction): Promise<ParkedSharedHistory[]> {
if (!txn) {
txn = this.db.transaction("parked_shared_history", "readwrite");
}
const cursorReq = txn.objectStore("parked_shared_history").openCursor(roomId);
return new Promise((resolve, reject) => {
cursorReq.onsuccess = (): void => {
const cursor = cursorReq.result;
if (!cursor) {
resolve([]);
return;
}
const data = cursor.value;
cursor.delete();
resolve(data);
};
cursorReq.onerror = reject;
});
}
public doTxn<T>(
mode: Mode,
stores: string | string[],
func: (txn: IDBTransaction) => T,
log: Logger = logger,
log: PrefixedLogger = logger,
): Promise<T> {
let startTime: number;
let description: string;
@@ -590,7 +997,7 @@ const DB_MIGRATIONS: DbMigration[] = [
export const VERSION = DB_MIGRATIONS.length;
export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void {
logger.log(`Upgrading IndexedDBCryptoStore from version ${oldVersion} to ${VERSION}`);
logger.log(`Upgrading IndexedDBCryptoStore from version ${oldVersion}` + ` to ${VERSION}`);
DB_MIGRATIONS.forEach((migration, index) => {
if (oldVersion <= index) migration(db);
});
@@ -607,7 +1014,7 @@ function createDatabase(db: IDBDatabase): void {
}
interface IWrappedIDBTransaction extends IDBTransaction {
_mx_abortexception: Error;
_mx_abortexception: Error; // eslint-disable-line camelcase
}
/*
@@ -621,7 +1028,7 @@ function abortWithException(txn: IDBTransaction, e: Error): void {
(txn as IWrappedIDBTransaction)._mx_abortexception = e;
try {
txn.abort();
} catch {
} catch (e) {
// sometimes we won't be able to abort the transaction
// (ie. if it's aborted or completed)
}
@@ -632,7 +1039,6 @@ function promiseifyTxn<T>(txn: IDBTransaction): Promise<T | null> {
txn.oncomplete = (): void => {
if ((txn as IWrappedIDBTransaction)._mx_abortexception !== undefined) {
reject((txn as IWrappedIDBTransaction)._mx_abortexception);
return;
}
resolve(null);
};

View File

@@ -14,28 +14,31 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger, type Logger } from "../../logger.ts";
import { LocalStorageCryptoStore } from "./localStorage-crypto-store.ts";
import { MemoryCryptoStore } from "./memory-crypto-store.ts";
import * as IndexedDBCryptoStoreBackend from "./indexeddb-crypto-store-backend.ts";
import { InvalidCryptoStoreError, InvalidCryptoStoreState } from "../../errors.ts";
import * as IndexedDBHelpers from "../../indexeddb-helpers.ts";
import { logger, PrefixedLogger } from "../../logger";
import { LocalStorageCryptoStore } from "./localStorage-crypto-store";
import { MemoryCryptoStore } from "./memory-crypto-store";
import * as IndexedDBCryptoStoreBackend from "./indexeddb-crypto-store-backend";
import { InvalidCryptoStoreError, InvalidCryptoStoreState } from "../../errors";
import * as IndexedDBHelpers from "../../indexeddb-helpers";
import {
type CryptoStore,
type ISession,
type SessionExtended,
type ISessionInfo,
type IWithheld,
MigrationState,
type Mode,
type SecretStorePrivateKeys,
ACCOUNT_OBJECT_KEY_MIGRATION_STATE,
type InboundGroupSessionData,
type IRoomEncryption,
} from "./base.ts";
import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts";
CryptoStore,
IDeviceData,
IProblem,
ISession,
ISessionInfo,
IWithheld,
Mode,
OutgoingRoomKeyRequest,
ParkedSharedHistory,
SecretStorePrivateKeys,
} from "./base";
import { IRoomKeyRequestBody } from "../index";
import { ICrossSigningKey } from "../../client";
import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { InboundGroupSessionData } from "../OlmDevice";
/*
/**
* Internal module. indexeddb storage for e2e.
*/
@@ -58,52 +61,6 @@ export class IndexedDBCryptoStore implements CryptoStore {
return IndexedDBHelpers.exists(indexedDB, dbName);
}
/**
* Utility to check if a legacy crypto store exists and has not been migrated.
* Returns true if the store exists and has not been migrated, false otherwise.
*/
public static existsAndIsNotMigrated(indexedDb: IDBFactory, dbName: string): Promise<boolean> {
return new Promise<boolean>((resolve, reject) => {
let exists = true;
const openDBRequest = indexedDb.open(dbName);
openDBRequest.onupgradeneeded = (): void => {
// Since we did not provide an explicit version when opening, this event
// should only fire if the DB did not exist before at any version.
exists = false;
};
openDBRequest.onblocked = (): void => reject(openDBRequest.error);
openDBRequest.onsuccess = (): void => {
const db = openDBRequest.result;
if (!exists) {
db.close();
// The DB did not exist before, but has been created as part of this
// existence check. Delete it now to restore previous state. Delete can
// actually take a while to complete in some browsers, so don't wait for
// it. This won't block future open calls that a store might issue next to
// properly set up the DB.
indexedDb.deleteDatabase(dbName);
resolve(false);
} else {
const tx = db.transaction([IndexedDBCryptoStore.STORE_ACCOUNT], "readonly");
const objectStore = tx.objectStore(IndexedDBCryptoStore.STORE_ACCOUNT);
const getReq = objectStore.get(ACCOUNT_OBJECT_KEY_MIGRATION_STATE);
getReq.onsuccess = (): void => {
const migrationState = getReq.result ?? MigrationState.NOT_STARTED;
resolve(migrationState === MigrationState.NOT_STARTED);
};
getReq.onerror = (): void => {
reject(getReq.error);
};
db.close();
}
};
openDBRequest.onerror = (): void => reject(openDBRequest.error);
});
}
private backendPromise?: Promise<CryptoStore>;
private backend?: CryptoStore;
@@ -113,21 +70,7 @@ export class IndexedDBCryptoStore implements CryptoStore {
* @param indexedDB - global indexedDB instance
* @param dbName - name of db to connect to
*/
public constructor(
private readonly indexedDB: IDBFactory,
private readonly dbName: string,
) {}
/**
* Returns true if this CryptoStore has ever been initialised (ie, it might contain data).
*
* Implementation of {@link CryptoStore.containsData}.
*
* @internal
*/
public async containsData(): Promise<boolean> {
return IndexedDBCryptoStore.exists(this.indexedDB, this.dbName);
}
public constructor(private readonly indexedDB: IDBFactory, private readonly dbName: string) {}
/**
* Ensure the database exists and is up-to-date, or fall back to
@@ -199,15 +142,14 @@ export class IndexedDBCryptoStore implements CryptoStore {
// in this db so we should use it or nothing at all.
throw new InvalidCryptoStoreError(InvalidCryptoStoreState.TooNew);
}
logger.warn(`unable to connect to indexeddb ${this.dbName}: falling back to localStorage store: ${e}`);
logger.warn(
`unable to connect to indexeddb ${this.dbName}` + `: falling back to localStorage store: ${e}`,
);
try {
if (!(globalThis.localStorage instanceof Storage)) {
throw new Error("localStorage is not available");
}
return new LocalStorageCryptoStore(globalThis.localStorage);
return new LocalStorageCryptoStore(global.localStorage);
} catch (e) {
logger.warn(`Unable to open localStorage: falling back to in-memory store: ${e}`);
logger.warn(`unable to open localStorage: falling back to in-memory store: ${e}`);
return new MemoryCryptoStore();
}
})
@@ -256,25 +198,107 @@ export class IndexedDBCryptoStore implements CryptoStore {
}
/**
* Get data on how much of the libolm to Rust Crypto migration has been done.
* Look for an existing outgoing room key request, and if none is found,
* add a new one
*
* Implementation of {@link CryptoStore.getMigrationState}.
*
* @internal
* @returns resolves to
* {@link OutgoingRoomKeyRequest}: either the
* same instance as passed in, or the existing one.
*/
public getMigrationState(): Promise<MigrationState> {
return this.backend!.getMigrationState();
public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> {
return this.backend!.getOrAddOutgoingRoomKeyRequest(request);
}
/**
* Set data on how much of the libolm to Rust Crypto migration has been done.
* Look for an existing room key request
*
* Implementation of {@link CryptoStore.setMigrationState}.
* @param requestBody - existing request to look for
*
* @internal
* @returns resolves to the matching
* {@link OutgoingRoomKeyRequest}, or null if
* not found
*/
public setMigrationState(migrationState: MigrationState): Promise<void> {
return this.backend!.setMigrationState(migrationState);
public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null> {
return this.backend!.getOutgoingRoomKeyRequest(requestBody);
}
/**
* Look for room key requests by state
*
* @param wantedStates - list of acceptable states
*
* @returns resolves to the a
* {@link OutgoingRoomKeyRequest}, or null if
* there are no pending requests in those states. If there are multiple
* requests in those states, an arbitrary one is chosen.
*/
public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null> {
return this.backend!.getOutgoingRoomKeyRequestByState(wantedStates);
}
/**
* Look for room key requests by state –
* unlike above, return a list of all entries in one state.
*
* @returns Returns an array of requests in the given state
*/
public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]> {
return this.backend!.getAllOutgoingRoomKeyRequestsByState(wantedState);
}
/**
* Look for room key requests by target device and state
*
* @param userId - Target user ID
* @param deviceId - Target device ID
* @param wantedStates - list of acceptable states
*
* @returns resolves to a list of all the
* {@link OutgoingRoomKeyRequest}
*/
public getOutgoingRoomKeyRequestsByTarget(
userId: string,
deviceId: string,
wantedStates: number[],
): Promise<OutgoingRoomKeyRequest[]> {
return this.backend!.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates);
}
/**
* Look for an existing room key request by id and state, and update it if
* found
*
* @param requestId - ID of request to update
* @param expectedState - state we expect to find the request in
* @param updates - name/value map of updates to apply
*
* @returns resolves to
* {@link OutgoingRoomKeyRequest}
* updated request, or null if no matching row was found
*/
public updateOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
updates: Partial<OutgoingRoomKeyRequest>,
): Promise<OutgoingRoomKeyRequest | null> {
return this.backend!.updateOutgoingRoomKeyRequest(requestId, expectedState, updates);
}
/**
* Look for an existing room key request by id and state, and delete it if
* found
*
* @param requestId - ID of request to update
* @param expectedState - state we expect to find the request in
*
* @returns resolves once the operation is completed
*/
public deleteOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
): Promise<OutgoingRoomKeyRequest | null> {
return this.backend!.deleteOutgoingRoomKeyRequest(requestId, expectedState);
}
// Olm Account
@@ -311,7 +335,7 @@ export class IndexedDBCryptoStore implements CryptoStore {
*/
public getCrossSigningKeys(
txn: IDBTransaction,
func: (keys: Record<string, CrossSigningKeyInfo> | null) => void,
func: (keys: Record<string, ICrossSigningKey> | null) => void,
): void {
this.backend!.getCrossSigningKeys(txn, func);
}
@@ -329,6 +353,16 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.getSecretStorePrivateKey(txn, func, type);
}
/**
* Write the cross-signing keys back to the store
*
* @param txn - An active transaction. See doTxn().
* @param keys - keys object as getCrossSigningKeys()
*/
public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, ICrossSigningKey>): void {
this.backend!.storeCrossSigningKeys(txn, keys);
}
/**
* Write the cross-signing private keys back to the store
*
@@ -395,6 +429,17 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.getEndToEndSessions(deviceKey, txn, func);
}
/**
* Retrieve all end-to-end sessions
* @param txn - An active transaction. See doTxn().
* @param func - Called one for each session with
* an object with, deviceKey, lastReceivedMessageTs, sessionId
* and session keys.
*/
public getAllEndToEndSessions(txn: IDBTransaction, func: (session: ISessionInfo | null) => void): void {
this.backend!.getAllEndToEndSessions(txn, func);
}
/**
* Store a session between the logged-in user and another device
* @param deviceKey - The public key of the other device.
@@ -411,37 +456,16 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn);
}
/**
* Count the number of Megolm sessions in the database.
*
* Implementation of {@link CryptoStore.countEndToEndInboundGroupSessions}.
*
* @internal
*/
public countEndToEndInboundGroupSessions(): Promise<number> {
return this.backend!.countEndToEndInboundGroupSessions();
public storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
return this.backend!.storeEndToEndSessionProblem(deviceKey, type, fixed);
}
/**
* Fetch a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndSessionsBatch}.
*
* @internal
*/
public getEndToEndSessionsBatch(): Promise<null | ISessionInfo[]> {
return this.backend!.getEndToEndSessionsBatch();
public getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> {
return this.backend!.getEndToEndSessionProblem(deviceKey, timestamp);
}
/**
* Delete a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndSessionsBatch}.
*
* @internal
*/
public deleteEndToEndSessionsBatch(sessions: { deviceKey: string; sessionId: string }[]): Promise<void> {
return this.backend!.deleteEndToEndSessionsBatch(sessions);
public filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
return this.backend!.filterOutNotifiedErrorDevices(devices);
}
// Inbound group sessions
@@ -464,6 +488,35 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func);
}
/**
* Fetches all inbound group sessions in the store
* @param txn - An active transaction. See doTxn().
* @param func - Called once for each group session
* in the store with an object having keys `{senderKey, sessionId, sessionData}`,
* then once with null to indicate the end of the list.
*/
public getAllEndToEndInboundGroupSessions(txn: IDBTransaction, func: (session: ISession | null) => void): void {
this.backend!.getAllEndToEndInboundGroupSessions(txn, func);
}
/**
* Adds an end-to-end inbound group session to the store.
* If there already exists an inbound group session with the same
* senderCurve25519Key and sessionID, the session will not be added.
* @param senderCurve25519Key - The sender's curve 25519 key
* @param sessionId - The ID of the session
* @param sessionData - The session data structure
* @param txn - An active transaction. See doTxn().
*/
public addEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: IDBTransaction,
): void {
this.backend!.addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn);
}
/**
* Writes an end-to-end inbound group session to the store.
* If there already exists an inbound group session with the same
@@ -482,28 +535,51 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn);
}
public storeEndToEndInboundGroupSessionWithheld(
senderCurve25519Key: string,
sessionId: string,
sessionData: IWithheld,
txn: IDBTransaction,
): void {
this.backend!.storeEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId, sessionData, txn);
}
// End-to-end device tracking
/**
* Fetch a batch of Megolm sessions from the database.
* Store the state of all tracked devices
* This contains devices for each user, a tracking state for each user
* and a sync token matching the point in time the snapshot represents.
* These all need to be written out in full each time such that the snapshot
* is always consistent, so they are stored in one object.
*
* Implementation of {@link CryptoStore.getEndToEndInboundGroupSessionsBatch}.
*
* @internal
* @param txn - An active transaction. See doTxn().
*/
public getEndToEndInboundGroupSessionsBatch(): Promise<SessionExtended[] | null> {
return this.backend!.getEndToEndInboundGroupSessionsBatch();
public storeEndToEndDeviceData(deviceData: IDeviceData, txn: IDBTransaction): void {
this.backend!.storeEndToEndDeviceData(deviceData, txn);
}
/**
* Delete a batch of Megolm sessions from the database.
* Get the state of all tracked devices
*
* Implementation of {@link CryptoStore.deleteEndToEndInboundGroupSessionsBatch}.
*
* @internal
* @param txn - An active transaction. See doTxn().
* @param func - Function called with the
* device data
*/
public deleteEndToEndInboundGroupSessionsBatch(
sessions: { senderKey: string; sessionId: string }[],
): Promise<void> {
return this.backend!.deleteEndToEndInboundGroupSessionsBatch(sessions);
public getEndToEndDeviceData(txn: IDBTransaction, func: (deviceData: IDeviceData | null) => void): void {
this.backend!.getEndToEndDeviceData(txn, func);
}
// End to End Rooms
/**
* Store the end-to-end state for a room.
* @param roomId - The room's ID.
* @param roomInfo - The end-to-end info for the room.
* @param txn - An active transaction. See doTxn().
*/
public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: IDBTransaction): void {
this.backend!.storeEndToEndRoom(roomId, roomInfo, txn);
}
/**
@@ -515,6 +591,37 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.getEndToEndRooms(txn, func);
}
// session backups
/**
* Get the inbound group sessions that need to be backed up.
* @param limit - The maximum number of sessions to retrieve. 0
* for no limit.
* @returns resolves to an array of inbound group sessions
*/
public getSessionsNeedingBackup(limit: number): Promise<ISession[]> {
return this.backend!.getSessionsNeedingBackup(limit);
}
/**
* Count the inbound group sessions that need to be backed up.
* @param txn - An active transaction. See doTxn(). (optional)
* @returns resolves to the number of sessions
*/
public countSessionsNeedingBackup(txn?: IDBTransaction): Promise<number> {
return this.backend!.countSessionsNeedingBackup(txn);
}
/**
* Unmark sessions as needing to be backed up.
* @param sessions - The sessions that need to be backed up.
* @param txn - An active transaction. See doTxn(). (optional)
* @returns resolves when the sessions are unmarked
*/
public unmarkSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> {
return this.backend!.unmarkSessionsNeedingBackup(sessions, txn);
}
/**
* Mark sessions as needing to be backed up.
* @param sessions - The sessions that need to be backed up.
@@ -525,6 +632,49 @@ export class IndexedDBCryptoStore implements CryptoStore {
return this.backend!.markSessionsNeedingBackup(sessions, txn);
}
/**
* Add a shared-history group session for a room.
* @param roomId - The room that the key belongs to
* @param senderKey - The sender's curve 25519 key
* @param sessionId - The ID of the session
* @param txn - An active transaction. See doTxn(). (optional)
*/
public addSharedHistoryInboundGroupSession(
roomId: string,
senderKey: string,
sessionId: string,
txn?: IDBTransaction,
): void {
this.backend!.addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn);
}
/**
* Get the shared-history group session for a room.
* @param roomId - The room that the key belongs to
* @param txn - An active transaction. See doTxn(). (optional)
* @returns Promise which resolves to an array of [senderKey, sessionId]
*/
public getSharedHistoryInboundGroupSessions(
roomId: string,
txn?: IDBTransaction,
): Promise<[senderKey: string, sessionId: string][]> {
return this.backend!.getSharedHistoryInboundGroupSessions(roomId, txn);
}
/**
* Park a shared-history group session for a room we may be invited to later.
*/
public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory, txn?: IDBTransaction): void {
this.backend!.addParkedSharedHistory(roomId, parkedData, txn);
}
/**
* Pop out all shared-history group sessions for a room.
*/
public takeParkedSharedHistory(roomId: string, txn?: IDBTransaction): Promise<ParkedSharedHistory[]> {
return this.backend!.takeParkedSharedHistory(roomId, txn);
}
/**
* Perform a transaction on the crypto store. Any store methods
* that require a transaction (txn) object to be passed in may
@@ -547,7 +697,12 @@ export class IndexedDBCryptoStore implements CryptoStore {
* reject with that exception. On synchronous backends, the
* exception will propagate to the caller of the getFoo method.
*/
public doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: IDBTransaction) => T, log?: Logger): Promise<T> {
public doTxn<T>(
mode: Mode,
stores: Iterable<string>,
func: (txn: IDBTransaction) => T,
log?: PrefixedLogger,
): Promise<T> {
return this.backend!.doTxn<T>(mode, stores, func as (txn: unknown) => T, log);
}
}

View File

@@ -14,22 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "../../logger.ts";
import { MemoryCryptoStore } from "./memory-crypto-store.ts";
import {
type CryptoStore,
type ISession,
type SessionExtended,
type ISessionInfo,
type IWithheld,
MigrationState,
type Mode,
type SecretStorePrivateKeys,
SESSION_BATCH_SIZE,
type InboundGroupSessionData,
type IRoomEncryption,
} from "./base.ts";
import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts";
import { logger } from "../../logger";
import { MemoryCryptoStore } from "./memory-crypto-store";
import { IDeviceData, IProblem, ISession, ISessionInfo, IWithheld, Mode, SecretStorePrivateKeys } from "./base";
import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { ICrossSigningKey } from "../../client";
import { InboundGroupSessionData } from "../OlmDevice";
import { safeSet } from "../../utils";
/**
* Internal module. Partial localStorage backed storage for e2e.
@@ -40,9 +32,10 @@ import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts";
*/
const E2E_PREFIX = "crypto.";
const KEY_END_TO_END_MIGRATION_STATE = E2E_PREFIX + "migration";
const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account";
const KEY_CROSS_SIGNING_KEYS = E2E_PREFIX + "cross_signing_keys";
const KEY_NOTIFIED_ERROR_DEVICES = E2E_PREFIX + "notified_error_devices";
const KEY_DEVICE_DATA = E2E_PREFIX + "device_data";
const KEY_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/";
const KEY_INBOUND_SESSION_WITHHELD_PREFIX = E2E_PREFIX + "inboundgroupsessions.withheld/";
const KEY_ROOMS_PREFIX = E2E_PREFIX + "rooms/";
@@ -52,6 +45,10 @@ function keyEndToEndSessions(deviceKey: string): string {
return E2E_PREFIX + "sessions/" + deviceKey;
}
function keyEndToEndSessionProblems(deviceKey: string): string {
return E2E_PREFIX + "session.problems/" + deviceKey;
}
function keyEndToEndInboundGroupSession(senderKey: string, sessionId: string): string {
return KEY_INBOUND_SESSION_PREFIX + senderKey + "/" + sessionId;
}
@@ -64,7 +61,7 @@ function keyEndToEndRoomsPrefix(roomId: string): string {
return KEY_ROOMS_PREFIX + roomId;
}
export class LocalStorageCryptoStore extends MemoryCryptoStore implements CryptoStore {
export class LocalStorageCryptoStore extends MemoryCryptoStore {
public static exists(store: Storage): boolean {
const length = store.length;
for (let i = 0; i < length; i++) {
@@ -79,53 +76,17 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
super();
}
/**
* Returns true if this CryptoStore has ever been initialised (ie, it might contain data).
*
* Implementation of {@link CryptoStore.containsData}.
*
* @internal
*/
public async containsData(): Promise<boolean> {
return LocalStorageCryptoStore.exists(this.store);
}
/**
* Get data on how much of the libolm to Rust Crypto migration has been done.
*
* Implementation of {@link CryptoStore.getMigrationState}.
*
* @internal
*/
public async getMigrationState(): Promise<MigrationState> {
return getJsonItem(this.store, KEY_END_TO_END_MIGRATION_STATE) ?? MigrationState.NOT_STARTED;
}
/**
* Set data on how much of the libolm to Rust Crypto migration has been done.
*
* Implementation of {@link CryptoStore.setMigrationState}.
*
* @internal
*/
public async setMigrationState(migrationState: MigrationState): Promise<void> {
setJsonItem(this.store, KEY_END_TO_END_MIGRATION_STATE, migrationState);
}
// Olm Sessions
public countEndToEndSessions(txn: unknown, func: (count: number) => void): void {
let count = 0;
for (let i = 0; i < this.store.length; ++i) {
const key = this.store.key(i);
if (key?.startsWith(keyEndToEndSessions(""))) {
const sessions = getJsonItem(this.store, key);
count += Object.keys(sessions ?? {}).length;
}
if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) ++count;
}
func(count);
}
// eslint-disable-next-line @typescript-eslint/naming-convention
private _getEndToEndSessions(deviceKey: string): Record<string, ISessionInfo> {
const sessions = getJsonItem(this.store, keyEndToEndSessions(deviceKey));
const fixedSessions: Record<string, ISessionInfo> = {};
@@ -151,7 +112,7 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
func: (session: ISessionInfo) => void,
): void {
const sessions = this._getEndToEndSessions(deviceKey);
func(sessions[sessionId] ?? {});
func(sessions[sessionId] || {});
}
public getEndToEndSessions(
@@ -159,7 +120,18 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
txn: unknown,
func: (sessions: { [sessionId: string]: ISessionInfo }) => void,
): void {
func(this._getEndToEndSessions(deviceKey) ?? {});
func(this._getEndToEndSessions(deviceKey) || {});
}
public getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo) => void): void {
for (let i = 0; i < this.store.length; ++i) {
if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) {
const deviceKey = this.store.key(i)!.split("/")[1];
for (const sess of Object.values(this._getEndToEndSessions(deviceKey))) {
func(sess);
}
}
}
}
public storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void {
@@ -168,54 +140,56 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
setJsonItem(this.store, keyEndToEndSessions(deviceKey), sessions);
}
/**
* Fetch a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndSessionsBatch}.
*
* @internal
*/
public async getEndToEndSessionsBatch(): Promise<null | ISessionInfo[]> {
const result: ISessionInfo[] = [];
for (let i = 0; i < this.store.length; ++i) {
if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) {
const deviceKey = this.store.key(i)!.split("/")[1];
for (const session of Object.values(this._getEndToEndSessions(deviceKey))) {
result.push(session);
if (result.length >= SESSION_BATCH_SIZE) {
return result;
}
}
}
}
if (result.length === 0) {
// No sessions left.
return null;
}
// There are fewer sessions than the batch size; return the final batch of sessions.
return result;
public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
const key = keyEndToEndSessionProblems(deviceKey);
const problems = getJsonItem<IProblem[]>(this.store, key) || [];
problems.push({ type, fixed, time: Date.now() });
problems.sort((a, b) => {
return a.time - b.time;
});
setJsonItem(this.store, key, problems);
}
/**
* Delete a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndSessionsBatch}.
*
* @internal
*/
public async deleteEndToEndSessionsBatch(sessions: { deviceKey: string; sessionId: string }[]): Promise<void> {
for (const { deviceKey, sessionId } of sessions) {
const deviceSessions = this._getEndToEndSessions(deviceKey) || {};
delete deviceSessions[sessionId];
if (Object.keys(deviceSessions).length === 0) {
// No more sessions for this device.
this.store.removeItem(keyEndToEndSessions(deviceKey));
} else {
setJsonItem(this.store, keyEndToEndSessions(deviceKey), deviceSessions);
public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> {
const key = keyEndToEndSessionProblems(deviceKey);
const problems = getJsonItem<IProblem[]>(this.store, key) || [];
if (!problems.length) {
return null;
}
const lastProblem = problems[problems.length - 1];
for (const problem of problems) {
if (problem.time > timestamp) {
return Object.assign({}, problem, { fixed: lastProblem.fixed });
}
}
if (lastProblem.fixed) {
return null;
} else {
return lastProblem;
}
}
public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
const notifiedErrorDevices =
getJsonItem<MemoryCryptoStore["notifiedErrorDevices"]>(this.store, KEY_NOTIFIED_ERROR_DEVICES) || {};
const ret: IOlmDevice[] = [];
for (const device of devices) {
const { userId, deviceInfo } = device;
if (userId in notifiedErrorDevices) {
if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) {
ret.push(device);
safeSet(notifiedErrorDevices[userId], deviceInfo.deviceId, true);
}
} else {
ret.push(device);
safeSet(notifiedErrorDevices, userId, { [deviceInfo.deviceId]: true });
}
}
setJsonItem(this.store, KEY_NOTIFIED_ERROR_DEVICES, notifiedErrorDevices);
return ret;
}
// Inbound Group Sessions
@@ -232,6 +206,37 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
);
}
public getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void {
for (let i = 0; i < this.store.length; ++i) {
const key = this.store.key(i);
if (key?.startsWith(KEY_INBOUND_SESSION_PREFIX)) {
// we can't use split, as the components we are trying to split out
// might themselves contain '/' characters. We rely on the
// senderKey being a (32-byte) curve25519 key, base64-encoded
// (hence 43 characters long).
func({
senderKey: key.slice(KEY_INBOUND_SESSION_PREFIX.length, KEY_INBOUND_SESSION_PREFIX.length + 43),
sessionId: key.slice(KEY_INBOUND_SESSION_PREFIX.length + 44),
sessionData: getJsonItem(this.store, key)!,
});
}
}
func(null);
}
public addEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: unknown,
): void {
const existing = getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId));
if (!existing) {
this.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn);
}
}
public storeEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
@@ -241,80 +246,25 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
setJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), sessionData);
}
/**
* Count the number of Megolm sessions in the database.
*
* Implementation of {@link CryptoStore.countEndToEndInboundGroupSessions}.
*
* @internal
*/
public async countEndToEndInboundGroupSessions(): Promise<number> {
let count = 0;
for (let i = 0; i < this.store.length; ++i) {
const key = this.store.key(i);
if (key?.startsWith(KEY_INBOUND_SESSION_PREFIX)) {
count += 1;
}
}
return count;
public storeEndToEndInboundGroupSessionWithheld(
senderCurve25519Key: string,
sessionId: string,
sessionData: IWithheld,
txn: unknown,
): void {
setJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId), sessionData);
}
/**
* Fetch a batch of Megolm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndInboundGroupSessionsBatch}.
*
* @internal
*/
public async getEndToEndInboundGroupSessionsBatch(): Promise<SessionExtended[] | null> {
const sessionsNeedingBackup = getJsonItem<string[]>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
const result: SessionExtended[] = [];
for (let i = 0; i < this.store.length; ++i) {
const key = this.store.key(i);
if (key?.startsWith(KEY_INBOUND_SESSION_PREFIX)) {
const key2 = key.slice(KEY_INBOUND_SESSION_PREFIX.length);
// we can't use split, as the components we are trying to split out
// might themselves contain '/' characters. We rely on the
// senderKey being a (32-byte) curve25519 key, base64-encoded
// (hence 43 characters long).
result.push({
senderKey: key2.slice(0, 43),
sessionId: key2.slice(44),
sessionData: getJsonItem(this.store, key)!,
needsBackup: key2 in sessionsNeedingBackup,
});
if (result.length >= SESSION_BATCH_SIZE) {
return result;
}
}
}
if (result.length === 0) {
// No sessions left.
return null;
}
// There are fewer sessions than the batch size; return the final batch of sessions.
return result;
public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void {
func(getJsonItem(this.store, KEY_DEVICE_DATA));
}
/**
* Delete a batch of Megolm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndInboundGroupSessionsBatch}.
*
* @internal
*/
public async deleteEndToEndInboundGroupSessionsBatch(
sessions: { senderKey: string; sessionId: string }[],
): Promise<void> {
for (const { senderKey, sessionId } of sessions) {
const k = keyEndToEndInboundGroupSession(senderKey, sessionId);
this.store.removeItem(k);
}
public storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void {
setJsonItem(this.store, KEY_DEVICE_DATA, deviceData);
}
public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void {
setJsonItem(this.store, keyEndToEndRoomsPrefix(roomId), roomInfo);
}
public getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void {
@@ -331,6 +281,47 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
func(result);
}
public getSessionsNeedingBackup(limit: number): Promise<ISession[]> {
const sessionsNeedingBackup = getJsonItem<string[]>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
const sessions: ISession[] = [];
for (const session in sessionsNeedingBackup) {
if (Object.prototype.hasOwnProperty.call(sessionsNeedingBackup, session)) {
// see getAllEndToEndInboundGroupSessions for the magic number explanations
const senderKey = session.slice(0, 43);
const sessionId = session.slice(44);
this.getEndToEndInboundGroupSession(senderKey, sessionId, null, (sessionData) => {
sessions.push({
senderKey: senderKey,
sessionId: sessionId,
sessionData: sessionData!,
});
});
if (limit && sessions.length >= limit) {
break;
}
}
}
return Promise.resolve(sessions);
}
public countSessionsNeedingBackup(): Promise<number> {
const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
return Promise.resolve(Object.keys(sessionsNeedingBackup).length);
}
public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
const sessionsNeedingBackup =
getJsonItem<{
[senderKeySessionId: string]: string;
}>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
for (const session of sessions) {
delete sessionsNeedingBackup[session.senderKey + "/" + session.sessionId];
}
setJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup);
return Promise.resolve();
}
public markSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
const sessionsNeedingBackup =
getJsonItem<{
@@ -364,8 +355,8 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
setJsonItem(this.store, KEY_END_TO_END_ACCOUNT, accountPickle);
}
public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, CrossSigningKeyInfo> | null) => void): void {
const keys = getJsonItem<Record<string, CrossSigningKeyInfo>>(this.store, KEY_CROSS_SIGNING_KEYS);
public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void {
const keys = getJsonItem<Record<string, ICrossSigningKey>>(this.store, KEY_CROSS_SIGNING_KEYS);
func(keys);
}
@@ -378,6 +369,10 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
func(key);
}
public storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void {
setJsonItem(this.store, KEY_CROSS_SIGNING_KEYS, keys);
}
public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
txn: unknown,
type: K,

View File

@@ -14,61 +14,47 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { safeSet } from "../../utils.ts";
import { logger } from "../../logger";
import { safeSet, deepCompare, promiseTry } from "../../utils";
import {
type CryptoStore,
type ISession,
type SessionExtended,
type ISessionInfo,
type IWithheld,
MigrationState,
type Mode,
type SecretStorePrivateKeys,
SESSION_BATCH_SIZE,
type InboundGroupSessionData,
type IRoomEncryption,
} from "./base.ts";
import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts";
function encodeSessionKey(senderCurve25519Key: string, sessionId: string): string {
return encodeURIComponent(senderCurve25519Key) + "/" + encodeURIComponent(sessionId);
}
function decodeSessionKey(key: string): { senderKey: string; sessionId: string } {
const keyParts = key.split("/");
const senderKey = decodeURIComponent(keyParts[0]);
const sessionId = decodeURIComponent(keyParts[1]);
return { senderKey, sessionId };
}
CryptoStore,
IDeviceData,
IProblem,
ISession,
ISessionInfo,
IWithheld,
Mode,
OutgoingRoomKeyRequest,
ParkedSharedHistory,
SecretStorePrivateKeys,
} from "./base";
import { IRoomKeyRequestBody } from "../index";
import { ICrossSigningKey } from "../../client";
import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { InboundGroupSessionData } from "../OlmDevice";
/**
* Internal module. in-memory storage for e2e.
*/
export class MemoryCryptoStore implements CryptoStore {
private migrationState: MigrationState = MigrationState.NOT_STARTED;
private outgoingRoomKeyRequests: OutgoingRoomKeyRequest[] = [];
private account: string | null = null;
private crossSigningKeys: Record<string, CrossSigningKeyInfo> | null = null;
private crossSigningKeys: Record<string, ICrossSigningKey> | null = null;
private privateKeys: Partial<SecretStorePrivateKeys> = {};
private sessions: { [deviceKey: string]: { [sessionId: string]: ISessionInfo } } = {};
private sessionProblems: { [deviceKey: string]: IProblem[] } = {};
private notifiedErrorDevices: { [userId: string]: { [deviceId: string]: boolean } } = {};
private inboundGroupSessions: { [sessionKey: string]: InboundGroupSessionData } = {};
private inboundGroupSessionsWithheld: Record<string, IWithheld> = {};
// Opaque device data object
private deviceData: IDeviceData | null = null;
private rooms: { [roomId: string]: IRoomEncryption } = {};
private sessionsNeedingBackup: { [sessionKey: string]: boolean } = {};
/**
* Returns true if this CryptoStore has ever been initialised (ie, it might contain data).
*
* Implementation of {@link CryptoStore.containsData}.
*
* @internal
*/
public async containsData(): Promise<boolean> {
// If it contains anything, it should contain an account.
return this.account !== null;
}
private sharedHistoryInboundGroupSessions: { [roomId: string]: [senderKey: string, sessionId: string][] } = {};
private parkedSharedHistory = new Map<string, ParkedSharedHistory[]>(); // keyed by room ID
/**
* Ensure the database exists and is up-to-date.
@@ -92,25 +78,186 @@ export class MemoryCryptoStore implements CryptoStore {
}
/**
* Get data on how much of the libolm to Rust Crypto migration has been done.
* Look for an existing outgoing room key request, and if none is found,
* add a new one
*
* Implementation of {@link CryptoStore.getMigrationState}.
*
* @internal
* @returns resolves to
* {@link OutgoingRoomKeyRequest}: either the
* same instance as passed in, or the existing one.
*/
public async getMigrationState(): Promise<MigrationState> {
return this.migrationState;
public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> {
const requestBody = request.requestBody;
return promiseTry(() => {
// first see if we already have an entry for this request.
const existing = this._getOutgoingRoomKeyRequest(requestBody);
if (existing) {
// this entry matches the request - return it.
logger.log(
`already have key request outstanding for ` +
`${requestBody.room_id} / ${requestBody.session_id}: ` +
`not sending another`,
);
return existing;
}
// we got to the end of the list without finding a match
// - add the new request.
logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id);
this.outgoingRoomKeyRequests.push(request);
return request;
});
}
/**
* Set data on how much of the libolm to Rust Crypto migration has been done.
* Look for an existing room key request
*
* Implementation of {@link CryptoStore.setMigrationState}.
* @param requestBody - existing request to look for
*
* @returns resolves to the matching
* {@link OutgoingRoomKeyRequest}, or null if
* not found
*/
public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null> {
return Promise.resolve(this._getOutgoingRoomKeyRequest(requestBody));
}
/**
* Looks for existing room key request, and returns the result synchronously.
*
* @internal
*
* @param requestBody - existing request to look for
*
* @returns
* the matching request, or null if not found
*/
public async setMigrationState(migrationState: MigrationState): Promise<void> {
this.migrationState = migrationState;
// eslint-disable-next-line @typescript-eslint/naming-convention
private _getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): OutgoingRoomKeyRequest | null {
for (const existing of this.outgoingRoomKeyRequests) {
if (deepCompare(existing.requestBody, requestBody)) {
return existing;
}
}
return null;
}
/**
* Look for room key requests by state
*
* @param wantedStates - list of acceptable states
*
* @returns resolves to the a
* {@link OutgoingRoomKeyRequest}, or null if
* there are no pending requests in those states
*/
public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null> {
for (const req of this.outgoingRoomKeyRequests) {
for (const state of wantedStates) {
if (req.state === state) {
return Promise.resolve(req);
}
}
}
return Promise.resolve(null);
}
/**
*
* @returns All OutgoingRoomKeyRequests in state
*/
public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]> {
return Promise.resolve(this.outgoingRoomKeyRequests.filter((r) => r.state == wantedState));
}
public getOutgoingRoomKeyRequestsByTarget(
userId: string,
deviceId: string,
wantedStates: number[],
): Promise<OutgoingRoomKeyRequest[]> {
const results: OutgoingRoomKeyRequest[] = [];
for (const req of this.outgoingRoomKeyRequests) {
for (const state of wantedStates) {
if (
req.state === state &&
req.recipients.some((recipient) => recipient.userId === userId && recipient.deviceId === deviceId)
) {
results.push(req);
}
}
}
return Promise.resolve(results);
}
/**
* Look for an existing room key request by id and state, and update it if
* found
*
* @param requestId - ID of request to update
* @param expectedState - state we expect to find the request in
* @param updates - name/value map of updates to apply
*
* @returns resolves to
* {@link OutgoingRoomKeyRequest}
* updated request, or null if no matching row was found
*/
public updateOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
updates: Partial<OutgoingRoomKeyRequest>,
): Promise<OutgoingRoomKeyRequest | null> {
for (const req of this.outgoingRoomKeyRequests) {
if (req.requestId !== requestId) {
continue;
}
if (req.state !== expectedState) {
logger.warn(
`Cannot update room key request from ${expectedState} ` +
`as it was already updated to ${req.state}`,
);
return Promise.resolve(null);
}
Object.assign(req, updates);
return Promise.resolve(req);
}
return Promise.resolve(null);
}
/**
* Look for an existing room key request by id and state, and delete it if
* found
*
* @param requestId - ID of request to update
* @param expectedState - state we expect to find the request in
*
* @returns resolves once the operation is completed
*/
public deleteOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
): Promise<OutgoingRoomKeyRequest | null> {
for (let i = 0; i < this.outgoingRoomKeyRequests.length; i++) {
const req = this.outgoingRoomKeyRequests[i];
if (req.requestId !== requestId) {
continue;
}
if (req.state != expectedState) {
logger.warn(`Cannot delete room key request in state ${req.state} ` + `(expected ${expectedState})`);
return Promise.resolve(null);
}
this.outgoingRoomKeyRequests.splice(i, 1);
return Promise.resolve(req);
}
return Promise.resolve(null);
}
// Olm Account
@@ -123,7 +270,7 @@ export class MemoryCryptoStore implements CryptoStore {
this.account = accountPickle;
}
public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, CrossSigningKeyInfo> | null) => void): void {
public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void {
func(this.crossSigningKeys);
}
@@ -132,10 +279,14 @@ export class MemoryCryptoStore implements CryptoStore {
func: (key: SecretStorePrivateKeys[K] | null) => void,
type: K,
): void {
const result = this.privateKeys[type];
const result = this.privateKeys[type] as SecretStorePrivateKeys[K] | undefined;
func(result || null);
}
public storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void {
this.crossSigningKeys = keys;
}
public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
txn: unknown,
type: K,
@@ -147,11 +298,7 @@ export class MemoryCryptoStore implements CryptoStore {
// Olm Sessions
public countEndToEndSessions(txn: unknown, func: (count: number) => void): void {
let count = 0;
for (const deviceSessions of Object.values(this.sessions)) {
count += Object.keys(deviceSessions).length;
}
func(count);
func(Object.keys(this.sessions).length);
}
public getEndToEndSession(
@@ -172,6 +319,18 @@ export class MemoryCryptoStore implements CryptoStore {
func(this.sessions[deviceKey] || {});
}
public getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo) => void): void {
Object.entries(this.sessions).forEach(([deviceKey, deviceSessions]) => {
Object.entries(deviceSessions).forEach(([sessionId, session]) => {
func({
...session,
deviceKey,
sessionId,
});
});
});
}
public storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void {
let deviceSessions = this.sessions[deviceKey];
if (deviceSessions === undefined) {
@@ -181,49 +340,50 @@ export class MemoryCryptoStore implements CryptoStore {
safeSet(deviceSessions, sessionId, sessionInfo);
}
/**
* Fetch a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndSessionsBatch}.
*
* @internal
*/
public async getEndToEndSessionsBatch(): Promise<null | ISessionInfo[]> {
const result: ISessionInfo[] = [];
for (const deviceSessions of Object.values(this.sessions)) {
for (const session of Object.values(deviceSessions)) {
result.push(session);
if (result.length >= SESSION_BATCH_SIZE) {
return result;
}
}
}
if (result.length === 0) {
// No sessions left.
return null;
}
// There are fewer sessions than the batch size; return the final batch of sessions.
return result;
public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
const problems = (this.sessionProblems[deviceKey] = this.sessionProblems[deviceKey] || []);
problems.push({ type, fixed, time: Date.now() });
problems.sort((a, b) => {
return a.time - b.time;
});
}
/**
* Delete a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndSessionsBatch}.
*
* @internal
*/
public async deleteEndToEndSessionsBatch(sessions: { deviceKey: string; sessionId: string }[]): Promise<void> {
for (const { deviceKey, sessionId } of sessions) {
const deviceSessions = this.sessions[deviceKey] || {};
delete deviceSessions[sessionId];
if (Object.keys(deviceSessions).length === 0) {
// No more sessions for this device.
delete this.sessions[deviceKey];
public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> {
const problems = this.sessionProblems[deviceKey] || [];
if (!problems.length) {
return null;
}
const lastProblem = problems[problems.length - 1];
for (const problem of problems) {
if (problem.time > timestamp) {
return Object.assign({}, problem, { fixed: lastProblem.fixed });
}
}
if (lastProblem.fixed) {
return null;
} else {
return lastProblem;
}
}
public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
const notifiedErrorDevices = this.notifiedErrorDevices;
const ret: IOlmDevice[] = [];
for (const device of devices) {
const { userId, deviceInfo } = device;
if (userId in notifiedErrorDevices) {
if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) {
ret.push(device);
safeSet(notifiedErrorDevices[userId], deviceInfo.deviceId, true);
}
} else {
ret.push(device);
safeSet(notifiedErrorDevices, userId, { [deviceInfo.deviceId]: true });
}
}
return ret;
}
// Inbound Group Sessions
@@ -234,90 +394,136 @@ export class MemoryCryptoStore implements CryptoStore {
txn: unknown,
func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void,
): void {
const k = encodeSessionKey(senderCurve25519Key, sessionId);
const k = senderCurve25519Key + "/" + sessionId;
func(this.inboundGroupSessions[k] || null, this.inboundGroupSessionsWithheld[k] || null);
}
public getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void {
for (const key of Object.keys(this.inboundGroupSessions)) {
// we can't use split, as the components we are trying to split out
// might themselves contain '/' characters. We rely on the
// senderKey being a (32-byte) curve25519 key, base64-encoded
// (hence 43 characters long).
func({
senderKey: key.slice(0, 43),
sessionId: key.slice(44),
sessionData: this.inboundGroupSessions[key],
});
}
func(null);
}
public addEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: unknown,
): void {
const k = senderCurve25519Key + "/" + sessionId;
if (this.inboundGroupSessions[k] === undefined) {
this.inboundGroupSessions[k] = sessionData;
}
}
public storeEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: unknown,
): void {
const k = encodeSessionKey(senderCurve25519Key, sessionId);
this.inboundGroupSessions[k] = sessionData;
this.inboundGroupSessions[senderCurve25519Key + "/" + sessionId] = sessionData;
}
/**
* Count the number of Megolm sessions in the database.
*
* Implementation of {@link CryptoStore.countEndToEndInboundGroupSessions}.
*
* @internal
*/
public async countEndToEndInboundGroupSessions(): Promise<number> {
return Object.keys(this.inboundGroupSessions).length;
public storeEndToEndInboundGroupSessionWithheld(
senderCurve25519Key: string,
sessionId: string,
sessionData: IWithheld,
txn: unknown,
): void {
const k = senderCurve25519Key + "/" + sessionId;
this.inboundGroupSessionsWithheld[k] = sessionData;
}
/**
* Fetch a batch of Megolm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndInboundGroupSessionsBatch}.
*
* @internal
*/
public async getEndToEndInboundGroupSessionsBatch(): Promise<null | SessionExtended[]> {
const result: SessionExtended[] = [];
for (const [key, session] of Object.entries(this.inboundGroupSessions)) {
result.push({
...decodeSessionKey(key),
sessionData: session,
needsBackup: key in this.sessionsNeedingBackup,
});
if (result.length >= SESSION_BATCH_SIZE) {
return result;
}
}
// Device Data
if (result.length === 0) {
// No sessions left.
return null;
}
// There are fewer sessions than the batch size; return the final batch of sessions.
return result;
public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void {
func(this.deviceData);
}
/**
* Delete a batch of Megolm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndInboundGroupSessionsBatch}.
*
* @internal
*/
public async deleteEndToEndInboundGroupSessionsBatch(
sessions: { senderKey: string; sessionId: string }[],
): Promise<void> {
for (const { senderKey, sessionId } of sessions) {
const k = encodeSessionKey(senderKey, sessionId);
delete this.inboundGroupSessions[k];
}
public storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void {
this.deviceData = deviceData;
}
// E2E rooms
public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void {
this.rooms[roomId] = roomInfo;
}
public getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void {
func(this.rooms);
}
public getSessionsNeedingBackup(limit: number): Promise<ISession[]> {
const sessions: ISession[] = [];
for (const session in this.sessionsNeedingBackup) {
if (this.inboundGroupSessions[session]) {
sessions.push({
senderKey: session.slice(0, 43),
sessionId: session.slice(44),
sessionData: this.inboundGroupSessions[session],
});
if (limit && session.length >= limit) {
break;
}
}
}
return Promise.resolve(sessions);
}
public countSessionsNeedingBackup(): Promise<number> {
return Promise.resolve(Object.keys(this.sessionsNeedingBackup).length);
}
public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
for (const session of sessions) {
const sessionKey = session.senderKey + "/" + session.sessionId;
delete this.sessionsNeedingBackup[sessionKey];
}
return Promise.resolve();
}
public markSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
for (const session of sessions) {
const sessionKey = encodeSessionKey(session.senderKey, session.sessionId);
const sessionKey = session.senderKey + "/" + session.sessionId;
this.sessionsNeedingBackup[sessionKey] = true;
}
return Promise.resolve();
}
public addSharedHistoryInboundGroupSession(roomId: string, senderKey: string, sessionId: string): void {
const sessions = this.sharedHistoryInboundGroupSessions[roomId] || [];
sessions.push([senderKey, sessionId]);
this.sharedHistoryInboundGroupSessions[roomId] = sessions;
}
public getSharedHistoryInboundGroupSessions(roomId: string): Promise<[senderKey: string, sessionId: string][]> {
return Promise.resolve(this.sharedHistoryInboundGroupSessions[roomId] || []);
}
public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory): void {
const parked = this.parkedSharedHistory.get(roomId) ?? [];
parked.push(parkedData);
this.parkedSharedHistory.set(roomId, parked);
}
public takeParkedSharedHistory(roomId: string): Promise<ParkedSharedHistory[]> {
const parked = this.parkedSharedHistory.get(roomId) ?? [];
this.parkedSharedHistory.delete(roomId);
return Promise.resolve(parked);
}
// Session key backups
public doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn?: unknown) => T): Promise<T> {