UNPKG

matrix-js-sdk

Version:
933 lines (846 loc) 31.9 kB
/* Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd Copyright 2020 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'; import * as utils from "../../utils"; export const VERSION = 10; 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. * * @implements {module:crypto/store/base~CryptoStore} */ export class Backend { /** * @param {IDBDatabase} db */ constructor(db) { this._db = db; this._nextTxnId = 0; // 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 = (ev) => { logger.log(`versionchange for indexeddb ${this._dbName}: closing`); db.close(); }; } /** * Look for an existing outgoing room key request, and if none is found, * add a new one * * @param {module:crypto/store/base~OutgoingRoomKeyRequest} request * * @returns {Promise} resolves to * {@link module:crypto/store/base~OutgoingRoomKeyRequest}: either the * same instance as passed in, or the existing one. */ getOrAddOutgoingRoomKeyRequest(request) { 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 = () => {resolve(request);}; const store = txn.objectStore("outgoingRoomKeyRequests"); store.add(request); }); }); } /** * Look for an existing room key request * * @param {module:crypto~RoomKeyRequestBody} requestBody * existing request to look for * * @return {Promise} resolves to the matching * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if * not found */ getOutgoingRoomKeyRequest(requestBody) { 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 * * @private * @param {IDBTransaction} txn database transaction * @param {module:crypto~RoomKeyRequestBody} requestBody * existing request to look for * @param {Function} callback function to call with the results of the * search. Either passed a matching * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if * not found. */ _getOutgoingRoomKeyRequest(txn, requestBody, callback) { const store = txn.objectStore("outgoingRoomKeyRequests"); const idx = store.index("session"); const cursorReq = idx.openCursor([ requestBody.room_id, requestBody.session_id, ]); cursorReq.onsuccess = (ev) => { const cursor = ev.target.result; if (!cursor) { // no match found callback(null); return; } const existing = cursor.value; if (utils.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 {Array<Number>} wantedStates list of acceptable states * * @return {Promise} resolves to the a * {@link module:crypto/store/base~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. */ getOutgoingRoomKeyRequestByState(wantedStates) { 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; function onsuccess(ev) { const cursor = ev.target.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 = ev.target.source.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); } /** * * @param {Number} wantedState * @return {Promise<Array<*>>} All elements in a given state */ getAllOutgoingRoomKeyRequestsByState(wantedState) { 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 = (ev) => resolve(ev.target.result); request.onerror = (ev) => reject(ev.target.error); }); } getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) { let stateIndex = 0; const results = []; function onsuccess(ev) { const cursor = ev.target.result; if (cursor) { const keyReq = cursor.value; if (keyReq.recipients.includes({ userId, 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 = ev.target.source.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 {string} requestId ID of request to update * @param {number} expectedState state we expect to find the request in * @param {Object} updates name/value map of updates to apply * * @returns {Promise} resolves to * {@link module:crypto/store/base~OutgoingRoomKeyRequest} * updated request, or null if no matching row was found */ updateOutgoingRoomKeyRequest(requestId, expectedState, updates) { let result = null; function onsuccess(ev) { const cursor = ev.target.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 {string} requestId ID of request to update * @param {number} expectedState state we expect to find the request in * * @returns {Promise} resolves once the operation is completed */ deleteOutgoingRoomKeyRequest(requestId, expectedState) { const txn = this._db.transaction("outgoingRoomKeyRequests", "readwrite"); const cursorReq = txn.objectStore("outgoingRoomKeyRequests") .openCursor(requestId); cursorReq.onsuccess = (ev) => { const cursor = ev.target.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(txn); } // Olm Account getAccount(txn, func) { const objectStore = txn.objectStore("account"); const getReq = objectStore.get("-"); getReq.onsuccess = function() { try { func(getReq.result || null); } catch (e) { abortWithException(txn, e); } }; } storeAccount(txn, newData) { const objectStore = txn.objectStore("account"); objectStore.put(newData, "-"); } getCrossSigningKeys(txn, func) { const objectStore = txn.objectStore("account"); const getReq = objectStore.get("crossSigningKeys"); getReq.onsuccess = function() { try { func(getReq.result || null); } catch (e) { abortWithException(txn, e); } }; } getSecretStorePrivateKey(txn, func, type) { const objectStore = txn.objectStore("account"); const getReq = objectStore.get(`ssss_cache:${type}`); getReq.onsuccess = function() { try { func(getReq.result || null); } catch (e) { abortWithException(txn, e); } }; } storeCrossSigningKeys(txn, keys) { const objectStore = txn.objectStore("account"); objectStore.put(keys, "crossSigningKeys"); } storeSecretStorePrivateKey(txn, type, key) { const objectStore = txn.objectStore("account"); objectStore.put(key, `ssss_cache:${type}`); } // Olm Sessions countEndToEndSessions(txn, func) { const objectStore = txn.objectStore("sessions"); const countReq = objectStore.count(); countReq.onsuccess = function() { try { func(countReq.result); } catch (e) { abortWithException(txn, e); } }; } getEndToEndSessions(deviceKey, txn, func) { const objectStore = txn.objectStore("sessions"); const idx = objectStore.index("deviceKey"); const getReq = idx.openCursor(deviceKey); const results = {}; getReq.onsuccess = function() { 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, e); } } }; } getEndToEndSession(deviceKey, sessionId, txn, func) { const objectStore = txn.objectStore("sessions"); const getReq = objectStore.get([deviceKey, sessionId]); getReq.onsuccess = function() { try { if (getReq.result) { func({ session: getReq.result.session, lastReceivedMessageTs: getReq.result.lastReceivedMessageTs, }); } else { func(null); } } catch (e) { abortWithException(txn, e); } }; } getAllEndToEndSessions(txn, func) { const objectStore = txn.objectStore("sessions"); const getReq = objectStore.openCursor(); getReq.onsuccess = function() { try { const cursor = getReq.result; if (cursor) { func(cursor.value); cursor.continue(); } else { func(null); } } catch (e) { abortWithException(txn, e); } }; } storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) { const objectStore = txn.objectStore("sessions"); objectStore.put({ deviceKey, sessionId, session: sessionInfo.session, lastReceivedMessageTs: sessionInfo.lastReceivedMessageTs, }); } async storeEndToEndSessionProblem(deviceKey, type, fixed) { const txn = this._db.transaction("session_problems", "readwrite"); const objectStore = txn.objectStore("session_problems"); objectStore.put({ deviceKey, type, fixed, time: Date.now(), }); return promiseifyTxn(txn); } async getEndToEndSessionProblem(deviceKey, timestamp) { let result; 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 = (event) => { 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; } // FIXME: we should probably prune this when devices get deleted async filterOutNotifiedErrorDevices(devices) { const txn = this._db.transaction("notified_error_devices", "readwrite"); const objectStore = txn.objectStore("notified_error_devices"); const ret = []; await Promise.all(devices.map((device) => { return new Promise((resolve) => { const { userId, deviceInfo } = device; const getReq = objectStore.get([userId, deviceInfo.deviceId]); getReq.onsuccess = function() { if (!getReq.result) { objectStore.put({ userId, deviceId: deviceInfo.deviceId }); ret.push(device); } resolve(); }; }); })); return ret; } // Inbound group sessions getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) { let session = false; let withheld = false; const objectStore = txn.objectStore("inbound_group_sessions"); const getReq = objectStore.get([senderCurve25519Key, sessionId]); getReq.onsuccess = function() { try { if (getReq.result) { session = getReq.result.session; } else { session = null; } if (withheld !== false) { func(session, withheld); } } catch (e) { abortWithException(txn, e); } }; const withheldObjectStore = txn.objectStore("inbound_group_sessions_withheld"); const withheldGetReq = withheldObjectStore.get([senderCurve25519Key, sessionId]); withheldGetReq.onsuccess = function() { try { if (withheldGetReq.result) { withheld = withheldGetReq.result.session; } else { withheld = null; } if (session !== false) { func(session, withheld); } } catch (e) { abortWithException(txn, e); } }; } getAllEndToEndInboundGroupSessions(txn, func) { const objectStore = txn.objectStore("inbound_group_sessions"); const getReq = objectStore.openCursor(); getReq.onsuccess = function() { const cursor = getReq.result; if (cursor) { try { func({ senderKey: cursor.value.senderCurve25519Key, sessionId: cursor.value.sessionId, sessionData: cursor.value.session, }); } catch (e) { abortWithException(txn, e); } cursor.continue(); } else { try { func(null); } catch (e) { abortWithException(txn, e); } } }; } addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) { const objectStore = txn.objectStore("inbound_group_sessions"); const addReq = objectStore.add({ senderCurve25519Key, sessionId, session: sessionData, }); addReq.onerror = (ev) => { 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, )); } }; } storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) { const objectStore = txn.objectStore("inbound_group_sessions"); objectStore.put({ senderCurve25519Key, sessionId, session: sessionData, }); } storeEndToEndInboundGroupSessionWithheld( senderCurve25519Key, sessionId, sessionData, txn, ) { const objectStore = txn.objectStore("inbound_group_sessions_withheld"); objectStore.put({ senderCurve25519Key, sessionId, session: sessionData, }); } getEndToEndDeviceData(txn, func) { const objectStore = txn.objectStore("device_data"); const getReq = objectStore.get("-"); getReq.onsuccess = function() { try { func(getReq.result || null); } catch (e) { abortWithException(txn, e); } }; } storeEndToEndDeviceData(deviceData, txn) { const objectStore = txn.objectStore("device_data"); objectStore.put(deviceData, "-"); } storeEndToEndRoom(roomId, roomInfo, txn) { const objectStore = txn.objectStore("rooms"); objectStore.put(roomInfo, roomId); } getEndToEndRooms(txn, func) { const rooms = {}; const objectStore = txn.objectStore("rooms"); const getReq = objectStore.openCursor(); getReq.onsuccess = function() { const cursor = getReq.result; if (cursor) { rooms[cursor.key] = cursor.value; cursor.continue(); } else { try { func(rooms); } catch (e) { abortWithException(txn, e); } } }; } // session backups getSessionsNeedingBackup(limit) { return new Promise((resolve, reject) => { const sessions = []; const txn = this._db.transaction( ["sessions_needing_backup", "inbound_group_sessions"], "readonly", ); txn.onerror = reject; txn.oncomplete = function() { resolve(sessions); }; const objectStore = txn.objectStore("sessions_needing_backup"); const sessionStore = txn.objectStore("inbound_group_sessions"); const getReq = objectStore.openCursor(); getReq.onsuccess = function() { const cursor = getReq.result; if (cursor) { const sessionGetReq = sessionStore.get(cursor.key); sessionGetReq.onsuccess = function() { sessions.push({ senderKey: sessionGetReq.result.senderCurve25519Key, sessionId: sessionGetReq.result.sessionId, sessionData: sessionGetReq.result.session, }); }; if (!limit || sessions.length < limit) { cursor.continue(); } } }; }); } countSessionsNeedingBackup(txn) { 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 = () => resolve(req.result); }); } unmarkSessionsNeedingBackup(sessions, txn) { if (!txn) { txn = this._db.transaction("sessions_needing_backup", "readwrite"); } const objectStore = txn.objectStore("sessions_needing_backup"); return Promise.all(sessions.map((session) => { return new Promise((resolve, reject) => { const req = objectStore.delete([session.senderKey, session.sessionId]); req.onsuccess = resolve; req.onerror = reject; }); })); } markSessionsNeedingBackup(sessions, txn) { if (!txn) { txn = this._db.transaction("sessions_needing_backup", "readwrite"); } const objectStore = txn.objectStore("sessions_needing_backup"); return 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; }); })); } addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn) { 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 = () => { const { sessions } = req.result || { sessions: [] }; sessions.push([senderKey, sessionId]); objectStore.put({ roomId, sessions }); }; } getSharedHistoryInboundGroupSessions(roomId, txn) { 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 = () => { const { sessions } = req.result || { sessions: [] }; resolve(sessions); }; req.onerror = reject; }); } doTxn(mode, stores, func, log = logger) { let startTime; let description; 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; }); } } export function upgradeDatabase(db, oldVersion) { logger.log( `Upgrading IndexedDBCryptoStore from version ${oldVersion}` + ` to ${VERSION}`, ); if (oldVersion < 1) { // The database did not previously exist. createDatabase(db); } if (oldVersion < 2) { db.createObjectStore("account"); } if (oldVersion < 3) { const sessionsStore = db.createObjectStore("sessions", { keyPath: ["deviceKey", "sessionId"], }); sessionsStore.createIndex("deviceKey", "deviceKey"); } if (oldVersion < 4) { db.createObjectStore("inbound_group_sessions", { keyPath: ["senderCurve25519Key", "sessionId"], }); } if (oldVersion < 5) { db.createObjectStore("device_data"); } if (oldVersion < 6) { db.createObjectStore("rooms"); } if (oldVersion < 7) { db.createObjectStore("sessions_needing_backup", { keyPath: ["senderCurve25519Key", "sessionId"], }); } if (oldVersion < 8) { db.createObjectStore("inbound_group_sessions_withheld", { keyPath: ["senderCurve25519Key", "sessionId"], }); } if (oldVersion < 9) { const problemsStore = db.createObjectStore("session_problems", { keyPath: ["deviceKey", "time"], }); problemsStore.createIndex("deviceKey", "deviceKey"); db.createObjectStore("notified_error_devices", { keyPath: ["userId", "deviceId"], }); } if (oldVersion < 10) { db.createObjectStore("shared_history_inbound_group_sessions", { keyPath: ["roomId"], }); } // Expand as needed. } function createDatabase(db) { 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"); } /* * Aborts a transaction with a given exception * The transaction promise will be rejected with this exception. */ function abortWithException(txn, e) { // 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._mx_abortexception = e; try { txn.abort(); } catch (e) { // sometimes we won't be able to abort the transaction // (ie. if it's aborted or completed) } } function promiseifyTxn(txn) { return new Promise((resolve, reject) => { txn.oncomplete = () => { if (txn._mx_abortexception !== undefined) { reject(txn._mx_abortexception); } resolve(); }; txn.onerror = (event) => { if (txn._mx_abortexception !== undefined) { reject(txn._mx_abortexception); } else { logger.log("Error performing indexeddb txn", event); reject(event.target.error); } }; txn.onabort = (event) => { if (txn._mx_abortexception !== undefined) { reject(txn._mx_abortexception); } else { logger.log("Error performing indexeddb txn", event); reject(event.target.error); } }; }); }