switch the encryption secret from localStorage to IndexedDB (because localStorage gets lost so often)

This commit is contained in:
2024-12-08 19:34:31 -07:00
parent fb0d855fac
commit bb3807a805
35 changed files with 295 additions and 255 deletions

View File

@@ -5,6 +5,7 @@ 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,
@@ -14,6 +15,7 @@ import { Temp, TempSchema } from "./tables/temp";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
// Define types for tables that hold sensitive and non-sensitive data
type SecretTable = { secret: Table<Secret> };
type SensitiveTables = { accounts: Table<Account> };
type NonsensitiveTables = {
contacts: Table<Contact>;
@@ -23,25 +25,39 @@ type NonsensitiveTables = {
};
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
export type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T;
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
BaseDexie & T;
// Initialize Dexie databases for sensitive and non-sensitive data
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
//// 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;
// Manage the encryption key. If not present in localStorage, create and store it.
const secret =
localStorage.getItem("secret") || Encryption.createRandomEncryptionKey();
if (!localStorage.getItem("secret")) localStorage.setItem("secret", secret);
// Apply encryption to the sensitive database using the secret key
encrypted(accountsDB, { secretKey: secret });
// Define the schemas for our databases
// Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning
accountsDB.version(1).stores(AccountsSchema);
// v1 also had contacts & settings
// v2 added Log
db.version(2).stores({
@@ -73,6 +89,79 @@ 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.)
// check for the secret in storage
async function useSecretAndInitializeAccountsDB(
secretDB: SecretDexie,
accountsDB: SensitiveDexie,
): Promise<SensitiveDexie> {
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<Settings> {