From: Chris Duncan Date: Thu, 3 Jul 2025 16:12:43 +0000 (-0700) Subject: Refactor Safe to run as a worker. Since `sessionStorage` is unavailable in this conte... X-Git-Tag: v0.10.5~136^2~26 X-Git-Url: https://git.codecow.com/?a=commitdiff_plain;h=5ea98f22bd00a56c714e50a0abc4fab294475977;p=libnemo.git Refactor Safe to run as a worker. Since `sessionStorage` is unavailable in this context, next focus should be refactoring to use `IndexedDB` instead. --- diff --git a/src/lib/account.ts b/src/lib/account.ts index f434cce..315a8b8 100644 --- a/src/lib/account.ts +++ b/src/lib/account.ts @@ -3,10 +3,10 @@ import { Blake2b } from './blake2b' import { ACCOUNT_KEY_LENGTH, ALPHABET, PREFIX, PREFIX_LEGACY } from './constants' -import { base32, bytes, hex } from './convert' +import { base32, bytes, hex, utf8 } from './convert' +import { Pool } from './pool' import { Rpc } from './rpc' -import { Safe } from './safe' -import { NanoNaCl } from '#workers' +import { NanoNaCl, SafeWorker } from '#workers' /** * Represents a single Nano address and the associated public key. To include the @@ -16,6 +16,7 @@ import { NanoNaCl } from '#workers' */ export class Account { static #isInternal: boolean = false + static #poolSafe: Pool #a: string #pub: string #prv: string | null @@ -25,7 +26,6 @@ export class Account { #r?: bigint #rep?: Account #w?: bigint - #s: Safe get address () { return `${PREFIX}${this.#a}` } get publicKey () { return this.#pub } @@ -64,7 +64,7 @@ export class Account { this.#pub = publicKey this.#prv = privateKey ?? null this.#i = index - this.#s = new Safe() + Account.#poolSafe ??= new Pool(SafeWorker) Account.#isInternal = false } @@ -73,7 +73,10 @@ export class Account { * allow garbage collection. */ destroy (): void { - this.#s.destroy(this.#pub) + Account.#poolSafe.assign({ + method: 'destroy', + name: this.#pub + }) this.#prv = null this.#i = undefined this.#f = undefined @@ -130,12 +133,17 @@ export class Account { return account } - async lock (password: string): Promise - async lock (key: CryptoKey): Promise - async lock (passkey: string | CryptoKey): Promise { + async lock (password: string | Uint8Array): Promise { + if (typeof password === 'string') { + password = utf8.toBytes(password) + } try { if (this.#prv != null) { - await this.#s.put(this.#pub, passkey as string, this.#prv) + await Account.#poolSafe.assign({ + method: 'put', + name: this.#prv, + password + }) } } catch (err) { console.error(`Failed to lock account ${this.address}`, err) @@ -145,11 +153,16 @@ export class Account { return true } - async unlock (password: string): Promise - async unlock (key: CryptoKey): Promise - async unlock (passkey: string | CryptoKey): Promise { + async unlock (password: string | Uint8Array): Promise { + if (typeof password === 'string') { + password = utf8.toBytes(password) + } try { - this.#prv = await this.#s.get(this.#pub, passkey as string) + this.#prv = await Account.#poolSafe.assign({ + method: 'get', + name: this.#pub, + password + }) } catch (err) { console.error(`Failed to unlock account ${this.address}`, err) return false diff --git a/src/lib/convert.ts b/src/lib/convert.ts index 8511e8e..2811916 100644 --- a/src/lib/convert.ts +++ b/src/lib/convert.ts @@ -6,14 +6,14 @@ import { ALPHABET } from './constants' const encoder = new TextEncoder() const decoder = new TextDecoder() -export const base32 = { +export class base32 { /** * Converts a base32 string to a Uint8Array of bytes. * * @param {string} base32 - String to convert * @returns {Uint8Array} Byte array representation of the input string */ - toBytes (base32: string): Uint8Array { + static toBytes (base32: string): Uint8Array { const leftover = (base32.length * 5) % 8 const offset = leftover === 0 ? 0 @@ -37,26 +37,27 @@ export const base32 = { output = output.slice(1) } return output - }, + } + /** * Converts a base32 string to a hexadecimal string. * * @param {string} base32 - String to convert * @returns {string} Hexadecimal representation of the input base32 */ - toHex (base32: string): string { + static toHex (base32: string): string { return bytes.toHex(this.toBytes(base32)) } } -export const bin = { +export class bin { /** * Convert a binary string to a Uint8Array of bytes. * * @param {string} bin - String to convert * @returns {Uint8Array} Byte array representation of the input string */ - toBytes (bin: string): Uint8Array { + static toBytes (bin: string): Uint8Array { const bytes: number[] = [] while (bin.length > 0) { const bits = bin.substring(0, 8) @@ -64,37 +65,40 @@ export const bin = { bin = bin.substring(8) } return new Uint8Array(bytes) - }, + } + /** * Convert a binary string to a hexadecimal string. * * @param {string} bin - String to convert * @returns {string} Hexadecimal string representation of the input binary */ - toHex (bin: string): string { + static toHex (bin: string): string { return parseInt(bin, 2).toString(16) } } -export const buffer = { +export class buffer { /** * Converts an ArrayBuffer to a base32 string. * * @param {ArrayBuffer} buffer - Buffer to convert * @returns {string} Base32 string representation of the input buffer */ - toBase32 (buffer: ArrayBuffer): string { + static toBase32 (buffer: ArrayBuffer): string { return bytes.toBase32(new Uint8Array(buffer)) - }, + } + /** * Converts an ArrayBuffer to a binary string. * * @param {ArrayBuffer} buffer - Buffer to convert * @returns {string} Binary string representation of the input buffer */ - toBin (buffer: ArrayBuffer): string { + static toBin (buffer: ArrayBuffer): string { return bytes.toBin(new Uint8Array(buffer)) - }, + } + /** * Sums an ArrayBuffer to a decimal integer. If the result is larger than * Number.MAX_SAFE_INTEGER, it will be returned as a bigint. @@ -102,37 +106,39 @@ export const buffer = { * @param {ArrayBuffer} buffer - Buffer to convert * @returns {bigint|number} Decimal sum of the literal buffer values */ - toDec (buffer: ArrayBuffer): bigint | number { + static toDec (buffer: ArrayBuffer): bigint | number { return bytes.toDec(new Uint8Array(buffer)) - }, + } + /** * Converts an ArrayBuffer to a hexadecimal string. * * @param {ArrayBuffer} buffer - Buffer to convert * @returns {string} Hexadecimal string representation of the input buffer */ - toHex (buffer: ArrayBuffer): string { + static toHex (buffer: ArrayBuffer): string { return bytes.toHex(new Uint8Array(buffer)) - }, + } + /** * Converts an ArrayBuffer to a UTF-8 text string. * * @param {ArrayBuffer} buffer - Buffer to convert * @returns {string} UTF-8 encoded text string */ - toUtf8 (buffer: ArrayBuffer): string { + static toUtf8 (buffer: ArrayBuffer): string { return bytes.toUtf8(new Uint8Array(buffer)) } } -export const bytes = { +export class bytes { /** * Converts a Uint8Aarray of bytes to a base32 string. * * @param {Uint8Array} bytes - Byte array to convert * @returns {string} Base32 string representation of the input bytes */ - toBase32 (bytes: Uint8Array): string { + static toBase32 (bytes: Uint8Array): string { const leftover = (bytes.length * 8) % 5 const offset = leftover === 0 ? 0 @@ -152,16 +158,18 @@ export const bytes = { output += ALPHABET[(value << (5 - (bits + offset))) & 31] } return output - }, + } + /** * Convert a Uint8Array of bytes to a binary string. * * @param {Uint8Array} bytes - Byte array to convert * @returns {string} Binary string representation of the input value */ - toBin (bytes: Uint8Array): string { + static toBin (bytes: Uint8Array): string { return [...bytes].map(b => b.toString(2).padStart(8, '0')).join('') - }, + } + /** * Sums an array of bytes to a decimal integer. If the result is larger than * Number.MAX_SAFE_INTEGER, it will be returned as a bigint. @@ -169,7 +177,7 @@ export const bytes = { * @param {Uint8Array} bytes - Byte array to convert * @returns {bigint|number} Decimal sum of the literal byte values */ - toDec (bytes: Uint8Array): bigint | number { + static toDec (bytes: Uint8Array): bigint | number { const integers: bigint[] = [] bytes.reverse().forEach(b => integers.push(BigInt(b))) let decimal = 0n @@ -181,29 +189,31 @@ export const bytes = { } else { return Number(decimal) } - }, + } + /** * Converts a Uint8Array of bytes to a hexadecimal string. * * @param {Uint8Array} bytes - Byte array to convert * @returns {string} Hexadecimal string representation of the input bytes */ - toHex (bytes: Uint8Array): string { + static toHex (bytes: Uint8Array): string { const byteArray = [...bytes].map(byte => byte.toString(16).padStart(2, '0')) return byteArray.join('').toUpperCase() - }, + } + /** * Converts a Uint8Array of bytes to a UTF-8 text string. * * @param {Uint8Array} bytes - Byte array to convert * @returns {string} UTF-8 encoded text string */ - toUtf8 (bytes: Uint8Array): string { + static toUtf8 (bytes: Uint8Array): string { return decoder.decode(bytes) } } -export const dec = { +export class dec { /** * Convert a decimal integer to a binary string. * @@ -211,7 +221,7 @@ export const dec = { * @param {number} [padding=0] - Minimum length of the resulting string padded as necessary with starting zeroes * @returns {string} Binary string representation of the input decimal */ - toBin (decimal: bigint | number | string, padding: number = 0): string { + static toBin (decimal: bigint | number | string, padding: number = 0): string { if (typeof padding !== 'number') { throw new TypeError('Invalid padding') } @@ -222,7 +232,8 @@ export const dec = { } catch (err) { throw new RangeError('Invalid decimal integer') } - }, + } + /** * Convert a decimal integer to a Uint8Array of bytes. Fractional part is truncated. * @@ -230,7 +241,7 @@ export const dec = { * @param {number} [padding=0] - Minimum length of the resulting array padded as necessary with starting 0x00 bytes * @returns {Uint8Array} Byte array representation of the input decimal */ - toBytes (decimal: bigint | number | string, padding: number = 0): Uint8Array { + static toBytes (decimal: bigint | number | string, padding: number = 0): Uint8Array { if (typeof padding !== 'number') { throw new TypeError('Invalid padding') } @@ -244,7 +255,8 @@ export const dec = { const result = new Uint8Array(Math.max(padding, bytes.length)) result.set(bytes) return (result.reverse()) - }, + } + /** * Convert a decimal integer to a hexadecimal string. * @@ -252,7 +264,7 @@ export const dec = { * @param {number} [padding=0] - Minimum length of the resulting string padded as necessary with starting zeroes * @returns {string} Hexadecimal string representation of the input decimal */ - toHex (decimal: bigint | number | string, padding: number = 0): string { + static toHex (decimal: bigint | number | string, padding: number = 0): string { if (typeof padding !== 'number') { throw new TypeError('Invalid padding') } @@ -267,7 +279,7 @@ export const dec = { } } -export const hex = { +export class hex { /** * Convert a hexadecimal string to an array of decimal byte values. * @@ -275,7 +287,7 @@ export const hex = { * @param {number}[padding=0] - Minimum length of the resulting array padded as necessary with starting 0 values * @returns {number[]} Decimal array representation of the input value */ - toArray (hex: string, padding: number = 0): number[] { + static toArray (hex: string, padding: number = 0): number[] { if (typeof hex !== 'string' || !/^[A-Fa-f0-9]+$/i.test(hex)) { throw new TypeError('Invalid string when converting hex to array') } @@ -291,16 +303,18 @@ export const hex = { hexArray.unshift('0') } return hexArray.map(v => parseInt(v, 16)) - }, + } + /** * Convert a hexadecimal string to a binary string. * * @param {string} hex - Hexadecimal number string to convert * @returns {string} Binary string representation of the input value */ - toBin (hex: string): string { + static toBin (hex: string): string { return [...hex].map(c => dec.toBin(parseInt(c, 16), 4)).join('') - }, + } + /** * Convert a hexadecimal string to a Uint8Array of bytes. * @@ -308,40 +322,41 @@ export const hex = { * @param {number} [padding=0] - Minimum length of the resulting array padded as necessary with starting 0x00 bytes * @returns {Uint8Array} Byte array representation of the input value */ - toBytes (hex: string, padding: number = 0): Uint8Array { + static toBytes (hex: string, padding: number = 0): Uint8Array { return new Uint8Array(this.toArray(hex, padding)) } } -export const utf8 = { +export class utf8 { /** * Convert a UTF-8 text string to a Uint8Array of bytes. * * @param {string} utf8 - String to convert * @returns {Uint8Array} Byte array representation of the input string */ - toBytes (utf8: string): Uint8Array { + static toBytes (utf8: string): Uint8Array { return encoder.encode(utf8) - }, + } + /** * Convert a string to a hexadecimal representation * * @param {string} utf8 - String to convert * @returns {string} Hexadecimal representation of the input string */ - toHex (utf8: string): string { + static toHex (utf8: string): string { return bytes.toHex(this.toBytes(utf8)) } } -export const obj = { +export class obj { /** * Convert a numerically-indexed object of 8-bit values to a Uint8Array of bytes. * * @param {object} obj - Object to convert * @returns {Uint8Array} Byte array representation of the input object */ - toBytes (obj: { [key: number]: number }): Uint8Array { + static toBytes (obj: { [key: number]: number }): Uint8Array { const values = Object.keys(obj) .map(key => +key) .sort((a, b) => a - b) @@ -350,4 +365,12 @@ export const obj = { } } -export default { base32, bin, bytes, dec, hex, utf8 } +export default ` + const base32 = ${base32} + const bin = ${bin} + const bytes = ${bytes} + const dec = ${dec} + const hex = ${hex} + const obj = ${obj} + const utf8 = ${utf8} +` diff --git a/src/lib/pool.ts b/src/lib/pool.ts index 8c3f000..33d3f7f 100644 --- a/src/lib/pool.ts +++ b/src/lib/pool.ts @@ -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 index 0157602..0000000 --- a/src/lib/safe.ts +++ /dev/null @@ -1,181 +0,0 @@ -// SPDX-FileCopyrightText: 2025 Chris Duncan -// 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 - /** - * Encrypts data with a CryptoKey and stores it in the Safe. - */ - async put (name: string, key: CryptoKey, data: any): Promise - async put (name: string, passkey: string | CryptoKey, data: any): Promise { - 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 - /** - * Encrypts data with a CryptoKey and stores it in the Safe. - */ - async overwrite (name: string, key: CryptoKey, data: any): Promise - async overwrite (name: string, passkey: string | CryptoKey, data: any): Promise { - 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 - /** - * Retrieves data from the Safe and decrypts data with a CryptoKey. - */ - async get (name: string, key: CryptoKey): Promise - async get (name: string, passkey: string | CryptoKey): Promise { - 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 - } -} diff --git a/src/lib/wallets/bip44-wallet.ts b/src/lib/wallets/bip44-wallet.ts index 918a9c1..2f5d9aa 100644 --- a/src/lib/wallets/bip44-wallet.ts +++ b/src/lib/wallets/bip44-wallet.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Chris Duncan // SPDX-License-Identifier: GPL-3.0-or-later -import { KeyPair, Wallet } from '.' +import { KeyPair, Wallet } from './wallet' import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js' import { SEED_LENGTH_BIP44 } from '#src/lib/constants.js' import { Entropy } from '#src/lib/entropy.js' diff --git a/src/lib/wallets/blake2b-wallet.ts b/src/lib/wallets/blake2b-wallet.ts index 102435b..b5c7ac2 100644 --- a/src/lib/wallets/blake2b-wallet.ts +++ b/src/lib/wallets/blake2b-wallet.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Chris Duncan // SPDX-License-Identifier: GPL-3.0-or-later -import { KeyPair, Wallet } from '.' +import { KeyPair, Wallet } from './wallet' import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js' import { Blake2b } from '#src/lib/blake2b.js' import { SEED_LENGTH_BLAKE2B } from '#src/lib/constants.js' diff --git a/src/lib/wallets/index.ts b/src/lib/wallets/index.ts index ee3f931..574fc70 100644 --- a/src/lib/wallets/index.ts +++ b/src/lib/wallets/index.ts @@ -1,304 +1,6 @@ // SPDX-FileCopyrightText: 2025 Chris Duncan // SPDX-License-Identifier: GPL-3.0-or-later -import { Account, AccountList } from '#src/lib/account.js' -import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js' -import { ADDRESS_GAP } from '#src/lib/constants.js' -import { Entropy } from '#src/lib/entropy.js' -import { Pool } from '#src/lib/pool.js' -import { Rpc } from '#src/lib/rpc.js' -import { Safe } from '#src/lib/safe.js' -import { NanoNaClWorker } from '#workers' - export { Bip44Wallet } from './bip44-wallet' export { Blake2bWallet } from './blake2b-wallet' export { LedgerWallet } from './ledger-wallet' -export type KeyPair = { - publicKey?: string, - privateKey?: string, - index?: number -} - -/** -* Represents a wallet containing numerous Nano accounts derived from a single -* source, the form of which can vary based on the type of wallet. The Wallet -* class itself is abstract and cannot be directly instantiated. Currently, three -* types of wallets are supported, each as a derived class: Bip44Wallet, -* Blake2bWallet, LedgerWallet. -*/ -export abstract class Wallet { - #accounts: AccountList - #id: Entropy - #locked: boolean = true - #mnemonic: Bip39Mnemonic | null - #poolNanoNacl: Pool - #safe: Safe - #seed: string | null - get id () { return this.#id.hex } - get isLocked () { return this.#locked } - get isUnlocked () { return !this.#locked } - get mnemonic () { - if (this.#mnemonic instanceof Bip39Mnemonic) { - return this.#mnemonic.phrase - } - return '' - } - get seed () { - if (typeof this.#seed === 'string') { - return this.#seed - } - return '' - } - - abstract ckd (index: number[]): Promise - - constructor (id: Entropy, seed?: string, mnemonic?: Bip39Mnemonic) { - if (this.constructor === Wallet) { - throw new Error('Wallet is an abstract class and cannot be instantiated directly.') - } - this.#accounts = new AccountList() - this.#id = id - this.#mnemonic = mnemonic ?? null - this.#poolNanoNacl = new Pool(NanoNaClWorker) - this.#safe = new Safe() - this.#seed = seed ?? null - } - - /** - * Removes encrypted secrets in storage and releases variable references to - * allow garbage collection. - */ - destroy (): void { - let i = 0 - for (const a in this.#accounts) { - this.#accounts[a].destroy() - delete this.#accounts[a] - i++ - } - this.#safe.destroy(this.id) - this.#mnemonic = null - this.#seed = null - this.#poolNanoNacl.terminate() - } - - /** - * Retrieves an account from a wallet using its child key derivation function. - * Defaults to the first account at index 0. - * - * ``` - * console.log(await wallet.account(5)) - * // outputs sixth account of the wallet - * // { - * // privateKey: <...>, - * // index: 5 - * // } - * ``` - * - * @param {number} index - Wallet index of secret key. Default: 0 - * @returns {Account} Account derived at the specified wallet index - */ - async account (index: number = 0): Promise { - return (await this.accounts(index))[index] - } - - /** - * Retrieves accounts from a wallet using its child key derivation function. - * Defaults to the first account at index 0. - * - * The returned object will have keys corresponding with the requested range - * of account indexes. The value of each key will be the Account derived for - * that index in the wallet. - * - * ``` - * console.log(await wallet.accounts(5)) - * // outputs sixth account of the wallet - * // { - * // 5: { - * // privateKey: <...>, - * // index: 5 - * // } - * // } - * ``` - * - * @param {number} from - Start index of secret keys. Default: 0 - * @param {number} to - End index of secret keys. Default: `from` - * @returns {AccountList} Object with keys of account indexes and values of the corresponding Accounts - */ - async accounts (from: number = 0, to: number = from): Promise { - if (from > to) { - const swap = from - from = to - to = swap - } - const output = new AccountList() - const indexes: number[] = [] - for (let i = from; i <= to; i++) { - if (this.#accounts[i] == null) { - indexes.push(i) - } else { - output[i] = this.#accounts[i] - } - } - if (indexes.length > 0) { - let results = await this.ckd(indexes) - const data: any = [] - results.forEach(r => data.push({ - method: 'convert', - privateKey: r.privateKey, - index: r.index - })) - const keypairs: KeyPair[] = await this.#poolNanoNacl.assign(data) - for (const keypair of keypairs) { - if (keypair.privateKey == null) throw new RangeError('Account private key missing') - if (keypair.publicKey == null) throw new RangeError('Account public key missing') - if (keypair.index == null) throw new RangeError('Account keys derived but index missing') - const { privateKey, index } = keypair - output[keypair.index] = Account.fromPrivateKey(privateKey, index) - this.#accounts[keypair.index] = output[keypair.index] - } - } - return output - } - - /** - * Fetches the lowest-indexed unopened account from a wallet in sequential - * order. An account is unopened if it has no frontier block. - * - * @param {Rpc|string|URL} rpc - RPC node information required to refresh accounts, calculate PoW, and process blocks - * @param {number} batchSize - Number of accounts to fetch and check per RPC callout - * @param {number} from - Account index from which to start the search - * @returns {Promise} The lowest-indexed unopened account belonging to the wallet - */ - async getNextNewAccount (rpc: Rpc, batchSize: number = ADDRESS_GAP, from: number = 0): Promise { - if (!Number.isSafeInteger(batchSize) || batchSize < 1) { - throw new RangeError(`Invalid batch size ${batchSize}`) - } - const accounts = await this.accounts(from, from + batchSize - 1) - const addresses = [] - for (const a in accounts) { - addresses.push(accounts[a].address) - } - const data = { - "accounts": addresses - } - const { errors } = await rpc.call('accounts_frontiers', data) - for (const key of Object.keys(errors ?? {})) { - const value = errors[key] - if (value === 'Account not found') { - return Account.fromAddress(key) - } - } - return await this.getNextNewAccount(rpc, batchSize, from + batchSize) - } - - /** - * Refreshes wallet account balances, frontiers, and representatives from the - * current state on the network. - * - * A successful response will set these properties on each account. - * - * @param {Rpc|string|URL} rpc - RPC node information required to refresh accounts, calculate PoW, and process blocks - * @returns {Promise} Accounts with updated balances, frontiers, and representatives - */ - async refresh (rpc: Rpc | string | URL, from: number = 0, to: number = from): Promise { - if (typeof rpc === 'string' || rpc.constructor === URL) { - rpc = new Rpc(rpc) - } - if (rpc.constructor !== Rpc) { - throw new TypeError('RPC must be a valid node') - } - const accounts = await this.accounts(from, to) - for (const a in accounts) { - try { - await accounts[a].refresh(rpc) - } catch (err) { - delete accounts[a] - } - } - return accounts - } - - /** - * Locks the wallet with a password that will be needed to unlock it later. - * - * @param {string} password Used to lock the wallet - * @returns True if successfully locked - */ - async lock (password: string): Promise - /** - * Locks the wallet with a CryptoKey that will be needed to unlock it later. - * - * @param {CryptoKey} key Used to lock the wallet - * @returns True if successfully locked - */ - async lock (key: CryptoKey): Promise - async lock (passkey: string | CryptoKey): Promise { - let success = true - try { - const data: { id: string, mnemonic: string | null, seed: string | null } = { - id: this.id, - mnemonic: null, - seed: null - } - if (this.#mnemonic instanceof Bip39Mnemonic) { - data.mnemonic = this.#mnemonic.phrase - } - if (typeof this.#seed === 'string') { - data.seed = this.#seed - } - success &&= await this.#safe.put(this.id, passkey as string, data) - const promises = [] - for (const account of this.#accounts) { - promises.push(account.lock(passkey as string)) - } - await Promise.all(promises) - if (!success) { - throw null - } - } catch (err) { - throw new Error('Failed to lock wallet') - } - this.#locked = true - this.#mnemonic = null - this.#seed = null - return true - } - - /** - * Unlocks the wallet using the same password as used prior to lock it. - * - * @param {string} password Used previously to lock the wallet - * @returns True if successfully unlocked - */ - async unlock (password: string): Promise - /** - * Unlocks the wallet using the same CryptoKey as used prior to lock it. - * - * @param {CryptoKey} key Used previously to lock the wallet - * @returns True if successfully unlocked - */ - async unlock (key: CryptoKey): Promise - async unlock (passkey: string | CryptoKey): Promise { - try { - const { id, mnemonic, seed } = await this.#safe.get(this.id, passkey as string) - if (id !== this.id) { - throw null - } - const promises = [] - for (const account of this.#accounts) { - promises.push(account.unlock(passkey as string)) - } - await Promise.all(promises) - if (mnemonic != null) { - this.#mnemonic = await Bip39Mnemonic.fromPhrase(mnemonic) - } - if (seed != null) { - this.#seed = seed - } - this.#locked = false - } catch (err) { - throw new Error('Failed to unlock wallet') - } - return true - } -} diff --git a/src/lib/wallets/ledger-wallet.ts b/src/lib/wallets/ledger-wallet.ts index 2b7b7cd..a2129c4 100644 --- a/src/lib/wallets/ledger-wallet.ts +++ b/src/lib/wallets/ledger-wallet.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Chris Duncan // SPDX-License-Identifier: GPL-3.0-or-later -import { KeyPair, Wallet } from '.' +import { KeyPair, Wallet } from './wallet' import { Entropy } from '#src/lib/entropy.js' import { Ledger } from '#src/lib/ledger.js' diff --git a/src/lib/wallets/wallet.ts b/src/lib/wallets/wallet.ts new file mode 100644 index 0000000..3b54258 --- /dev/null +++ b/src/lib/wallets/wallet.ts @@ -0,0 +1,306 @@ +// SPDX-FileCopyrightText: 2025 Chris Duncan +// SPDX-License-Identifier: GPL-3.0-or-later + +import { Account, AccountList } from '#src/lib/account.js' +import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js' +import { ADDRESS_GAP } from '#src/lib/constants.js' +import { Entropy } from '#src/lib/entropy.js' +import { Pool } from '#src/lib/pool.js' +import { Rpc } from '#src/lib/rpc.js' +import { NanoNaClWorker, SafeWorker } from '#workers' +import { utf8 } from '../convert' + +export type KeyPair = { + publicKey?: string, + privateKey?: string, + index?: number +} + +/** +* Represents a wallet containing numerous Nano accounts derived from a single +* source, the form of which can vary based on the type of wallet. The Wallet +* class itself is abstract and cannot be directly instantiated. Currently, three +* types of wallets are supported, each as a derived class: Bip44Wallet, +* Blake2bWallet, LedgerWallet. +*/ +export abstract class Wallet { + #accounts: AccountList + #id: Entropy + #locked: boolean = true + #mnemonic: Bip39Mnemonic | null + #poolNanoNacl: Pool + #poolSafe: Pool + #seed: string | null + get id () { return this.#id.hex } + get isLocked () { return this.#locked } + get isUnlocked () { return !this.#locked } + get mnemonic () { + if (this.#mnemonic instanceof Bip39Mnemonic) { + return this.#mnemonic.phrase + } + return '' + } + get seed () { + if (typeof this.#seed === 'string') { + return this.#seed + } + return '' + } + + abstract ckd (index: number[]): Promise + + constructor (id: Entropy, seed?: string, mnemonic?: Bip39Mnemonic) { + if (this.constructor === Wallet) { + throw new Error('Wallet is an abstract class and cannot be instantiated directly.') + } + this.#accounts = new AccountList() + this.#id = id + this.#mnemonic = mnemonic ?? null + this.#poolNanoNacl = new Pool(NanoNaClWorker) + this.#poolSafe = new Pool(SafeWorker) + console.log(SafeWorker) + this.#seed = seed ?? null + } + + /** + * Removes encrypted secrets in storage and releases variable references to + * allow garbage collection. + */ + destroy (): void { + let i = 0 + for (const a in this.#accounts) { + this.#accounts[a].destroy() + delete this.#accounts[a] + i++ + } + this.#mnemonic = null + this.#seed = null + this.#poolNanoNacl.terminate() + this.#poolSafe.assign({ + method: 'destroy', + name: this.id + }).finally(this.#poolSafe.terminate) + } + + /** + * Retrieves an account from a wallet using its child key derivation function. + * Defaults to the first account at index 0. + * + * ``` + * console.log(await wallet.account(5)) + * // outputs sixth account of the wallet + * // { + * // privateKey: <...>, + * // index: 5 + * // } + * ``` + * + * @param {number} index - Wallet index of secret key. Default: 0 + * @returns {Account} Account derived at the specified wallet index + */ + async account (index: number = 0): Promise { + return (await this.accounts(index))[index] + } + + /** + * Retrieves accounts from a wallet using its child key derivation function. + * Defaults to the first account at index 0. + * + * The returned object will have keys corresponding with the requested range + * of account indexes. The value of each key will be the Account derived for + * that index in the wallet. + * + * ``` + * console.log(await wallet.accounts(5)) + * // outputs sixth account of the wallet + * // { + * // 5: { + * // privateKey: <...>, + * // index: 5 + * // } + * // } + * ``` + * + * @param {number} from - Start index of secret keys. Default: 0 + * @param {number} to - End index of secret keys. Default: `from` + * @returns {AccountList} Object with keys of account indexes and values of the corresponding Accounts + */ + async accounts (from: number = 0, to: number = from): Promise { + if (from > to) { + const swap = from + from = to + to = swap + } + const output = new AccountList() + const indexes: number[] = [] + for (let i = from; i <= to; i++) { + if (this.#accounts[i] == null) { + indexes.push(i) + } else { + output[i] = this.#accounts[i] + } + } + if (indexes.length > 0) { + let results = await this.ckd(indexes) + const data: any = [] + results.forEach(r => data.push({ + method: 'convert', + privateKey: r.privateKey, + index: r.index + })) + const keypairs: KeyPair[] = await this.#poolNanoNacl.assign(data) + for (const keypair of keypairs) { + if (keypair.privateKey == null) throw new RangeError('Account private key missing') + if (keypair.publicKey == null) throw new RangeError('Account public key missing') + if (keypair.index == null) throw new RangeError('Account keys derived but index missing') + const { privateKey, index } = keypair + output[keypair.index] = Account.fromPrivateKey(privateKey, index) + this.#accounts[keypair.index] = output[keypair.index] + } + } + return output + } + + /** + * Fetches the lowest-indexed unopened account from a wallet in sequential + * order. An account is unopened if it has no frontier block. + * + * @param {Rpc|string|URL} rpc - RPC node information required to refresh accounts, calculate PoW, and process blocks + * @param {number} batchSize - Number of accounts to fetch and check per RPC callout + * @param {number} from - Account index from which to start the search + * @returns {Promise} The lowest-indexed unopened account belonging to the wallet + */ + async getNextNewAccount (rpc: Rpc, batchSize: number = ADDRESS_GAP, from: number = 0): Promise { + if (!Number.isSafeInteger(batchSize) || batchSize < 1) { + throw new RangeError(`Invalid batch size ${batchSize}`) + } + const accounts = await this.accounts(from, from + batchSize - 1) + const addresses = [] + for (const a in accounts) { + addresses.push(accounts[a].address) + } + const data = { + "accounts": addresses + } + const { errors } = await rpc.call('accounts_frontiers', data) + for (const key of Object.keys(errors ?? {})) { + const value = errors[key] + if (value === 'Account not found') { + return Account.fromAddress(key) + } + } + return await this.getNextNewAccount(rpc, batchSize, from + batchSize) + } + + /** + * Refreshes wallet account balances, frontiers, and representatives from the + * current state on the network. + * + * A successful response will set these properties on each account. + * + * @param {Rpc|string|URL} rpc - RPC node information required to refresh accounts, calculate PoW, and process blocks + * @returns {Promise} Accounts with updated balances, frontiers, and representatives + */ + async refresh (rpc: Rpc | string | URL, from: number = 0, to: number = from): Promise { + if (typeof rpc === 'string' || rpc.constructor === URL) { + rpc = new Rpc(rpc) + } + if (rpc.constructor !== Rpc) { + throw new TypeError('RPC must be a valid node') + } + const accounts = await this.accounts(from, to) + for (const a in accounts) { + try { + await accounts[a].refresh(rpc) + } catch (err) { + delete accounts[a] + } + } + return accounts + } + + /** + * Locks the wallet with a password that will be needed to unlock it later. + * + * @param {(string|Uint8Array)} password Used to lock the wallet + * @returns True if successfully locked + */ + async lock (password: string | Uint8Array): Promise { + if (typeof password === 'string') { + password = utf8.toBytes(password) + } + let success = true + try { + const data: { id: string, mnemonic: string | null, seed: string | null } = { + id: this.id, + mnemonic: null, + seed: null + } + if (this.#mnemonic instanceof Bip39Mnemonic) { + data.mnemonic = this.#mnemonic.phrase + } + if (typeof this.#seed === 'string') { + data.seed = this.#seed + } + success &&= await this.#poolSafe.assign({ + method: 'put', + name: this.id, + password, + data + }) + const promises = [] + for (const account of this.#accounts) { + promises.push(account.lock(password)) + } + await Promise.all(promises) + password.fill(0) + if (!success) { + throw null + } + } catch (err) { + throw new Error('Failed to lock wallet') + } + this.#locked = true + this.#mnemonic = null + this.#seed = null + return true + } + + /** + * Unlocks the wallet using the same password as used prior to lock it. + * + * @param {(string|Uint8Array)} password Used previously to lock the wallet + * @returns True if successfully unlocked + */ + async unlock (password: string | Uint8Array): Promise { + if (typeof password === 'string') { + password = utf8.toBytes(password) + } + try { + const { id, mnemonic, seed } = await this.#poolSafe.assign({ + method: 'get', + name: this.id, + password + }) + if (id !== this.id) { + throw null + } + const promises = [] + for (const account of this.#accounts) { + promises.push(account.unlock(password)) + } + await Promise.all(promises) + password.fill(0) + if (mnemonic != null) { + this.#mnemonic = await Bip39Mnemonic.fromPhrase(mnemonic) + } + if (seed != null) { + this.#seed = seed + } + this.#locked = false + } catch (err) { + throw new Error('Failed to unlock wallet') + } + return true + } +} diff --git a/src/lib/workers/index.ts b/src/lib/workers/index.ts index c1defde..b9bd85e 100644 --- a/src/lib/workers/index.ts +++ b/src/lib/workers/index.ts @@ -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 index 0000000..b851917 --- /dev/null +++ b/src/lib/workers/safe.ts @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: 2025 Chris Duncan +// 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 { + return new Promise(async (resolve, reject): Promise => { + 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 { + 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 { + 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 { + 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} +` diff --git a/src/main.ts b/src/main.ts index d77e61d..d80e88b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 }