]> git.codecow.com Git - libnemo.git/commitdiff
Convert Safe storage from `sessionStorage` to `IndexedDB`.
authorChris Duncan <chris@zoso.dev>
Thu, 3 Jul 2025 19:24:17 +0000 (12:24 -0700)
committerChris Duncan <chris@zoso.dev>
Thu, 3 Jul 2025 19:24:17 +0000 (12:24 -0700)
src/lib/workers/safe.ts

index 04c696ba702cad3698a5220eb41c2fd25d0f6485..0f0fed1f8f2d4b3b2ed5e2c37974ec0b54cb3947 100644 (file)
@@ -7,6 +7,11 @@ import { buffer, hex, obj, utf8, default as Convert } from '#src/lib/convert.js'
 import { Entropy } from '#src/lib/entropy.js'
 import { WorkerInterface } from '#src/lib/pool.js'
 
+type SafeRecord = {
+       encrypted: string,
+       iv: string
+}
+
 type SafeInput = {
        method: string
        name: string
@@ -20,19 +25,21 @@ type SafeOutput = {
        result: any
 }
 
-const ERR_MSG = 'Failed to store item in Safe'
-
 /**
 * Encrypts and stores data in the browser using IndexedDB.
 */
 export class Safe extends WorkerInterface {
-       static #storage: Storage = globalThis.sessionStorage
+       static DB_NAME = 'libnemo'
+       static STORE_NAME = 'Safe'
+       static ERR_MSG = 'Failed to store item in Safe'
+       static #storage: IDBDatabase
 
        static {
                Safe.listen()
        }
 
        static async work (data: any[]): Promise<any[]> {
+               this.#storage = await this.#open(this.DB_NAME)
                const results: SafeOutput[] = []
                for (const d of data) {
                        console.log(d)
@@ -73,40 +80,39 @@ export class Safe extends WorkerInterface {
        /**
        * Removes data from the Safe without decrypting.
        */
-       static destroy (name: string): boolean {
+       static async destroy (name: string): Promise<boolean> {
                try {
-                       this.#storage.removeItem(name)
+                       return await this.#delete(name)
                } catch (err) {
-                       throw new Error(ERR_MSG)
+                       throw new Error(this.ERR_MSG)
                }
-               return (this.#storage.getItem(name) == null)
        }
 
        /**
-       * Encrypts data with a password as bytes and stores it in the Safe.
+       * Encrypts data with a password byte array and stores it in the Safe.
        */
        static async put (name: string, password: Uint8Array, data: any): Promise<boolean> {
-               if (this.#storage.getItem(name)) {
+               if (await this.#exists(name)) {
                        password.fill(0)
-                       throw new Error(ERR_MSG)
+                       throw new Error(this.ERR_MSG)
                }
                return this.overwrite(name, password, data)
        }
 
        /**
-       * Encrypts data with a password as bytes and stores it in the Safe.
+       * Encrypts data with a password byte array and stores it in the Safe.
        */
        static async overwrite (name: string, password: Uint8Array, data: any): Promise<boolean> {
                let passkey: CryptoKey
                try {
                        passkey = await crypto.subtle.importKey('raw', password, 'PBKDF2', false, ['deriveBits', 'deriveKey'])
                } catch {
-                       throw new Error(ERR_MSG)
+                       throw new Error(this.ERR_MSG)
                } finally {
                        password.fill(0)
                }
                if (this.#isInvalid(name, passkey, data)) {
-                       throw new Error(ERR_MSG)
+                       throw new Error(this.ERR_MSG)
                }
 
                try {
@@ -131,16 +137,14 @@ export class Safe extends WorkerInterface {
                                encrypted: buffer.toHex(encrypted),
                                iv: iv.hex
                        }
-                       this.#storage.setItem(name, JSON.stringify(record))
+                       return await this.#put(record, name)
                } catch (err) {
-                       throw new Error(ERR_MSG)
+                       throw new Error(this.ERR_MSG)
                }
-
-               return (this.#storage.getItem(name) != null)
        }
 
        /**
-       * Retrieves data from the Safe and decrypts data with a password as bytes.
+       * Retrieves data from the Safe and decrypts it with a password byte array.
        */
        static async get (name: string, password: Uint8Array): Promise<any> {
                let passkey: CryptoKey
@@ -155,16 +159,15 @@ export class Safe extends WorkerInterface {
                        return null
                }
 
-               let item
+               let record: SafeRecord
                try {
-                       item = this.#storage.getItem(name)
+                       record = await this.#get(name)
                } catch {
                        return null
                }
-               if (item == null) {
+               if (record == null) {
                        return null
                }
-               const record = JSON.parse(item)
                const encrypted = hex.toBytes(record.encrypted)
                const iv = await Entropy.import(record.iv)
 
@@ -206,6 +209,63 @@ export class Safe extends WorkerInterface {
                }
                return false
        }
+
+       static async #exists (name: string): Promise<boolean> {
+               return await this.#get(name) !== undefined
+       }
+
+       static async #delete (name: string): Promise<boolean> {
+               try {
+                       return await this.#transact<undefined>('readwrite', db => db.delete(name)) === undefined
+               } catch {
+                       throw new Error(this.ERR_MSG)
+               }
+       }
+
+       static async #get (name: string): Promise<SafeRecord> {
+               try {
+                       return await this.#transact<SafeRecord>('readonly', db => db.get(name))
+               } catch {
+                       throw new Error(this.ERR_MSG)
+               }
+       }
+
+       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
+                               if (!db.objectStoreNames.contains(this.STORE_NAME)) {
+                                       db.createObjectStore(this.STORE_NAME)
+                               }
+                       }
+                       request.onsuccess = (event) => {
+                               resolve((event.target as IDBOpenDBRequest).result)
+                       }
+                       request.onerror = (event) => {
+                               reject(new Error('Failed to open IndexedDB', { cause: event }))
+                       }
+               })
+       }
+
+       static async #put (record: SafeRecord, name: string): Promise<boolean> {
+               const result = await this.#transact<typeof name>('readwrite', db => db.put(record, name))
+               return await this.#exists(result)
+       }
+
+       static async #transact<T> (mode: IDBTransactionMode, method: (db: IDBObjectStore) => IDBRequest): Promise<T> {
+               const db = this.#storage.transaction(this.STORE_NAME, mode).objectStore(this.STORE_NAME)
+               return new Promise((resolve, reject) => {
+                       const request = method(db)
+                       request.onsuccess = (event) => {
+                               resolve((event.target as IDBRequest).result)
+                       }
+                       request.onerror = (event) => {
+                               console.error('IndexedDB transaction error:', (event.target as IDBRequest).error)
+                               reject((event.target as IDBRequest).error)
+                       }
+               })
+       }
 }
 
 export default `