From: Chris Duncan Date: Thu, 31 Jul 2025 21:02:03 +0000 (-0700) Subject: Begin moving to single wallet implementation. X-Git-Tag: v0.10.5~47^2~39 X-Git-Url: https://git.codecow.com/?a=commitdiff_plain;h=d29472e68637131a6a7ed2b77b328ff28bbc13ae;p=libnemo.git Begin moving to single wallet implementation. --- diff --git a/src/lib/safe/safe.ts b/src/lib/safe/safe.ts index 3e1eea9..4cb6468 100644 --- a/src/lib/safe/safe.ts +++ b/src/lib/safe/safe.ts @@ -48,7 +48,7 @@ export class Safe { NODE: process.exit() } case 'create': { - result = await this.create(type, key) + result = await this.create(type, key, keySalt, mnemonicSalt) break } case 'derive': { @@ -56,7 +56,7 @@ export class Safe { break } case 'import': { - result = await this.import(type, key, mnemonicPhrase ?? seed, mnemonicSalt) + result = await this.import(type, key, keySalt, mnemonicPhrase ?? seed, mnemonicSalt) break } case 'lock': { @@ -102,11 +102,11 @@ export class Safe { * Generates a new mnemonic and seed and then returns the initialization vector * vector, salt, and encrypted data representing the wallet in a locked state. */ - static async create (type?: 'BIP-44' | 'BLAKE2b', key?: CryptoKey, mnemonicSalt?: string): Promise> { + static async create (type?: 'BIP-44' | 'BLAKE2b', key?: CryptoKey, keySalt?: ArrayBuffer, mnemonicSalt?: string): Promise> { try { const entropy = crypto.getRandomValues(new Uint8Array(32)) const mnemonicPhrase = (await Bip39Mnemonic.fromEntropy(entropy)).phrase - return await this.import(type, key, mnemonicPhrase, mnemonicSalt) + return await this.import(type, key, keySalt, mnemonicPhrase, mnemonicSalt) } catch (err) { throw new Error('Failed to unlock wallet', { cause: err }) } @@ -117,7 +117,7 @@ export class Safe { * wallet seed at a specified index and then returns the public key. The wallet * must be unlocked prior to derivation. */ - static async derive (index?: number): Promise> { + static async derive (index?: number): Promise> { try { if (this.#locked) { throw new Error('Wallet is locked') @@ -135,7 +135,7 @@ export class Safe { ? await Bip44Ckd.nanoCKD(this.#seed, index) : await Blake2bCkd.ckd(this.#seed, index) const pub = await NanoNaCl.convert(new Uint8Array(prv)) - return { publicKey: pub.buffer } + return { index, publicKey: pub.buffer } } catch (err) { throw new Error('Failed to derive account', { cause: err }) } @@ -145,24 +145,24 @@ export class Safe { * Encrypts an existing seed or mnemonic+salt and returns the initialization * vector, salt, and encrypted data representing the wallet in a locked state. */ - static async import (type?: 'BIP-44' | 'BLAKE2b', key?: CryptoKey, secret?: string | ArrayBuffer, salt?: string): Promise> { + static async import (type?: 'BIP-44' | 'BLAKE2b', key?: CryptoKey, keySalt?: ArrayBuffer, secret?: string | ArrayBuffer, mnemonicSalt?: string): Promise> { try { if (!this.#locked) { throw new Error('Wallet is in use') } + if (key == null || keySalt == null) { + throw new Error('Wallet password is required') + } if (type == null) { throw new TypeError('Wallet type is required') } if (type !== 'BIP-44' && type !== 'BLAKE2b') { throw new TypeError('Invalid wallet type') } - if (key == null) { - throw new TypeError('Wallet password is required') - } if (secret == null) { throw new TypeError('Seed or mnemonic is required') } - if (typeof secret !== 'string' && salt !== undefined) { + if (typeof secret !== 'string' && mnemonicSalt !== undefined) { throw new TypeError('Mnemonic must be a string') } if (type === 'BIP-44') { @@ -181,10 +181,11 @@ export class Safe { } else { this.#mnemonic = await Bip39Mnemonic.fromPhrase(secret) this.#seed = type === 'BIP-44' - ? (await this.#mnemonic.toBip39Seed(salt ?? '')).buffer + ? (await this.#mnemonic.toBip39Seed(mnemonicSalt ?? '')).buffer : (await this.#mnemonic.toBlake2bSeed()).buffer } - return await this.#encryptWallet(key) + const { iv, encrypted } = await this.#encryptWallet(key) + return { iv, salt: keySalt, encrypted } } catch (err) { this.lock() throw new Error('Failed to import wallet', { cause: err }) @@ -292,14 +293,14 @@ export class Safe { } } - static async #createAesKey (purpose: 'encrypt' | 'decrypt', password: ArrayBuffer, salt: ArrayBuffer): Promise { + static async #createAesKey (purpose: 'encrypt' | 'decrypt', password: ArrayBuffer, keySalt: ArrayBuffer): Promise { const derivationKey = await crypto.subtle.importKey('raw', password, 'PBKDF2', false, ['deriveBits', 'deriveKey']) new Uint8Array(password).fill(0).buffer.transfer() const derivationAlgorithm: Pbkdf2Params = { name: 'PBKDF2', hash: 'SHA-512', iterations: 210000, - salt + salt: keySalt } const derivedKeyType: AesKeyGenParams = { name: 'AES-GCM', diff --git a/src/lib/wallets/bip44-wallet.ts b/src/lib/wallets/bip44-wallet.ts index cc02eff..a37b608 100644 --- a/src/lib/wallets/bip44-wallet.ts +++ b/src/lib/wallets/bip44-wallet.ts @@ -39,7 +39,7 @@ export class Bip44Wallet extends Wallet { throw new Error(`Bip44Wallet cannot be instantiated directly. Use 'await Bip44Wallet.create()' instead.`) } Bip44Wallet.#isInternal = false - super(id, 'BIP-44', seed, mnemonic) + super(id.hex, 'BIP-44') } /** @@ -169,31 +169,4 @@ export class Bip44Wallet extends Wallet { Bip44Wallet.#isInternal = true return new this(await Entropy.import(id)) } - - /** - * Derives BIP-44 Nano account private keys. - * - * @param {number[]} indexes - Indexes of the accounts - * @returns {Promise} - */ - async ckd (indexes: number[]): Promise { - if (this.isLocked) { - throw new Error('wallet must be unlocked to derive accounts') - } - const results = await SafeWorker.request({ - action: 'derive', - type: 'BIP-44', - indexes, - seed: hex.toBuffer(this.seed) - }) - const privateKeys: KeyPair[] = [] - for (const i of Object.keys(results)) { - if (results[i] == null || !(results[i] instanceof ArrayBuffer)) { - throw new Error('Failed to derive private keys') - } - const privateKey = new Uint8Array(results[i]) - privateKeys.push({ index: +i, privateKey }) - } - return privateKeys - } } diff --git a/src/lib/wallets/blake2b-wallet.ts b/src/lib/wallets/blake2b-wallet.ts index ed7fe13..57279c6 100644 --- a/src/lib/wallets/blake2b-wallet.ts +++ b/src/lib/wallets/blake2b-wallet.ts @@ -33,7 +33,7 @@ export class Blake2bWallet extends Wallet { throw new Error(`Blake2bWallet cannot be instantiated directly. Use 'await Blake2bWallet.create()' instead.`) } Blake2bWallet.#isInternal = false - super(id, 'BLAKE2b', seed, mnemonic) + super(id.hex, 'BLAKE2b') } /** @@ -132,25 +132,4 @@ export class Blake2bWallet extends Wallet { Blake2bWallet.#isInternal = true return new this(await Entropy.import(id)) } - - /** - * Derives BLAKE2b account private keys. - * - * @param {number[]} indexes - Indexes of the accounts - * @returns {Promise} - */ - async ckd (indexes: number[]): Promise { - if (this.isLocked) { - throw new Error('wallet must be unlocked to derive accounts') - } - const results = [] - for (const index of indexes) { - const indexHex = index.toString(16).padStart(8, '0').toUpperCase() - const inputHex = `${this.seed}${indexHex}`.padStart(72, '0') - const inputBytes = hex.toBytes(inputHex) - const privateKey = new Blake2b(32).update(inputBytes).digest() - results.push({ index, privateKey }) - } - return results - } } diff --git a/src/lib/wallets/ledger-wallet.ts b/src/lib/wallets/ledger-wallet.ts index 7076408..46d62a9 100644 --- a/src/lib/wallets/ledger-wallet.ts +++ b/src/lib/wallets/ledger-wallet.ts @@ -78,7 +78,7 @@ export class LedgerWallet extends Wallet { throw new Error(`LedgerWallet cannot be instantiated directly. Use 'await LedgerWallet.create()' instead.`) } LedgerWallet.#isInternal = false - super(id, 'Ledger') + super(id.hex, 'Ledger') } /** diff --git a/src/lib/wallets/wallet.ts b/src/lib/wallets/wallet.ts index 725ccff..0156598 100644 --- a/src/lib/wallets/wallet.ts +++ b/src/lib/wallets/wallet.ts @@ -19,41 +19,52 @@ import { SafeWorker } from '#workers' * types of wallets are supported, each as a derived class: Bip44Wallet, * Blake2bWallet, LedgerWallet. */ -export abstract class Wallet { - abstract ckd (index: number[]): Promise - +export class Wallet { static #DB_NAME = 'Wallet' + /** + * Creates a new HD wallet by using an entropy value generated using a + * cryptographically strong pseudorandom number generator. + * + * @param {string} password - Encrypts the wallet to lock and unlock it + * @param {string} [salt=''] - Used when generating the final seed + * @returns {Bip44Wallet} A newly instantiated Wallet + */ + static async create (name: string, type: 'BIP-44' | 'BLAKE2b', password: string, mnemonicSalt?: string): Promise { + try { + const { iv, salt, encrypted } = await SafeWorker.request({ + action: 'create', + type, + password: utf8.toBuffer(password), + mnemonicSalt: mnemonicSalt ?? '' + }) + const encoded = JSON.stringify({ + type, + iv: bytes.toHex(new Uint8Array(iv)), + salt: bytes.toHex(new Uint8Array(salt)), + encrypted: bytes.toHex(new Uint8Array(encrypted)) + }) + await Database.put({ [name]: encoded }, this.#DB_NAME) + return new this(name, type) + } catch (err) { + throw new Error('Error creating new Bip44Wallet', { cause: err }) + } + } + #accounts: AccountList - #id: Entropy - #locked: boolean #lockTimer?: any - #m?: Bip39Mnemonic - #s?: Uint8Array + #name: string #type: WalletType - get id () { return `${this.type}_${this.#id.hex}` } - get isLocked () { return this.#locked } - get isUnlocked () { return !this.#locked } - get mnemonic () { - if (this.#locked || this.#m == null) throw new Error('failed to get mnemonic', { cause: 'wallet locked' }) - return this.#m.phrase - } - get seed () { - if (this.#locked || this.#s == null) throw new Error('failed to get seed', { cause: 'wallet locked' }) - return bytes.toHex(this.#s) - } + get name () { return `${this.type}_${this.#name}` } get type () { return this.#type } - constructor (id: Entropy, type: WalletType, seed?: Uint8Array, mnemonic?: Bip39Mnemonic) { + constructor (name: string, type: WalletType) { 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.#locked = false - this.#m = mnemonic - this.#s = seed + this.#name = name this.#type = type } @@ -116,27 +127,18 @@ export abstract class Wallet { } } if (indexes.length > 0) { - const keypairs = await this.ckd(indexes) - const privateKeys: KeyPair[] = [] - const publicKeys: KeyPair[] = [] - for (const keypair of keypairs) { - const { index, privateKey, publicKey } = keypair - if (index == null) { - throw new RangeError('Account keys derived but index missing') - } - if (privateKey != null) { - privateKeys.push(keypair) - } else if (publicKey != null) { - publicKeys.push(keypair) - } + const promises = [] + for (const index of indexes) { + promises.push(SafeWorker.request({ + action: 'derive', + index + })) } - const privateAccounts = privateKeys.length > 0 - ? await Account.import(privateKeys, this.seed) - : [] + const publicKeys = await Promise.all(promises) const publicAccounts = publicKeys.length > 0 ? Account.import(publicKeys) : [] - const accounts = [...privateAccounts, ...publicAccounts] + const accounts = [...publicAccounts] for (const a of accounts) { if (a.index == null) { throw new RangeError('Index missing for Account') @@ -153,15 +155,7 @@ export abstract class Wallet { */ async destroy (): Promise { try { - this.#m?.destroy() - bytes.erase(this.#s) - this.#m = undefined - this.#s = undefined - for (const a in this.#accounts) { - this.#accounts[a].destroy() - delete this.#accounts[a] - } - await Database.delete(this.id, Wallet.#DB_NAME) + await Database.delete(this.name, Wallet.#DB_NAME) } catch (err) { console.error(err) throw new Error('failed to destroy wallet', { cause: err }) @@ -179,10 +173,13 @@ export abstract class Wallet { const { isLocked } = await SafeWorker.request({ action: 'lock' }) - this.#locked = isLocked - return this.#locked + if (!isLocked) { + throw new Error('Lock request to Safe failed') + } + clearTimeout(this.#lockTimer) + return isLocked } catch (err) { - throw new Error('failed to lock wallet', { cause: err }) + throw new Error('Failed to lock wallet', { cause: err }) } } @@ -223,8 +220,6 @@ export abstract class Wallet { * @returns {Promise} Hexadecimal-formatted 64-byte signature */ async sign (index: number, block: ChangeBlock | ReceiveBlock | SendBlock): Promise { - if (this.#locked) throw new Error('Wallet must be unlocked to sign') - if (this.#s == null) throw new Error('Wallet seed not found') try { const { signature } = await SafeWorker.request({ action: 'sign', @@ -233,6 +228,8 @@ export abstract class Wallet { }) const sig = bytes.toHex(new Uint8Array(signature)) block.signature = sig + clearTimeout(this.#lockTimer) + this.#lockTimer = setTimeout(() => this.lock(), 120) return sig } catch (err) { throw new Error(`Failed to sign block`, { cause: err }) @@ -245,26 +242,26 @@ export abstract class Wallet { * @param {string} password Used previously to lock the wallet * @returns True if successfully unlocked */ - async unlock (password: string, iv: ArrayBuffer, salt: ArrayBuffer): Promise { + async unlock (password: string): Promise { try { - if (typeof password !== 'string') { - throw new TypeError('Invalid password') - } - const unlockRequest = SafeWorker.request({ + const record = await Database.get(this.#name, Wallet.#DB_NAME) + const decoded = JSON.parse(record[this.#name]) + const iv: ArrayBuffer = hex.toBuffer(decoded.iv) + const salt: ArrayBuffer = hex.toBuffer(decoded.salt) + const encrypted: ArrayBuffer = hex.toBuffer(decoded.encrypted) + const { isUnlocked } = await SafeWorker.request({ action: 'unlock', password: utf8.toBuffer(password), iv, - salt + salt, + encrypted }) - password = '' - const { isUnlocked } = await unlockRequest - if (isUnlocked) { - this.#lockTimer = setTimeout(this.lock, 120) - } else { - throw new Error('Request to wallet worker failed') + if (!isUnlocked) { + throw new Error('Unlock request to Safe failed') } - this.#locked = isUnlocked - return true + clearTimeout(this.#lockTimer) + this.#lockTimer = setTimeout(() => this.lock(), 120) + return isUnlocked } catch (err) { throw new Error('Failed to unlock wallet', { cause: err }) } diff --git a/src/main.ts b/src/main.ts index 7d4fb46..426665a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,7 +7,7 @@ import { ChangeBlock, ReceiveBlock, SendBlock } from './lib/block' import { Rolodex } from './lib/rolodex' import { Rpc } from './lib/rpc' import { Tools } from './lib/tools' -import { Bip44Wallet, Blake2bWallet, LedgerWallet } from './lib/wallets' +import { Wallet, Bip44Wallet, Blake2bWallet, LedgerWallet } from './lib/wallets' export { Account, @@ -16,5 +16,5 @@ export { Rolodex, Rpc, Tools, - Bip44Wallet, Blake2bWallet, LedgerWallet + Wallet, Bip44Wallet, Blake2bWallet, LedgerWallet } diff --git a/test/test.create-wallet.mjs b/test/test.create-wallet.mjs index 6a02378..f3365a6 100644 --- a/test/test.create-wallet.mjs +++ b/test/test.create-wallet.mjs @@ -14,17 +14,21 @@ let Bip44Wallet * @type {typeof import('../dist/types.d.ts').Blake2bWallet} */ let Blake2bWallet +/** +* @type {typeof import('../dist/types.d.ts').Wallet} +*/ +let Wallet if (isNode) { - ({ Bip44Wallet, Blake2bWallet } = await import('../dist/nodejs.min.js')) + ({ Bip44Wallet, Blake2bWallet, Wallet } = await import('../dist/nodejs.min.js')) } else { - ({ Bip44Wallet, Blake2bWallet } = await import('../dist/browser.min.js')) + ({ Bip44Wallet, Blake2bWallet, Wallet } = await import('../dist/browser.min.js')) } await Promise.all([ suite('Create wallets', async () => { await test('destroy BIP-44 wallet before unlocking', async () => { - const wallet = await Bip44Wallet.create(NANO_TEST_VECTORS.PASSWORD) + const wallet = await Wallet.create('BIP-44', NANO_TEST_VECTORS.PASSWORD) await assert.resolves(wallet.destroy()) assert.ok('mnemonic' in wallet)