]> git.codecow.com Git - libnemo.git/commitdiff
Start migrating to a "secure enclave" style implementation with IndexedDB on main...
authorChris Duncan <chris@zoso.dev>
Mon, 28 Jul 2025 08:27:30 +0000 (01:27 -0700)
committerChris Duncan <chris@zoso.dev>
Mon, 28 Jul 2025 08:27:30 +0000 (01:27 -0700)
src/lib/db.ts [new file with mode: 0644]
src/lib/workers/bip44-ckd.ts
src/lib/workers/index.ts
src/lib/workers/passkey.ts
src/lib/workers/safe.ts
src/lib/workers/worker-queue.ts

diff --git a/src/lib/db.ts b/src/lib/db.ts
new file mode 100644 (file)
index 0000000..ac7a887
--- /dev/null
@@ -0,0 +1,148 @@
+//! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
+//! 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<boolean>} True if data was successfully removed, else false
+       */
+       static async delete (names: string[], store: string): Promise<boolean> {
+               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<NamedData>} Object of key-value pairs
+       */
+       static async get (names: string[], store: string): Promise<NamedData> {
+               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<NamedData>} Object of key-value pairs
+       */
+       static async getAll (store: string): Promise<NamedData> {
+               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<IDBDatabase> {
+               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 }))
+                       }
+               })
+       }
+}
index 970612a4e9d1d94af218a00041d7f139a5096da0..1d59c2e007e0540df51aef08e66d8314e339cbe5 100644 (file)
@@ -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<NamedData<ArrayBuffer>> {
                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}
-`
index 7cfcfc4ee8a44d04c76f03ba801aa41f3b67183a..545a4014bb1924ae1318a557e6028fe6651eb107 100644 (file)
@@ -1,4 +1,4 @@
 //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
 //! SPDX-License-Identifier: GPL-3.0-or-later
 
-export { Bip44CkdWorker, NanoNaClWorker, PasskeyWorker, SafeWorker } from './worker-queue'
+export { NanoNaClWorker, PasskeyWorker, SafeWorker } from './worker-queue'
index 35ebdc63e8d2adcd379b80c042e8e317c108ea29..a886f44fdfff246b60802757fce1bddcccd0fe1d 100644 (file)
@@ -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<CryptoKey> {
-               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])
        }
 }
 
index f1adb1a578c9939340b2aed9bc339843e20e7fc7..df0064c75f76809eece1d0801a6f4b3d0ee6f082 100644 (file)
@@ -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}
 `
index 088857e7fef2c611b381230e936de2d5b6560b25..19d427f36347c3a4204eed5e5d3bca5d6f59cb2d 100644 (file)
@@ -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)