init
This commit is contained in:
381
node_modules/matrix-js-sdk/src/crypto/store/base.ts
generated
vendored
Normal file
381
node_modules/matrix-js-sdk/src/crypto/store/base.ts
generated
vendored
Normal file
@@ -0,0 +1,381 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
/**
|
||||
* Internal module. Definitions for storage for the crypto module
|
||||
*/
|
||||
|
||||
export interface SecretStorePrivateKeys {
|
||||
"m.megolm_backup.v1": AESEncryptedSecretStoragePayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>;
|
||||
|
||||
// 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;
|
||||
getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
|
||||
txn: unknown,
|
||||
func: (key: SecretStorePrivateKeys[K] | null) => void,
|
||||
type: K,
|
||||
): void;
|
||||
storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
|
||||
txn: unknown,
|
||||
type: K,
|
||||
key: SecretStorePrivateKeys[K],
|
||||
): void;
|
||||
|
||||
// Olm Sessions
|
||||
countEndToEndSessions(txn: unknown, func: (count: number) => void): void;
|
||||
getEndToEndSession(
|
||||
deviceKey: string,
|
||||
sessionId: string,
|
||||
txn: unknown,
|
||||
func: (session: ISessionInfo | null) => void,
|
||||
): void;
|
||||
getEndToEndSessions(
|
||||
deviceKey: string,
|
||||
txn: unknown,
|
||||
func: (sessions: { [sessionId: string]: ISessionInfo }) => 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>;
|
||||
|
||||
// Inbound Group Sessions
|
||||
getEndToEndInboundGroupSession(
|
||||
senderCurve25519Key: string,
|
||||
sessionId: string,
|
||||
txn: unknown,
|
||||
func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void,
|
||||
): 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>;
|
||||
|
||||
// Device Data
|
||||
getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void;
|
||||
markSessionsNeedingBackup(sessions: ISession[], txn?: unknown): Promise<void>;
|
||||
|
||||
// Session key backups
|
||||
doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T, log?: Logger): 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;
|
||||
session?: string;
|
||||
lastReceivedMessageTs?: number;
|
||||
}
|
||||
|
||||
export interface IDeviceData {
|
||||
devices: {
|
||||
[userId: string]: {
|
||||
[deviceId: string]: IDevice;
|
||||
};
|
||||
};
|
||||
trackingStatus: {
|
||||
[userId: string]: TrackingStatus;
|
||||
};
|
||||
crossSigningInfo?: Record<string, ICrossSigningInfo>;
|
||||
syncToken?: string;
|
||||
}
|
||||
|
||||
export interface IWithheld {
|
||||
room_id: string;
|
||||
code: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an outgoing room key request
|
||||
*/
|
||||
export interface OutgoingRoomKeyRequest {
|
||||
/**
|
||||
* Unique id for this request. Used for both an id within the request for later pairing with a cancellation,
|
||||
* and for the transaction id when sending the to_device messages to our local server.
|
||||
*/
|
||||
requestId: string;
|
||||
requestTxnId?: string;
|
||||
/**
|
||||
* Transaction id for the cancellation, if any
|
||||
*/
|
||||
cancellationTxnId?: string;
|
||||
/**
|
||||
* List of recipients for the request
|
||||
*/
|
||||
recipients: IRoomKeyRequestRecipient[];
|
||||
/**
|
||||
* Parameters for the request
|
||||
*/
|
||||
requestBody: IRoomKeyRequestBody;
|
||||
/**
|
||||
* current state of this request
|
||||
*/
|
||||
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). */
|
||||
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,
|
||||
}
|
||||
656
node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store-backend.ts
generated
vendored
Normal file
656
node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store-backend.ts
generated
vendored
Normal file
@@ -0,0 +1,656 @@
|
||||
/*
|
||||
Copyright 2017 - 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.
|
||||
*/
|
||||
|
||||
import { type Logger, logger } from "../../logger.ts";
|
||||
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";
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
*/
|
||||
public constructor(private db: IDBDatabase) {
|
||||
// make sure we close the db on `onversionchange` - otherwise
|
||||
// attempts to delete the database will block (and subsequent
|
||||
// attempts to re-create it will also block).
|
||||
db.onversionchange = (): void => {
|
||||
logger.log(`versionchange for indexeddb ${this.db.name}: closing`);
|
||||
db.close();
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.getMigrationState}.
|
||||
*/
|
||||
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;
|
||||
};
|
||||
});
|
||||
return migrationState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set data on how much of the libolm to Rust Crypto migration has been done.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.setMigrationState}.
|
||||
*/
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// Olm Account
|
||||
|
||||
public getAccount(txn: IDBTransaction, func: (accountPickle: string | null) => void): void {
|
||||
const objectStore = txn.objectStore("account");
|
||||
const getReq = objectStore.get("-");
|
||||
getReq.onsuccess = function (): void {
|
||||
try {
|
||||
func(getReq.result || null);
|
||||
} catch (e) {
|
||||
abortWithException(txn, <Error>e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public storeAccount(txn: IDBTransaction, accountPickle: string): void {
|
||||
const objectStore = txn.objectStore("account");
|
||||
objectStore.put(accountPickle, "-");
|
||||
}
|
||||
|
||||
public getCrossSigningKeys(
|
||||
txn: IDBTransaction,
|
||||
func: (keys: Record<string, CrossSigningKeyInfo> | null) => void,
|
||||
): void {
|
||||
const objectStore = txn.objectStore("account");
|
||||
const getReq = objectStore.get("crossSigningKeys");
|
||||
getReq.onsuccess = function (): void {
|
||||
try {
|
||||
func(getReq.result || null);
|
||||
} catch (e) {
|
||||
abortWithException(txn, <Error>e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
|
||||
txn: IDBTransaction,
|
||||
func: (key: SecretStorePrivateKeys[K] | null) => void,
|
||||
type: K,
|
||||
): void {
|
||||
const objectStore = txn.objectStore("account");
|
||||
const getReq = objectStore.get(`ssss_cache:${type}`);
|
||||
getReq.onsuccess = function (): void {
|
||||
try {
|
||||
func(getReq.result || null);
|
||||
} catch (e) {
|
||||
abortWithException(txn, <Error>e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
|
||||
txn: IDBTransaction,
|
||||
type: K,
|
||||
key: SecretStorePrivateKeys[K],
|
||||
): void {
|
||||
const objectStore = txn.objectStore("account");
|
||||
objectStore.put(key, `ssss_cache:${type}`);
|
||||
}
|
||||
|
||||
// Olm Sessions
|
||||
|
||||
public countEndToEndSessions(txn: IDBTransaction, func: (count: number) => void): void {
|
||||
const objectStore = txn.objectStore("sessions");
|
||||
const countReq = objectStore.count();
|
||||
countReq.onsuccess = function (): void {
|
||||
try {
|
||||
func(countReq.result);
|
||||
} catch (e) {
|
||||
abortWithException(txn, <Error>e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public getEndToEndSessions(
|
||||
deviceKey: string,
|
||||
txn: IDBTransaction,
|
||||
func: (sessions: { [sessionId: string]: ISessionInfo }) => void,
|
||||
): void {
|
||||
const objectStore = txn.objectStore("sessions");
|
||||
const idx = objectStore.index("deviceKey");
|
||||
const getReq = idx.openCursor(deviceKey);
|
||||
const results: Parameters<Parameters<Backend["getEndToEndSessions"]>[2]>[0] = {};
|
||||
getReq.onsuccess = function (): void {
|
||||
const cursor = getReq.result;
|
||||
if (cursor) {
|
||||
results[cursor.value.sessionId] = {
|
||||
session: cursor.value.session,
|
||||
lastReceivedMessageTs: cursor.value.lastReceivedMessageTs,
|
||||
};
|
||||
cursor.continue();
|
||||
} else {
|
||||
try {
|
||||
func(results);
|
||||
} catch (e) {
|
||||
abortWithException(txn, <Error>e);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public getEndToEndSession(
|
||||
deviceKey: string,
|
||||
sessionId: string,
|
||||
txn: IDBTransaction,
|
||||
func: (session: ISessionInfo | null) => void,
|
||||
): void {
|
||||
const objectStore = txn.objectStore("sessions");
|
||||
const getReq = objectStore.get([deviceKey, sessionId]);
|
||||
getReq.onsuccess = function (): void {
|
||||
try {
|
||||
if (getReq.result) {
|
||||
func({
|
||||
session: getReq.result.session,
|
||||
lastReceivedMessageTs: getReq.result.lastReceivedMessageTs,
|
||||
});
|
||||
} else {
|
||||
func(null);
|
||||
}
|
||||
} catch (e) {
|
||||
abortWithException(txn, <Error>e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public storeEndToEndSession(
|
||||
deviceKey: string,
|
||||
sessionId: string,
|
||||
sessionInfo: ISessionInfo,
|
||||
txn: IDBTransaction,
|
||||
): void {
|
||||
const objectStore = txn.objectStore("sessions");
|
||||
objectStore.put({
|
||||
deviceKey,
|
||||
sessionId,
|
||||
session: sessionInfo.session,
|
||||
lastReceivedMessageTs: sessionInfo.lastReceivedMessageTs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (result.length === 0) {
|
||||
// No sessions left.
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Inbound group sessions
|
||||
|
||||
public getEndToEndInboundGroupSession(
|
||||
senderCurve25519Key: string,
|
||||
sessionId: string,
|
||||
txn: IDBTransaction,
|
||||
func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void,
|
||||
): void {
|
||||
let session: InboundGroupSessionData | null | boolean = false;
|
||||
let withheld: IWithheld | null | boolean = false;
|
||||
const objectStore = txn.objectStore("inbound_group_sessions");
|
||||
const getReq = objectStore.get([senderCurve25519Key, sessionId]);
|
||||
getReq.onsuccess = function (): void {
|
||||
try {
|
||||
if (getReq.result) {
|
||||
session = getReq.result.session;
|
||||
} else {
|
||||
session = null;
|
||||
}
|
||||
if (withheld !== false) {
|
||||
func(session as InboundGroupSessionData, withheld as IWithheld);
|
||||
}
|
||||
} catch (e) {
|
||||
abortWithException(txn, <Error>e);
|
||||
}
|
||||
};
|
||||
|
||||
const withheldObjectStore = txn.objectStore("inbound_group_sessions_withheld");
|
||||
const withheldGetReq = withheldObjectStore.get([senderCurve25519Key, sessionId]);
|
||||
withheldGetReq.onsuccess = function (): void {
|
||||
try {
|
||||
if (withheldGetReq.result) {
|
||||
withheld = withheldGetReq.result.session;
|
||||
} else {
|
||||
withheld = null;
|
||||
}
|
||||
if (session !== false) {
|
||||
func(session as InboundGroupSessionData, withheld as IWithheld);
|
||||
}
|
||||
} catch (e) {
|
||||
abortWithException(txn, <Error>e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public storeEndToEndInboundGroupSession(
|
||||
senderCurve25519Key: string,
|
||||
sessionId: string,
|
||||
sessionData: InboundGroupSessionData,
|
||||
txn: IDBTransaction,
|
||||
): void {
|
||||
const objectStore = txn.objectStore("inbound_group_sessions");
|
||||
objectStore.put({
|
||||
senderCurve25519Key,
|
||||
sessionId,
|
||||
session: sessionData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 getEndToEndDeviceData(txn: IDBTransaction, func: (deviceData: IDeviceData | null) => void): void {
|
||||
const objectStore = txn.objectStore("device_data");
|
||||
const getReq = objectStore.get("-");
|
||||
getReq.onsuccess = function (): void {
|
||||
try {
|
||||
func(getReq.result || null);
|
||||
} catch (e) {
|
||||
abortWithException(txn, <Error>e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public getEndToEndRooms(txn: IDBTransaction, func: (rooms: Record<string, IRoomEncryption>) => void): void {
|
||||
const rooms: Parameters<Parameters<Backend["getEndToEndRooms"]>[1]>[0] = {};
|
||||
const objectStore = txn.objectStore("rooms");
|
||||
const getReq = objectStore.openCursor();
|
||||
getReq.onsuccess = function (): void {
|
||||
const cursor = getReq.result;
|
||||
if (cursor) {
|
||||
rooms[cursor.key as string] = cursor.value;
|
||||
cursor.continue();
|
||||
} else {
|
||||
try {
|
||||
func(rooms);
|
||||
} catch (e) {
|
||||
abortWithException(txn, <Error>e);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async markSessionsNeedingBackup(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.put({
|
||||
senderCurve25519Key: session.senderKey,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
req.onsuccess = resolve;
|
||||
req.onerror = reject;
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public doTxn<T>(
|
||||
mode: Mode,
|
||||
stores: string | string[],
|
||||
func: (txn: IDBTransaction) => T,
|
||||
log: Logger = logger,
|
||||
): Promise<T> {
|
||||
let startTime: number;
|
||||
let description: string;
|
||||
if (PROFILE_TRANSACTIONS) {
|
||||
const txnId = this.nextTxnId++;
|
||||
startTime = Date.now();
|
||||
description = `${mode} crypto store transaction ${txnId} in ${stores}`;
|
||||
log.debug(`Starting ${description}`);
|
||||
}
|
||||
const txn = this.db.transaction(stores, mode);
|
||||
const promise = promiseifyTxn(txn);
|
||||
const result = func(txn);
|
||||
if (PROFILE_TRANSACTIONS) {
|
||||
promise.then(
|
||||
() => {
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
log.debug(`Finished ${description}, took ${elapsedTime} ms`);
|
||||
},
|
||||
() => {
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
log.error(`Failed ${description}, took ${elapsedTime} ms`);
|
||||
},
|
||||
);
|
||||
}
|
||||
return promise.then(() => {
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type DbMigration = (db: IDBDatabase) => void;
|
||||
const DB_MIGRATIONS: DbMigration[] = [
|
||||
(db): void => {
|
||||
createDatabase(db);
|
||||
},
|
||||
(db): void => {
|
||||
db.createObjectStore("account");
|
||||
},
|
||||
(db): void => {
|
||||
const sessionsStore = db.createObjectStore("sessions", {
|
||||
keyPath: ["deviceKey", "sessionId"],
|
||||
});
|
||||
sessionsStore.createIndex("deviceKey", "deviceKey");
|
||||
},
|
||||
(db): void => {
|
||||
db.createObjectStore("inbound_group_sessions", {
|
||||
keyPath: ["senderCurve25519Key", "sessionId"],
|
||||
});
|
||||
},
|
||||
(db): void => {
|
||||
db.createObjectStore("device_data");
|
||||
},
|
||||
(db): void => {
|
||||
db.createObjectStore("rooms");
|
||||
},
|
||||
(db): void => {
|
||||
db.createObjectStore("sessions_needing_backup", {
|
||||
keyPath: ["senderCurve25519Key", "sessionId"],
|
||||
});
|
||||
},
|
||||
(db): void => {
|
||||
db.createObjectStore("inbound_group_sessions_withheld", {
|
||||
keyPath: ["senderCurve25519Key", "sessionId"],
|
||||
});
|
||||
},
|
||||
(db): void => {
|
||||
const problemsStore = db.createObjectStore("session_problems", {
|
||||
keyPath: ["deviceKey", "time"],
|
||||
});
|
||||
problemsStore.createIndex("deviceKey", "deviceKey");
|
||||
|
||||
db.createObjectStore("notified_error_devices", {
|
||||
keyPath: ["userId", "deviceId"],
|
||||
});
|
||||
},
|
||||
(db): void => {
|
||||
db.createObjectStore("shared_history_inbound_group_sessions", {
|
||||
keyPath: ["roomId"],
|
||||
});
|
||||
},
|
||||
(db): void => {
|
||||
db.createObjectStore("parked_shared_history", {
|
||||
keyPath: ["roomId"],
|
||||
});
|
||||
},
|
||||
// Expand as needed.
|
||||
];
|
||||
export const VERSION = DB_MIGRATIONS.length;
|
||||
|
||||
export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void {
|
||||
logger.log(`Upgrading IndexedDBCryptoStore from version ${oldVersion} to ${VERSION}`);
|
||||
DB_MIGRATIONS.forEach((migration, index) => {
|
||||
if (oldVersion <= index) migration(db);
|
||||
});
|
||||
}
|
||||
|
||||
function createDatabase(db: IDBDatabase): void {
|
||||
const outgoingRoomKeyRequestsStore = db.createObjectStore("outgoingRoomKeyRequests", { keyPath: "requestId" });
|
||||
|
||||
// we assume that the RoomKeyRequestBody will have room_id and session_id
|
||||
// properties, to make the index efficient.
|
||||
outgoingRoomKeyRequestsStore.createIndex("session", ["requestBody.room_id", "requestBody.session_id"]);
|
||||
|
||||
outgoingRoomKeyRequestsStore.createIndex("state", "state");
|
||||
}
|
||||
|
||||
interface IWrappedIDBTransaction extends IDBTransaction {
|
||||
_mx_abortexception: Error;
|
||||
}
|
||||
|
||||
/*
|
||||
* Aborts a transaction with a given exception
|
||||
* The transaction promise will be rejected with this exception.
|
||||
*/
|
||||
function abortWithException(txn: IDBTransaction, e: Error): void {
|
||||
// We cheekily stick our exception onto the transaction object here
|
||||
// We could alternatively make the thing we pass back to the app
|
||||
// an object containing the transaction and exception.
|
||||
(txn as IWrappedIDBTransaction)._mx_abortexception = e;
|
||||
try {
|
||||
txn.abort();
|
||||
} catch {
|
||||
// sometimes we won't be able to abort the transaction
|
||||
// (ie. if it's aborted or completed)
|
||||
}
|
||||
}
|
||||
|
||||
function promiseifyTxn<T>(txn: IDBTransaction): Promise<T | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
txn.oncomplete = (): void => {
|
||||
if ((txn as IWrappedIDBTransaction)._mx_abortexception !== undefined) {
|
||||
reject((txn as IWrappedIDBTransaction)._mx_abortexception);
|
||||
return;
|
||||
}
|
||||
resolve(null);
|
||||
};
|
||||
txn.onerror = (event): void => {
|
||||
if ((txn as IWrappedIDBTransaction)._mx_abortexception !== undefined) {
|
||||
reject((txn as IWrappedIDBTransaction)._mx_abortexception);
|
||||
} else {
|
||||
logger.log("Error performing indexeddb txn", event);
|
||||
reject(txn.error);
|
||||
}
|
||||
};
|
||||
txn.onabort = (event): void => {
|
||||
if ((txn as IWrappedIDBTransaction)._mx_abortexception !== undefined) {
|
||||
reject((txn as IWrappedIDBTransaction)._mx_abortexception);
|
||||
} else {
|
||||
logger.log("Error performing indexeddb txn", event);
|
||||
reject(txn.error);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
553
node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store.ts
generated
vendored
Normal file
553
node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store.ts
generated
vendored
Normal file
@@ -0,0 +1,553 @@
|
||||
/*
|
||||
Copyright 2017 - 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.
|
||||
*/
|
||||
|
||||
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 {
|
||||
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";
|
||||
|
||||
/*
|
||||
* Internal module. indexeddb storage for e2e.
|
||||
*/
|
||||
|
||||
/**
|
||||
* An implementation of CryptoStore, which is normally backed by an indexeddb,
|
||||
* but with fallback to MemoryCryptoStore.
|
||||
*/
|
||||
export class IndexedDBCryptoStore implements CryptoStore {
|
||||
public static STORE_ACCOUNT = "account";
|
||||
public static STORE_SESSIONS = "sessions";
|
||||
public static STORE_INBOUND_GROUP_SESSIONS = "inbound_group_sessions";
|
||||
public static STORE_INBOUND_GROUP_SESSIONS_WITHHELD = "inbound_group_sessions_withheld";
|
||||
public static STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS = "shared_history_inbound_group_sessions";
|
||||
public static STORE_PARKED_SHARED_HISTORY = "parked_shared_history";
|
||||
public static STORE_DEVICE_DATA = "device_data";
|
||||
public static STORE_ROOMS = "rooms";
|
||||
public static STORE_BACKUP = "sessions_needing_backup";
|
||||
|
||||
public static exists(indexedDB: IDBFactory, dbName: string): Promise<boolean> {
|
||||
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;
|
||||
|
||||
/**
|
||||
* Create a new IndexedDBCryptoStore
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the database exists and is up-to-date, or fall back to
|
||||
* a local storage or in-memory store.
|
||||
*
|
||||
* This must be called before the store can be used.
|
||||
*
|
||||
* @returns resolves to either an IndexedDBCryptoStoreBackend.Backend,
|
||||
* or a MemoryCryptoStore
|
||||
*/
|
||||
public startup(): Promise<CryptoStore> {
|
||||
if (this.backendPromise) {
|
||||
return this.backendPromise;
|
||||
}
|
||||
|
||||
this.backendPromise = new Promise<CryptoStore>((resolve, reject) => {
|
||||
if (!this.indexedDB) {
|
||||
reject(new Error("no indexeddb support available"));
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log(`connecting to indexeddb ${this.dbName}`);
|
||||
|
||||
const req = this.indexedDB.open(this.dbName, IndexedDBCryptoStoreBackend.VERSION);
|
||||
|
||||
req.onupgradeneeded = (ev): void => {
|
||||
const db = req.result;
|
||||
const oldVersion = ev.oldVersion;
|
||||
IndexedDBCryptoStoreBackend.upgradeDatabase(db, oldVersion);
|
||||
};
|
||||
|
||||
req.onblocked = (): void => {
|
||||
logger.log(`can't yet open IndexedDBCryptoStore because it is open elsewhere`);
|
||||
};
|
||||
|
||||
req.onerror = (ev): void => {
|
||||
logger.log("Error connecting to indexeddb", ev);
|
||||
reject(req.error);
|
||||
};
|
||||
|
||||
req.onsuccess = (): void => {
|
||||
const db = req.result;
|
||||
|
||||
logger.log(`connected to indexeddb ${this.dbName}`);
|
||||
resolve(new IndexedDBCryptoStoreBackend.Backend(db));
|
||||
};
|
||||
})
|
||||
.then((backend) => {
|
||||
// Edge has IndexedDB but doesn't support compund keys which we use fairly extensively.
|
||||
// Try a dummy query which will fail if the browser doesn't support compund keys, so
|
||||
// we can fall back to a different backend.
|
||||
return backend
|
||||
.doTxn(
|
||||
"readonly",
|
||||
[
|
||||
IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS,
|
||||
IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD,
|
||||
],
|
||||
(txn) => {
|
||||
backend.getEndToEndInboundGroupSession("", "", txn, () => {});
|
||||
},
|
||||
)
|
||||
.then(() => backend);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e.name === "VersionError") {
|
||||
logger.warn("Crypto DB is too new for us to use!", e);
|
||||
// don't fall back to a different store: the user has crypto data
|
||||
// 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}`);
|
||||
|
||||
try {
|
||||
if (!(globalThis.localStorage instanceof Storage)) {
|
||||
throw new Error("localStorage is not available");
|
||||
}
|
||||
return new LocalStorageCryptoStore(globalThis.localStorage);
|
||||
} catch (e) {
|
||||
logger.warn(`Unable to open localStorage: falling back to in-memory store: ${e}`);
|
||||
return new MemoryCryptoStore();
|
||||
}
|
||||
})
|
||||
.then((backend) => {
|
||||
this.backend = backend;
|
||||
return backend;
|
||||
});
|
||||
|
||||
return this.backendPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all data from this store.
|
||||
*
|
||||
* @returns resolves when the store has been cleared.
|
||||
*/
|
||||
public deleteAllData(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!this.indexedDB) {
|
||||
reject(new Error("no indexeddb support available"));
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log(`Removing indexeddb instance: ${this.dbName}`);
|
||||
const req = this.indexedDB.deleteDatabase(this.dbName);
|
||||
|
||||
req.onblocked = (): void => {
|
||||
logger.log(`can't yet delete IndexedDBCryptoStore because it is open elsewhere`);
|
||||
};
|
||||
|
||||
req.onerror = (ev): void => {
|
||||
logger.log("Error deleting data from indexeddb", ev);
|
||||
reject(req.error);
|
||||
};
|
||||
|
||||
req.onsuccess = (): void => {
|
||||
logger.log(`Removed indexeddb instance: ${this.dbName}`);
|
||||
resolve();
|
||||
};
|
||||
}).catch((e) => {
|
||||
// in firefox, with indexedDB disabled, this fails with a
|
||||
// DOMError. We treat this as non-fatal, so that people can
|
||||
// still use the app.
|
||||
logger.warn(`unable to delete IndexedDBCryptoStore: ${e}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data on how much of the libolm to Rust Crypto migration has been done.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.getMigrationState}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public getMigrationState(): Promise<MigrationState> {
|
||||
return this.backend!.getMigrationState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set data on how much of the libolm to Rust Crypto migration has been done.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.setMigrationState}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public setMigrationState(migrationState: MigrationState): Promise<void> {
|
||||
return this.backend!.setMigrationState(migrationState);
|
||||
}
|
||||
|
||||
// Olm Account
|
||||
|
||||
/*
|
||||
* Get the account pickle from the store.
|
||||
* This requires an active transaction. See doTxn().
|
||||
*
|
||||
* @param txn - An active transaction. See doTxn().
|
||||
* @param func - Called with the account pickle
|
||||
*/
|
||||
public getAccount(txn: IDBTransaction, func: (accountPickle: string | null) => void): void {
|
||||
this.backend!.getAccount(txn, func);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the account pickle to the store.
|
||||
* This requires an active transaction. See doTxn().
|
||||
*
|
||||
* @param txn - An active transaction. See doTxn().
|
||||
* @param accountPickle - The new account pickle to store.
|
||||
*/
|
||||
public storeAccount(txn: IDBTransaction, accountPickle: string): void {
|
||||
this.backend!.storeAccount(txn, accountPickle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public part of the cross-signing keys (eg. self-signing key,
|
||||
* user signing key).
|
||||
*
|
||||
* @param txn - An active transaction. See doTxn().
|
||||
* @param func - Called with the account keys object:
|
||||
* `{ key_type: base64 encoded seed }` where key type = user_signing_key_seed or self_signing_key_seed
|
||||
*/
|
||||
public getCrossSigningKeys(
|
||||
txn: IDBTransaction,
|
||||
func: (keys: Record<string, CrossSigningKeyInfo> | null) => void,
|
||||
): void {
|
||||
this.backend!.getCrossSigningKeys(txn, func);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param txn - An active transaction. See doTxn().
|
||||
* @param func - Called with the private key
|
||||
* @param type - A key type
|
||||
*/
|
||||
public getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
|
||||
txn: IDBTransaction,
|
||||
func: (key: SecretStorePrivateKeys[K] | null) => void,
|
||||
type: K,
|
||||
): void {
|
||||
this.backend!.getSecretStorePrivateKey(txn, func, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the cross-signing private keys back to the store
|
||||
*
|
||||
* @param txn - An active transaction. See doTxn().
|
||||
* @param type - The type of cross-signing private key to store
|
||||
* @param key - keys object as getCrossSigningKeys()
|
||||
*/
|
||||
public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
|
||||
txn: IDBTransaction,
|
||||
type: K,
|
||||
key: SecretStorePrivateKeys[K],
|
||||
): void {
|
||||
this.backend!.storeSecretStorePrivateKey(txn, type, key);
|
||||
}
|
||||
|
||||
// Olm sessions
|
||||
|
||||
/**
|
||||
* Returns the number of end-to-end sessions in the store
|
||||
* @param txn - An active transaction. See doTxn().
|
||||
* @param func - Called with the count of sessions
|
||||
*/
|
||||
public countEndToEndSessions(txn: IDBTransaction, func: (count: number) => void): void {
|
||||
this.backend!.countEndToEndSessions(txn, func);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a specific end-to-end session between the logged-in user
|
||||
* and another device.
|
||||
* @param deviceKey - The public key of the other device.
|
||||
* @param sessionId - The ID of the session to retrieve
|
||||
* @param txn - An active transaction. See doTxn().
|
||||
* @param func - Called with A map from sessionId
|
||||
* to session information object with 'session' key being the
|
||||
* Base64 end-to-end session and lastReceivedMessageTs being the
|
||||
* timestamp in milliseconds at which the session last received
|
||||
* a message.
|
||||
*/
|
||||
public getEndToEndSession(
|
||||
deviceKey: string,
|
||||
sessionId: string,
|
||||
txn: IDBTransaction,
|
||||
func: (session: ISessionInfo | null) => void,
|
||||
): void {
|
||||
this.backend!.getEndToEndSession(deviceKey, sessionId, txn, func);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the end-to-end sessions between the logged-in user and another
|
||||
* device.
|
||||
* @param deviceKey - The public key of the other device.
|
||||
* @param txn - An active transaction. See doTxn().
|
||||
* @param func - Called with A map from sessionId
|
||||
* to session information object with 'session' key being the
|
||||
* Base64 end-to-end session and lastReceivedMessageTs being the
|
||||
* timestamp in milliseconds at which the session last received
|
||||
* a message.
|
||||
*/
|
||||
public getEndToEndSessions(
|
||||
deviceKey: string,
|
||||
txn: IDBTransaction,
|
||||
func: (sessions: { [sessionId: string]: ISessionInfo }) => void,
|
||||
): void {
|
||||
this.backend!.getEndToEndSessions(deviceKey, txn, func);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a session between the logged-in user and another device
|
||||
* @param deviceKey - The public key of the other device.
|
||||
* @param sessionId - The ID for this end-to-end session.
|
||||
* @param sessionInfo - Session information object
|
||||
* @param txn - An active transaction. See doTxn().
|
||||
*/
|
||||
public storeEndToEndSession(
|
||||
deviceKey: string,
|
||||
sessionId: string,
|
||||
sessionInfo: ISessionInfo,
|
||||
txn: IDBTransaction,
|
||||
): void {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a batch of Olm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.getEndToEndSessionsBatch}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public getEndToEndSessionsBatch(): Promise<null | ISessionInfo[]> {
|
||||
return this.backend!.getEndToEndSessionsBatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
// Inbound group sessions
|
||||
|
||||
/**
|
||||
* Retrieve the end-to-end inbound group session for a given
|
||||
* server key and session ID
|
||||
* @param senderCurve25519Key - The sender's curve 25519 key
|
||||
* @param sessionId - The ID of the session
|
||||
* @param txn - An active transaction. See doTxn().
|
||||
* @param func - Called with A map from sessionId
|
||||
* to Base64 end-to-end session.
|
||||
*/
|
||||
public getEndToEndInboundGroupSession(
|
||||
senderCurve25519Key: string,
|
||||
sessionId: string,
|
||||
txn: IDBTransaction,
|
||||
func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void,
|
||||
): void {
|
||||
this.backend!.getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an end-to-end inbound group session to the store.
|
||||
* If there already exists an inbound group session with the same
|
||||
* senderCurve25519Key and sessionID, it will be overwritten.
|
||||
* @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 storeEndToEndInboundGroupSession(
|
||||
senderCurve25519Key: string,
|
||||
sessionId: string,
|
||||
sessionData: InboundGroupSessionData,
|
||||
txn: IDBTransaction,
|
||||
): void {
|
||||
this.backend!.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a batch of Megolm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.getEndToEndInboundGroupSessionsBatch}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public getEndToEndInboundGroupSessionsBatch(): Promise<SessionExtended[] | null> {
|
||||
return this.backend!.getEndToEndInboundGroupSessionsBatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a batch of Megolm sessions from the database.
|
||||
*
|
||||
* Implementation of {@link CryptoStore.deleteEndToEndInboundGroupSessionsBatch}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public deleteEndToEndInboundGroupSessionsBatch(
|
||||
sessions: { senderKey: string; sessionId: string }[],
|
||||
): Promise<void> {
|
||||
return this.backend!.deleteEndToEndInboundGroupSessionsBatch(sessions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an object of `roomId->roomInfo` for all e2e rooms in the store
|
||||
* @param txn - An active transaction. See doTxn().
|
||||
* @param func - Function called with the end-to-end encrypted rooms
|
||||
*/
|
||||
public getEndToEndRooms(txn: IDBTransaction, func: (rooms: Record<string, IRoomEncryption>) => void): void {
|
||||
this.backend!.getEndToEndRooms(txn, func);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark 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 marked
|
||||
*/
|
||||
public markSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> {
|
||||
return this.backend!.markSessionsNeedingBackup(sessions, txn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a transaction on the crypto store. Any store methods
|
||||
* that require a transaction (txn) object to be passed in may
|
||||
* only be called within a callback of either this function or
|
||||
* one of the store functions operating on the same transaction.
|
||||
*
|
||||
* @param mode - 'readwrite' if you need to call setter
|
||||
* functions with this transaction. Otherwise, 'readonly'.
|
||||
* @param stores - List IndexedDBCryptoStore.STORE_*
|
||||
* options representing all types of object that will be
|
||||
* accessed or written to with this transaction.
|
||||
* @param func - Function called with the
|
||||
* transaction object: an opaque object that should be passed
|
||||
* to store functions.
|
||||
* @param log - A possibly customised log
|
||||
* @returns Promise that resolves with the result of the `func`
|
||||
* when the transaction is complete. If the backend is
|
||||
* async (ie. the indexeddb backend) any of the callback
|
||||
* functions throwing an exception will cause this promise to
|
||||
* 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> {
|
||||
return this.backend!.doTxn<T>(mode, stores, func as (txn: unknown) => T, log);
|
||||
}
|
||||
}
|
||||
408
node_modules/matrix-js-sdk/src/crypto/store/localStorage-crypto-store.ts
generated
vendored
Normal file
408
node_modules/matrix-js-sdk/src/crypto/store/localStorage-crypto-store.ts
generated
vendored
Normal file
@@ -0,0 +1,408 @@
|
||||
/*
|
||||
Copyright 2017 - 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.
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
/**
|
||||
* Internal module. Partial localStorage backed storage for e2e.
|
||||
* This is not a full crypto store, just the in-memory store with
|
||||
* some things backed by localStorage. It exists because indexedDB
|
||||
* is broken in Firefox private mode or set to, "will not remember
|
||||
* history".
|
||||
*/
|
||||
|
||||
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_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/";
|
||||
const KEY_INBOUND_SESSION_WITHHELD_PREFIX = E2E_PREFIX + "inboundgroupsessions.withheld/";
|
||||
const KEY_ROOMS_PREFIX = E2E_PREFIX + "rooms/";
|
||||
const KEY_SESSIONS_NEEDING_BACKUP = E2E_PREFIX + "sessionsneedingbackup";
|
||||
|
||||
function keyEndToEndSessions(deviceKey: string): string {
|
||||
return E2E_PREFIX + "sessions/" + deviceKey;
|
||||
}
|
||||
|
||||
function keyEndToEndInboundGroupSession(senderKey: string, sessionId: string): string {
|
||||
return KEY_INBOUND_SESSION_PREFIX + senderKey + "/" + sessionId;
|
||||
}
|
||||
|
||||
function keyEndToEndInboundGroupSessionWithheld(senderKey: string, sessionId: string): string {
|
||||
return KEY_INBOUND_SESSION_WITHHELD_PREFIX + senderKey + "/" + sessionId;
|
||||
}
|
||||
|
||||
function keyEndToEndRoomsPrefix(roomId: string): string {
|
||||
return KEY_ROOMS_PREFIX + roomId;
|
||||
}
|
||||
|
||||
export class LocalStorageCryptoStore extends MemoryCryptoStore implements CryptoStore {
|
||||
public static exists(store: Storage): boolean {
|
||||
const length = store.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (store.key(i)?.startsWith(E2E_PREFIX)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public constructor(private readonly store: Storage) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
func(count);
|
||||
}
|
||||
|
||||
private _getEndToEndSessions(deviceKey: string): Record<string, ISessionInfo> {
|
||||
const sessions = getJsonItem(this.store, keyEndToEndSessions(deviceKey));
|
||||
const fixedSessions: Record<string, ISessionInfo> = {};
|
||||
|
||||
// fix up any old sessions to be objects rather than just the base64 pickle
|
||||
for (const [sid, val] of Object.entries(sessions || {})) {
|
||||
if (typeof val === "string") {
|
||||
fixedSessions[sid] = {
|
||||
session: val,
|
||||
};
|
||||
} else {
|
||||
fixedSessions[sid] = val;
|
||||
}
|
||||
}
|
||||
|
||||
return fixedSessions;
|
||||
}
|
||||
|
||||
public getEndToEndSession(
|
||||
deviceKey: string,
|
||||
sessionId: string,
|
||||
txn: unknown,
|
||||
func: (session: ISessionInfo) => void,
|
||||
): void {
|
||||
const sessions = this._getEndToEndSessions(deviceKey);
|
||||
func(sessions[sessionId] ?? {});
|
||||
}
|
||||
|
||||
public getEndToEndSessions(
|
||||
deviceKey: string,
|
||||
txn: unknown,
|
||||
func: (sessions: { [sessionId: string]: ISessionInfo }) => void,
|
||||
): void {
|
||||
func(this._getEndToEndSessions(deviceKey) ?? {});
|
||||
}
|
||||
|
||||
public storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void {
|
||||
const sessions = this._getEndToEndSessions(deviceKey) || {};
|
||||
sessions[sessionId] = sessionInfo;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inbound Group Sessions
|
||||
|
||||
public getEndToEndInboundGroupSession(
|
||||
senderCurve25519Key: string,
|
||||
sessionId: string,
|
||||
txn: unknown,
|
||||
func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void,
|
||||
): void {
|
||||
func(
|
||||
getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId)),
|
||||
getJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId)),
|
||||
);
|
||||
}
|
||||
|
||||
public storeEndToEndInboundGroupSession(
|
||||
senderCurve25519Key: string,
|
||||
sessionId: string,
|
||||
sessionData: InboundGroupSessionData,
|
||||
txn: unknown,
|
||||
): void {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void {
|
||||
const result: Record<string, IRoomEncryption> = {};
|
||||
const prefix = keyEndToEndRoomsPrefix("");
|
||||
|
||||
for (let i = 0; i < this.store.length; ++i) {
|
||||
const key = this.store.key(i);
|
||||
if (key?.startsWith(prefix)) {
|
||||
const roomId = key.slice(prefix.length);
|
||||
result[roomId] = getJsonItem(this.store, key)!;
|
||||
}
|
||||
}
|
||||
func(result);
|
||||
}
|
||||
|
||||
public markSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
|
||||
const sessionsNeedingBackup =
|
||||
getJsonItem<{
|
||||
[senderKeySessionId: string]: boolean;
|
||||
}>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
|
||||
for (const session of sessions) {
|
||||
sessionsNeedingBackup[session.senderKey + "/" + session.sessionId] = true;
|
||||
}
|
||||
setJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all data from this store.
|
||||
*
|
||||
* @returns Promise which resolves when the store has been cleared.
|
||||
*/
|
||||
public deleteAllData(): Promise<void> {
|
||||
this.store.removeItem(KEY_END_TO_END_ACCOUNT);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Olm account
|
||||
|
||||
public getAccount(txn: unknown, func: (accountPickle: string | null) => void): void {
|
||||
const accountPickle = getJsonItem<string>(this.store, KEY_END_TO_END_ACCOUNT);
|
||||
func(accountPickle);
|
||||
}
|
||||
|
||||
public storeAccount(txn: unknown, accountPickle: string): void {
|
||||
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);
|
||||
func(keys);
|
||||
}
|
||||
|
||||
public getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
|
||||
txn: unknown,
|
||||
func: (key: SecretStorePrivateKeys[K] | null) => void,
|
||||
type: K,
|
||||
): void {
|
||||
const key = getJsonItem<SecretStorePrivateKeys[K]>(this.store, E2E_PREFIX + `ssss_cache.${type}`);
|
||||
func(key);
|
||||
}
|
||||
|
||||
public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
|
||||
txn: unknown,
|
||||
type: K,
|
||||
key: SecretStorePrivateKeys[K],
|
||||
): void {
|
||||
setJsonItem(this.store, E2E_PREFIX + `ssss_cache.${type}`, key);
|
||||
}
|
||||
|
||||
public doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T): Promise<T> {
|
||||
return Promise.resolve(func(null));
|
||||
}
|
||||
}
|
||||
|
||||
function getJsonItem<T>(store: Storage, key: string): T | null {
|
||||
try {
|
||||
// if the key is absent, store.getItem() returns null, and
|
||||
// JSON.parse(null) === null, so this returns null.
|
||||
return JSON.parse(store.getItem(key)!);
|
||||
} catch (e) {
|
||||
logger.log("Error: Failed to get key %s: %s", key, (<Error>e).message);
|
||||
logger.log((<Error>e).stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setJsonItem<T>(store: Storage, key: string, val: T): void {
|
||||
store.setItem(key, JSON.stringify(val));
|
||||
}
|
||||
326
node_modules/matrix-js-sdk/src/crypto/store/memory-crypto-store.ts
generated
vendored
Normal file
326
node_modules/matrix-js-sdk/src/crypto/store/memory-crypto-store.ts
generated
vendored
Normal file
@@ -0,0 +1,326 @@
|
||||
/*
|
||||
Copyright 2017 - 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.
|
||||
*/
|
||||
|
||||
import { safeSet } from "../../utils.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";
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal module. in-memory storage for e2e.
|
||||
*/
|
||||
|
||||
export class MemoryCryptoStore implements CryptoStore {
|
||||
private migrationState: MigrationState = MigrationState.NOT_STARTED;
|
||||
private account: string | null = null;
|
||||
private crossSigningKeys: Record<string, CrossSigningKeyInfo> | null = null;
|
||||
private privateKeys: Partial<SecretStorePrivateKeys> = {};
|
||||
|
||||
private sessions: { [deviceKey: string]: { [sessionId: string]: ISessionInfo } } = {};
|
||||
private inboundGroupSessions: { [sessionKey: string]: InboundGroupSessionData } = {};
|
||||
private inboundGroupSessionsWithheld: Record<string, IWithheld> = {};
|
||||
// Opaque device data object
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the database exists and is up-to-date.
|
||||
*
|
||||
* This must be called before the store can be used.
|
||||
*
|
||||
* @returns resolves to the store.
|
||||
*/
|
||||
public async startup(): Promise<CryptoStore> {
|
||||
// No startup work to do for the memory store.
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all data from this store.
|
||||
*
|
||||
* @returns Promise which resolves when the store has been cleared.
|
||||
*/
|
||||
public deleteAllData(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 this.migrationState;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
this.migrationState = migrationState;
|
||||
}
|
||||
|
||||
// Olm Account
|
||||
|
||||
public getAccount(txn: unknown, func: (accountPickle: string | null) => void): void {
|
||||
func(this.account);
|
||||
}
|
||||
|
||||
public storeAccount(txn: unknown, accountPickle: string): void {
|
||||
this.account = accountPickle;
|
||||
}
|
||||
|
||||
public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, CrossSigningKeyInfo> | null) => void): void {
|
||||
func(this.crossSigningKeys);
|
||||
}
|
||||
|
||||
public getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
|
||||
txn: unknown,
|
||||
func: (key: SecretStorePrivateKeys[K] | null) => void,
|
||||
type: K,
|
||||
): void {
|
||||
const result = this.privateKeys[type];
|
||||
func(result || null);
|
||||
}
|
||||
|
||||
public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
|
||||
txn: unknown,
|
||||
type: K,
|
||||
key: SecretStorePrivateKeys[K],
|
||||
): void {
|
||||
this.privateKeys[type] = key;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
public getEndToEndSession(
|
||||
deviceKey: string,
|
||||
sessionId: string,
|
||||
txn: unknown,
|
||||
func: (session: ISessionInfo) => void,
|
||||
): void {
|
||||
const deviceSessions = this.sessions[deviceKey] || {};
|
||||
func(deviceSessions[sessionId] || null);
|
||||
}
|
||||
|
||||
public getEndToEndSessions(
|
||||
deviceKey: string,
|
||||
txn: unknown,
|
||||
func: (sessions: { [sessionId: string]: ISessionInfo }) => void,
|
||||
): void {
|
||||
func(this.sessions[deviceKey] || {});
|
||||
}
|
||||
|
||||
public storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void {
|
||||
let deviceSessions = this.sessions[deviceKey];
|
||||
if (deviceSessions === undefined) {
|
||||
deviceSessions = {};
|
||||
this.sessions[deviceKey] = deviceSessions;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inbound Group Sessions
|
||||
|
||||
public getEndToEndInboundGroupSession(
|
||||
senderCurve25519Key: string,
|
||||
sessionId: string,
|
||||
txn: unknown,
|
||||
func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void,
|
||||
): void {
|
||||
const k = encodeSessionKey(senderCurve25519Key, sessionId);
|
||||
func(this.inboundGroupSessions[k] || null, this.inboundGroupSessionsWithheld[k] || null);
|
||||
}
|
||||
|
||||
public storeEndToEndInboundGroupSession(
|
||||
senderCurve25519Key: string,
|
||||
sessionId: string,
|
||||
sessionData: InboundGroupSessionData,
|
||||
txn: unknown,
|
||||
): void {
|
||||
const k = encodeSessionKey(senderCurve25519Key, sessionId);
|
||||
this.inboundGroupSessions[k] = 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
}
|
||||
|
||||
// E2E rooms
|
||||
|
||||
public getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void {
|
||||
func(this.rooms);
|
||||
}
|
||||
|
||||
public markSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
|
||||
for (const session of sessions) {
|
||||
const sessionKey = encodeSessionKey(session.senderKey, session.sessionId);
|
||||
this.sessionsNeedingBackup[sessionKey] = true;
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Session key backups
|
||||
|
||||
public doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn?: unknown) => T): Promise<T> {
|
||||
return Promise.resolve(func(null));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user