diff --git a/web/database/database-module-provider.js b/web/database/database-module-provider.js index be3956f01..b0c05c7f9 100644 --- a/web/database/database-module-provider.js +++ b/web/database/database-module-provider.js @@ -1,160 +1,155 @@ // @flow import invariant from 'invariant'; import localforage from 'localforage'; import { DATABASE_WORKER_PATH, DATABASE_MODULE_FILE_PATH, SQLITE_ENCRYPTION_KEY, } from './utils/constants.js'; import { isDesktopSafari, isSQLiteSupported } from './utils/db-utils.js'; import { exportKeyToJWK, generateDatabaseCryptoKey, } from './utils/worker-crypto-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 commQueryExecutorFilename: string; -declare var preloadedState: AppState; - const databaseStatuses = Object.freeze({ - notSupported: 'NOT_SUPPORTED', + notRunning: 'NOT_RUNNING', initSuccess: 'INIT_SUCCESS', initInProgress: 'INIT_IN_PROGRESS', initError: 'INIT_ERROR', }); type DatabaseStatus = $Values; +type InitOptions = { +clearDatabase: boolean }; + class DatabaseModule { worker: ?SharedWorker; workerProxy: ?WorkerConnectionProxy; initPromise: ?Promise; - status: DatabaseStatus = databaseStatuses.notSupported; + status: DatabaseStatus = databaseStatuses.notRunning; - async init(currentLoggedInUserID: ?string): Promise { - if (!currentLoggedInUserID) { + async init({ clearDatabase }: InitOptions): Promise { + if (!isSQLiteSupported()) { + console.warn('SQLite is not supported'); + this.status = databaseStatuses.initError; return; } - if (!isSQLiteSupported(currentLoggedInUserID)) { - console.warn('Sqlite is not supported'); - this.status = databaseStatuses.notSupported; - return; + if (clearDatabase && this.status === databaseStatuses.initSuccess) { + console.info('Clearing sensitive data'); + invariant(this.workerProxy, 'Worker proxy should exist'); + await this.workerProxy.scheduleOnWorker({ + type: workerRequestMessageTypes.CLEAR_SENSITIVE_DATA, + }); + this.status = databaseStatuses.notRunning; } if (this.status === databaseStatuses.initInProgress) { await this.initPromise; return; } if ( this.status === databaseStatuses.initSuccess || this.status === databaseStatuses.initError ) { return; } this.status = databaseStatuses.initInProgress; let encryptionKey = null; if (isDesktopSafari) { encryptionKey = await getSafariEncryptionKey(); } 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 { invariant(this.workerProxy, 'Worker proxy should exist'); await this.workerProxy.scheduleOnWorker({ type: workerRequestMessageTypes.INIT, databaseModuleFilePath: `${origin}${DATABASE_MODULE_FILE_PATH}`, encryptionKey, commQueryExecutorFilename, }); this.status = databaseStatuses.initSuccess; console.info('Database initialization success'); } catch (error) { this.status = databaseStatuses.initError; console.error(`Database initialization failure`, error); } })(); await this.initPromise; } - async clearSensitiveData(): Promise { - this.status = databaseStatuses.notSupported; - invariant(this.workerProxy, 'Worker proxy should exist'); - 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.notRunning) { + throw new Error('Database not running'); } if (this.status === databaseStatuses.initInProgress) { await this.initPromise; } if (this.status === databaseStatuses.initError) { throw new Error('Database could not be initialized'); } invariant(this.workerProxy, 'Worker proxy should exist'); return this.workerProxy.scheduleOnWorker(payload); } } async function getSafariEncryptionKey(): Promise { const encryptionKey = await localforage.getItem(SQLITE_ENCRYPTION_KEY); if (encryptionKey) { return await exportKeyToJWK(encryptionKey); } const newEncryptionKey = await generateDatabaseCryptoKey({ extractable: true, }); await localforage.setItem(SQLITE_ENCRYPTION_KEY, newEncryptionKey); return await exportKeyToJWK(newEncryptionKey); } let databaseModule: ?DatabaseModule = null; async function getDatabaseModule(): Promise { if (!databaseModule) { databaseModule = new DatabaseModule(); - const currentLoggedInUserID = preloadedState.currentUserInfo?.anonymous - ? undefined - : preloadedState.currentUserInfo?.id; - await databaseModule.init(currentLoggedInUserID); + await databaseModule.init({ clearDatabase: false }); } return databaseModule; } +// Start initializing the database immediately +getDatabaseModule(); export { getDatabaseModule }; diff --git a/web/database/sqlite-data-handler.js b/web/database/sqlite-data-handler.js index f98df6d3b..0b52ac264 100644 --- a/web/database/sqlite-data-handler.js +++ b/web/database/sqlite-data-handler.js @@ -1,96 +1,93 @@ // @flow import * as React from 'react'; import { useDispatch } from 'react-redux'; import { setClientDBStoreActionType } from 'lib/actions/client-db-store-actions.js'; import { reportStoreOpsHandlers } from 'lib/ops/report-store-ops.js'; import { getDatabaseModule } from './database-module-provider.js'; import { useSelector } from '../redux/redux-utils.js'; import { workerRequestMessageTypes } from '../types/worker-types.js'; function SQLiteDataHandler(): React.Node { const dispatch = useDispatch(); 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 () => { const databaseModule = await getDatabaseModule(); try { const currentUserData = await databaseModule.schedule({ type: workerRequestMessageTypes.GET_CURRENT_USER_ID, }); const currentDBUserID = currentUserData?.userID; - if (currentDBUserID && currentDBUserID !== currentLoggedInUserID) { - await databaseModule.clearSensitiveData(); + if (currentDBUserID === currentLoggedInUserID) { + return; + } + + if (currentDBUserID) { + await databaseModule.init({ clearDatabase: true }); } - if ( - currentLoggedInUserID && - (currentDBUserID || currentDBUserID !== currentLoggedInUserID) - ) { + if (currentLoggedInUserID) { await databaseModule.schedule({ type: workerRequestMessageTypes.SET_CURRENT_USER_ID, userID: currentLoggedInUserID, }); } } catch (error) { console.error(error); throw error; } }, [currentLoggedInUserID]); React.useEffect(() => { (async () => { const databaseModule = await getDatabaseModule(); - if (currentLoggedInUserID) { - await databaseModule.init(currentLoggedInUserID); - } - if (!rehydrateConcluded) { return; } const isSupported = await databaseModule.isDatabaseSupported(); if (!isSupported) { return; } await handleSensitiveData(); if (!currentLoggedInUserID) { return; } const data = await databaseModule.schedule({ type: workerRequestMessageTypes.GET_CLIENT_STORE, }); if (!data?.store?.drafts && !data?.store?.reports) { return; } const reports = reportStoreOpsHandlers.translateClientDBData( data.store.reports, ); dispatch({ type: setClientDBStoreActionType, payload: { drafts: data.store.drafts, reports, }, }); })(); }, [ currentLoggedInUserID, dispatch, handleSensitiveData, rehydrateConcluded, ]); return null; } export { SQLiteDataHandler }; diff --git a/web/database/utils/db-utils.js b/web/database/utils/db-utils.js index d5eb5e9a6..173658567 100644 --- a/web/database/utils/db-utils.js +++ b/web/database/utils/db-utils.js @@ -1,65 +1,55 @@ // @flow import { detect as detectBrowser } from 'detect-browser'; -import { isStaff } from 'lib/shared/staff-utils.js'; -import { isDev } from 'lib/utils/dev-utils.js'; - import { DB_SUPPORTED_BROWSERS, DB_SUPPORTED_OS } from './constants.js'; import type { EmscriptenModule } from '../types/module.js'; import { type SQLiteQueryExecutor } from '../types/sqlite-query-executor.js'; const browser = detectBrowser(); function clearSensitiveData( dbModule: EmscriptenModule, path: string, sqliteQueryExecutor: SQLiteQueryExecutor, ) { sqliteQueryExecutor.delete(); dbModule.FS.unlink(path); } function importDatabaseContent( content: Uint8Array, dbModule: EmscriptenModule, path: string, ) { const stream = dbModule.FS.open(path, 'w+'); dbModule.FS.write(stream, content, 0, content.length, 0); dbModule.FS.close(stream); } function exportDatabaseContent( dbModule: EmscriptenModule, path: string, ): Uint8Array { return dbModule.FS.readFile(path, { encoding: 'binary', }); } -function isSQLiteSupported(currentLoggedInUserID: ?string): boolean { - if (!currentLoggedInUserID) { - return false; - } - if (!isDev && (!currentLoggedInUserID || !isStaff(currentLoggedInUserID))) { - return false; - } - +function isSQLiteSupported(): boolean { return ( DB_SUPPORTED_OS.includes(browser.os) && DB_SUPPORTED_BROWSERS.includes(browser.name) ); } const isDesktopSafari: boolean = browser && browser.name === 'safari' && browser.os === 'Mac OS'; export { isSQLiteSupported, isDesktopSafari, importDatabaseContent, exportDatabaseContent, clearSensitiveData, }; diff --git a/web/redux/persist.js b/web/redux/persist.js index 31e2c2f77..9453355e8 100644 --- a/web/redux/persist.js +++ b/web/redux/persist.js @@ -1,223 +1,247 @@ // @flow import invariant from 'invariant'; import { getStoredState, purgeStoredState, createTransform, } from 'redux-persist'; import storage from 'redux-persist/es/storage/index.js'; import type { Transform } from 'redux-persist/es/types.js'; import type { PersistConfig } from 'redux-persist/src/types.js'; import { createAsyncMigrate, type StorageMigrationFunction, } from 'lib/shared/create-async-migrate.js'; import type { KeyserverInfo, KeyserverStore, } from 'lib/types/keyserver-types.js'; import { defaultConnectionInfo, type ConnectionInfo, } from 'lib/types/socket-types.js'; import { isDev } from 'lib/utils/dev-utils.js'; import { generateIDSchemaMigrationOpsForDrafts, convertDraftStoreToNewIDSchema, } from 'lib/utils/migration-utils.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import commReduxStorageEngine from './comm-redux-storage-engine.js'; import type { AppState } from './redux-setup.js'; import { getDatabaseModule } from '../database/database-module-provider.js'; import { isSQLiteSupported } from '../database/utils/db-utils.js'; import { workerRequestMessageTypes } from '../types/worker-types.js'; declare var preloadedState: AppState; -const initiallyLoggedInUserID = preloadedState.currentUserInfo?.anonymous - ? undefined - : preloadedState.currentUserInfo?.id; -const isDatabaseSupported = isSQLiteSupported(initiallyLoggedInUserID); - const migrations = { [1]: async state => { const { primaryIdentityPublicKey, ...stateWithoutPrimaryIdentityPublicKey } = state; return { ...stateWithoutPrimaryIdentityPublicKey, cryptoStore: { primaryAccount: null, primaryIdentityKeys: null, notificationAccount: null, notificationIdentityKeys: null, }, }; }, [2]: async state => { const databaseModule = await getDatabaseModule(); + const isDatabaseSupported = await databaseModule.isDatabaseSupported(); if (!isDatabaseSupported) { return state; } const { drafts } = state.draftStore; const draftStoreOperations = []; for (const key in drafts) { const text = drafts[key]; draftStoreOperations.push({ type: 'update', payload: { key, text }, }); } await databaseModule.schedule({ type: workerRequestMessageTypes.PROCESS_STORE_OPERATIONS, storeOperations: { draftStoreOperations }, }); return state; }, [3]: async (state: AppState) => { let newState = state; if (state.draftStore) { newState = { ...newState, draftStore: convertDraftStoreToNewIDSchema(state.draftStore), }; } const databaseModule = await getDatabaseModule(); + const isDatabaseSupported = await databaseModule.isDatabaseSupported(); if (!isDatabaseSupported) { return newState; } const stores = await databaseModule.schedule({ type: workerRequestMessageTypes.GET_CLIENT_STORE, }); invariant(stores?.store, 'Stores should exist'); await databaseModule.schedule({ type: workerRequestMessageTypes.PROCESS_STORE_OPERATIONS, storeOperations: { draftStoreOperations: generateIDSchemaMigrationOpsForDrafts( stores.store.drafts, ), }, }); return newState; }, [4]: async state => { const { lastCommunicatedPlatformDetails, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [ashoatKeyserverID]: { ...keyserverStore.keyserverInfos[ashoatKeyserverID], lastCommunicatedPlatformDetails, }, }, }, }; }, }; const persistWhitelist = [ 'enabledApps', 'deviceID', 'cryptoStore', 'notifPermissionAlertInfo', 'commServicesAccessToken', 'keyserverStore', ]; const rootKey = 'root'; const migrateStorageToSQLite: StorageMigrationFunction = async debug => { const databaseModule = await getDatabaseModule(); const isSupported = await databaseModule.isDatabaseSupported(); if (!isSupported) { return undefined; } const oldStorage = await getStoredState({ storage, key: rootKey }); if (!oldStorage) { return undefined; } purgeStoredState({ storage, key: rootKey }); if (debug) { console.log('redux-persist: migrating state to SQLite storage'); } + // We need to simulate the keyserverStoreTransform for data stored in the + // old local storage (because redux persist will only run it for the + // sqlite storage which is empty in this case). + // We don't just use keyserverStoreTransform.out(oldStorage) because + // the transform might change in the future, but we need to treat + // this code like migration code (it shouldn't change). + if (oldStorage?._persist?.version === 4) { + const { connection, updatesCurrentAsOf, sessionID } = + preloadedState.keyserverStore.keyserverInfos[ashoatKeyserverID]; + + return { + ...oldStorage, + keyserverStore: { + ...oldStorage.keyserverStore, + keyserverInfos: { + ...oldStorage.keyserverStore.keyserverInfos, + [ashoatKeyserverID]: { + ...oldStorage.keyserverStore.keyserverInfos[ashoatKeyserverID], + connection, + updatesCurrentAsOf, + sessionID, + }, + }, + }, + }; + } + return oldStorage; }; type PersistedKeyserverInfo = $Diff< KeyserverInfo, { +connection: ConnectionInfo, +updatesCurrentAsOf: number, +sessionID?: ?string, }, >; type PersistedKeyserverStore = { +keyserverInfos: { +[key: string]: PersistedKeyserverInfo }, }; const keyserverStoreTransform: Transform = createTransform( (state: KeyserverStore): PersistedKeyserverStore => { const keyserverInfos = {}; for (const key in state.keyserverInfos) { const { connection, updatesCurrentAsOf, sessionID, ...rest } = state.keyserverInfos[key]; keyserverInfos[key] = rest; } return { ...state, keyserverInfos, }; }, (state: PersistedKeyserverStore): KeyserverStore => { const keyserverInfos = {}; const defaultConnection = defaultConnectionInfo; for (const key in state.keyserverInfos) { keyserverInfos[key] = { ...state.keyserverInfos[key], connection: { ...defaultConnection }, updatesCurrentAsOf: preloadedState.keyserverStore.keyserverInfos[key].updatesCurrentAsOf, sessionID: preloadedState.keyserverStore.keyserverInfos[key].sessionID, }; } return { ...state, keyserverInfos, }; }, { whitelist: ['keyserverStore'] }, ); const persistConfig: PersistConfig = { key: rootKey, storage: commReduxStorageEngine, - whitelist: isDatabaseSupported + whitelist: isSQLiteSupported() ? persistWhitelist : [...persistWhitelist, 'draftStore'], migrate: (createAsyncMigrate( migrations, { debug: isDev }, migrateStorageToSQLite, ): any), version: 4, transforms: [keyserverStoreTransform], }; export { persistConfig };