From 8545dfa1489da24bf9cb22d06da06faf268e8724 Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Mon, 28 Jul 2025 01:27:30 -0700 Subject: [PATCH] Start migrating to a "secure enclave" style implementation with IndexedDB on main thread and all sensitive wallet operations in "active wallet" worker. --- src/lib/db.ts | 148 ++++++++++++++++++++++++++++++++ src/lib/workers/bip44-ckd.ts | 14 +-- src/lib/workers/index.ts | 2 +- src/lib/workers/passkey.ts | 14 +-- src/lib/workers/safe.ts | 2 + src/lib/workers/worker-queue.ts | 2 - 6 files changed, 159 insertions(+), 23 deletions(-) create mode 100644 src/lib/db.ts diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..ac7a887 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,148 @@ +//! SPDX-FileCopyrightText: 2025 Chris Duncan +//! SPDX-License-Identifier: GPL-3.0-or-later + +'use strict' + +import { NamedData } from '#types' + +/** +* Encrypts and stores data in the browser using IndexedDB. +*/ +export class Safe { + static DB_NAME = 'libnemo' + static DB_STORES = ['Wallet', 'Account', 'Rolodex'] + static #storage: IDBDatabase + + /** + * Deletes records from a datastore. + * + * @param {string[]} names - Index keys of the records to delete + * @param {string} store - Datastore from which to delete records + * @returns {Promise} True if data was successfully removed, else false + */ + static async delete (names: string[], store: string): Promise { + this.#storage ??= await this.#open(this.DB_NAME) + const transaction = this.#storage.transaction(store, 'readwrite') + const db = transaction.objectStore(store) + return new Promise((resolve, reject) => { + const requests = names.map(name => db.delete(name)) + transaction.oncomplete = (event) => { + for (const request of requests) { + if (request?.error != null) { + reject(request.error) + } + if (request.result !== undefined) { + resolve(false) + } + } + resolve(true) + } + transaction.onerror = (event) => { + reject((event.target as IDBRequest).error) + } + }) + } + + /** + * Gets specific records from a datastore. + * + * @param {string[]} names - Index keys of the records to get + * @param {string} store - Datastore from which to get records + * @returns {Promise} Object of key-value pairs + */ + static async get (names: string[], store: string): Promise { + this.#storage ??= await this.#open(this.DB_NAME) + const transaction = this.#storage.transaction(store, 'readonly') + const db = transaction.objectStore(store) + return new Promise((resolve, reject) => { + const requests = names.map(name => db.get(name)) + transaction.oncomplete = (event) => { + const results: NamedData = {} + for (const request of requests) { + results[request.result.id] = request.error ?? request.result + } + resolve(results) + } + transaction.onerror = (event) => { + reject((event.target as IDBRequest).error) + } + }) + } + + /** + * Gets all records from a specific datastore. + * + * @param {string} store - Datastore from which to get records + * @returns {Promise} Object of key-value pairs + */ + static async getAll (store: string): Promise { + this.#storage ??= await this.#open(this.DB_NAME) + const transaction = this.#storage.transaction(store, 'readonly') + const db = transaction.objectStore(store) + return new Promise((resolve, reject) => { + const request = db.getAll() + transaction.oncomplete = (event) => { + if (request.error != null) { + reject(request.error) + } else if (request.result == null) { + reject('getAll request failed') + } else { + const results: NamedData = {} + for (const result of request.result) { + results[result.id] = request.error ?? request.result + } + resolve(results) + } + } + transaction.onerror = (event) => { + reject((event.target as IDBRequest).error) + } + }) + } + + /** + * Inserts records in a datastore, overwriting existing data. + * + * @param {NamedData} data - Object of key-value pairs + * @param {string} store - Datastore in which to put records + * @returns {Promise<(IDBValidKey | DOMException)[]>} Index keys of the records inserted + */ + static async put (data: NamedData, store: string): Promise<(IDBValidKey | DOMException)[]> { + this.#storage ??= await this.#open(this.DB_NAME) + const transaction = this.#storage.transaction(store, 'readwrite') + const db = transaction.objectStore(store) + return new Promise((resolve, reject) => { + const requests = Object.keys(data).map(key => db.put({ id: key, [key]: data[key] })) + transaction.oncomplete = (event) => { + const results = [] + for (const request of requests) { + results.push(request.error ?? request.result) + } + resolve(results) + } + transaction.onerror = (event) => { + reject((event.target as IDBRequest).error) + } + }) + } + + static async #open (database: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(database, 1) + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result + for (const DB_STORE of this.DB_STORES) { + if (!db.objectStoreNames.contains(DB_STORE)) { + db.createObjectStore(DB_STORE, { keyPath: 'id' }) + } + } + } + request.onsuccess = (event) => { + resolve((event.target as IDBOpenDBRequest).result) + } + request.onerror = (event) => { + reject(new Error('Database error', { cause: event })) + } + }) + } +} diff --git a/src/lib/workers/bip44-ckd.ts b/src/lib/workers/bip44-ckd.ts index 970612a..1d59c2e 100644 --- a/src/lib/workers/bip44-ckd.ts +++ b/src/lib/workers/bip44-ckd.ts @@ -9,16 +9,12 @@ type ExtendedKey = { chainCode: DataView } -export class Bip44Ckd extends WorkerInterface { +export class Bip44Ckd { static BIP44_COIN_NANO = 165 static BIP44_PURPOSE = 44 static HARDENED_OFFSET = 0x80000000 static SLIP10_ED25519 = 'ed25519 seed' - static { - this.listen() - } - static async work (data: NamedData): Promise> { if (data.coin != null && (typeof data.coin !== 'number' || !Number.isInteger(data.coin))) { throw new TypeError('BIP-44 coin derivation level must be an integer') @@ -135,11 +131,3 @@ export class Bip44Ckd extends WorkerInterface { return new Uint8Array(signature) } } - -let importWorkerThreads = '' -NODE: importWorkerThreads = `import { parentPort } from 'node:worker_threads'` -export default ` - ${importWorkerThreads} - const WorkerInterface = ${WorkerInterface} - const Bip44Ckd = ${Bip44Ckd} -` diff --git a/src/lib/workers/index.ts b/src/lib/workers/index.ts index 7cfcfc4..545a401 100644 --- a/src/lib/workers/index.ts +++ b/src/lib/workers/index.ts @@ -1,4 +1,4 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later -export { Bip44CkdWorker, NanoNaClWorker, PasskeyWorker, SafeWorker } from './worker-queue' +export { NanoNaClWorker, PasskeyWorker, SafeWorker } from './worker-queue' diff --git a/src/lib/workers/passkey.ts b/src/lib/workers/passkey.ts index 35ebdc6..a886f44 100644 --- a/src/lib/workers/passkey.ts +++ b/src/lib/workers/passkey.ts @@ -22,6 +22,7 @@ export class Passkey { try { const { purpose, password, salt } = this.#extractData(data) const key = await this.#createAesKey(purpose, password, salt) + new Uint8Array(password).fill(0).buffer.transfer() //@ts-expect-error BROWSER: postMessage({ key, salt }, [key, salt]) //@ts-expect-error @@ -56,18 +57,17 @@ export class Passkey { throw new TypeError('Invalid key purpose') } const purpose: 'encrypt' | 'decrypt' = dataObject.purpose - if (purpose === 'decrypt' && !('salt' in dataObject)) { + if (purpose === 'decrypt' && !(dataObject.salt instanceof ArrayBuffer)) { throw new TypeError('Salt required for decryption key') } - if (dataObject.salt != null && !(dataObject.salt instanceof ArrayBuffer)) { - throw new TypeError('Salt must be ArrayBuffer') - } - const salt: ArrayBuffer = dataObject.salt ?? globalThis.crypto.getRandomValues(new Uint8Array(32)).buffer + const salt: ArrayBuffer = purpose === 'decrypt' && dataObject.salt instanceof ArrayBuffer + ? dataObject.salt + : crypto.getRandomValues(new Uint8Array(32)).buffer return { purpose, password, salt } } static async #createAesKey (purpose: 'encrypt' | 'decrypt', password: ArrayBuffer, salt: ArrayBuffer): Promise { - const derivationKey = await globalThis.crypto.subtle.importKey('raw', password, 'PBKDF2', false, ['deriveBits', 'deriveKey']) + const derivationKey = await crypto.subtle.importKey('raw', password, 'PBKDF2', false, ['deriveBits', 'deriveKey']) const derivationAlgorithm: Pbkdf2Params = { name: 'PBKDF2', hash: 'SHA-512', @@ -78,7 +78,7 @@ export class Passkey { name: 'AES-GCM', length: 256 } - return await globalThis.crypto.subtle.deriveKey(derivationAlgorithm, derivationKey, derivedKeyType, false, [purpose]) + return await crypto.subtle.deriveKey(derivationAlgorithm, derivationKey, derivedKeyType, false, [purpose]) } } diff --git a/src/lib/workers/safe.ts b/src/lib/workers/safe.ts index f1adb1a..df0064c 100644 --- a/src/lib/workers/safe.ts +++ b/src/lib/workers/safe.ts @@ -3,6 +3,7 @@ 'use strict' +import { Bip44Ckd } from './bip44-ckd' import { WorkerInterface } from './worker-interface' import { PBKDF2_ITERATIONS } from '#src/lib/constants.js' import { default as Convert, bytes } from '#src/lib/convert.js' @@ -360,6 +361,7 @@ export default ` ${Convert} const PBKDF2_ITERATIONS = ${PBKDF2_ITERATIONS} const Entropy = ${Entropy} + const Bip44Ckd = ${Bip44Ckd} const WorkerInterface = ${WorkerInterface} const Safe = ${Safe} ` diff --git a/src/lib/workers/worker-queue.ts b/src/lib/workers/worker-queue.ts index 088857e..19d427f 100644 --- a/src/lib/workers/worker-queue.ts +++ b/src/lib/workers/worker-queue.ts @@ -2,7 +2,6 @@ //! SPDX-License-Identifier: GPL-3.0-or-later import { Worker as NodeWorker } from 'node:worker_threads' -import { default as bip44 } from './bip44-ckd' import { default as nacl } from './nano-nacl' import { default as passkey } from './passkey' import { default as safe } from './safe' @@ -114,7 +113,6 @@ export class WorkerQueue { } } -export const Bip44CkdWorker = new WorkerQueue(bip44) export const NanoNaClWorker = new WorkerQueue(nacl) export const PasskeyWorker = new WorkerQueue(passkey) export const SafeWorker = new WorkerQueue(safe) -- 2.47.3