diff --git a/web/database/utils/worker-crypto-utils.js b/web/database/utils/worker-crypto-utils.js index 920a50181..ae365b697 100644 --- a/web/database/utils/worker-crypto-utils.js +++ b/web/database/utils/worker-crypto-utils.js @@ -1,60 +1,64 @@ // @flow const ENCRYPTION_ALGORITHM = 'AES-GCM'; type EncryptedData = { +iv: BufferSource, +ciphertext: Uint8Array, }; -function generateDatabaseCryptoKey(): Promise { +function generateDatabaseCryptoKey({ + extractable, +}: { + +extractable: boolean, +}): Promise { return crypto.subtle.generateKey( { name: ENCRYPTION_ALGORITHM, length: 256, }, - false, + extractable, ['encrypt', 'decrypt'], ); } function generateIV(): BufferSource { return crypto.getRandomValues(new Uint8Array(12)); } async function encryptDatabaseFile( 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( 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); } export { generateDatabaseCryptoKey, encryptDatabaseFile, decryptDatabaseFile }; diff --git a/web/database/utils/worker-crypto-utlis.test.js b/web/database/utils/worker-crypto-utlis.test.js index 6a0d22a40..e7f3d6b45 100644 --- a/web/database/utils/worker-crypto-utlis.test.js +++ b/web/database/utils/worker-crypto-utlis.test.js @@ -1,85 +1,87 @@ // @flow import initSqlJs, { type SqliteDatabase } from 'sql.js'; import { decryptDatabaseFile, encryptDatabaseFile, generateDatabaseCryptoKey, } from './worker-crypto-utils.js'; import { getSQLiteDBVersion } from '../queries/db-queries.js'; const TEST_DB_VERSION = 5; const TAG_LENGTH = 16; const IV_LENGTH = 12; // calling export on empty schema will return empty buffer function setUpMockDb(database: SqliteDatabase) { database.exec(` CREATE TABLE test_table ( key TEXT UNIQUE PRIMARY KEY NOT NULL, value TEXT NOT NULL ); `); database.exec(` INSERT INTO test_table VALUES ("key_1", "value 1"); `); database.exec(`PRAGMA user_version=${TEST_DB_VERSION};`); } describe('database encryption utils', () => { let database; let cryptoKey; beforeAll(async () => { const SQL = await initSqlJs(); database = new SQL.Database(); setUpMockDb(database); - cryptoKey = await generateDatabaseCryptoKey(); + cryptoKey = await generateDatabaseCryptoKey({ extractable: false }); }); it('should encrypt database content', async () => { const dbContent: Uint8Array = database.export(); const { ciphertext, iv } = await encryptDatabaseFile(dbContent, cryptoKey); expect(iv.byteLength).toBe(IV_LENGTH); expect(ciphertext.length).toBe(dbContent.length + TAG_LENGTH); }); it('is decryptable', async () => { const dbContent: Uint8Array = database.export(); const encryptedData = await encryptDatabaseFile(dbContent, cryptoKey); const decrypted = await decryptDatabaseFile(encryptedData, cryptoKey); expect(decrypted).toEqual(dbContent); }); it('should fail with wrong key', async () => { const dbContent: Uint8Array = database.export(); const encryptedData = await encryptDatabaseFile(dbContent, cryptoKey); - const newCryptoKey = await generateDatabaseCryptoKey(); + const newCryptoKey = await generateDatabaseCryptoKey({ + extractable: false, + }); expect(decryptDatabaseFile(encryptedData, newCryptoKey)).rejects.toThrow(); }); it('should fail with wrong content', async () => { const dbContent: Uint8Array = database.export(); const encryptedData = await encryptDatabaseFile(dbContent, cryptoKey); const randomizedEncryptedData = { ...encryptedData, ciphertext: encryptedData.ciphertext.map(uint => uint ^ 1), }; expect( decryptDatabaseFile(randomizedEncryptedData, cryptoKey), ).rejects.toThrow(); }); it('should create database with decrypted content', async () => { const dbContent: Uint8Array = database.export(); const encryptedData = await encryptDatabaseFile(dbContent, cryptoKey); const decrypted = await decryptDatabaseFile(encryptedData, cryptoKey); const SQL = await initSqlJs(); const newDatabase = new SQL.Database(decrypted); expect(getSQLiteDBVersion(newDatabase)).toBe(TEST_DB_VERSION); }); }); diff --git a/web/database/worker/db-worker.js b/web/database/worker/db-worker.js index f155ca48e..4cdf44baa 100644 --- a/web/database/worker/db-worker.js +++ b/web/database/worker/db-worker.js @@ -1,302 +1,302 @@ // @flow import localforage from 'localforage'; import initSqlJs, { type SqliteDatabase } from 'sql.js'; import type { ClientDBReportStoreOperation } from 'lib/ops/report-store-ops.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 { migrate, 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 { getAllReports, removeAllReports, removeReports, updateReport, } from '../queries/report-queries.js'; import { getPersistStorageItem, removePersistStorageItem, setPersistStorageItem, } from '../queries/storage-engine-queries.js'; import { CURRENT_USER_ID_KEY, localforageConfig, SQLITE_CONTENT, SQLITE_ENCRYPTION_KEY, } from '../utils/constants.js'; import { decryptDatabaseFile, encryptDatabaseFile, generateDatabaseCryptoKey, } from '../utils/worker-crypto-utils.js'; localforage.config(localforageConfig); let sqliteDb: ?SqliteDatabase = null; let encryptionKey: ?CryptoKey = null; let persistNeeded: boolean = false; let persistInProgress: boolean = false; async function initDatabase(sqljsFilePath: string, sqljsFilename: ?string) { encryptionKey = await localforage.getItem(SQLITE_ENCRYPTION_KEY); if (!encryptionKey) { - const cryptoKey = await generateDatabaseCryptoKey(); + const cryptoKey = await generateDatabaseCryptoKey({ 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); } } 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', ); migrate(sqliteDb); } else { sqliteDb = new SQL.Database(); setupSQLiteDB(sqliteDb); console.info('Creating fresh database'); } } 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 processReportStoreOperations( operations: $ReadOnlyArray, ) { if (!sqliteDb) { throw new Error('Database not initialized'); } for (const operation: ClientDBReportStoreOperation of operations) { if (operation.type === 'remove_all_reports') { removeAllReports(sqliteDb); } else if (operation.type === 'remove_reports') { const { ids } = operation.payload; removeReports(sqliteDb, ids); } else if (operation.type === 'replace_report') { const { id, report } = operation.payload; updateReport(sqliteDb, id, report); } else { throw new Error('Unsupported report operation'); } } } function getClientStore(): ClientDBStore { if (!sqliteDb) { throw new Error('Database not initialized'); } return { drafts: getAllDrafts(sqliteDb), messages: [], threads: [], messageStoreThreads: [], reports: getAllReports(sqliteDb), }; } async function persist() { persistInProgress = true; if (!sqliteDb) { persistInProgress = false; throw new Error('Database not initialized'); } if (!encryptionKey) { encryptionKey = await localforage.getItem(SQLITE_ENCRYPTION_KEY); } while (persistNeeded) { persistNeeded = false; const dbData = sqliteDb.export(); if (!encryptionKey) { persistInProgress = false; throw new Error('Encryption key is missing'); } const encryptedData = await encryptDatabaseFile(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(); + const cryptoKey = await generateDatabaseCryptoKey({ extractable: false }); 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, reportStoreOperations } = message.storeOperations; if (draftStoreOperations) { processDraftStoreOperations(draftStoreOperations); } if (reportStoreOperations) { processReportStoreOperations(reportStoreOperations); } } 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); } 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);