]> git.codecow.com Git - libnemo.git/commitdiff
Begin moving to single wallet implementation.
authorChris Duncan <chris@zoso.dev>
Thu, 31 Jul 2025 21:02:03 +0000 (14:02 -0700)
committerChris Duncan <chris@zoso.dev>
Thu, 31 Jul 2025 21:02:03 +0000 (14:02 -0700)
src/lib/safe/safe.ts
src/lib/wallets/bip44-wallet.ts
src/lib/wallets/blake2b-wallet.ts
src/lib/wallets/ledger-wallet.ts
src/lib/wallets/wallet.ts
src/main.ts
test/test.create-wallet.mjs

index 3e1eea9216dbcd66c7219808ee74cc115eb96672..4cb646867d2cf65405202f9601e0b09f58b1bd9f 100644 (file)
@@ -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<NamedData<ArrayBuffer>> {
+       static async create (type?: 'BIP-44' | 'BLAKE2b', key?: CryptoKey, keySalt?: ArrayBuffer, mnemonicSalt?: string): Promise<NamedData<ArrayBuffer>> {
                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<NamedData<ArrayBuffer>> {
+       static async derive (index?: number): Promise<NamedData<number | ArrayBuffer>> {
                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<NamedData<ArrayBuffer>> {
+       static async import (type?: 'BIP-44' | 'BLAKE2b', key?: CryptoKey, keySalt?: ArrayBuffer, secret?: string | ArrayBuffer, mnemonicSalt?: string): Promise<NamedData<ArrayBuffer>> {
                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<CryptoKey> {
+       static async #createAesKey (purpose: 'encrypt' | 'decrypt', password: ArrayBuffer, keySalt: ArrayBuffer): Promise<CryptoKey> {
                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',
index cc02eff333681914bb9cda3f06eba8a611dbf6c1..a37b6082b918d32524639d9c635511ce363aa89a 100644 (file)
@@ -39,7 +39,7 @@ export class Bip44Wallet extends Wallet {
                        throw new Error(`Bip44Wallet cannot be instantiated directly. Use 'await Bip44Wallet.create()' instead.`)\r
                }\r
                Bip44Wallet.#isInternal = false\r
-               super(id, 'BIP-44', seed, mnemonic)\r
+               super(id.hex, 'BIP-44')\r
        }\r
 \r
        /**\r
@@ -169,31 +169,4 @@ export class Bip44Wallet extends Wallet {
                Bip44Wallet.#isInternal = true\r
                return new this(await Entropy.import(id))\r
        }\r
-\r
-       /**\r
-       * Derives BIP-44 Nano account private keys.\r
-       *\r
-       * @param {number[]} indexes - Indexes of the accounts\r
-       * @returns {Promise<Account>}\r
-       */\r
-       async ckd (indexes: number[]): Promise<KeyPair[]> {\r
-               if (this.isLocked) {\r
-                       throw new Error('wallet must be unlocked to derive accounts')\r
-               }\r
-               const results = await SafeWorker.request({\r
-                       action: 'derive',\r
-                       type: 'BIP-44',\r
-                       indexes,\r
-                       seed: hex.toBuffer(this.seed)\r
-               })\r
-               const privateKeys: KeyPair[] = []\r
-               for (const i of Object.keys(results)) {\r
-                       if (results[i] == null || !(results[i] instanceof ArrayBuffer)) {\r
-                               throw new Error('Failed to derive private keys')\r
-                       }\r
-                       const privateKey = new Uint8Array(results[i])\r
-                       privateKeys.push({ index: +i, privateKey })\r
-               }\r
-               return privateKeys\r
-       }\r
 }\r
index ed7fe133281e4eb6189ff2f7d204893d64db6498..57279c68473f886b6dfdecd10101fbdf078c5a53 100644 (file)
@@ -33,7 +33,7 @@ export class Blake2bWallet extends Wallet {
                        throw new Error(`Blake2bWallet cannot be instantiated directly. Use 'await Blake2bWallet.create()' instead.`)\r
                }\r
                Blake2bWallet.#isInternal = false\r
-               super(id, 'BLAKE2b', seed, mnemonic)\r
+               super(id.hex, 'BLAKE2b')\r
        }\r
 \r
        /**\r
@@ -132,25 +132,4 @@ export class Blake2bWallet extends Wallet {
                Blake2bWallet.#isInternal = true\r
                return new this(await Entropy.import(id))\r
        }\r
-\r
-       /**\r
-       * Derives BLAKE2b account private keys.\r
-       *\r
-       * @param {number[]} indexes - Indexes of the accounts\r
-       * @returns {Promise<Account>}\r
-       */\r
-       async ckd (indexes: number[]): Promise<KeyPair[]> {\r
-               if (this.isLocked) {\r
-                       throw new Error('wallet must be unlocked to derive accounts')\r
-               }\r
-               const results = []\r
-               for (const index of indexes) {\r
-                       const indexHex = index.toString(16).padStart(8, '0').toUpperCase()\r
-                       const inputHex = `${this.seed}${indexHex}`.padStart(72, '0')\r
-                       const inputBytes = hex.toBytes(inputHex)\r
-                       const privateKey = new Blake2b(32).update(inputBytes).digest()\r
-                       results.push({ index, privateKey })\r
-               }\r
-               return results\r
-       }\r
 }\r
index 70764080c322651f677f939d2829751c6a5d46ac..46d62a9a140f7c935b2c887bf23d6b1b75416e7d 100644 (file)
@@ -78,7 +78,7 @@ export class LedgerWallet extends Wallet {
                        throw new Error(`LedgerWallet cannot be instantiated directly. Use 'await LedgerWallet.create()' instead.`)\r
                }\r
                LedgerWallet.#isInternal = false\r
-               super(id, 'Ledger')\r
+               super(id.hex, 'Ledger')\r
        }\r
 \r
        /**\r
index 725ccff4780645984c393d49e95d19997661d4c2..0156598382e67f3b9775d1f969192ad13590822b 100644 (file)
@@ -19,41 +19,52 @@ import { SafeWorker } from '#workers'
 * types of wallets are supported, each as a derived class: Bip44Wallet,\r
 * Blake2bWallet, LedgerWallet.\r
 */\r
-export abstract class Wallet {\r
-       abstract ckd (index: number[]): Promise<KeyPair[]>\r
-\r
+export class Wallet {\r
        static #DB_NAME = 'Wallet'\r
 \r
+       /**\r
+       * Creates a new HD wallet by using an entropy value generated using a\r
+       * cryptographically strong pseudorandom number generator.\r
+       *\r
+       * @param {string} password - Encrypts the wallet to lock and unlock it\r
+       * @param {string} [salt=''] - Used when generating the final seed\r
+       * @returns {Bip44Wallet} A newly instantiated Wallet\r
+       */\r
+       static async create (name: string, type: 'BIP-44' | 'BLAKE2b', password: string, mnemonicSalt?: string): Promise<Wallet> {\r
+               try {\r
+                       const { iv, salt, encrypted } = await SafeWorker.request<ArrayBuffer>({\r
+                               action: 'create',\r
+                               type,\r
+                               password: utf8.toBuffer(password),\r
+                               mnemonicSalt: mnemonicSalt ?? ''\r
+                       })\r
+                       const encoded = JSON.stringify({\r
+                               type,\r
+                               iv: bytes.toHex(new Uint8Array(iv)),\r
+                               salt: bytes.toHex(new Uint8Array(salt)),\r
+                               encrypted: bytes.toHex(new Uint8Array(encrypted))\r
+                       })\r
+                       await Database.put({ [name]: encoded }, this.#DB_NAME)\r
+                       return new this(name, type)\r
+               } catch (err) {\r
+                       throw new Error('Error creating new Bip44Wallet', { cause: err })\r
+               }\r
+       }\r
+\r
        #accounts: AccountList\r
-       #id: Entropy\r
-       #locked: boolean\r
        #lockTimer?: any\r
-       #m?: Bip39Mnemonic\r
-       #s?: Uint8Array<ArrayBuffer>\r
+       #name: string\r
        #type: WalletType\r
 \r
-       get id () { return `${this.type}_${this.#id.hex}` }\r
-       get isLocked () { return this.#locked }\r
-       get isUnlocked () { return !this.#locked }\r
-       get mnemonic () {\r
-               if (this.#locked || this.#m == null) throw new Error('failed to get mnemonic', { cause: 'wallet locked' })\r
-               return this.#m.phrase\r
-       }\r
-       get seed () {\r
-               if (this.#locked || this.#s == null) throw new Error('failed to get seed', { cause: 'wallet locked' })\r
-               return bytes.toHex(this.#s)\r
-       }\r
+       get name () { return `${this.type}_${this.#name}` }\r
        get type () { return this.#type }\r
 \r
-       constructor (id: Entropy, type: WalletType, seed?: Uint8Array<ArrayBuffer>, mnemonic?: Bip39Mnemonic) {\r
+       constructor (name: string, type: WalletType) {\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.#locked = false\r
-               this.#m = mnemonic\r
-               this.#s = seed\r
+               this.#name = name\r
                this.#type = type\r
        }\r
 \r
@@ -116,27 +127,18 @@ export abstract class Wallet {
                        }\r
                }\r
                if (indexes.length > 0) {\r
-                       const keypairs = await this.ckd(indexes)\r
-                       const privateKeys: KeyPair[] = []\r
-                       const publicKeys: KeyPair[] = []\r
-                       for (const keypair of keypairs) {\r
-                               const { index, privateKey, publicKey } = keypair\r
-                               if (index == null) {\r
-                                       throw new RangeError('Account keys derived but index missing')\r
-                               }\r
-                               if (privateKey != null) {\r
-                                       privateKeys.push(keypair)\r
-                               } else if (publicKey != null) {\r
-                                       publicKeys.push(keypair)\r
-                               }\r
+                       const promises = []\r
+                       for (const index of indexes) {\r
+                               promises.push(SafeWorker.request<ArrayBuffer>({\r
+                                       action: 'derive',\r
+                                       index\r
+                               }))\r
                        }\r
-                       const privateAccounts = privateKeys.length > 0\r
-                               ? await Account.import(privateKeys, this.seed)\r
-                               : []\r
+                       const publicKeys = await Promise.all(promises)\r
                        const publicAccounts = publicKeys.length > 0\r
                                ? Account.import(publicKeys)\r
                                : []\r
-                       const accounts = [...privateAccounts, ...publicAccounts]\r
+                       const accounts = [...publicAccounts]\r
                        for (const a of accounts) {\r
                                if (a.index == null) {\r
                                        throw new RangeError('Index missing for Account')\r
@@ -153,15 +155,7 @@ export abstract class Wallet {
        */\r
        async destroy (): Promise<void> {\r
                try {\r
-                       this.#m?.destroy()\r
-                       bytes.erase(this.#s)\r
-                       this.#m = undefined\r
-                       this.#s = undefined\r
-                       for (const a in this.#accounts) {\r
-                               this.#accounts[a].destroy()\r
-                               delete this.#accounts[a]\r
-                       }\r
-                       await Database.delete(this.id, Wallet.#DB_NAME)\r
+                       await Database.delete(this.name, Wallet.#DB_NAME)\r
                } catch (err) {\r
                        console.error(err)\r
                        throw new Error('failed to destroy wallet', { cause: err })\r
@@ -179,10 +173,13 @@ export abstract class Wallet {
                        const { isLocked } = await SafeWorker.request<boolean>({\r
                                action: 'lock'\r
                        })\r
-                       this.#locked = isLocked\r
-                       return this.#locked\r
+                       if (!isLocked) {\r
+                               throw new Error('Lock request to Safe failed')\r
+                       }\r
+                       clearTimeout(this.#lockTimer)\r
+                       return isLocked\r
                } catch (err) {\r
-                       throw new Error('failed to lock wallet', { cause: err })\r
+                       throw new Error('Failed to lock wallet', { cause: err })\r
                }\r
        }\r
 \r
@@ -223,8 +220,6 @@ export abstract class Wallet {
        * @returns {Promise<string>} Hexadecimal-formatted 64-byte signature\r
        */\r
        async sign (index: number, block: ChangeBlock | ReceiveBlock | SendBlock): Promise<string> {\r
-               if (this.#locked) throw new Error('Wallet must be unlocked to sign')\r
-               if (this.#s == null) throw new Error('Wallet seed not found')\r
                try {\r
                        const { signature } = await SafeWorker.request<ArrayBuffer>({\r
                                action: 'sign',\r
@@ -233,6 +228,8 @@ export abstract class Wallet {
                        })\r
                        const sig = bytes.toHex(new Uint8Array(signature))\r
                        block.signature = sig\r
+                       clearTimeout(this.#lockTimer)\r
+                       this.#lockTimer = setTimeout(() => this.lock(), 120)\r
                        return sig\r
                } catch (err) {\r
                        throw new Error(`Failed to sign block`, { cause: err })\r
@@ -245,26 +242,26 @@ export abstract class Wallet {
        * @param {string} password Used previously to lock the wallet\r
        * @returns True if successfully unlocked\r
        */\r
-       async unlock (password: string, iv: ArrayBuffer, salt: ArrayBuffer): Promise<boolean> {\r
+       async unlock (password: string): Promise<boolean> {\r
                try {\r
-                       if (typeof password !== 'string') {\r
-                               throw new TypeError('Invalid password')\r
-                       }\r
-                       const unlockRequest = SafeWorker.request<boolean>({\r
+                       const record = await Database.get<string>(this.#name, Wallet.#DB_NAME)\r
+                       const decoded = JSON.parse(record[this.#name])\r
+                       const iv: ArrayBuffer = hex.toBuffer(decoded.iv)\r
+                       const salt: ArrayBuffer = hex.toBuffer(decoded.salt)\r
+                       const encrypted: ArrayBuffer = hex.toBuffer(decoded.encrypted)\r
+                       const { isUnlocked } = await SafeWorker.request<boolean>({\r
                                action: 'unlock',\r
                                password: utf8.toBuffer(password),\r
                                iv,\r
-                               salt\r
+                               salt,\r
+                               encrypted\r
                        })\r
-                       password = ''\r
-                       const { isUnlocked } = await unlockRequest\r
-                       if (isUnlocked) {\r
-                               this.#lockTimer = setTimeout(this.lock, 120)\r
-                       } else {\r
-                               throw new Error('Request to wallet worker failed')\r
+                       if (!isUnlocked) {\r
+                               throw new Error('Unlock request to Safe failed')\r
                        }\r
-                       this.#locked = isUnlocked\r
-                       return true\r
+                       clearTimeout(this.#lockTimer)\r
+                       this.#lockTimer = setTimeout(() => this.lock(), 120)\r
+                       return isUnlocked\r
                } catch (err) {\r
                        throw new Error('Failed to unlock wallet', { cause: err })\r
                }\r
index 7d4fb46568fd001180f07ef7ba85c11b155bf1cd..426665aba3de0fc81fef49ee48cd94a4ff4541f9 100644 (file)
@@ -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
 }
index 6a0237865296f835115f3e6bed54db17774dde8c..f3365a6f6453f0679cc43afeb27c194de1f23050 100644 (file)
@@ -14,17 +14,21 @@ let Bip44Wallet
 * @type {typeof import('../dist/types.d.ts').Blake2bWallet}\r
 */\r
 let Blake2bWallet\r
+/**\r
+* @type {typeof import('../dist/types.d.ts').Wallet}\r
+*/\r
+let Wallet\r
 if (isNode) {\r
-       ({ Bip44Wallet, Blake2bWallet } = await import('../dist/nodejs.min.js'))\r
+       ({ Bip44Wallet, Blake2bWallet, Wallet } = await import('../dist/nodejs.min.js'))\r
 } else {\r
-       ({ Bip44Wallet, Blake2bWallet } = await import('../dist/browser.min.js'))\r
+       ({ Bip44Wallet, Blake2bWallet, Wallet } = await import('../dist/browser.min.js'))\r
 }\r
 \r
 await Promise.all([\r
        suite('Create wallets', async () => {\r
 \r
                await test('destroy BIP-44 wallet before unlocking', async () => {\r
-                       const wallet = await Bip44Wallet.create(NANO_TEST_VECTORS.PASSWORD)\r
+                       const wallet = await Wallet.create('BIP-44', NANO_TEST_VECTORS.PASSWORD)\r
                        await assert.resolves(wallet.destroy())\r
 \r
                        assert.ok('mnemonic' in wallet)\r