import BaseDexie, { Table } from "dexie"; import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon"; import * as R from "ramda"; import { Account, AccountsSchema } from "./tables/accounts"; import { Contact, ContactSchema } from "./tables/contacts"; import { Log, LogSchema } from "./tables/logs"; import { MASTER_SECRET_KEY, Secret, SecretSchema } from "./tables/secret"; import { MASTER_SETTINGS_KEY, Settings, SettingsSchema, } from "./tables/settings"; import { Temp, TempSchema } from "./tables/temp"; import { DEFAULT_ENDORSER_API_SERVER } from "../constants/app"; import { logger } from "../utils/logger"; // Define types for tables that hold sensitive and non-sensitive data type SecretTable = { secret: Table }; type SensitiveTables = { accounts: Table }; type NonsensitiveTables = { contacts: Table; logs: Table; settings: Table; temp: Table; }; // Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings export type SecretDexie = BaseDexie & T; export type SensitiveDexie = BaseDexie & T; export type NonsensitiveDexie = BaseDexie & T; //// Initialize the DBs, starting with the sensitive ones. // Initialize Dexie database for secret, which is then used to encrypt accountsDB export const secretDB = new BaseDexie("TimeSafariSecret") as SecretDexie; secretDB.version(1).stores(SecretSchema); // Initialize Dexie database for accounts const accountsDexie = new BaseDexie("TimeSafariAccounts") as SensitiveDexie; // Instead of accountsDBPromise, use libs/util retrieveAccountMetadata or retrieveFullyDecryptedAccount // so that it's clear whether the usage needs the private key inside. // // This is a promise because the decryption key comes from IndexedDB // and someday it may come from a password or keystore or external wallet. // It's important that usages take into account that there may be a delay due // to a user action required to unlock the data. export const accountsDBPromise = useSecretAndInitializeAccountsDB( secretDB, accountsDexie, ); //// Now initialize the other DB. // Initialize Dexie databases for non-sensitive data export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie; // Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning // v1 also had contacts & settings // v2 added Log db.version(2).stores({ ...ContactSchema, ...LogSchema, ...{ settings: "id" }, // old Settings schema }); // v3 added Temp db.version(3).stores(TempSchema); db.version(4) .stores(SettingsSchema) .upgrade((tx) => { return tx .table("settings") .toCollection() .modify((settings) => { settings.accountDid = ""; // make it non-null for the default master settings, but still indexable }); }); const DEFAULT_SETTINGS: Settings = { id: MASTER_SETTINGS_KEY, activeDid: undefined, apiServer: DEFAULT_ENDORSER_API_SERVER, }; // Event handler to initialize the non-sensitive database with default settings db.on("populate", async () => { await db.settings.add(DEFAULT_SETTINGS); }); // Manage the encryption key. // It's not really secure to maintain the secret next to the user's data. // However, until we have better hooks into a real wallet or reliable secure // storage, we'll do this for user convenience. As they sign more records // and integrate with more people, they'll value it more and want to be more // secure, so we'll prompt them to take steps to back it up, properly encrypt, // etc. At the beginning, we'll prompt for a password, then we'll prompt for a // PWA so it's not in a browser... and then we hope to be integrated with a // real wallet or something else more secure. // One might ask: why encrypt at all? We figure a basic encryption is better // than none. Plus, we expect to support their own password or keystore or // external wallet as better signing options in the future, so it's gonna be // important to have the structure where each account access might require // user action. // (Once upon a time we stored the secret in localStorage, but it frequently // got erased, even though the IndexedDB still had the identity data. This // ended up throwing lots of errors to the user... and they'd end up in a state // where they couldn't take action because they couldn't unlock that identity.) async function useSecretAndInitializeAccountsDB( secretDB: SecretDexie, accountsDB: SensitiveDexie, ): Promise { return secretDB .open() .then(() => { return secretDB.secret.get(MASTER_SECRET_KEY); }) .then((secretRow?: Secret) => { let secret = secretRow?.secret; if (secret != null) { // they already have it in IndexedDB, so just pass it along return secret; } else { // check localStorage (for users before v 0.3.37) const localSecret = localStorage.getItem("secret"); if (localSecret != null) { // they had one, so we want to move it to IndexedDB secret = localSecret; } else { // they didn't have one, so let's generate one secret = Encryption.createRandomEncryptionKey(); } // it is not in IndexedDB, so add it now return secretDB.secret .add({ id: MASTER_SECRET_KEY, secret }) .then(() => { return secret; }); } }) .then((secret?: string) => { if (secret == null) { throw new Error("No secret found or created."); } else { // apply encryption to the sensitive database using the secret key encrypted(accountsDB, { secretKey: secret }); accountsDB.version(1).stores(AccountsSchema); accountsDB.open(); return accountsDB; } }) .catch((error) => { logConsoleAndDb("Error processing secret & encrypted accountsDB.", error); // alert("There was an error processing encrypted data. See the Help page."); throw error; }); } // retrieves default settings // calls db.open() export async function retrieveSettingsForDefaultAccount(): Promise { await db.open(); return (await db.settings.get(MASTER_SETTINGS_KEY)) || DEFAULT_SETTINGS; } export async function retrieveSettingsForActiveAccount(): Promise { const defaultSettings = await retrieveSettingsForDefaultAccount(); if (!defaultSettings.activeDid) { return defaultSettings; } else { const overrideSettings = (await db.settings .where("accountDid") .equals(defaultSettings.activeDid) .first()) || {}; return R.mergeDeepRight(defaultSettings, overrideSettings); } } export async function updateDefaultSettings( settingsChanges: Settings, ): Promise { delete settingsChanges.accountDid; // just in case // ensure there is no "id" that would override the key delete settingsChanges.id; await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges); } export async function updateAccountSettings( accountDid: string, settingsChanges: Settings, ): Promise { settingsChanges.accountDid = accountDid; delete settingsChanges.id; // key off account, not ID const result = await db.settings .where("accountDid") .equals(settingsChanges.accountDid) .modify(settingsChanges); if (result === 0) { if (!settingsChanges.id) { // It is unfortunate that we have to set this explicitly. // We didn't make id a "++id" at the beginning and Dexie won't let us change it, // plus we made our first settings objects MASTER_SETTINGS_KEY = 1 instead of 0 settingsChanges.id = (await db.settings.count()) + 1; } await db.settings.add(settingsChanges); } } export async function logToDb(message: string): Promise { await db.open(); const todayKey = new Date().toDateString(); // only keep one day's worth of logs const previous = await db.logs.get(todayKey); if (!previous) { // when this is today's first log, clear out everything previous // to avoid the log table getting too large // (let's limit a different way someday) await db.logs.clear(); } const prevMessages = (previous && previous.message) || ""; const fullMessage = `${prevMessages}\n${new Date().toISOString()} ${message}`; await db.logs.update(todayKey, { message: fullMessage }); } // similar method is in the sw_scripts/additional-scripts.js file export async function logConsoleAndDb( message: string, isError = false, ): Promise { if (isError) { logger.error(`${new Date().toISOString()} ${message}`); } else { logger.log(`${new Date().toISOString()} ${message}`); } await logToDb(message); }