From dc68442f0f4e47ace684760cbb714a19a631d3fe Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Thu, 3 Jul 2025 12:24:17 -0700 Subject: [PATCH] Convert Safe storage from `sessionStorage` to `IndexedDB`. --- src/lib/workers/safe.ts | 104 +++++++++++++++++++++++++++++++--------- 1 file changed, 82 insertions(+), 22 deletions(-) diff --git a/src/lib/workers/safe.ts b/src/lib/workers/safe.ts index 04c696b..0f0fed1 100644 --- a/src/lib/workers/safe.ts +++ b/src/lib/workers/safe.ts @@ -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 { + 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 { 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 { - 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 { 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 { 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 { + return await this.#get(name) !== undefined + } + + static async #delete (name: string): Promise { + try { + return await this.#transact('readwrite', db => db.delete(name)) === undefined + } catch { + throw new Error(this.ERR_MSG) + } + } + + static async #get (name: string): Promise { + try { + return await this.#transact('readonly', db => db.get(name)) + } catch { + throw new Error(this.ERR_MSG) + } + } + + 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 + 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 { + const result = await this.#transact('readwrite', db => db.put(record, name)) + return await this.#exists(result) + } + + static async #transact (mode: IDBTransactionMode, method: (db: IDBObjectStore) => IDBRequest): Promise { + 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 ` -- 2.47.3