diff --git a/web/database/utils/worker-crypto-utils.js b/web/crypto/aes-gcm-crypto-utils.js similarity index 89% rename from web/database/utils/worker-crypto-utils.js rename to web/crypto/aes-gcm-crypto-utils.js index b2c3d1c11..1bf280750 100644 --- a/web/database/utils/worker-crypto-utils.js +++ b/web/crypto/aes-gcm-crypto-utils.js @@ -1,92 +1,92 @@ // @flow const ENCRYPTION_ALGORITHM = 'AES-GCM'; const ENCRYPTION_KEY_USAGES: $ReadOnlyArray = [ 'encrypt', 'decrypt', ]; type EncryptedData = { +iv: BufferSource, +ciphertext: Uint8Array, }; -function generateDatabaseCryptoKey({ +function generateCryptoKey({ extractable, }: { +extractable: boolean, }): Promise { return crypto.subtle.generateKey( { name: ENCRYPTION_ALGORITHM, length: 256, }, extractable, ENCRYPTION_KEY_USAGES, ); } function generateIV(): BufferSource { return crypto.getRandomValues(new Uint8Array(12)); } -async function encryptDatabaseFile( +async function encryptData( data: Uint8Array, key: CryptoKey, ): Promise { const iv = generateIV(); const ciphertext = await crypto.subtle.encrypt( { name: ENCRYPTION_ALGORITHM, iv: iv, }, key, data, ); return { ciphertext: new Uint8Array(ciphertext), iv, }; } -async function decryptDatabaseFile( +async function decryptData( encryptedData: EncryptedData, key: CryptoKey, ): Promise { const { ciphertext, iv } = encryptedData; const decrypted = await crypto.subtle.decrypt( { name: ENCRYPTION_ALGORITHM, iv, }, key, ciphertext, ); return new Uint8Array(decrypted); } async function exportKeyToJWK( key: CryptoKey, ): Promise { return await crypto.subtle.exportKey('jwk', key); } async function importJWKKey( jwkKey: SubtleCrypto$JsonWebKey, ): Promise { return await crypto.subtle.importKey( 'jwk', jwkKey, ENCRYPTION_ALGORITHM, true, ENCRYPTION_KEY_USAGES, ); } export { - generateDatabaseCryptoKey, - encryptDatabaseFile, - decryptDatabaseFile, + generateCryptoKey, + encryptData, + decryptData, exportKeyToJWK, importJWKKey, }; diff --git a/web/database/database-module-provider.js b/web/database/database-module-provider.js index 12dd9290d..3d54d1116 100644 --- a/web/database/database-module-provider.js +++ b/web/database/database-module-provider.js @@ -1,156 +1,156 @@ // @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 WorkerConnectionProxy from './utils/WorkerConnectionProxy.js'; import { exportKeyToJWK, - generateDatabaseCryptoKey, -} from './utils/worker-crypto-utils.js'; -import WorkerConnectionProxy from './utils/WorkerConnectionProxy.js'; + generateCryptoKey, +} from '../crypto/aes-gcm-crypto-utils.js'; import { workerRequestMessageTypes, type WorkerRequestMessage, type WorkerResponseMessage, } from '../types/worker-types.js'; declare var baseURL: string; declare var commQueryExecutorFilename: string; const databaseStatuses = Object.freeze({ notRunning: 'NOT_RUNNING', initSuccess: 'INIT_SUCCESS', initInProgress: 'INIT_IN_PROGRESS', initError: 'INIT_ERROR', }); type DatabaseStatus = | { +type: 'NOT_RUNNING' | 'INIT_SUCCESS' | 'INIT_ERROR' } | { +type: 'INIT_IN_PROGRESS', +initPromise: Promise }; type InitOptions = { +clearDatabase: boolean }; class DatabaseModule { worker: ?SharedWorker; workerProxy: ?WorkerConnectionProxy; status: DatabaseStatus = { type: databaseStatuses.notRunning }; async init({ clearDatabase }: InitOptions): Promise { if (!isSQLiteSupported()) { console.warn('SQLite is not supported'); this.status = { type: databaseStatuses.initError }; return; } if (clearDatabase && this.status.type === 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 = { type: databaseStatuses.notRunning }; } if (this.status.type === databaseStatuses.initInProgress) { await this.status.initPromise; return; } if ( this.status.type === databaseStatuses.initSuccess || this.status.type === databaseStatuses.initError ) { return; } 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; const initPromise = (async () => { try { let encryptionKey = null; if (isDesktopSafari) { encryptionKey = await getSafariEncryptionKey(); } invariant(this.workerProxy, 'Worker proxy should exist'); await this.workerProxy.scheduleOnWorker({ type: workerRequestMessageTypes.INIT, databaseModuleFilePath: `${origin}${baseURL}${DATABASE_MODULE_FILE_PATH}`, encryptionKey, commQueryExecutorFilename, }); this.status = { type: databaseStatuses.initSuccess }; console.info('Database initialization success'); } catch (error) { this.status = { type: databaseStatuses.initError }; console.error(`Database initialization failure`, error); } })(); this.status = { type: databaseStatuses.initInProgress, initPromise }; await initPromise; } async isDatabaseSupported(): Promise { if (this.status.type === databaseStatuses.initInProgress) { await this.status.initPromise; } return this.status.type === databaseStatuses.initSuccess; } async schedule( payload: WorkerRequestMessage, ): Promise { if (this.status.type === databaseStatuses.notRunning) { throw new Error('Database not running'); } if (this.status.type === databaseStatuses.initInProgress) { await this.status.initPromise; } if (this.status.type === 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({ + const newEncryptionKey = await generateCryptoKey({ 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(); await databaseModule.init({ clearDatabase: false }); } return databaseModule; } // Start initializing the database immediately getDatabaseModule(); export { getDatabaseModule }; diff --git a/web/database/utils/worker-crypto-utils.test.js b/web/database/utils/worker-crypto-utils.test.js index 3515b4caf..c65a1d65d 100644 --- a/web/database/utils/worker-crypto-utils.test.js +++ b/web/database/utils/worker-crypto-utils.test.js @@ -1,102 +1,100 @@ // @flow import { exportDatabaseContent, importDatabaseContent } from './db-utils.js'; import { - decryptDatabaseFile, - encryptDatabaseFile, + decryptData, + encryptData, exportKeyToJWK, - generateDatabaseCryptoKey, + generateCryptoKey, importJWKKey, -} from './worker-crypto-utils.js'; +} from '../../crypto/aes-gcm-crypto-utils.js'; import { getDatabaseModule } from '../db-module.js'; const TAG_LENGTH = 16; const IV_LENGTH = 12; const FILE_PATH = 'test.sqlite'; const TEST_KEY = 'key'; const TEST_VAL = 'val'; describe('database encryption utils', () => { let sqliteQueryExecutor; let dbModule; let cryptoKey; beforeAll(async () => { dbModule = getDatabaseModule(); sqliteQueryExecutor = new dbModule.SQLiteQueryExecutor('test.sqlite'); sqliteQueryExecutor.setMetadata(TEST_KEY, TEST_VAL); - cryptoKey = await generateDatabaseCryptoKey({ extractable: false }); + cryptoKey = await generateCryptoKey({ extractable: false }); }); it('should encrypt database content', async () => { const dbContent: Uint8Array = exportDatabaseContent(dbModule, FILE_PATH); - const { ciphertext, iv } = await encryptDatabaseFile(dbContent, cryptoKey); + const { ciphertext, iv } = await encryptData(dbContent, cryptoKey); expect(iv.byteLength).toBe(IV_LENGTH); expect(ciphertext.length).toBe(dbContent.length + TAG_LENGTH); }); it('is decryptable', async () => { const dbContent: Uint8Array = exportDatabaseContent(dbModule, FILE_PATH); - const encryptedData = await encryptDatabaseFile(dbContent, cryptoKey); - const decrypted = await decryptDatabaseFile(encryptedData, cryptoKey); + const encryptedData = await encryptData(dbContent, cryptoKey); + const decrypted = await decryptData(encryptedData, cryptoKey); expect(decrypted).toEqual(dbContent); }); it('should fail with wrong key', async () => { const dbContent: Uint8Array = exportDatabaseContent(dbModule, FILE_PATH); - const encryptedData = await encryptDatabaseFile(dbContent, cryptoKey); + const encryptedData = await encryptData(dbContent, cryptoKey); - const newCryptoKey = await generateDatabaseCryptoKey({ + const newCryptoKey = await generateCryptoKey({ extractable: false, }); - expect(decryptDatabaseFile(encryptedData, newCryptoKey)).rejects.toThrow(); + expect(decryptData(encryptedData, newCryptoKey)).rejects.toThrow(); }); it('should fail with wrong content', async () => { const dbContent: Uint8Array = exportDatabaseContent(dbModule, FILE_PATH); - const encryptedData = await encryptDatabaseFile(dbContent, cryptoKey); + const encryptedData = await encryptData(dbContent, cryptoKey); const randomizedEncryptedData = { ...encryptedData, ciphertext: encryptedData.ciphertext.map(uint => uint ^ 1), }; - expect( - decryptDatabaseFile(randomizedEncryptedData, cryptoKey), - ).rejects.toThrow(); + expect(decryptData(randomizedEncryptedData, cryptoKey)).rejects.toThrow(); }); it('should create database with decrypted content', async () => { const dbContent: Uint8Array = exportDatabaseContent(dbModule, FILE_PATH); - const encryptedData = await encryptDatabaseFile(dbContent, cryptoKey); - const decrypted = await decryptDatabaseFile(encryptedData, cryptoKey); + const encryptedData = await encryptData(dbContent, cryptoKey); + const decrypted = await decryptData(encryptedData, cryptoKey); importDatabaseContent(decrypted, dbModule, 'new-file.sqlite'); const executor = new dbModule.SQLiteQueryExecutor('new-file.sqlite'); expect(executor.getMetadata(TEST_KEY)).toBe(TEST_VAL); }); it('should export and import key in JWK format', async () => { // creating new key - const key = await generateDatabaseCryptoKey({ extractable: true }); + const key = await generateCryptoKey({ extractable: true }); const dbContent: Uint8Array = dbModule.FS.readFile(FILE_PATH, { encoding: 'binary', }); - const encryptedData = await encryptDatabaseFile(dbContent, key); + const encryptedData = await encryptData(dbContent, key); // exporting and importing key const exportedKey = await exportKeyToJWK(key); const importedKey = await importJWKKey(exportedKey); // decrypt using re-created on import key - const decrypted = await decryptDatabaseFile(encryptedData, importedKey); + const decrypted = await decryptData(encryptedData, importedKey); importDatabaseContent(decrypted, dbModule, 'new-file.sqlite'); const executor = new dbModule.SQLiteQueryExecutor('new-file.sqlite'); expect(executor.getMetadata(TEST_KEY)).toBe(TEST_VAL); }); }); diff --git a/web/database/worker/db-worker.js b/web/database/worker/db-worker.js index 2a286c74c..b80a554f3 100644 --- a/web/database/worker/db-worker.js +++ b/web/database/worker/db-worker.js @@ -1,252 +1,252 @@ // @flow import localforage from 'localforage'; import { getClientStore, processDBStoreOperations, } from './process-operations.js'; +import { + decryptData, + encryptData, + generateCryptoKey, + importJWKKey, +} from '../../crypto/aes-gcm-crypto-utils.js'; import { type SharedWorkerMessageEvent, type WorkerRequestMessage, type WorkerResponseMessage, workerRequestMessageTypes, workerResponseMessageTypes, type WorkerRequestProxyMessage, workerWriteRequests, } from '../../types/worker-types.js'; import { getDatabaseModule } from '../db-module.js'; import { type EmscriptenModule } from '../types/module.js'; import { type SQLiteQueryExecutor } from '../types/sqlite-query-executor.js'; import { COMM_SQLITE_DATABASE_PATH, CURRENT_USER_ID_KEY, localforageConfig, SQLITE_CONTENT, SQLITE_ENCRYPTION_KEY, } from '../utils/constants.js'; import { clearSensitiveData, exportDatabaseContent, importDatabaseContent, } from '../utils/db-utils.js'; -import { - decryptDatabaseFile, - encryptDatabaseFile, - generateDatabaseCryptoKey, - importJWKKey, -} from '../utils/worker-crypto-utils.js'; localforage.config(localforageConfig); let encryptionKey: ?CryptoKey = null; let sqliteQueryExecutor: ?SQLiteQueryExecutor = null; let dbModule: ?EmscriptenModule = null; let persistNeeded: boolean = false; let persistInProgress: boolean = false; async function initDatabase( databaseModuleFilePath: string, commQueryExecutorFilename: ?string, encryptionKeyJWK?: ?SubtleCrypto$JsonWebKey, ) { dbModule = getDatabaseModule( commQueryExecutorFilename, databaseModuleFilePath, ); try { const result = dbModule.CommQueryExecutor.testDBOperation(); console.log(result); } catch (e) { console.error(e); } if (encryptionKeyJWK) { encryptionKey = await importJWKKey(encryptionKeyJWK); } else { encryptionKey = await localforage.getItem(SQLITE_ENCRYPTION_KEY); if (!encryptionKey) { - const cryptoKey = await generateDatabaseCryptoKey({ extractable: false }); + const cryptoKey = await generateCryptoKey({ extractable: false }); 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); + dbContent = await decryptData(encryptedContent, encryptionKey); } } catch (e) { console.error('Error while decrypting content, clearing database content'); await localforage.removeItem(SQLITE_CONTENT); } if (dbContent) { importDatabaseContent(dbContent, dbModule, COMM_SQLITE_DATABASE_PATH); console.info( 'Database exists and is properly encrypted, using persisted data', ); } else { console.info('Creating fresh database'); } sqliteQueryExecutor = new dbModule.SQLiteQueryExecutor( COMM_SQLITE_DATABASE_PATH, ); } async function persist() { persistInProgress = true; if (!sqliteQueryExecutor || !dbModule) { persistInProgress = false; throw new Error('Database not initialized'); } if (!encryptionKey) { encryptionKey = await localforage.getItem(SQLITE_ENCRYPTION_KEY); } while (persistNeeded) { persistNeeded = false; const dbData = exportDatabaseContent(dbModule, COMM_SQLITE_DATABASE_PATH); if (!encryptionKey) { persistInProgress = false; throw new Error('Encryption key is missing'); } - const encryptedData = await encryptDatabaseFile(dbData, encryptionKey); + const encryptedData = await encryptData(dbData, encryptionKey); await localforage.setItem(SQLITE_CONTENT, encryptedData); } persistInProgress = false; } 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({ extractable: false }); + const cryptoKey = await generateCryptoKey({ extractable: false }); await localforage.setItem(SQLITE_ENCRYPTION_KEY, cryptoKey); return undefined; } // database operations if (message.type === workerRequestMessageTypes.INIT) { await initDatabase( message.databaseModuleFilePath, message.commQueryExecutorFilename, message.encryptionKey, ); return undefined; } else if (message.type === workerRequestMessageTypes.CLEAR_SENSITIVE_DATA) { encryptionKey = null; await localforage.clear(); if (dbModule && sqliteQueryExecutor) { clearSensitiveData( dbModule, COMM_SQLITE_DATABASE_PATH, sqliteQueryExecutor, ); } sqliteQueryExecutor = null; return undefined; } if (!sqliteQueryExecutor) { throw new Error('Database not initialized'); } // read-only operations if (message.type === workerRequestMessageTypes.GET_CLIENT_STORE) { return { type: workerResponseMessageTypes.CLIENT_STORE, store: getClientStore(sqliteQueryExecutor), }; } else if (message.type === workerRequestMessageTypes.GET_CURRENT_USER_ID) { return { type: workerResponseMessageTypes.GET_CURRENT_USER_ID, userID: sqliteQueryExecutor.getMetadata(CURRENT_USER_ID_KEY), }; } else if ( message.type === workerRequestMessageTypes.GET_PERSIST_STORAGE_ITEM ) { return { type: workerResponseMessageTypes.GET_PERSIST_STORAGE_ITEM, item: sqliteQueryExecutor.getPersistStorageItem(message.key), }; } // write operations if (!workerWriteRequests.includes(message.type)) { throw new Error('Request type not supported'); } if (!sqliteQueryExecutor) { throw new Error('Database not initialized'); } if (message.type === workerRequestMessageTypes.PROCESS_STORE_OPERATIONS) { processDBStoreOperations(sqliteQueryExecutor, message.storeOperations); } else if (message.type === workerRequestMessageTypes.SET_CURRENT_USER_ID) { sqliteQueryExecutor.setMetadata(CURRENT_USER_ID_KEY, message.userID); } else if ( message.type === workerRequestMessageTypes.SET_PERSIST_STORAGE_ITEM ) { sqliteQueryExecutor.setPersistStorageItem(message.key, message.item); } else if ( message.type === workerRequestMessageTypes.REMOVE_PERSIST_STORAGE_ITEM ) { sqliteQueryExecutor.removePersistStorageItem(message.key); } persistNeeded = true; if (!persistInProgress) { persist(); } 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: 'Request without identifier', }); } try { const result = await processAppRequest(message); port.postMessage({ id, message: result, }); } catch (e) { port.postMessage({ id, error: e.message, }); } }; } self.addEventListener('connect', connectHandler);