diff --git a/web/database/database-module-provider.js b/web/database/database-module-provider.js index b879812ac..f479c23ee 100644 --- a/web/database/database-module-provider.js +++ b/web/database/database-module-provider.js @@ -1,101 +1,119 @@ // @flow import { DATABASE_WORKER_PATH, SQLJS_FILE_PATH } from './utils/constants.js'; import { isSQLiteSupported } from './utils/db-utils.js'; import WorkerConnectionProxy from './utils/WorkerConnectionProxy.js'; import type { AppState } from '../redux/redux-setup.js'; import { workerRequestMessageTypes, type WorkerRequestMessage, type WorkerResponseMessage, } from '../types/worker-types.js'; declare var sqljsFilename: string; declare var preloadedState: AppState; const databaseStatuses = Object.freeze({ notSupported: 'NOT_SUPPORTED', initSuccess: 'INIT_SUCCESS', initInProgress: 'INIT_IN_PROGRESS', initError: 'INIT_ERROR', }); type DatabaseStatus = $Values; class DatabaseModule { worker: SharedWorker; workerProxy: WorkerConnectionProxy; initPromise: Promise; status: DatabaseStatus; constructor() { const currentLoggedInUserID = preloadedState.currentUserInfo?.anonymous ? undefined : preloadedState.currentUserInfo?.id; const isSupported = isSQLiteSupported(currentLoggedInUserID); if (!isSupported) { this.status = databaseStatuses.notSupported; } else { this.init(); } } init() { this.status = databaseStatuses.initInProgress; this.worker = new SharedWorker(DATABASE_WORKER_PATH); this.worker.onerror = console.error; this.workerProxy = new WorkerConnectionProxy( this.worker.port, console.error, ); const origin = window.location.origin; this.initPromise = (async () => { try { await this.workerProxy.scheduleOnWorker({ type: workerRequestMessageTypes.INIT, sqljsFilePath: `${origin}${SQLJS_FILE_PATH}`, sqljsFilename, }); this.status = databaseStatuses.initSuccess; console.info('Database initialization success'); } catch (error) { this.status = databaseStatuses.initError; console.error(`Database initialization failure`, error); } })(); } - userLoggedIn(currentLoggedInUserID: ?string) { + initDBForLoggedInUser(currentLoggedInUserID: ?string) { + if (this.status === databaseStatuses.initSuccess) { + return; + } + if ( this.status === databaseStatuses.notSupported && isSQLiteSupported(currentLoggedInUserID) ) { this.init(); } } + async clearSensitiveData(): Promise { + this.status = databaseStatuses.notSupported; + await this.workerProxy.scheduleOnWorker({ + type: workerRequestMessageTypes.CLEAR_SENSITIVE_DATA, + }); + } + + async isDatabaseSupported(): Promise { + if (this.status === databaseStatuses.initInProgress) { + await this.initPromise; + } + return this.status === databaseStatuses.initSuccess; + } + async schedule( payload: WorkerRequestMessage, ): Promise { if (this.status === databaseStatuses.notSupported) { throw new Error('Database not supported'); } if (this.status === databaseStatuses.initInProgress) { await this.initPromise; } if (this.status === databaseStatuses.initError) { throw new Error('Database could not be initialized'); } return this.workerProxy.scheduleOnWorker(payload); } } const databaseModule: DatabaseModule = new DatabaseModule(); export { databaseModule }; diff --git a/web/database/sqlite-data-handler.js b/web/database/sqlite-data-handler.js new file mode 100644 index 000000000..6b8274f9a --- /dev/null +++ b/web/database/sqlite-data-handler.js @@ -0,0 +1,62 @@ +// @flow + +import * as React from 'react'; + +import { databaseModule } from './database-module-provider.js'; +import { useSelector } from '../redux/redux-utils.js'; +import { workerRequestMessageTypes } from '../types/worker-types.js'; + +function SQLiteDataHandler(): React.Node { + const rehydrateConcluded = useSelector( + state => !!(state._persist && state._persist.rehydrated), + ); + const currentLoggedInUserID = useSelector(state => + state.currentUserInfo?.anonymous ? undefined : state.currentUserInfo?.id, + ); + + const handleSensitiveData = React.useCallback(async () => { + try { + const currentUserData = await databaseModule.schedule({ + type: workerRequestMessageTypes.GET_CURRENT_USER_ID, + }); + const currentDBUserID = currentUserData?.userID; + + if (currentDBUserID && currentDBUserID !== currentLoggedInUserID) { + await databaseModule.clearSensitiveData(); + } + if ( + currentLoggedInUserID && + (currentDBUserID || currentDBUserID !== currentLoggedInUserID) + ) { + await databaseModule.schedule({ + type: workerRequestMessageTypes.SET_CURRENT_USER_ID, + userID: currentLoggedInUserID, + }); + } + } catch (error) { + console.error(error); + throw error; + } + }, [currentLoggedInUserID]); + + React.useEffect(() => { + (async () => { + if (currentLoggedInUserID) { + await databaseModule.initDBForLoggedInUser(currentLoggedInUserID); + } + if (!rehydrateConcluded) { + return; + } + + const isSupported = await databaseModule.isDatabaseSupported(); + if (!isSupported) { + return; + } + await handleSensitiveData(); + })(); + }, [currentLoggedInUserID, handleSensitiveData, rehydrateConcluded]); + + return null; +} + +export { SQLiteDataHandler }; diff --git a/web/database/worker/db-worker.js b/web/database/worker/db-worker.js index a77ae0ff5..702af9a17 100644 --- a/web/database/worker/db-worker.js +++ b/web/database/worker/db-worker.js @@ -1,260 +1,267 @@ // @flow import localforage from 'localforage'; import _throttle from 'lodash/throttle.js'; import initSqlJs, { type SqliteDatabase } from 'sql.js'; import type { ClientDBDraftStoreOperation, DraftStoreOperation, } from 'lib/types/draft-types.js'; import type { ClientDBStore } from 'lib/types/store-ops-types.js'; import { type SharedWorkerMessageEvent, type WorkerRequestMessage, type WorkerResponseMessage, workerRequestMessageTypes, workerResponseMessageTypes, type WorkerRequestProxyMessage, workerWriteRequests, } from '../../types/worker-types.js'; import { getSQLiteDBVersion, setupSQLiteDB } from '../queries/db-queries.js'; import { getAllDrafts, moveDraft, removeAllDrafts, updateDraft, } from '../queries/draft-queries.js'; import { getMetadata, setMetadata } from '../queries/metadata-queries.js'; import { getPersistStorageItem, removePersistStorageItem, setPersistStorageItem, } from '../queries/storage-engine-queries.js'; import { CURRENT_USER_ID_KEY, DB_PERSIST_THROTTLE_WAIT_MS, SQLITE_CONTENT, SQLITE_ENCRYPTION_KEY, } from '../utils/constants.js'; import { decryptDatabaseFile, encryptDatabaseFile, generateDatabaseCryptoKey, } from '../utils/worker-crypto-utils.js'; const localforageConfig: PartialConfig = { driver: localforage.INDEXEDDB, name: 'comm', storeName: 'commStorage', description: 'Comm encrypted database storage', version: '1.0', }; localforage.config(localforageConfig); let sqliteDb: ?SqliteDatabase = null; let encryptionKey: ?CryptoKey = null; async function initDatabase(sqljsFilePath: string, sqljsFilename: ?string) { encryptionKey = await localforage.getItem(SQLITE_ENCRYPTION_KEY); if (!encryptionKey) { const cryptoKey = await generateDatabaseCryptoKey(); await localforage.setItem(SQLITE_ENCRYPTION_KEY, cryptoKey); } const encryptedContent = await localforage.getItem(SQLITE_CONTENT); let dbContent = null; try { if (encryptionKey && encryptedContent) { dbContent = await decryptDatabaseFile(encryptedContent, encryptionKey); } } catch (e) { console.error('Error while decrypting content, clearing database content'); await localforage.removeItem(SQLITE_CONTENT); } const locateFile = defaultFilename => { if (sqljsFilename) { return `${sqljsFilePath}/${sqljsFilename}`; } return `${sqljsFilePath}/${defaultFilename}`; }; const SQL = await initSqlJs({ locateFile, }); if (dbContent) { sqliteDb = new SQL.Database(dbContent); console.info( 'Database exists and is properly encrypted, using persisted data', ); } else { sqliteDb = new SQL.Database(); setupSQLiteDB(sqliteDb); console.info('Creating fresh database'); } const dbVersion = getSQLiteDBVersion(sqliteDb); console.info(`Db version: ${dbVersion}`); } function processDraftStoreOperations( operations: $ReadOnlyArray, ) { if (!sqliteDb) { throw new Error('Database not initialized'); } for (const operation: DraftStoreOperation of operations) { if (operation.type === 'remove_all') { removeAllDrafts(sqliteDb); } else if (operation.type === 'update') { const { key, text } = operation.payload; updateDraft(sqliteDb, key, text); } else if (operation.type === 'move') { const { oldKey, newKey } = operation.payload; moveDraft(sqliteDb, oldKey, newKey); } else { throw new Error('Unsupported draft operation'); } } } function getClientStore(): ClientDBStore { if (!sqliteDb) { throw new Error('Database not initialized'); } return { drafts: getAllDrafts(sqliteDb), messages: [], threads: [], messageStoreThreads: [], }; } async function persist() { if (!sqliteDb) { throw new Error('Database not initialized'); } if (!encryptionKey) { encryptionKey = await localforage.getItem(SQLITE_ENCRYPTION_KEY); } const dbData = sqliteDb.export(); if (!encryptionKey) { throw new Error('Encryption key is missing'); } const encryptedData = await encryptDatabaseFile(dbData, encryptionKey); await localforage.setItem(SQLITE_CONTENT, encryptedData); } const throttledPersist = _throttle(persist, DB_PERSIST_THROTTLE_WAIT_MS); async function processAppRequest( message: WorkerRequestMessage, ): Promise { // non-database operations if (message.type === workerRequestMessageTypes.PING) { return { type: workerResponseMessageTypes.PONG, text: 'PONG', }; } else if ( message.type === workerRequestMessageTypes.GENERATE_DATABASE_ENCRYPTION_KEY ) { const cryptoKey = await generateDatabaseCryptoKey(); await localforage.setItem(SQLITE_ENCRYPTION_KEY, cryptoKey); return undefined; } // database operations if (message.type === workerRequestMessageTypes.INIT) { await initDatabase(message.sqljsFilePath, message.sqljsFilename); return undefined; + } else if (message.type === workerRequestMessageTypes.CLEAR_SENSITIVE_DATA) { + encryptionKey = null; + if (sqliteDb) { + sqliteDb.close(); + } + await localforage.clear(); + return undefined; } if (!sqliteDb) { throw new Error('Database not initialized'); } // read-only operations if (message.type === workerRequestMessageTypes.GET_CLIENT_STORE) { return { type: workerResponseMessageTypes.CLIENT_STORE, store: getClientStore(), }; } else if (message.type === workerRequestMessageTypes.GET_CURRENT_USER_ID) { return { type: workerResponseMessageTypes.GET_CURRENT_USER_ID, userID: getMetadata(sqliteDb, CURRENT_USER_ID_KEY), }; } else if ( message.type === workerRequestMessageTypes.GET_PERSIST_STORAGE_ITEM ) { return { type: workerResponseMessageTypes.GET_PERSIST_STORAGE_ITEM, item: getPersistStorageItem(sqliteDb, message.key), }; } // write operations if (!workerWriteRequests.includes(message.type)) { throw new Error('Request type not supported'); } if (message.type === workerRequestMessageTypes.PROCESS_STORE_OPERATIONS) { const { draftStoreOperations } = message.storeOperations; if (draftStoreOperations) { processDraftStoreOperations(draftStoreOperations); } } else if (message.type === workerRequestMessageTypes.SET_CURRENT_USER_ID) { setMetadata(sqliteDb, CURRENT_USER_ID_KEY, message.userID); } else if ( message.type === workerRequestMessageTypes.SET_PERSIST_STORAGE_ITEM ) { setPersistStorageItem(sqliteDb, message.key, message.item); } else if ( message.type === workerRequestMessageTypes.REMOVE_PERSIST_STORAGE_ITEM ) { removePersistStorageItem(sqliteDb, message.key); } throttledPersist(); return undefined; } function connectHandler(event: SharedWorkerMessageEvent) { if (!event.ports.length) { return; } const port: MessagePort = event.ports[0]; console.log('Web database worker alive!'); port.onmessage = async function (messageEvent: MessageEvent) { const data: WorkerRequestProxyMessage = (messageEvent.data: any); const { id, message } = data; if (!id) { port.postMessage({ error: new Error('Request without identifier'), }); } try { const result = await processAppRequest(message); port.postMessage({ id, message: result, }); } catch (e) { port.postMessage({ id, error: e, }); } }; } self.addEventListener('connect', connectHandler); diff --git a/web/root.js b/web/root.js index 5145e63a7..67e25559a 100644 --- a/web/root.js +++ b/web/root.js @@ -1,78 +1,80 @@ // @flow import * as React from 'react'; import { Provider } from 'react-redux'; import { Router, Route } from 'react-router'; import { createStore, applyMiddleware, type Store } from 'redux'; import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction.js'; import { createMigrate, persistReducer, persistStore } from 'redux-persist'; import { PersistGate } from 'redux-persist/es/integration/react.js'; import storage from 'redux-persist/es/storage/index.js'; import thunk from 'redux-thunk'; import { reduxLoggerMiddleware } from 'lib/utils/action-logger.js'; import { isDev } from 'lib/utils/dev-utils.js'; import App from './app.react.js'; +import { SQLiteDataHandler } from './database/sqlite-data-handler.js'; import ErrorBoundary from './error-boundary.react.js'; import Loading from './loading.react.js'; import { reducer } from './redux/redux-setup.js'; import type { AppState, Action } from './redux/redux-setup.js'; import history from './router-history.js'; import Socket from './socket.react.js'; const migrations = { [1]: state => { const { primaryIdentityPublicKey, ...stateWithoutPrimaryIdentityPublicKey } = state; return { ...stateWithoutPrimaryIdentityPublicKey, cryptoStore: { primaryAccount: null, primaryIdentityKeys: null, notificationAccount: null, notificationIdentityKeys: null, }, }; }, }; const persistConfig = { key: 'root', storage, whitelist: [ 'enabledApps', 'deviceID', 'draftStore', 'cryptoStore', 'notifPermissionAlertInfo', 'commServicesAccessToken', ], migrate: (createMigrate(migrations, { debug: isDev }): any), version: 1, }; declare var preloadedState: AppState; const persistedReducer = persistReducer(persistConfig, reducer); const store: Store = createStore( persistedReducer, preloadedState, composeWithDevTools({})(applyMiddleware(thunk, reduxLoggerMiddleware)), ); const persistor = persistStore(store); const RootProvider = (): React.Node => ( }> + ); export default RootProvider; diff --git a/web/types/worker-types.js b/web/types/worker-types.js index c71dd1a3d..61dd3ef3c 100644 --- a/web/types/worker-types.js +++ b/web/types/worker-types.js @@ -1,139 +1,145 @@ // @flow import type { ClientDBStore, ClientDBStoreOperations, } from 'lib/types/store-ops-types.js'; // The types of messages sent from app to worker export const workerRequestMessageTypes = Object.freeze({ PING: 0, INIT: 1, GENERATE_DATABASE_ENCRYPTION_KEY: 2, PROCESS_STORE_OPERATIONS: 3, GET_CLIENT_STORE: 4, SET_CURRENT_USER_ID: 5, GET_CURRENT_USER_ID: 6, GET_PERSIST_STORAGE_ITEM: 7, SET_PERSIST_STORAGE_ITEM: 8, REMOVE_PERSIST_STORAGE_ITEM: 9, + CLEAR_SENSITIVE_DATA: 10, }); export const workerWriteRequests: $ReadOnlyArray = [ workerRequestMessageTypes.PROCESS_STORE_OPERATIONS, workerRequestMessageTypes.SET_CURRENT_USER_ID, workerRequestMessageTypes.SET_PERSIST_STORAGE_ITEM, workerRequestMessageTypes.REMOVE_PERSIST_STORAGE_ITEM, ]; export type PingWorkerRequestMessage = { +type: 0, +text: string, }; export type InitWorkerRequestMessage = { +type: 1, +sqljsFilePath: string, +sqljsFilename: ?string, }; export type GenerateDatabaseEncryptionKeyRequestMessage = { +type: 2, }; export type ProcessStoreOperationsRequestMessage = { +type: 3, +storeOperations: ClientDBStoreOperations, }; export type GetClientStoreRequestMessage = { +type: 4, }; export type SetCurrentUserIDRequestMessage = { +type: 5, +userID: string, }; export type GetCurrentUserIDRequestMessage = { +type: 6, }; export type GetPersistStorageItemRequestMessage = { +type: 7, +key: string, }; export type SetPersistStorageItemRequestMessage = { +type: 8, +key: string, +item: string, }; export type RemovePersistStorageItemRequestMessage = { +type: 9, +key: string, }; +export type ClearSensitiveDataRequestMessage = { + +type: 10, +}; + export type WorkerRequestMessage = | PingWorkerRequestMessage | InitWorkerRequestMessage | GenerateDatabaseEncryptionKeyRequestMessage | ProcessStoreOperationsRequestMessage | GetClientStoreRequestMessage | SetCurrentUserIDRequestMessage | GetCurrentUserIDRequestMessage | GetPersistStorageItemRequestMessage | SetPersistStorageItemRequestMessage - | RemovePersistStorageItemRequestMessage; + | RemovePersistStorageItemRequestMessage + | ClearSensitiveDataRequestMessage; export type WorkerRequestProxyMessage = { +id: number, +message: WorkerRequestMessage, }; // The types of messages sent from worker to app export const workerResponseMessageTypes = Object.freeze({ PONG: 0, CLIENT_STORE: 1, GET_CURRENT_USER_ID: 2, GET_PERSIST_STORAGE_ITEM: 3, }); export type PongWorkerResponseMessage = { +type: 0, +text: string, }; export type ClientStoreResponseMessage = { +type: 1, +store: ClientDBStore, }; export type GetCurrentUserIDResponseMessage = { +type: 2, +userID: ?string, }; export type GetPersistStorageItemResponseMessage = { +type: 3, +item: string, }; export type WorkerResponseMessage = | PongWorkerResponseMessage | ClientStoreResponseMessage | GetCurrentUserIDResponseMessage | GetPersistStorageItemResponseMessage; export type WorkerResponseProxyMessage = { +id?: number, +message?: WorkerResponseMessage, +error?: Error, }; // SharedWorker types export type SharedWorkerMessageEvent = MessageEvent & { +ports: $ReadOnlyArray, ... };