]> git.codecow.com Git - libnemo.git/commitdiff
Refactor Safe to run as a worker. Since `sessionStorage` is unavailable in this conte...
authorChris Duncan <chris@zoso.dev>
Thu, 3 Jul 2025 16:12:43 +0000 (09:12 -0700)
committerChris Duncan <chris@zoso.dev>
Thu, 3 Jul 2025 16:12:43 +0000 (09:12 -0700)
12 files changed:
src/lib/account.ts
src/lib/convert.ts
src/lib/pool.ts
src/lib/safe.ts [deleted file]
src/lib/wallets/bip44-wallet.ts
src/lib/wallets/blake2b-wallet.ts
src/lib/wallets/index.ts
src/lib/wallets/ledger-wallet.ts
src/lib/wallets/wallet.ts [new file with mode: 0644]
src/lib/workers/index.ts
src/lib/workers/safe.ts [new file with mode: 0644]
src/main.ts

index f434cce3762628f1bd0a95754bf7f159377e111e..315a8b859bf848cbe61536a36abd82ffafad9036 100644 (file)
@@ -3,10 +3,10 @@
 \r
 import { Blake2b } from './blake2b'\r
 import { ACCOUNT_KEY_LENGTH, ALPHABET, PREFIX, PREFIX_LEGACY } from './constants'\r
-import { base32, bytes, hex } from './convert'\r
+import { base32, bytes, hex, utf8 } from './convert'\r
+import { Pool } from './pool'\r
 import { Rpc } from './rpc'\r
-import { Safe } from './safe'\r
-import { NanoNaCl } from '#workers'\r
+import { NanoNaCl, SafeWorker } from '#workers'\r
 \r
 /**\r
 * Represents a single Nano address and the associated public key. To include the\r
@@ -16,6 +16,7 @@ import { NanoNaCl } from '#workers'
 */\r
 export class Account {\r
        static #isInternal: boolean = false\r
+       static #poolSafe: Pool\r
        #a: string\r
        #pub: string\r
        #prv: string | null\r
@@ -25,7 +26,6 @@ export class Account {
        #r?: bigint\r
        #rep?: Account\r
        #w?: bigint\r
-       #s: Safe\r
 \r
        get address () { return `${PREFIX}${this.#a}` }\r
        get publicKey () { return this.#pub }\r
@@ -64,7 +64,7 @@ export class Account {
                this.#pub = publicKey\r
                this.#prv = privateKey ?? null\r
                this.#i = index\r
-               this.#s = new Safe()\r
+               Account.#poolSafe ??= new Pool(SafeWorker)\r
                Account.#isInternal = false\r
        }\r
 \r
@@ -73,7 +73,10 @@ export class Account {
        * allow garbage collection.\r
        */\r
        destroy (): void {\r
-               this.#s.destroy(this.#pub)\r
+               Account.#poolSafe.assign({\r
+                       method: 'destroy',\r
+                       name: this.#pub\r
+               })\r
                this.#prv = null\r
                this.#i = undefined\r
                this.#f = undefined\r
@@ -130,12 +133,17 @@ export class Account {
                return account\r
        }\r
 \r
-       async lock (password: string): Promise<boolean>\r
-       async lock (key: CryptoKey): Promise<boolean>\r
-       async lock (passkey: string | CryptoKey): Promise<boolean> {\r
+       async lock (password: string | Uint8Array): Promise<boolean> {\r
+               if (typeof password === 'string') {\r
+                       password = utf8.toBytes(password)\r
+               }\r
                try {\r
                        if (this.#prv != null) {\r
-                               await this.#s.put(this.#pub, passkey as string, this.#prv)\r
+                               await Account.#poolSafe.assign({\r
+                                       method: 'put',\r
+                                       name: this.#prv,\r
+                                       password\r
+                               })\r
                        }\r
                } catch (err) {\r
                        console.error(`Failed to lock account ${this.address}`, err)\r
@@ -145,11 +153,16 @@ export class Account {
                return true\r
        }\r
 \r
-       async unlock (password: string): Promise<boolean>\r
-       async unlock (key: CryptoKey): Promise<boolean>\r
-       async unlock (passkey: string | CryptoKey): Promise<boolean> {\r
+       async unlock (password: string | Uint8Array): Promise<boolean> {\r
+               if (typeof password === 'string') {\r
+                       password = utf8.toBytes(password)\r
+               }\r
                try {\r
-                       this.#prv = await this.#s.get(this.#pub, passkey as string)\r
+                       this.#prv = await Account.#poolSafe.assign({\r
+                               method: 'get',\r
+                               name: this.#pub,\r
+                               password\r
+                       })\r
                } catch (err) {\r
                        console.error(`Failed to unlock account ${this.address}`, err)\r
                        return false\r
index 8511e8eeb98cff87699e69193330251802c2cbcf..28119169a34fc828bb56ab5c30efa96e4abde0ae 100644 (file)
@@ -6,14 +6,14 @@ import { ALPHABET } from './constants'
 const encoder = new TextEncoder()\r
 const decoder = new TextDecoder()\r
 \r
-export const base32 = {\r
+export class base32 {\r
        /**\r
        * Converts a base32 string to a Uint8Array of bytes.\r
        *\r
        * @param {string} base32 - String to convert\r
        * @returns {Uint8Array} Byte array representation of the input string\r
        */\r
-       toBytes (base32: string): Uint8Array {\r
+       static toBytes (base32: string): Uint8Array {\r
                const leftover = (base32.length * 5) % 8\r
                const offset = leftover === 0\r
                        ? 0\r
@@ -37,26 +37,27 @@ export const base32 = {
                        output = output.slice(1)\r
                }\r
                return output\r
-       },\r
+       }\r
+\r
        /**\r
        * Converts a base32 string to a hexadecimal string.\r
        *\r
        * @param {string} base32 - String to convert\r
        * @returns {string} Hexadecimal representation of the input base32\r
        */\r
-       toHex (base32: string): string {\r
+       static toHex (base32: string): string {\r
                return bytes.toHex(this.toBytes(base32))\r
        }\r
 }\r
 \r
-export const bin = {\r
+export class bin {\r
        /**\r
        * Convert a binary string to a Uint8Array of bytes.\r
        *\r
        * @param {string} bin - String to convert\r
        * @returns {Uint8Array} Byte array representation of the input string\r
        */\r
-       toBytes (bin: string): Uint8Array<ArrayBuffer> {\r
+       static toBytes (bin: string): Uint8Array<ArrayBuffer> {\r
                const bytes: number[] = []\r
                while (bin.length > 0) {\r
                        const bits = bin.substring(0, 8)\r
@@ -64,37 +65,40 @@ export const bin = {
                        bin = bin.substring(8)\r
                }\r
                return new Uint8Array(bytes)\r
-       },\r
+       }\r
+\r
        /**\r
        * Convert a binary string to a hexadecimal string.\r
        *\r
        * @param {string} bin - String to convert\r
        * @returns {string} Hexadecimal string representation of the input binary\r
        */\r
-       toHex (bin: string): string {\r
+       static toHex (bin: string): string {\r
                return parseInt(bin, 2).toString(16)\r
        }\r
 }\r
 \r
-export const buffer = {\r
+export class buffer {\r
        /**\r
        * Converts an ArrayBuffer to a base32 string.\r
        *\r
        * @param {ArrayBuffer} buffer - Buffer to convert\r
        * @returns {string} Base32 string representation of the input buffer\r
        */\r
-       toBase32 (buffer: ArrayBuffer): string {\r
+       static toBase32 (buffer: ArrayBuffer): string {\r
                return bytes.toBase32(new Uint8Array(buffer))\r
-       },\r
+       }\r
+\r
        /**\r
        * Converts an ArrayBuffer to a binary string.\r
        *\r
        * @param {ArrayBuffer} buffer - Buffer to convert\r
        * @returns {string} Binary string representation of the input buffer\r
        */\r
-       toBin (buffer: ArrayBuffer): string {\r
+       static toBin (buffer: ArrayBuffer): string {\r
                return bytes.toBin(new Uint8Array(buffer))\r
-       },\r
+       }\r
+\r
        /**\r
        * Sums an ArrayBuffer to a decimal integer. If the result is larger than\r
        * Number.MAX_SAFE_INTEGER, it will be returned as a bigint.\r
@@ -102,37 +106,39 @@ export const buffer = {
        * @param {ArrayBuffer} buffer - Buffer to convert\r
        * @returns {bigint|number} Decimal sum of the literal buffer values\r
        */\r
-       toDec (buffer: ArrayBuffer): bigint | number {\r
+       static toDec (buffer: ArrayBuffer): bigint | number {\r
                return bytes.toDec(new Uint8Array(buffer))\r
-       },\r
+       }\r
+\r
        /**\r
        * Converts an ArrayBuffer to a hexadecimal string.\r
        *\r
        * @param {ArrayBuffer} buffer - Buffer to convert\r
        * @returns {string} Hexadecimal string representation of the input buffer\r
        */\r
-       toHex (buffer: ArrayBuffer): string {\r
+       static toHex (buffer: ArrayBuffer): string {\r
                return bytes.toHex(new Uint8Array(buffer))\r
-       },\r
+       }\r
+\r
        /**\r
        * Converts an ArrayBuffer to a UTF-8 text string.\r
        *\r
        * @param {ArrayBuffer} buffer - Buffer to convert\r
        * @returns {string} UTF-8 encoded text string\r
        */\r
-       toUtf8 (buffer: ArrayBuffer): string {\r
+       static toUtf8 (buffer: ArrayBuffer): string {\r
                return bytes.toUtf8(new Uint8Array(buffer))\r
        }\r
 }\r
 \r
-export const bytes = {\r
+export class bytes {\r
        /**\r
        * Converts a Uint8Aarray of bytes to a base32 string.\r
        *\r
        * @param {Uint8Array} bytes - Byte array to convert\r
        * @returns {string} Base32 string representation of the input bytes\r
        */\r
-       toBase32 (bytes: Uint8Array): string {\r
+       static toBase32 (bytes: Uint8Array): string {\r
                const leftover = (bytes.length * 8) % 5\r
                const offset = leftover === 0\r
                        ? 0\r
@@ -152,16 +158,18 @@ export const bytes = {
                        output += ALPHABET[(value << (5 - (bits + offset))) & 31]\r
                }\r
                return output\r
-       },\r
+       }\r
+\r
        /**\r
        * Convert a Uint8Array of bytes to a binary string.\r
        *\r
        * @param {Uint8Array} bytes - Byte array to convert\r
        * @returns {string} Binary string representation of the input value\r
        */\r
-       toBin (bytes: Uint8Array): string {\r
+       static toBin (bytes: Uint8Array): string {\r
                return [...bytes].map(b => b.toString(2).padStart(8, '0')).join('')\r
-       },\r
+       }\r
+\r
        /**\r
        * Sums an array of bytes to a decimal integer. If the result is larger than\r
        * Number.MAX_SAFE_INTEGER, it will be returned as a bigint.\r
@@ -169,7 +177,7 @@ export const bytes = {
        * @param {Uint8Array} bytes - Byte array to convert\r
        * @returns {bigint|number} Decimal sum of the literal byte values\r
        */\r
-       toDec (bytes: Uint8Array): bigint | number {\r
+       static toDec (bytes: Uint8Array): bigint | number {\r
                const integers: bigint[] = []\r
                bytes.reverse().forEach(b => integers.push(BigInt(b)))\r
                let decimal = 0n\r
@@ -181,29 +189,31 @@ export const bytes = {
                } else {\r
                        return Number(decimal)\r
                }\r
-       },\r
+       }\r
+\r
        /**\r
        * Converts a Uint8Array of bytes to a hexadecimal string.\r
        *\r
        * @param {Uint8Array} bytes - Byte array to convert\r
        * @returns {string} Hexadecimal string representation of the input bytes\r
        */\r
-       toHex (bytes: Uint8Array): string {\r
+       static toHex (bytes: Uint8Array): string {\r
                const byteArray = [...bytes].map(byte => byte.toString(16).padStart(2, '0'))\r
                return byteArray.join('').toUpperCase()\r
-       },\r
+       }\r
+\r
        /**\r
        * Converts a Uint8Array of bytes to a UTF-8 text string.\r
        *\r
        * @param {Uint8Array} bytes - Byte array to convert\r
        * @returns {string} UTF-8 encoded text string\r
        */\r
-       toUtf8 (bytes: Uint8Array): string {\r
+       static toUtf8 (bytes: Uint8Array): string {\r
                return decoder.decode(bytes)\r
        }\r
 }\r
 \r
-export const dec = {\r
+export class dec {\r
        /**\r
        * Convert a decimal integer to a binary string.\r
        *\r
@@ -211,7 +221,7 @@ export const dec = {
        * @param {number} [padding=0] - Minimum length of the resulting string padded as necessary with starting zeroes\r
        * @returns {string} Binary string representation of the input decimal\r
        */\r
-       toBin (decimal: bigint | number | string, padding: number = 0): string {\r
+       static toBin (decimal: bigint | number | string, padding: number = 0): string {\r
                if (typeof padding !== 'number') {\r
                        throw new TypeError('Invalid padding')\r
                }\r
@@ -222,7 +232,8 @@ export const dec = {
                } catch (err) {\r
                        throw new RangeError('Invalid decimal integer')\r
                }\r
-       },\r
+       }\r
+\r
        /**\r
        * Convert a decimal integer to a Uint8Array of bytes. Fractional part is truncated.\r
        *\r
@@ -230,7 +241,7 @@ export const dec = {
        * @param {number} [padding=0] - Minimum length of the resulting array padded as necessary with starting 0x00 bytes\r
        * @returns {Uint8Array} Byte array representation of the input decimal\r
        */\r
-       toBytes (decimal: bigint | number | string, padding: number = 0): Uint8Array {\r
+       static toBytes (decimal: bigint | number | string, padding: number = 0): Uint8Array {\r
                if (typeof padding !== 'number') {\r
                        throw new TypeError('Invalid padding')\r
                }\r
@@ -244,7 +255,8 @@ export const dec = {
                const result = new Uint8Array(Math.max(padding, bytes.length))\r
                result.set(bytes)\r
                return (result.reverse())\r
-       },\r
+       }\r
+\r
        /**\r
        * Convert a decimal integer to a hexadecimal string.\r
        *\r
@@ -252,7 +264,7 @@ export const dec = {
        * @param {number} [padding=0] - Minimum length of the resulting string padded as necessary with starting zeroes\r
        * @returns {string} Hexadecimal string representation of the input decimal\r
        */\r
-       toHex (decimal: bigint | number | string, padding: number = 0): string {\r
+       static toHex (decimal: bigint | number | string, padding: number = 0): string {\r
                if (typeof padding !== 'number') {\r
                        throw new TypeError('Invalid padding')\r
                }\r
@@ -267,7 +279,7 @@ export const dec = {
        }\r
 }\r
 \r
-export const hex = {\r
+export class hex {\r
        /**\r
        * Convert a hexadecimal string to an array of decimal byte values.\r
        *\r
@@ -275,7 +287,7 @@ export const hex = {
        * @param {number}[padding=0] - Minimum length of the resulting array padded as necessary with starting 0 values\r
        * @returns {number[]} Decimal array representation of the input value\r
        */\r
-       toArray (hex: string, padding: number = 0): number[] {\r
+       static toArray (hex: string, padding: number = 0): number[] {\r
                if (typeof hex !== 'string' || !/^[A-Fa-f0-9]+$/i.test(hex)) {\r
                        throw new TypeError('Invalid string when converting hex to array')\r
                }\r
@@ -291,16 +303,18 @@ export const hex = {
                        hexArray.unshift('0')\r
                }\r
                return hexArray.map(v => parseInt(v, 16))\r
-       },\r
+       }\r
+\r
        /**\r
        * Convert a hexadecimal string to a binary string.\r
        *\r
        * @param {string} hex - Hexadecimal number string to convert\r
        * @returns {string} Binary string representation of the input value\r
        */\r
-       toBin (hex: string): string {\r
+       static toBin (hex: string): string {\r
                return [...hex].map(c => dec.toBin(parseInt(c, 16), 4)).join('')\r
-       },\r
+       }\r
+\r
        /**\r
        * Convert a hexadecimal string to a Uint8Array of bytes.\r
        *\r
@@ -308,40 +322,41 @@ export const hex = {
        * @param {number} [padding=0] - Minimum length of the resulting array padded as necessary with starting 0x00 bytes\r
        * @returns {Uint8Array} Byte array representation of the input value\r
        */\r
-       toBytes (hex: string, padding: number = 0): Uint8Array<ArrayBuffer> {\r
+       static toBytes (hex: string, padding: number = 0): Uint8Array<ArrayBuffer> {\r
                return new Uint8Array(this.toArray(hex, padding))\r
        }\r
 }\r
 \r
-export const utf8 = {\r
+export class utf8 {\r
        /**\r
        * Convert a UTF-8 text string to a Uint8Array of bytes.\r
        *\r
        * @param {string} utf8 - String to convert\r
        * @returns {Uint8Array} Byte array representation of the input string\r
        */\r
-       toBytes (utf8: string): Uint8Array {\r
+       static toBytes (utf8: string): Uint8Array {\r
                return encoder.encode(utf8)\r
-       },\r
+       }\r
+\r
        /**\r
        * Convert a string to a hexadecimal representation\r
        *\r
        * @param {string} utf8 - String to convert\r
        * @returns {string} Hexadecimal representation of the input string\r
        */\r
-       toHex (utf8: string): string {\r
+       static toHex (utf8: string): string {\r
                return bytes.toHex(this.toBytes(utf8))\r
        }\r
 }\r
 \r
-export const obj = {\r
+export class obj {\r
        /**\r
        * Convert a numerically-indexed object of 8-bit values to a Uint8Array of bytes.\r
        *\r
        * @param {object} obj - Object to convert\r
        * @returns {Uint8Array} Byte array representation of the input object\r
        */\r
-       toBytes (obj: { [key: number]: number }): Uint8Array {\r
+       static toBytes (obj: { [key: number]: number }): Uint8Array {\r
                const values = Object.keys(obj)\r
                        .map(key => +key)\r
                        .sort((a, b) => a - b)\r
@@ -350,4 +365,12 @@ export const obj = {
        }\r
 }\r
 \r
-export default { base32, bin, bytes, dec, hex, utf8 }\r
+export default `\r
+       const base32 = ${base32}\r
+       const bin = ${bin}\r
+       const bytes = ${bytes}\r
+       const dec = ${dec}\r
+       const hex = ${hex}\r
+       const obj = ${obj}\r
+       const utf8 = ${utf8}\r
+`\r
index 8c3f0008c731eaa9e992a836c0013d9ce4d166de..33d3f7f84daa0796a585bd96e7eee570d7195fba 100644 (file)
@@ -103,6 +103,10 @@ export class Pool {
                        if (next?.length > 0) {
                                const buffer = new TextEncoder().encode(JSON.stringify(next)).buffer
                                thread.job = job
+                               console.log(JSON.stringify(next))
+                               console.log(thread)
+                               console.log(thread.worker)
+                               console.log(this.#url)
                                thread.worker.postMessage({ buffer }, [buffer])
                        }
                }
diff --git a/src/lib/safe.ts b/src/lib/safe.ts
deleted file mode 100644 (file)
index 0157602..0000000
+++ /dev/null
@@ -1,181 +0,0 @@
-// SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
-// SPDX-License-Identifier: GPL-3.0-or-later
-
-import { buffer, hex, utf8 } from './convert'
-import { Entropy } from './entropy'
-
-const { subtle } = globalThis.crypto
-const ERR_MSG = 'Failed to store item in Safe'
-
-export class Safe {
-       #storage: Storage
-
-       constructor () {
-               this.#storage = globalThis.sessionStorage
-       }
-
-       /**
-       * Removes data from the Safe without decrypting.
-       */
-       destroy (name: string): void {
-               try {
-                       this.#storage.removeItem(name)
-               } catch (err) {
-                       console.log(err)
-               }
-       }
-
-       /**
-       * Encrypts data with a password and stores it in the Safe.
-       */
-       async put (name: string, password: string, data: any): Promise<boolean>
-       /**
-       * Encrypts data with a CryptoKey and stores it in the Safe.
-       */
-       async put (name: string, key: CryptoKey, data: any): Promise<boolean>
-       async put (name: string, passkey: string | CryptoKey, data: any): Promise<boolean> {
-               if (typeof passkey === 'string') {
-                       try {
-                               passkey = await subtle.importKey('raw', utf8.toBytes(passkey), 'PBKDF2', false, ['deriveBits', 'deriveKey'])
-                       } catch {
-                               throw new Error(ERR_MSG)
-                       }
-               }
-               if (this.#storage.getItem(name)) {
-                       throw new Error(ERR_MSG)
-               }
-               return this.overwrite(name, passkey, data)
-       }
-
-       /**
-       * Encrypts data with a password and stores it in the Safe.
-       */
-       async overwrite (name: string, password: string, data: any): Promise<boolean>
-       /**
-       * Encrypts data with a CryptoKey and stores it in the Safe.
-       */
-       async overwrite (name: string, key: CryptoKey, data: any): Promise<boolean>
-       async overwrite (name: string, passkey: string | CryptoKey, data: any): Promise<boolean> {
-               if (typeof passkey === 'string') {
-                       try {
-                               passkey = await subtle.importKey('raw', utf8.toBytes(passkey), 'PBKDF2', false, ['deriveBits', 'deriveKey'])
-                       } catch {
-                               throw new Error(ERR_MSG)
-                       }
-               }
-               if (this.#isInvalid(name, passkey, data)) {
-                       throw new Error(ERR_MSG)
-               }
-
-               const iv = await Entropy.create()
-               if (passkey.usages.includes('deriveKey')) {
-                       try {
-                               const derivationAlgorithm: Pbkdf2Params = {
-                                       name: 'PBKDF2',
-                                       hash: 'SHA-512',
-                                       salt: iv.bytes,
-                                       iterations: 210000
-                               }
-                               const derivedKeyType: AesKeyGenParams = {
-                                       name: 'AES-GCM',
-                                       length: 256
-                               }
-                               passkey = await subtle.deriveKey(derivationAlgorithm, passkey, derivedKeyType, false, ['encrypt'])
-                       } catch {
-                               throw new Error(ERR_MSG)
-                       }
-               }
-
-               try {
-                       if (typeof data === 'bigint') {
-                               data = data.toString()
-                       }
-                       data = JSON.stringify(data)
-                       const encrypted = await subtle.encrypt({ name: 'AES-GCM', iv: iv.buffer }, passkey, utf8.toBytes(data))
-                       const record = {
-                               encrypted: buffer.toHex(encrypted),
-                               iv: iv.hex
-                       }
-                       this.#storage.setItem(name, JSON.stringify(record))
-               } catch (err) {
-                       throw new Error(ERR_MSG)
-               }
-               return (this.#storage.getItem(name) != null)
-       }
-
-       /**
-       * Retrieves data from the Safe and decrypts data with a password.
-       */
-       async get (name: string, password: string): Promise<any>
-       /**
-       * Retrieves data from the Safe and decrypts data with a CryptoKey.
-       */
-       async get (name: string, key: CryptoKey): Promise<any>
-       async get (name: string, passkey: string | CryptoKey): Promise<any> {
-               if (typeof passkey === 'string') {
-                       try {
-                               passkey = await subtle.importKey('raw', utf8.toBytes(passkey), 'PBKDF2', false, ['deriveBits', 'deriveKey'])
-                       } catch {
-                               return null
-                       }
-               }
-               if (this.#isInvalid(name, passkey)) {
-                       return null
-               }
-
-               const item = this.#storage.getItem(name)
-               if (item == null) {
-                       return null
-               }
-               const record = JSON.parse(item)
-               const encrypted = hex.toBytes(record.encrypted)
-               const iv = await Entropy.import(record.iv)
-
-               try {
-                       if (passkey.usages.includes('deriveKey')) {
-                               const derivationAlgorithm: Pbkdf2Params = {
-                                       name: 'PBKDF2',
-                                       hash: 'SHA-512',
-                                       salt: iv.bytes,
-                                       iterations: 210000
-                               }
-                               const derivedKeyType: AesKeyGenParams = {
-                                       name: 'AES-GCM',
-                                       length: 256
-                               }
-                               passkey = await subtle.deriveKey(derivationAlgorithm, passkey, derivedKeyType, false, ['decrypt'])
-                       }
-               } catch (err) {
-                       return null
-               }
-
-               try {
-                       const decrypted = await subtle.decrypt({ name: 'AES-GCM', iv: iv.buffer }, passkey, encrypted)
-                       const decoded = buffer.toUtf8(decrypted)
-                       const data = JSON.parse(decoded)
-                       this.#storage.removeItem(name)
-                       return data
-               } catch (err) {
-                       return null
-               }
-       }
-
-       #isInvalid (name: string, passkey: string | CryptoKey, data?: any): boolean {
-               if (typeof name !== 'string' || name === '') {
-                       return true
-               }
-               if (typeof passkey !== 'string' || passkey === '') {
-                       if (!(passkey instanceof CryptoKey)) {
-                               return true
-                       }
-               }
-               if (typeof data === 'object') {
-                       try {
-                               JSON.stringify(data)
-                       } catch (err) {
-                               return true
-                       }
-               }
-               return false
-       }
-}
index 918a9c14c6139a684cbda448d77690b4d04c4cc8..2f5d9aa5316eac3351d5a6dbd76cd3aed6db2111 100644 (file)
@@ -1,7 +1,7 @@
 // SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>\r
 // SPDX-License-Identifier: GPL-3.0-or-later\r
 \r
-import { KeyPair, Wallet } from '.'\r
+import { KeyPair, Wallet } from './wallet'\r
 import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js'\r
 import { SEED_LENGTH_BIP44 } from '#src/lib/constants.js'\r
 import { Entropy } from '#src/lib/entropy.js'\r
index 102435b65bd7aef5a948be3044cf502befcee401..b5c7ac278b3b7cb6013bb17a7c3d28d24ccc6772 100644 (file)
@@ -1,7 +1,7 @@
 // SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>\r
 // SPDX-License-Identifier: GPL-3.0-or-later\r
 \r
-import { KeyPair, Wallet } from '.'\r
+import { KeyPair, Wallet } from './wallet'\r
 import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js'\r
 import { Blake2b } from '#src/lib/blake2b.js'\r
 import { SEED_LENGTH_BLAKE2B } from '#src/lib/constants.js'\r
index ee3f931691c399e3f902fc24598ca9e2310e2f8e..574fc703ec1128884fb8f64e9b22ce7b0d9bee6e 100644 (file)
@@ -1,304 +1,6 @@
 // SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>\r
 // SPDX-License-Identifier: GPL-3.0-or-later\r
 \r
-import { Account, AccountList } from '#src/lib/account.js'\r
-import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js'\r
-import { ADDRESS_GAP } from '#src/lib/constants.js'\r
-import { Entropy } from '#src/lib/entropy.js'\r
-import { Pool } from '#src/lib/pool.js'\r
-import { Rpc } from '#src/lib/rpc.js'\r
-import { Safe } from '#src/lib/safe.js'\r
-import { NanoNaClWorker } from '#workers'\r
-\r
 export { Bip44Wallet } from './bip44-wallet'\r
 export { Blake2bWallet } from './blake2b-wallet'\r
 export { LedgerWallet } from './ledger-wallet'\r
-export type KeyPair = {\r
-       publicKey?: string,\r
-       privateKey?: string,\r
-       index?: number\r
-}\r
-\r
-/**\r
-* Represents a wallet containing numerous Nano accounts derived from a single\r
-* source, the form of which can vary based on the type of wallet. The Wallet\r
-* class itself is abstract and cannot be directly instantiated. Currently, three\r
-* types of wallets are supported, each as a derived class: Bip44Wallet,\r
-* Blake2bWallet, LedgerWallet.\r
-*/\r
-export abstract class Wallet {\r
-       #accounts: AccountList\r
-       #id: Entropy\r
-       #locked: boolean = true\r
-       #mnemonic: Bip39Mnemonic | null\r
-       #poolNanoNacl: Pool\r
-       #safe: Safe\r
-       #seed: string | null\r
-       get id () { return this.#id.hex }\r
-       get isLocked () { return this.#locked }\r
-       get isUnlocked () { return !this.#locked }\r
-       get mnemonic () {\r
-               if (this.#mnemonic instanceof Bip39Mnemonic) {\r
-                       return this.#mnemonic.phrase\r
-               }\r
-               return ''\r
-       }\r
-       get seed () {\r
-               if (typeof this.#seed === 'string') {\r
-                       return this.#seed\r
-               }\r
-               return ''\r
-       }\r
-\r
-       abstract ckd (index: number[]): Promise<KeyPair[]>\r
-\r
-       constructor (id: Entropy, seed?: string, mnemonic?: Bip39Mnemonic) {\r
-               if (this.constructor === Wallet) {\r
-                       throw new Error('Wallet is an abstract class and cannot be instantiated directly.')\r
-               }\r
-               this.#accounts = new AccountList()\r
-               this.#id = id\r
-               this.#mnemonic = mnemonic ?? null\r
-               this.#poolNanoNacl = new Pool(NanoNaClWorker)\r
-               this.#safe = new Safe()\r
-               this.#seed = seed ?? null\r
-       }\r
-\r
-       /**\r
-       * Removes encrypted secrets in storage and releases variable references to\r
-       * allow garbage collection.\r
-       */\r
-       destroy (): void {\r
-               let i = 0\r
-               for (const a in this.#accounts) {\r
-                       this.#accounts[a].destroy()\r
-                       delete this.#accounts[a]\r
-                       i++\r
-               }\r
-               this.#safe.destroy(this.id)\r
-               this.#mnemonic = null\r
-               this.#seed = null\r
-               this.#poolNanoNacl.terminate()\r
-       }\r
-\r
-       /**\r
-       * Retrieves an account from a wallet using its child key derivation function.\r
-       * Defaults to the first account at index 0.\r
-       *\r
-       * ```\r
-       * console.log(await wallet.account(5))\r
-       * // outputs sixth account of the wallet\r
-       * // {\r
-       * //   privateKey: <...>,\r
-       * //   index: 5\r
-       * // }\r
-       * ```\r
-       *\r
-       * @param {number} index - Wallet index of secret key. Default: 0\r
-       * @returns {Account} Account derived at the specified wallet index\r
-       */\r
-       async account (index: number = 0): Promise<Account> {\r
-               return (await this.accounts(index))[index]\r
-       }\r
-\r
-       /**\r
-       * Retrieves accounts from a wallet using its child key derivation function.\r
-       * Defaults to the first account at index 0.\r
-       *\r
-       * The returned object will have keys corresponding with the requested range\r
-       * of account indexes. The value of each key will be the Account derived for\r
-       * that index in the wallet.\r
-       *\r
-       * ```\r
-       * console.log(await wallet.accounts(5))\r
-       * // outputs sixth account of the wallet\r
-       * // {\r
-       * //   5: {\r
-       * //     privateKey: <...>,\r
-       * //     index: 5\r
-       * //   }\r
-       * // }\r
-       * ```\r
-       *\r
-       * @param {number} from - Start index of secret keys. Default: 0\r
-       * @param {number} to - End index of secret keys. Default: `from`\r
-       * @returns {AccountList} Object with keys of account indexes and values of the corresponding Accounts\r
-       */\r
-       async accounts (from: number = 0, to: number = from): Promise<AccountList> {\r
-               if (from > to) {\r
-                       const swap = from\r
-                       from = to\r
-                       to = swap\r
-               }\r
-               const output = new AccountList()\r
-               const indexes: number[] = []\r
-               for (let i = from; i <= to; i++) {\r
-                       if (this.#accounts[i] == null) {\r
-                               indexes.push(i)\r
-                       } else {\r
-                               output[i] = this.#accounts[i]\r
-                       }\r
-               }\r
-               if (indexes.length > 0) {\r
-                       let results = await this.ckd(indexes)\r
-                       const data: any = []\r
-                       results.forEach(r => data.push({\r
-                               method: 'convert',\r
-                               privateKey: r.privateKey,\r
-                               index: r.index\r
-                       }))\r
-                       const keypairs: KeyPair[] = await this.#poolNanoNacl.assign(data)\r
-                       for (const keypair of keypairs) {\r
-                               if (keypair.privateKey == null) throw new RangeError('Account private key missing')\r
-                               if (keypair.publicKey == null) throw new RangeError('Account public key missing')\r
-                               if (keypair.index == null) throw new RangeError('Account keys derived but index missing')\r
-                               const { privateKey, index } = keypair\r
-                               output[keypair.index] = Account.fromPrivateKey(privateKey, index)\r
-                               this.#accounts[keypair.index] = output[keypair.index]\r
-                       }\r
-               }\r
-               return output\r
-       }\r
-\r
-       /**\r
-       * Fetches the lowest-indexed unopened account from a wallet in sequential\r
-       * order. An account is unopened if it has no frontier block.\r
-       *\r
-       * @param {Rpc|string|URL} rpc - RPC node information required to refresh accounts, calculate PoW, and process blocks\r
-       * @param {number} batchSize - Number of accounts to fetch and check per RPC callout\r
-       * @param {number} from - Account index from which to start the search\r
-       * @returns {Promise<Account>} The lowest-indexed unopened account belonging to the wallet\r
-       */\r
-       async getNextNewAccount (rpc: Rpc, batchSize: number = ADDRESS_GAP, from: number = 0): Promise<Account> {\r
-               if (!Number.isSafeInteger(batchSize) || batchSize < 1) {\r
-                       throw new RangeError(`Invalid batch size ${batchSize}`)\r
-               }\r
-               const accounts = await this.accounts(from, from + batchSize - 1)\r
-               const addresses = []\r
-               for (const a in accounts) {\r
-                       addresses.push(accounts[a].address)\r
-               }\r
-               const data = {\r
-                       "accounts": addresses\r
-               }\r
-               const { errors } = await rpc.call('accounts_frontiers', data)\r
-               for (const key of Object.keys(errors ?? {})) {\r
-                       const value = errors[key]\r
-                       if (value === 'Account not found') {\r
-                               return Account.fromAddress(key)\r
-                       }\r
-               }\r
-               return await this.getNextNewAccount(rpc, batchSize, from + batchSize)\r
-       }\r
-\r
-       /**\r
-       * Refreshes wallet account balances, frontiers, and representatives from the\r
-       * current state on the network.\r
-       *\r
-       * A successful response will set these properties on each account.\r
-       *\r
-       * @param {Rpc|string|URL} rpc - RPC node information required to refresh accounts, calculate PoW, and process blocks\r
-       * @returns {Promise<Account[]>} Accounts with updated balances, frontiers, and representatives\r
-       */\r
-       async refresh (rpc: Rpc | string | URL, from: number = 0, to: number = from): Promise<AccountList> {\r
-               if (typeof rpc === 'string' || rpc.constructor === URL) {\r
-                       rpc = new Rpc(rpc)\r
-               }\r
-               if (rpc.constructor !== Rpc) {\r
-                       throw new TypeError('RPC must be a valid node')\r
-               }\r
-               const accounts = await this.accounts(from, to)\r
-               for (const a in accounts) {\r
-                       try {\r
-                               await accounts[a].refresh(rpc)\r
-                       } catch (err) {\r
-                               delete accounts[a]\r
-                       }\r
-               }\r
-               return accounts\r
-       }\r
-\r
-       /**\r
-       * Locks the wallet with a password that will be needed to unlock it later.\r
-       *\r
-       * @param {string} password Used to lock the wallet\r
-       * @returns True if successfully locked\r
-       */\r
-       async lock (password: string): Promise<boolean>\r
-       /**\r
-       * Locks the wallet with a CryptoKey that will be needed to unlock it later.\r
-       *\r
-       * @param {CryptoKey} key Used to lock the wallet\r
-       * @returns True if successfully locked\r
-       */\r
-       async lock (key: CryptoKey): Promise<boolean>\r
-       async lock (passkey: string | CryptoKey): Promise<boolean> {\r
-               let success = true\r
-               try {\r
-                       const data: { id: string, mnemonic: string | null, seed: string | null } = {\r
-                               id: this.id,\r
-                               mnemonic: null,\r
-                               seed: null\r
-                       }\r
-                       if (this.#mnemonic instanceof Bip39Mnemonic) {\r
-                               data.mnemonic = this.#mnemonic.phrase\r
-                       }\r
-                       if (typeof this.#seed === 'string') {\r
-                               data.seed = this.#seed\r
-                       }\r
-                       success &&= await this.#safe.put(this.id, passkey as string, data)\r
-                       const promises = []\r
-                       for (const account of this.#accounts) {\r
-                               promises.push(account.lock(passkey as string))\r
-                       }\r
-                       await Promise.all(promises)\r
-                       if (!success) {\r
-                               throw null\r
-                       }\r
-               } catch (err) {\r
-                       throw new Error('Failed to lock wallet')\r
-               }\r
-               this.#locked = true\r
-               this.#mnemonic = null\r
-               this.#seed = null\r
-               return true\r
-       }\r
-\r
-       /**\r
-       * Unlocks the wallet using the same password as used prior to lock it.\r
-       *\r
-       * @param {string} password Used previously to lock the wallet\r
-       * @returns True if successfully unlocked\r
-       */\r
-       async unlock (password: string): Promise<boolean>\r
-       /**\r
-       * Unlocks the wallet using the same CryptoKey as used prior to lock it.\r
-       *\r
-       * @param {CryptoKey} key Used previously to lock the wallet\r
-       * @returns True if successfully unlocked\r
-       */\r
-       async unlock (key: CryptoKey): Promise<boolean>\r
-       async unlock (passkey: string | CryptoKey): Promise<boolean> {\r
-               try {\r
-                       const { id, mnemonic, seed } = await this.#safe.get(this.id, passkey as string)\r
-                       if (id !== this.id) {\r
-                               throw null\r
-                       }\r
-                       const promises = []\r
-                       for (const account of this.#accounts) {\r
-                               promises.push(account.unlock(passkey as string))\r
-                       }\r
-                       await Promise.all(promises)\r
-                       if (mnemonic != null) {\r
-                               this.#mnemonic = await Bip39Mnemonic.fromPhrase(mnemonic)\r
-                       }\r
-                       if (seed != null) {\r
-                               this.#seed = seed\r
-                       }\r
-                       this.#locked = false\r
-               } catch (err) {\r
-                       throw new Error('Failed to unlock wallet')\r
-               }\r
-               return true\r
-       }\r
-}\r
index 2b7b7cdad8a8e633ae36825e99f83a8d6e7ba636..a2129c4cc15add8f665f9b5e9ed31b555fe2a1d4 100644 (file)
@@ -1,7 +1,7 @@
 // SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>\r
 // SPDX-License-Identifier: GPL-3.0-or-later\r
 \r
-import { KeyPair, Wallet } from '.'\r
+import { KeyPair, Wallet } from './wallet'\r
 import { Entropy } from '#src/lib/entropy.js'\r
 import { Ledger } from '#src/lib/ledger.js'\r
 \r
diff --git a/src/lib/wallets/wallet.ts b/src/lib/wallets/wallet.ts
new file mode 100644 (file)
index 0000000..3b54258
--- /dev/null
@@ -0,0 +1,306 @@
+// SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>\r
+// SPDX-License-Identifier: GPL-3.0-or-later\r
+\r
+import { Account, AccountList } from '#src/lib/account.js'\r
+import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js'\r
+import { ADDRESS_GAP } from '#src/lib/constants.js'\r
+import { Entropy } from '#src/lib/entropy.js'\r
+import { Pool } from '#src/lib/pool.js'\r
+import { Rpc } from '#src/lib/rpc.js'\r
+import { NanoNaClWorker, SafeWorker } from '#workers'\r
+import { utf8 } from '../convert'\r
+\r
+export type KeyPair = {\r
+       publicKey?: string,\r
+       privateKey?: string,\r
+       index?: number\r
+}\r
+\r
+/**\r
+* Represents a wallet containing numerous Nano accounts derived from a single\r
+* source, the form of which can vary based on the type of wallet. The Wallet\r
+* class itself is abstract and cannot be directly instantiated. Currently, three\r
+* types of wallets are supported, each as a derived class: Bip44Wallet,\r
+* Blake2bWallet, LedgerWallet.\r
+*/\r
+export abstract class Wallet {\r
+       #accounts: AccountList\r
+       #id: Entropy\r
+       #locked: boolean = true\r
+       #mnemonic: Bip39Mnemonic | null\r
+       #poolNanoNacl: Pool\r
+       #poolSafe: Pool\r
+       #seed: string | null\r
+       get id () { return this.#id.hex }\r
+       get isLocked () { return this.#locked }\r
+       get isUnlocked () { return !this.#locked }\r
+       get mnemonic () {\r
+               if (this.#mnemonic instanceof Bip39Mnemonic) {\r
+                       return this.#mnemonic.phrase\r
+               }\r
+               return ''\r
+       }\r
+       get seed () {\r
+               if (typeof this.#seed === 'string') {\r
+                       return this.#seed\r
+               }\r
+               return ''\r
+       }\r
+\r
+       abstract ckd (index: number[]): Promise<KeyPair[]>\r
+\r
+       constructor (id: Entropy, seed?: string, mnemonic?: Bip39Mnemonic) {\r
+               if (this.constructor === Wallet) {\r
+                       throw new Error('Wallet is an abstract class and cannot be instantiated directly.')\r
+               }\r
+               this.#accounts = new AccountList()\r
+               this.#id = id\r
+               this.#mnemonic = mnemonic ?? null\r
+               this.#poolNanoNacl = new Pool(NanoNaClWorker)\r
+               this.#poolSafe = new Pool(SafeWorker)\r
+               console.log(SafeWorker)\r
+               this.#seed = seed ?? null\r
+       }\r
+\r
+       /**\r
+       * Removes encrypted secrets in storage and releases variable references to\r
+       * allow garbage collection.\r
+       */\r
+       destroy (): void {\r
+               let i = 0\r
+               for (const a in this.#accounts) {\r
+                       this.#accounts[a].destroy()\r
+                       delete this.#accounts[a]\r
+                       i++\r
+               }\r
+               this.#mnemonic = null\r
+               this.#seed = null\r
+               this.#poolNanoNacl.terminate()\r
+               this.#poolSafe.assign({\r
+                       method: 'destroy',\r
+                       name: this.id\r
+               }).finally(this.#poolSafe.terminate)\r
+       }\r
+\r
+       /**\r
+       * Retrieves an account from a wallet using its child key derivation function.\r
+       * Defaults to the first account at index 0.\r
+       *\r
+       * ```\r
+       * console.log(await wallet.account(5))\r
+       * // outputs sixth account of the wallet\r
+       * // {\r
+       * //   privateKey: <...>,\r
+       * //   index: 5\r
+       * // }\r
+       * ```\r
+       *\r
+       * @param {number} index - Wallet index of secret key. Default: 0\r
+       * @returns {Account} Account derived at the specified wallet index\r
+       */\r
+       async account (index: number = 0): Promise<Account> {\r
+               return (await this.accounts(index))[index]\r
+       }\r
+\r
+       /**\r
+       * Retrieves accounts from a wallet using its child key derivation function.\r
+       * Defaults to the first account at index 0.\r
+       *\r
+       * The returned object will have keys corresponding with the requested range\r
+       * of account indexes. The value of each key will be the Account derived for\r
+       * that index in the wallet.\r
+       *\r
+       * ```\r
+       * console.log(await wallet.accounts(5))\r
+       * // outputs sixth account of the wallet\r
+       * // {\r
+       * //   5: {\r
+       * //     privateKey: <...>,\r
+       * //     index: 5\r
+       * //   }\r
+       * // }\r
+       * ```\r
+       *\r
+       * @param {number} from - Start index of secret keys. Default: 0\r
+       * @param {number} to - End index of secret keys. Default: `from`\r
+       * @returns {AccountList} Object with keys of account indexes and values of the corresponding Accounts\r
+       */\r
+       async accounts (from: number = 0, to: number = from): Promise<AccountList> {\r
+               if (from > to) {\r
+                       const swap = from\r
+                       from = to\r
+                       to = swap\r
+               }\r
+               const output = new AccountList()\r
+               const indexes: number[] = []\r
+               for (let i = from; i <= to; i++) {\r
+                       if (this.#accounts[i] == null) {\r
+                               indexes.push(i)\r
+                       } else {\r
+                               output[i] = this.#accounts[i]\r
+                       }\r
+               }\r
+               if (indexes.length > 0) {\r
+                       let results = await this.ckd(indexes)\r
+                       const data: any = []\r
+                       results.forEach(r => data.push({\r
+                               method: 'convert',\r
+                               privateKey: r.privateKey,\r
+                               index: r.index\r
+                       }))\r
+                       const keypairs: KeyPair[] = await this.#poolNanoNacl.assign(data)\r
+                       for (const keypair of keypairs) {\r
+                               if (keypair.privateKey == null) throw new RangeError('Account private key missing')\r
+                               if (keypair.publicKey == null) throw new RangeError('Account public key missing')\r
+                               if (keypair.index == null) throw new RangeError('Account keys derived but index missing')\r
+                               const { privateKey, index } = keypair\r
+                               output[keypair.index] = Account.fromPrivateKey(privateKey, index)\r
+                               this.#accounts[keypair.index] = output[keypair.index]\r
+                       }\r
+               }\r
+               return output\r
+       }\r
+\r
+       /**\r
+       * Fetches the lowest-indexed unopened account from a wallet in sequential\r
+       * order. An account is unopened if it has no frontier block.\r
+       *\r
+       * @param {Rpc|string|URL} rpc - RPC node information required to refresh accounts, calculate PoW, and process blocks\r
+       * @param {number} batchSize - Number of accounts to fetch and check per RPC callout\r
+       * @param {number} from - Account index from which to start the search\r
+       * @returns {Promise<Account>} The lowest-indexed unopened account belonging to the wallet\r
+       */\r
+       async getNextNewAccount (rpc: Rpc, batchSize: number = ADDRESS_GAP, from: number = 0): Promise<Account> {\r
+               if (!Number.isSafeInteger(batchSize) || batchSize < 1) {\r
+                       throw new RangeError(`Invalid batch size ${batchSize}`)\r
+               }\r
+               const accounts = await this.accounts(from, from + batchSize - 1)\r
+               const addresses = []\r
+               for (const a in accounts) {\r
+                       addresses.push(accounts[a].address)\r
+               }\r
+               const data = {\r
+                       "accounts": addresses\r
+               }\r
+               const { errors } = await rpc.call('accounts_frontiers', data)\r
+               for (const key of Object.keys(errors ?? {})) {\r
+                       const value = errors[key]\r
+                       if (value === 'Account not found') {\r
+                               return Account.fromAddress(key)\r
+                       }\r
+               }\r
+               return await this.getNextNewAccount(rpc, batchSize, from + batchSize)\r
+       }\r
+\r
+       /**\r
+       * Refreshes wallet account balances, frontiers, and representatives from the\r
+       * current state on the network.\r
+       *\r
+       * A successful response will set these properties on each account.\r
+       *\r
+       * @param {Rpc|string|URL} rpc - RPC node information required to refresh accounts, calculate PoW, and process blocks\r
+       * @returns {Promise<Account[]>} Accounts with updated balances, frontiers, and representatives\r
+       */\r
+       async refresh (rpc: Rpc | string | URL, from: number = 0, to: number = from): Promise<AccountList> {\r
+               if (typeof rpc === 'string' || rpc.constructor === URL) {\r
+                       rpc = new Rpc(rpc)\r
+               }\r
+               if (rpc.constructor !== Rpc) {\r
+                       throw new TypeError('RPC must be a valid node')\r
+               }\r
+               const accounts = await this.accounts(from, to)\r
+               for (const a in accounts) {\r
+                       try {\r
+                               await accounts[a].refresh(rpc)\r
+                       } catch (err) {\r
+                               delete accounts[a]\r
+                       }\r
+               }\r
+               return accounts\r
+       }\r
+\r
+       /**\r
+       * Locks the wallet with a password that will be needed to unlock it later.\r
+       *\r
+       * @param {(string|Uint8Array)} password Used to lock the wallet\r
+       * @returns True if successfully locked\r
+       */\r
+       async lock (password: string | Uint8Array): Promise<boolean> {\r
+               if (typeof password === 'string') {\r
+                       password = utf8.toBytes(password)\r
+               }\r
+               let success = true\r
+               try {\r
+                       const data: { id: string, mnemonic: string | null, seed: string | null } = {\r
+                               id: this.id,\r
+                               mnemonic: null,\r
+                               seed: null\r
+                       }\r
+                       if (this.#mnemonic instanceof Bip39Mnemonic) {\r
+                               data.mnemonic = this.#mnemonic.phrase\r
+                       }\r
+                       if (typeof this.#seed === 'string') {\r
+                               data.seed = this.#seed\r
+                       }\r
+                       success &&= await this.#poolSafe.assign({\r
+                               method: 'put',\r
+                               name: this.id,\r
+                               password,\r
+                               data\r
+                       })\r
+                       const promises = []\r
+                       for (const account of this.#accounts) {\r
+                               promises.push(account.lock(password))\r
+                       }\r
+                       await Promise.all(promises)\r
+                       password.fill(0)\r
+                       if (!success) {\r
+                               throw null\r
+                       }\r
+               } catch (err) {\r
+                       throw new Error('Failed to lock wallet')\r
+               }\r
+               this.#locked = true\r
+               this.#mnemonic = null\r
+               this.#seed = null\r
+               return true\r
+       }\r
+\r
+       /**\r
+       * Unlocks the wallet using the same password as used prior to lock it.\r
+       *\r
+       * @param {(string|Uint8Array)} password Used previously to lock the wallet\r
+       * @returns True if successfully unlocked\r
+       */\r
+       async unlock (password: string | Uint8Array): Promise<boolean> {\r
+               if (typeof password === 'string') {\r
+                       password = utf8.toBytes(password)\r
+               }\r
+               try {\r
+                       const { id, mnemonic, seed } = await this.#poolSafe.assign({\r
+                               method: 'get',\r
+                               name: this.id,\r
+                               password\r
+                       })\r
+                       if (id !== this.id) {\r
+                               throw null\r
+                       }\r
+                       const promises = []\r
+                       for (const account of this.#accounts) {\r
+                               promises.push(account.unlock(password))\r
+                       }\r
+                       await Promise.all(promises)\r
+                       password.fill(0)\r
+                       if (mnemonic != null) {\r
+                               this.#mnemonic = await Bip39Mnemonic.fromPhrase(mnemonic)\r
+                       }\r
+                       if (seed != null) {\r
+                               this.#seed = seed\r
+                       }\r
+                       this.#locked = false\r
+               } catch (err) {\r
+                       throw new Error('Failed to unlock wallet')\r
+               }\r
+               return true\r
+       }\r
+}\r
index c1defde0259bf49305644e0774d9c1de9025fb5c..b9bd85ed931ba16724356bb35042be1670aaf89e 100644 (file)
@@ -2,10 +2,13 @@
 // SPDX-License-Identifier: GPL-3.0-or-later
 import { default as Bip44CkdWorker, Bip44Ckd } from './bip44-ckd'
 import { default as NanoNaClWorker, NanoNaCl } from './nano-nacl'
+import { default as SafeWorker, Safe } from './safe'
 
 export {
        Bip44Ckd,
        Bip44CkdWorker,
        NanoNaCl,
-       NanoNaClWorker
+       NanoNaClWorker,
+       Safe,
+       SafeWorker
 }
diff --git a/src/lib/workers/safe.ts b/src/lib/workers/safe.ts
new file mode 100644 (file)
index 0000000..b851917
--- /dev/null
@@ -0,0 +1,213 @@
+// SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+'use strict'
+
+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 SafeInput = {
+       method: string
+       name: string
+       password?: { [key: number]: number }
+       data?: any
+}
+
+type SafeOutput = {
+       method: string
+       name: string
+       result: any
+}
+
+const { subtle } = globalThis.crypto
+const ERR_MSG = 'Failed to store item in Safe'
+
+export class Safe extends WorkerInterface {
+       static #storage: Storage = globalThis.sessionStorage
+
+       static {
+               Safe.listen()
+       }
+
+       static async work (data: any[]): Promise<any[]> {
+               return new Promise(async (resolve, reject): Promise<void> => {
+                       const results: SafeOutput[] = []
+                       for (const d of data) {
+                               console.log(d)
+                               const { name, method, password, data } = d as SafeInput
+                               console.log(globalThis.sessionStorage)
+                               const backup = this.#storage.getItem(name)
+                               let result
+                               try {
+                                       const passwordBytes = obj.toBytes(password ?? [])
+                                       switch (d.method) {
+                                               case 'put': {
+                                                       result = await this.put(name, passwordBytes, data)
+                                                       break
+                                               }
+                                               case 'overwrite': {
+                                                       result = await this.overwrite(name, passwordBytes, data)
+                                                       break
+                                               }
+                                               case 'get': {
+                                                       result = await this.get(name, passwordBytes)
+                                                       break
+                                               }
+                                               case 'destroy': {
+                                                       result = await this.destroy(name)
+                                                       break
+                                               }
+                                               default: {
+                                                       result = `unknown Safe method ${method}`
+                                               }
+                                       }
+                                       results.push({ name, method, result })
+                               } catch (err) {
+                                       console.log(err)
+                                       if (backup != null) this.#storage.setItem(d.name, backup)
+                                       result = false
+                               }
+                       }
+                       resolve(results)
+               })
+       }
+
+       /**
+       * Removes data from the Safe without decrypting.
+       */
+       static destroy (name: string): boolean {
+               try {
+                       this.#storage.removeItem(name)
+               } catch (err) {
+                       throw new Error(ERR_MSG)
+               }
+               return (this.#storage.getItem(name) == null)
+       }
+
+       /**
+       * Encrypts data with a password or CryptoKey and stores it in the Safe.
+       */
+       static async put (name: string, password: Uint8Array, data: any): Promise<boolean> {
+               if (this.#storage.getItem(name)) {
+                       throw new Error(ERR_MSG)
+               }
+               return this.overwrite(name, password, data)
+       }
+
+       /**
+       * Encrypts data with a password as bytes and stores it in the Safe.
+       */
+       static async overwrite (name: string, password: Uint8Array, data: any): Promise<boolean> {
+               let passkey
+               try {
+                       passkey = await subtle.importKey('raw', password, 'PBKDF2', false, ['deriveBits', 'deriveKey'])
+               } catch {
+                       throw new Error(ERR_MSG)
+               } finally {
+                       password.fill(0)
+               }
+               if (this.#isInvalid(name, passkey, data)) {
+                       throw new Error(ERR_MSG)
+               }
+
+               try {
+                       const iv = await Entropy.create()
+                       if (typeof data === 'bigint') {
+                               data = data.toString()
+                       }
+                       data = JSON.stringify(data)
+                       const derivationAlgorithm: Pbkdf2Params = {
+                               name: 'PBKDF2',
+                               hash: 'SHA-512',
+                               salt: iv.bytes,
+                               iterations: 210000
+                       }
+                       const derivedKeyType: AesKeyGenParams = {
+                               name: 'AES-GCM',
+                               length: 256
+                       }
+                       passkey = await subtle.deriveKey(derivationAlgorithm, passkey, derivedKeyType, false, ['encrypt'])
+                       const encrypted = await subtle.encrypt({ name: 'AES-GCM', iv: iv.buffer }, passkey, utf8.toBytes(data))
+                       const record = {
+                               encrypted: buffer.toHex(encrypted),
+                               iv: iv.hex
+                       }
+                       this.#storage.setItem(name, JSON.stringify(record))
+               } catch (err) {
+                       throw new Error(ERR_MSG)
+               }
+
+               return (this.#storage.getItem(name) != null)
+       }
+
+       /**
+       * Retrieves data from the Safe and decrypts data with a password as bytes.
+       */
+       static async get (name: string, password: Uint8Array): Promise<any> {
+               let passkey
+               try {
+                       passkey = await subtle.importKey('raw', password, 'PBKDF2', false, ['deriveBits', 'deriveKey'])
+               } catch {
+                       return null
+               } finally {
+                       password.fill(0)
+               }
+               if (this.#isInvalid(name, passkey)) {
+                       return null
+               }
+
+               const item = this.#storage.getItem(name)
+               if (item == null) {
+                       return null
+               }
+               const record = JSON.parse(item)
+               const encrypted = hex.toBytes(record.encrypted)
+               const iv = await Entropy.import(record.iv)
+
+               try {
+                       const derivationAlgorithm: Pbkdf2Params = {
+                               name: 'PBKDF2',
+                               hash: 'SHA-512',
+                               salt: iv.bytes,
+                               iterations: 210000
+                       }
+                       const derivedKeyType: AesKeyGenParams = {
+                               name: 'AES-GCM',
+                               length: 256
+                       }
+                       passkey = await subtle.deriveKey(derivationAlgorithm, passkey, derivedKeyType, false, ['decrypt'])
+                       const decrypted = await subtle.decrypt({ name: 'AES-GCM', iv: iv.buffer }, passkey, encrypted)
+                       const decoded = buffer.toUtf8(decrypted)
+                       const data = JSON.parse(decoded)
+                       this.#storage.removeItem(name)
+                       return data
+               } catch (err) {
+                       return null
+               }
+       }
+
+       static #isInvalid (name: string, passkey: CryptoKey, data?: any): boolean {
+               if (typeof name !== 'string' || name === '') {
+                       return true
+               }
+               if (!(passkey instanceof CryptoKey)) {
+                       return true
+               }
+               if (typeof data === 'object') {
+                       try {
+                               JSON.stringify(data)
+                       } catch (err) {
+                               return true
+                       }
+               }
+               return false
+       }
+}
+
+export default `
+       ${Convert}
+       const Entropy = ${Entropy}
+       const WorkerInterface = ${WorkerInterface}
+       const Safe = ${Safe}
+`
index d77e61da9ea8340c1acda6799f1ccd8b2c7d86ee..d80e88b8685987f46c87b7d8b543b870cae6bdce 100644 (file)
@@ -6,8 +6,7 @@ import { Blake2b } from './lib/blake2b'
 import { SendBlock, ReceiveBlock, ChangeBlock } from './lib/block'
 import { Rolodex } from './lib/rolodex'
 import { Rpc } from './lib/rpc'
-import { Safe } from './lib/safe'
 import { Tools } from './lib/tools'
 import { Bip44Wallet, Blake2bWallet, LedgerWallet } from './lib/wallets'
 
-export { Account, Blake2b, SendBlock, ReceiveBlock, ChangeBlock, Rpc, Rolodex, Safe, Tools, Bip44Wallet, Blake2bWallet, LedgerWallet }
+export { Account, Blake2b, SendBlock, ReceiveBlock, ChangeBlock, Rpc, Rolodex, Tools, Bip44Wallet, Blake2bWallet, LedgerWallet }