]> git.codecow.com Git - libnemo.git/commitdiff
Create wallet tests now passing.
authorChris Duncan <chris@zoso.dev>
Fri, 1 Aug 2025 21:07:07 +0000 (14:07 -0700)
committerChris Duncan <chris@zoso.dev>
Fri, 1 Aug 2025 21:07:07 +0000 (14:07 -0700)
src/lib/block.ts
src/lib/convert.ts
src/lib/database.ts
src/lib/ledger.ts
src/lib/safe.ts
src/lib/wallet.ts
src/types.d.ts
test/main.test.mjs
test/test.blocks.mjs
test/test.create-wallet.mjs

index 35d73a169341cc399a4ce0e15e933d1c461181c6..805d71fc0ac3a8f269d03451b064b0e2b5e8f21f 100644 (file)
@@ -5,7 +5,7 @@ import { NanoPow } from 'nano-pow'
 import { Account } from './account'
 import { Blake2b } from './blake2b'
 import { BURN_ADDRESS, PREAMBLE, DIFFICULTY_RECEIVE, DIFFICULTY_SEND } from './constants'
-import { dec, hex } from './convert'
+import { bytes, dec, hex } from './convert'
 import { NanoNaCl } from './nano-nacl'
 import { Rpc } from './rpc'
 
@@ -132,7 +132,7 @@ abstract class Block {
        *
        * @param {string} [key] - Hexadecimal-formatted private key to use for signing
        */
-       async sign (key?: string): Promise<void>
+       async sign (key?: string): Promise<string>
        /**
        * Signs the block using a Ledger hardware wallet. If that fails, an error is
        * thrown with the status code from the device.
@@ -143,8 +143,8 @@ abstract class Block {
        * @param {number} index - Account index between 0x0 and 0x7fffffff
        * @param {object} [frontier] - JSON of frontier block for offline signing
        */
-       async sign (index?: number, frontier?: ChangeBlock | ReceiveBlock | SendBlock): Promise<void>
-       async sign (input?: number | string, frontier?: ChangeBlock | ReceiveBlock | SendBlock): Promise<void> {
+       async sign (index?: number, frontier?: ChangeBlock | ReceiveBlock | SendBlock): Promise<string>
+       async sign (input?: number | string, frontier?: ChangeBlock | ReceiveBlock | SendBlock): Promise<string> {
                if (typeof input === 'number') {
                        const index = input
                        const { LedgerWallet } = await import('./ledger')
@@ -159,16 +159,17 @@ abstract class Block {
                        }
                        try {
                                this.signature = await ledger.sign(index, this as SendBlock | ReceiveBlock | ChangeBlock)
+                               return this.signature
                        } catch (err) {
-                               throw new Error('failed to sign with ledger', { cause: err })
+                               throw new Error('Failed to sign block with Ledger', { cause: err })
                        }
                } else if (typeof input === 'string') {
                        try {
-                               const account = await Account.import({ index: 0, privateKey: input }, '')
-                               // this.signature = await account.sign(this, '')
-                               await account.destroy()
+                               const sig = await NanoNaCl.detached(hex.toBytes(this.hash), hex.toBytes(input))
+                               this.signature = bytes.toHex(sig)
+                               return this.signature
                        } catch (err) {
-                               throw new Error(`Failed to sign block`, { cause: err })
+                               throw new Error(`Failed to sign block with private key`, { cause: err })
                        }
                } else {
                        throw new TypeError('invalid key for block signature', { cause: typeof input })
index aa6440d1cebff4893cdb418af6bfafc09b1c6d9b..fdcf29188bffb42568018247a909d53dd76a448d 100644 (file)
@@ -142,8 +142,10 @@ export class bytes {
        */\r
        static toHex (bytes: Uint8Array): string {\r
                if (bytes.buffer instanceof ArrayBuffer && bytes.buffer.detached) return ''\r
-               const byteArray = [...bytes].map(byte => byte.toString(16).padStart(2, '0'))\r
-               return byteArray.join('').toUpperCase()\r
+               return [...bytes]\r
+                       .map(byte => byte.toString(16).padStart(2, '0'))\r
+                       .join('')\r
+                       .toUpperCase()\r
        }\r
 \r
        /**\r
index 529c77acbda21064ae564665d5d5c327171df6b4..fb833f253f962574b8aa4df2d1034ca63b645850 100644 (file)
@@ -13,29 +13,55 @@ export class Database {
        static DB_STORES = ['Wallet', 'Account', 'Rolodex']
        static #storage: IDBDatabase
 
+       /**
+       * Inserts records in a datastore, throwing if they already exist.
+       *
+       * @param {NamedData} data - Object of key-value pairs
+       * @param {string} store - Datastore in which to put records
+       * @returns {Promise<(IDBValidKey | DOMException)[]>} Index keys of the records inserted
+       */
+       static async add<T extends Data> (data: NamedData<T>, store: string): Promise<(IDBValidKey | DOMException)[]> {
+               this.#storage ??= await this.#open(this.DB_NAME)
+               const transaction = this.#storage.transaction(store, 'readwrite')
+               const db = transaction.objectStore(store)
+               return new Promise((resolve, reject) => {
+                       const requests = Object.keys(data).map(key => db.add(data[key], key))
+                       transaction.oncomplete = (event) => {
+                               const results = []
+                               for (const request of requests) {
+                                       results.push(request.error ?? request.result)
+                               }
+                               resolve(results)
+                       }
+                       transaction.onerror = (event) => {
+                               reject((event.target as IDBRequest).error)
+                       }
+               })
+       }
+
        /**
        * Deletes a record from a datastore.
        *
-       * @param {string} name - Index key of the record to delete
+       * @param {string} id - Index key of the record to delete
        * @param {string} store - Datastore from which to delete the record
        * @returns {Promise<boolean>} True if data was successfully removed, else false
        */
-       static async delete (name: string, store: string): Promise<boolean>
+       static async delete (id: string, store: string): Promise<boolean>
        /**
        * Deletes records from a datastore.
        *
-       * @param {string[]} names - Index keys of the records to delete
+       * @param {string[]} ids - Index keys of the records to delete
        * @param {string} store - Datastore from which to delete records
        * @returns {Promise<boolean>} True if data was successfully removed, else false
        */
-       static async delete (names: string[], store: string): Promise<boolean>
-       static async delete (names: string | string[], store: string): Promise<boolean> {
-               if (!Array.isArray(names)) names = [names]
+       static async delete (ids: string[], store: string): Promise<boolean>
+       static async delete (ids: string | string[], store: string): Promise<boolean> {
+               if (!Array.isArray(ids)) ids = [ids]
                this.#storage ??= await this.#open(this.DB_NAME)
                const transaction = this.#storage.transaction(store, 'readwrite')
                const db = transaction.objectStore(store)
                return new Promise((resolve, reject) => {
-                       const requests = names.map(name => db.delete(name))
+                       const requests = ids.map(id => db.delete(id))
                        transaction.oncomplete = (event) => {
                                for (const request of requests) {
                                        if (request?.error != null) {
@@ -60,7 +86,7 @@ export class Database {
        * @param {string} store - Datastore from which to get the record
        * @returns {Promise<NamedData>} Object of key-value pairs
        */
-       static async get<T extends Data> (name: string, store: string): Promise<NamedData<T>>
+       static async get<T extends Data> (id: string, store: string): Promise<NamedData<T>>
        /**
        * Gets specific records from a datastore.
        *
@@ -68,18 +94,20 @@ export class Database {
        * @param {string} store - Datastore from which to get records
        * @returns {Promise<NamedData>} Object of key-value pairs
        */
-       static async get<T extends Data> (names: string[], store: string): Promise<NamedData<T>>
-       static async get<T extends Data> (names: string | string[], store: string): Promise<NamedData<T>> {
-               if (!Array.isArray(names)) names = [names]
+       static async get<T extends Data> (ids: string[], store: string): Promise<NamedData<T>>
+       static async get<T extends Data> (ids: string | string[], store: string): Promise<NamedData<T>> {
+               if (!Array.isArray(ids)) ids = [ids]
                this.#storage ??= await this.#open(this.DB_NAME)
                const transaction = this.#storage.transaction(store, 'readonly')
                const db = transaction.objectStore(store)
                return new Promise((resolve, reject) => {
-                       const requests = names.map(name => db.get(name))
+                       const requests = ids.map(id => db.get(id))
                        transaction.oncomplete = (event) => {
                                const results: NamedData<T> = {}
                                for (const request of requests) {
-                                       results[request.result.name] = request.error ?? request.result
+                                       if (request?.result?.id != null) {
+                                               results[request.result.id] = request.error ?? request.result
+                                       }
                                }
                                resolve(results)
                        }
@@ -156,7 +184,7 @@ export class Database {
                                }
                                for (const DB_STORE of this.DB_STORES) {
                                        if (!db.objectStoreNames.contains(DB_STORE)) {
-                                               db.createObjectStore(DB_STORE)
+                                               db.createObjectStore(DB_STORE, { keyPath: 'id' })
                                        }
                                }
                        }
index 689d5e22f43d440d6de82225a294967434cc350d..ef3203101c298dabfc8dd3846651b5238ae81780 100644 (file)
@@ -8,6 +8,7 @@ import { default as TransportHID } from '@ledgerhq/hw-transport-webhid'
 import { ChangeBlock, ReceiveBlock, SendBlock } from './block'\r
 import { BIP44_COIN_NANO, BIP44_PURPOSE, HARDENED_OFFSET, LEDGER_ADPU_CODES, LEDGER_STATUS_CODES } from './constants'\r
 import { bytes, dec, hex } from './convert'\r
+import { Database } from './database'\r
 import { Rpc } from './rpc'\r
 import { Wallet } from './wallet'\r
 import { DeviceStatus, KeyPair, LedgerAccountResponse, LedgerResponse, LedgerSignResponse, LedgerVersionResponse } from '#types'\r
@@ -60,24 +61,32 @@ export class LedgerWallet extends Wallet {
        static async create (): Promise<LedgerWallet> {\r
                try {\r
                        if (this.isUnsupported) throw new Error('Browser is unsupported')\r
-                       const id = 'Ledger'\r
                        LedgerWallet.#isInternal = true\r
-                       const wallet = new this(id)\r
-                       return wallet\r
+                       const self = new this()\r
+                       await Database.add({ [self.id]: { id: self.id, type: 'Ledger' } }, Wallet.DB_NAME)\r
+                       return self\r
                } catch (err) {\r
-                       throw new Error('failed to initialize Ledger wallet', { cause: err })\r
+                       throw new Error('Failed to initialize Ledger wallet', { cause: err })\r
                }\r
        }\r
 \r
+       /**\r
+       * Overrides `import()` from the base Wallet class since Ledger secrets cannot\r
+       * be extracted from the device.\r
+       */\r
+       static import (): Promise<LedgerWallet> {\r
+               return LedgerWallet.create()\r
+       }\r
+\r
        #status: DeviceStatus = 'DISCONNECTED'\r
        get status (): DeviceStatus { return this.#status }\r
 \r
-       private constructor (id: string) {\r
+       private constructor () {\r
                if (!LedgerWallet.#isInternal) {\r
                        throw new Error(`LedgerWallet cannot be instantiated directly. Use 'await LedgerWallet.create()' instead.`)\r
                }\r
                LedgerWallet.#isInternal = false\r
-               super(id, 'Ledger')\r
+               super('Ledger')\r
        }\r
 \r
        /**\r
@@ -167,26 +176,6 @@ export class LedgerWallet extends Wallet {
                }\r
        }\r
 \r
-       /**\r
-       * Retrieves an existing Ledger wallet from storage using its ID.\r
-       *\r
-       * @param {string} id - Generated when the wallet was initially created\r
-       * @returns {LedgerWallet} Restored LedgerWallet\r
-       */\r
-       static async restore (id: string): Promise<LedgerWallet> {\r
-               if (typeof id !== 'string' || id === '') {\r
-                       throw new TypeError('Wallet ID is required to restore')\r
-               }\r
-               try {\r
-                       id = id.replace('Ledger_', '')\r
-                       LedgerWallet.#isInternal = true\r
-                       return new this(id)\r
-               } catch (err) {\r
-                       console.error(err)\r
-                       throw new Error('failed to restore wallet', { cause: err })\r
-               }\r
-       }\r
-\r
        /**\r
        * Sign a block with the Ledger device.\r
        *\r
@@ -266,6 +255,53 @@ export class LedgerWallet extends Wallet {
                return { status }\r
        }\r
 \r
+       /**\r
+       * Checks whether a given seed matches the wallet seed. The wallet must be\r
+       * unlocked prior to verification.\r
+       *\r
+       * @param {string} seed - Hexadecimal seed to be matched against the wallet data\r
+       * @returns True if input matches wallet seed\r
+       */\r
+       async verify (seed: string): Promise<boolean>\r
+       /**\r
+       * Checks whether a given mnemonic phrase matches the wallet mnemonic. If a\r
+       * personal salt was used when generating the mnemonic, it cannot be verified.\r
+       * The wallet must be unlocked prior to verification.\r
+       *\r
+       * @param {string} mnemonic - Phrase to be matched against the wallet data\r
+       * @returns True if input matches wallet mnemonic\r
+       */\r
+       async verify (mnemonic: string): Promise<boolean>\r
+       async verify (secret: string): Promise<boolean> {\r
+               const testWallet = await Wallet.import('BIP-44', '', secret)\r
+               await testWallet.unlock('')\r
+               const testAccount = await testWallet.account(0)\r
+               const testOpenBlock = new ReceiveBlock(\r
+                       testAccount.address,\r
+                       '0',\r
+                       testAccount.address,\r
+                       '0',\r
+                       testAccount.address,\r
+                       '0'\r
+               )\r
+               await testWallet.sign(0, testOpenBlock)\r
+               const testSendBlock = new SendBlock(\r
+                       testAccount.address,\r
+                       '0',\r
+                       testAccount.address,\r
+                       '0',\r
+                       testAccount.address,\r
+                       testOpenBlock.hash\r
+               )\r
+               const testSignature = await testWallet.sign(0, testOpenBlock)\r
+               try {\r
+                       const signature = await this.sign(0, testSendBlock, testOpenBlock)\r
+                       return signature === testSignature\r
+               } catch (err) {\r
+                       throw new Error('Failed to verify wallet', { cause: err })\r
+               }\r
+       }\r
+\r
        /**\r
        * Get the version of the current process. If a specific app is running, get\r
        * the app version. Otherwise, get the Ledger BOLOS version instead.\r
index 41fdadc8b079ca47e43a42e1db83cde639801db6..16795544a5dda68c5f395455403df21a15c3c114 100644 (file)
@@ -88,7 +88,6 @@ export class Safe {
                                                transfer.push(result[k])
                                        }
                                }
-                               debugger
                                //@ts-expect-error
                                BROWSER: postMessage(result, transfer)
                                //@ts-expect-error
@@ -111,9 +110,13 @@ export class Safe {
                try {
                        const entropy = crypto.getRandomValues(new Uint8Array(32))
                        const mnemonicPhrase = (await Bip39Mnemonic.fromEntropy(entropy)).phrase
-                       return await this.import(type, key, keySalt, mnemonicPhrase, mnemonicSalt)
+                       const record = await this.import(type, key, keySalt, mnemonicPhrase, mnemonicSalt)
+                       if (this.#seed == null || this.#mnemonic?.phrase == null) {
+                               throw new Error('Failed to generate seed and mnemonic')
+                       }
+                       return { ...record, seed: this.#seed.slice(), mnemonic: utf8.toBuffer(this.#mnemonic.phrase) }
                } catch (err) {
-                       throw new Error('Failed to unlock wallet', { cause: err })
+                       throw new Error('Failed to create wallet', { cause: err })
                }
        }
 
@@ -306,8 +309,6 @@ export class Safe {
        }
 
        static async #createAesKey (purpose: 'encrypt' | 'decrypt', password: ArrayBuffer, keySalt: ArrayBuffer): Promise<CryptoKey> {
-               console.log(keySalt)
-               debugger
                const derivationKey = await crypto.subtle.importKey('raw', password, 'PBKDF2', false, ['deriveBits', 'deriveKey'])
                new Uint8Array(password).fill(0).buffer.transfer()
                const derivationAlgorithm: Pbkdf2Params = {
@@ -324,8 +325,6 @@ export class Safe {
        }
 
        static async #decryptWallet (key: CryptoKey, iv: ArrayBuffer, encrypted: ArrayBuffer): Promise<NamedData<string | ArrayBuffer>> {
-               console.log(iv, encrypted)
-               debugger
                const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encrypted)
                const decoded = JSON.parse(bytes.toUtf8(new Uint8Array(decrypted)))
                const seed = hex.toBuffer(decoded.seed)
index 02810b50e5f2037e3ddf70f1efef36f67441bbc2..4b3b1c70801e1da8fe51108e053c8bfd816efb67 100644 (file)
@@ -17,16 +17,16 @@ import { KeyPair, NamedData, WalletType } from '#types'
 * three types of wallets are supported: BIP-44, BLAKE2b, and Ledger.\r
 */\r
 export class Wallet {\r
-       static #DB_NAME = 'Wallet'\r
        static #isInternal: boolean = false\r
+       static DB_NAME = 'Wallet'\r
 \r
        /**\r
        * Retrieves a wallet from the database.\r
        */\r
-       static async #get (name: string) {\r
+       static async #get (id: string) {\r
                try {\r
-                       const record = await Database.get<NamedData>(name, this.#DB_NAME)\r
-                       return record[name]\r
+                       const record = await Database.get<NamedData>(id, this.DB_NAME)\r
+                       return record[id]\r
                } catch (err) {\r
                        throw new Error('Failed to get wallet from database', { cause: err })\r
                }\r
@@ -40,25 +40,26 @@ export class Wallet {
        * @param {string} [salt=''] - Used when generating the final seed\r
        * @returns {Wallet} A newly instantiated Wallet\r
        */\r
-       static async create (name: string, type: 'BIP-44' | 'BLAKE2b', password: string, mnemonicSalt?: string): Promise<Wallet> {\r
+       static async create (type: 'BIP-44' | 'BLAKE2b', password: string, mnemonicSalt?: string): Promise<Wallet> {\r
                Wallet.#isInternal = true\r
-               const self = new this(name, type)\r
+               const self = new this(type)\r
                try {\r
-                       debugger\r
-                       const { iv, salt, encrypted } = await self.#safe.request<ArrayBuffer>({\r
+                       const { iv, salt, encrypted, seed, mnemonic } = await self.#safe.request<ArrayBuffer>({\r
                                action: 'create',\r
                                type,\r
                                password: utf8.toBuffer(password),\r
                                mnemonicSalt: mnemonicSalt ?? ''\r
                        })\r
-                       const data = {\r
-                               name,\r
+                       self.#mnemonic = mnemonic\r
+                       self.#seed = seed\r
+                       const record = {\r
+                               id: self.id,\r
                                type,\r
                                iv,\r
                                salt,\r
                                encrypted\r
                        }\r
-                       await Database.put({ [name]: data }, Wallet.#DB_NAME)\r
+                       await Database.add({ [self.id]: record }, Wallet.DB_NAME)\r
                        return self\r
                } catch (err) {\r
                        throw new Error('Error creating new Wallet', { cause: err })\r
@@ -67,13 +68,13 @@ export class Wallet {
 \r
        /**\r
        * Imports an existing HD wallet by using an entropy value generated using a\r
-       * cryptographically strong pseudorandom number generator.\r
+       * cryptographically strong pseudorandom number generator.NamedD\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 {Wallet} A newly instantiated Wallet\r
        */\r
-       static async import (name: string, type: 'BIP-44' | 'BLAKE2b', password: string, seed: string): Promise<Wallet>\r
+       static async import (type: 'BIP-44' | 'BLAKE2b', password: string, seed: string): Promise<Wallet>\r
        /**\r
        * Imports an existing HD wallet by using an entropy value generated using a\r
        * cryptographically strong pseudorandom number generator.\r
@@ -82,33 +83,32 @@ export class Wallet {
        * @param {string} [salt=''] - Used when generating the final seed\r
        * @returns {Wallet} A newly instantiated Wallet\r
        */\r
-       static async import (name: string, type: 'BIP-44' | 'BLAKE2b', password: string, mnemonicPhrase: string, mnemonicSalt?: string): Promise<Wallet>\r
-       static async import (name: string, type: 'BIP-44' | 'BLAKE2b', password: string, secret: string, mnemonicSalt?: string): Promise<Wallet> {\r
+       static async import (type: 'BIP-44' | 'BLAKE2b', password: string, mnemonicPhrase: string, mnemonicSalt?: string): Promise<Wallet>\r
+       static async import (type: 'BIP-44' | 'BLAKE2b', password: string, secret: string, mnemonicSalt?: string): Promise<Wallet> {\r
                Wallet.#isInternal = true\r
-               const self = new this(name, type)\r
+               const self = new this(type)\r
                try {\r
-                       const data: any = {\r
+                       const data: NamedData = {\r
                                action: 'import',\r
                                type,\r
-                               password: utf8.toBuffer(password),\r
-                               mnemonicSalt\r
+                               password: utf8.toBuffer(password)\r
                        }\r
-                       if (/^(?:[A-Fa-f0-9]{64}){1,2}$/.test(secret)) {\r
+                       if (/^[A-Fa-f0-9]+$/.test(secret)) {\r
                                data.seed = hex.toBuffer(secret)\r
                        } else {\r
                                data.mnemonicPhrase = secret\r
+                               if (mnemonicSalt != null) data.mnemonicSalt = mnemonicSalt\r
                        }\r
                        const result = self.#safe.request<ArrayBuffer>(data)\r
                        const { iv, salt, encrypted } = await result\r
                        const record = {\r
-                               name,\r
+                               id: self.id,\r
                                type,\r
                                iv,\r
                                salt,\r
                                encrypted\r
                        }\r
-                       console.log(record)\r
-                       await Database.put({ [name]: record }, Wallet.#DB_NAME)\r
+                       await Database.add({ [self.id]: record }, Wallet.DB_NAME)\r
                        return self\r
                } catch (err) {\r
                        throw new Error('Error creating new Wallet', { cause: err })\r
@@ -116,22 +116,22 @@ export class Wallet {
        }\r
 \r
        /**\r
-       * Retrieves an existing wallet from the database using its name.\r
+       * Retrieves an existing wallet from the database using its UUID.\r
        *\r
-       * @param {string} name - Entered by user when the wallet was initially created\r
+       * @param {string} id - Entered by user when the wallet was initially created\r
        * @returns {Wallet} Restored locked Wallet\r
        */\r
-       static async restore (name: string): Promise<Wallet> {\r
+       static async restore (id: string): Promise<Wallet> {\r
                try {\r
-                       if (typeof name !== 'string' || name === '') {\r
-                               throw new TypeError('Wallet name is required to restore')\r
+                       if (typeof id !== 'string' || id === '') {\r
+                               throw new TypeError('Wallet ID is required to restore')\r
                        }\r
-                       const { type } = await this.#get(name)\r
-                       if (type !== 'BIP-44' && type !== 'BLAKE2b') {\r
+                       const { type } = await this.#get(id)\r
+                       if (type !== 'BIP-44' && type !== 'BLAKE2b' && type !== 'Ledger') {\r
                                throw new Error('Invalid wallet type from database')\r
                        }\r
                        Wallet.#isInternal = true\r
-                       return new this(name, type)\r
+                       return new this(type)\r
                } catch (err) {\r
                        throw new Error('Failed to restore wallet', { cause: err })\r
                }\r
@@ -139,21 +139,52 @@ export class Wallet {
 \r
        #accounts: AccountList\r
        #lockTimer?: any\r
-       #name: string\r
+       #id: string\r
+       #mnemonic?: ArrayBuffer\r
        #safe: WorkerQueue\r
+       #seed?: ArrayBuffer\r
        #type: WalletType\r
 \r
-       get name () { return `${this.type}_${this.#name}` }\r
+       get id () { return this.#id }\r
        get type () { return this.#type }\r
 \r
-       constructor (name: string, type: WalletType) {\r
+       /** Set when calling `create()` and self-destructs after the first read. */\r
+       get mnemonic () {\r
+               if (this.#mnemonic == null) return undefined\r
+               try {\r
+                       const b = new Uint8Array(this.#mnemonic)\r
+                       this.#mnemonic = undefined\r
+                       const m = bytes.toUtf8(b)\r
+                       b.fill(0).buffer.transfer()\r
+                       return m\r
+               } catch {\r
+                       this.#mnemonic = undefined\r
+                       return undefined\r
+               }\r
+       }\r
+       /** Set when calling `create()` and self-destructs after the first read. */\r
+       get seed () {\r
+               if (this.#seed == null) return undefined\r
+               try {\r
+                       const b = new Uint8Array(this.#seed)\r
+                       this.#seed = undefined\r
+                       const s = bytes.toHex(b)\r
+                       b.fill(0).buffer.transfer()\r
+                       return s\r
+               } catch {\r
+                       this.#seed = undefined\r
+                       return undefined\r
+               }\r
+       }\r
+\r
+       constructor (type: WalletType) {\r
                if (!Wallet.#isInternal) {\r
                        throw new Error(`Wallet cannot be instantiated directly. Use 'await Wallet.create()' instead.`)\r
                }\r
                Wallet.#isInternal = false\r
                this.#accounts = new AccountList()\r
-               this.#name = name\r
-               this.#safe = new WorkerQueue(SafeWorker)\r
+               this.#id = crypto.randomUUID()\r
+               this.#safe = new WorkerQueue(type === 'Ledger' ? '' : SafeWorker)\r
                this.#type = type\r
        }\r
 \r
@@ -242,10 +273,14 @@ export class Wallet {
        */\r
        async destroy (): Promise<void> {\r
                try {\r
-                       await Database.delete(this.name, Wallet.#DB_NAME)\r
+                       const isDeleted = await Database.delete(this.#id, Wallet.DB_NAME)\r
+                       if (!isDeleted) {\r
+                               throw new Error('Failed to delete wallet from database')\r
+                       }\r
+                       this.#safe.terminate()\r
                } catch (err) {\r
                        console.error(err)\r
-                       throw new Error('failed to destroy wallet', { cause: err })\r
+                       throw new Error('Failed to destroy wallet', { cause: err })\r
                }\r
        }\r
 \r
@@ -302,8 +337,8 @@ export class Wallet {
        * specified. The signature is appended to the signature field of the block\r
        * before being returned. The wallet must be unlocked prior to signing.\r
        *\r
-       * @param {(ChangeBlock|ReceiveBlock|SendBlock)} block - Block data to be hashed and signed\r
        * @param {number} index - Account to use for signing\r
+       * @param {(ChangeBlock|ReceiveBlock|SendBlock)} block - Block data to be hashed and signed\r
        * @returns {Promise<string>} Hexadecimal-formatted 64-byte signature\r
        */\r
        async sign (index: number, block: ChangeBlock | ReceiveBlock | SendBlock): Promise<string> {\r
@@ -331,22 +366,19 @@ export class Wallet {
        */\r
        async unlock (password: string): Promise<boolean> {\r
                try {\r
-                       debugger\r
-                       const { iv, salt, encrypted } = await Wallet.#get(this.#name)\r
-                       console.log(iv, salt, encrypted)\r
-                       const result = await this.#safe.request<boolean>({\r
+                       const { iv, salt, encrypted } = await Wallet.#get(this.#id)\r
+                       const { isUnlocked } = await this.#safe.request<boolean>({\r
                                action: 'unlock',\r
                                password: utf8.toBuffer(password),\r
                                iv,\r
                                keySalt: salt,\r
                                encrypted\r
                        })\r
-                       const { isUnlocked } = result\r
                        if (!isUnlocked) {\r
                                throw new Error('Unlock request to Safe failed')\r
                        }\r
                        clearTimeout(this.#lockTimer)\r
-                       this.#lockTimer = setTimeout(() => this.lock(), 120)\r
+                       this.#lockTimer = setTimeout(() => this.lock(), 120000)\r
                        return isUnlocked\r
                } catch (err) {\r
                        throw new Error('Failed to unlock wallet', { cause: err })\r
@@ -383,4 +415,44 @@ export class Wallet {
                }\r
                return await this.unopened(rpc, batchSize, from + batchSize)\r
        }\r
+\r
+       /**\r
+       * Checks whether a given seed matches the wallet seed. The wallet must be\r
+       * unlocked prior to verification.\r
+       *\r
+       * @param {string} seed - Hexadecimal seed to be matched against the wallet data\r
+       * @returns True if input matches wallet seed\r
+       */\r
+       async verify (seed: string): Promise<boolean>\r
+       /**\r
+       * Checks whether a given mnemonic phrase matches the wallet mnemonic. If a\r
+       * personal salt was used when generating the mnemonic, it cannot be verified.\r
+       * The wallet must be unlocked prior to verification.\r
+       *\r
+       * @param {string} mnemonic - Phrase to be matched against the wallet data\r
+       * @returns True if input matches wallet mnemonic\r
+       */\r
+       async verify (mnemonic: string): Promise<boolean>\r
+       async verify (secret: string): Promise<boolean> {\r
+               try {\r
+                       const data: NamedData<string> = {\r
+                               action: 'verify'\r
+                       }\r
+                       if (/^[A-Fa-f0-9]+$/.test(secret)) {\r
+                               data.seed = secret\r
+                       } else {\r
+                               data.mnemonicPhrase = secret\r
+                       }\r
+                       const result = await this.#safe.request<boolean>(data)\r
+                       const { isUnlocked } = result\r
+                       if (!isUnlocked) {\r
+                               throw new Error('Unlock request to Safe failed')\r
+                       }\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
+       }\r
 }\r
index 4261cb07e599d9a2c5cfbd4b1e5df498aca3ce36..9be9b96a95177e8dcc741c87cf36b8204abd0d14 100644 (file)
@@ -592,6 +592,7 @@ export type WalletType = 'BIP-44' | 'BLAKE2b' | 'Ledger'
 */
 export declare class Wallet {
        #private
+       static DB_NAME: string
        /**
        * Creates a new HD wallet by using an entropy value generated using a
        * cryptographically strong pseudorandom number generator.
@@ -600,16 +601,16 @@ export declare class Wallet {
        * @param {string} [salt=''] - Used when generating the final seed
        * @returns {Wallet} A newly instantiated Wallet
        */
-       static create (name: string, type: 'BIP-44' | 'BLAKE2b', password: string, mnemonicSalt?: string): Promise<Wallet>
+       static create (type: 'BIP-44' | 'BLAKE2b', password: string, mnemonicSalt?: string): Promise<Wallet>
        /**
        * Imports an existing HD wallet by using an entropy value generated using a
-       * cryptographically strong pseudorandom number generator.
+       * cryptographically strong pseudorandom number generator.NamedD
        *
        * @param {string} password - Encrypts the wallet to lock and unlock it
        * @param {string} [salt=''] - Used when generating the final seed
        * @returns {Wallet} A newly instantiated Wallet
        */
-       static import (name: string, type: 'BIP-44' | 'BLAKE2b', password: string, seed: string): Promise<Wallet>
+       static import (type: 'BIP-44' | 'BLAKE2b', password: string, seed: string): Promise<Wallet>
        /**
        * Imports an existing HD wallet by using an entropy value generated using a
        * cryptographically strong pseudorandom number generator.
@@ -618,17 +619,21 @@ export declare class Wallet {
        * @param {string} [salt=''] - Used when generating the final seed
        * @returns {Wallet} A newly instantiated Wallet
        */
-       static import (name: string, type: 'BIP-44' | 'BLAKE2b', password: string, mnemonicPhrase: string, mnemonicSalt?: string): Promise<Wallet>
+       static import (type: 'BIP-44' | 'BLAKE2b', password: string, mnemonicPhrase: string, mnemonicSalt?: string): Promise<Wallet>
        /**
-       * Retrieves an existing wallet from the database using its name.
+       * Retrieves an existing wallet from the database using its UUID.
        *
-       * @param {string} name - Entered by user when the wallet was initially created
+       * @param {string} id - Entered by user when the wallet was initially created
        * @returns {Wallet} Restored locked Wallet
        */
-       static restore (name: string): Promise<Wallet>
-       get name (): string
+       static restore (id: string): Promise<Wallet>
+       get id (): string
        get type (): WalletType
-       constructor (name: string, type: WalletType)
+       /** Set when calling `create()` and self-destructs after the first read. */
+       get mnemonic (): string | undefined
+       /** Set when calling `create()` and self-destructs after the first read. */
+       get seed (): string | undefined
+       constructor (type: WalletType)
        /**
        * Retrieves an account from a wallet using its child key derivation function.
        * Defaults to the first account at index 0.
@@ -697,8 +702,8 @@ export declare class Wallet {
        * specified. The signature is appended to the signature field of the block
        * before being returned. The wallet must be unlocked prior to signing.
        *
-       * @param {(ChangeBlock|ReceiveBlock|SendBlock)} block - Block data to be hashed and signed
        * @param {number} index - Account to use for signing
+       * @param {(ChangeBlock|ReceiveBlock|SendBlock)} block - Block data to be hashed and signed
        * @returns {Promise<string>} Hexadecimal-formatted 64-byte signature
        */
        sign (index: number, block: ChangeBlock | ReceiveBlock | SendBlock): Promise<string>
@@ -719,6 +724,23 @@ export declare class Wallet {
        * @returns {Promise<Account>} The lowest-indexed unopened account belonging to the wallet
        */
        unopened (rpc: Rpc, batchSize?: number, from?: number): Promise<Account>
+       /**
+       * Checks whether a given seed matches the wallet seed. The wallet must be
+       * unlocked prior to verification.
+       *
+       * @param {string} seed - Hexadecimal seed to be matched against the wallet data
+       * @returns True if input matches wallet seed
+       */
+       verify (seed: string): Promise<boolean>
+       /**
+       * Checks whether a given mnemonic phrase matches the wallet mnemonic. If a
+       * personal salt was used when generating the mnemonic, it cannot be verified.
+       * The wallet must be unlocked prior to verification.
+       *
+       * @param {string} mnemonic - Phrase to be matched against the wallet data
+       * @returns True if input matches wallet mnemonic
+       */
+       verify (mnemonic: string): Promise<boolean>
 }
 
 type DeviceStatus = 'DISCONNECTED' | 'BUSY' | 'LOCKED' | 'CONNECTED'
index 3ab48d6ad4c1eacdfa59c884afa7e240ce61e552..ada0c28a6a82c56c51ec92ec5f2b578718caf2c9 100644 (file)
@@ -4,17 +4,17 @@
 import { failures, passes } from './GLOBALS.mjs'
 import './test.runner-check.mjs'
 
-// import './test.blake2b.mjs'
+import './test.blake2b.mjs'
 import './test.blocks.mjs'
-// import './test.calculate-pow.mjs'
-// import './test.create-wallet.mjs'
-// import './test.derive-accounts.mjs'
-// import './test.import-wallet.mjs'
-// import './test.ledger.mjs'
-// import './test.lock-unlock.mjs'
-// import './test.manage-rolodex.mjs'
-// import './test.refresh-accounts.mjs'
-// import './test.tools.mjs'
+import './test.calculate-pow.mjs'
+import './test.create-wallet.mjs'
+import './test.derive-accounts.mjs'
+import './test.import-wallet.mjs'
+import './test.ledger.mjs'
+import './test.lock-unlock.mjs'
+import './test.manage-rolodex.mjs'
+import './test.refresh-accounts.mjs'
+import './test.tools.mjs'
 
 console.log('%cTESTING COMPLETE', 'color:orange;font-weight:bold')
 console.log('%cPASS: ', 'color:green;font-weight:bold', passes.length)
index 6347a6474161b1de307171f6c964e2688643c410..398c1e9d38f7ba02123e8c7b4c276c1242341aa0 100644 (file)
@@ -101,7 +101,8 @@ await Promise.all([
                        assert.equal(block.signature, signature)\r
                })\r
 \r
-               await test('sign open block with account', async () => {\r
+               await test('fail to sign open block with wallet when locked', async () => {\r
+                       const wallet = await Wallet.import('BIP-44', NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED)\r
                        const block = new ReceiveBlock(\r
                                NANO_TEST_VECTORS.OPEN_BLOCK.account,\r
                                '0',\r
@@ -111,7 +112,7 @@ await Promise.all([
                                NANO_TEST_VECTORS.OPEN_BLOCK.previous,\r
                                NANO_TEST_VECTORS.OPEN_BLOCK.work\r
                        )\r
-                       await block.sign(NANO_TEST_VECTORS.OPEN_BLOCK.key)\r
+                       await wallet.sign(0, block)\r
                        assert.equal(block.hash, NANO_TEST_VECTORS.OPEN_BLOCK.hash)\r
                        assert.equal(block.signature, NANO_TEST_VECTORS.OPEN_BLOCK.signature)\r
                })\r
index f3365a6f6453f0679cc43afeb27c194de1f23050..af4bfcfbb6568ebc1451c2a1115898c80eb65bd1 100644 (file)
@@ -6,22 +6,14 @@
 import { assert, isNode, suite, test } from './GLOBALS.mjs'\r
 import { NANO_TEST_VECTORS } from './VECTORS.mjs'\r
 \r
-/**\r
-* @type {typeof import('../dist/types.d.ts').Bip44Wallet}\r
-*/\r
-let Bip44Wallet\r
-/**\r
-* @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, Wallet } = await import('../dist/nodejs.min.js'))\r
+       ({ Wallet } = await import('../dist/nodejs.min.js'))\r
 } else {\r
-       ({ Bip44Wallet, Blake2bWallet, Wallet } = await import('../dist/browser.min.js'))\r
+       ({ Wallet } = await import('../dist/browser.min.js'))\r
 }\r
 \r
 await Promise.all([\r
@@ -29,40 +21,49 @@ await Promise.all([
 \r
                await test('destroy BIP-44 wallet before unlocking', async () => {\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
-                       assert.ok('seed' in wallet)\r
-                       assert.throws(() => wallet.mnemonic)\r
-                       assert.throws(() => wallet.seed)\r
 \r
+                       await assert.resolves(wallet.destroy())\r
                        await assert.rejects(wallet.unlock(NANO_TEST_VECTORS.PASSWORD))\r
                })\r
 \r
                await test('BIP-44 wallet with random entropy', 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 wallet.unlock(NANO_TEST_VECTORS.PASSWORD)\r
 \r
                        assert.ok('id' in wallet)\r
-                       assert.ok(/^BIP-44_[A-Fa-f0-9]{32,64}$/.test(wallet.id))\r
+                       assert.ok(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(wallet.id))\r
+                       assert.ok('mnemonic' in wallet)\r
+                       assert.ok(/^(?:[a-z]{3,} ){11,23}[a-z]{3,}$/.test(wallet.mnemonic ?? ''))\r
+                       assert.ok('seed' in wallet)\r
+                       assert.ok(/^[A-Fa-f0-9]{128}$/.test(wallet.seed ?? ''))\r
+\r
+                       assert.ok('id' in wallet)\r
+                       assert.ok(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(wallet.id))\r
                        assert.ok('mnemonic' in wallet)\r
-                       assert.ok(/^(?:[a-z]{3,} ){11,23}[a-z]{3,}$/.test(wallet.mnemonic))\r
+                       assert.ok(wallet.mnemonic === undefined)\r
                        assert.ok('seed' in wallet)\r
-                       assert.ok(/^[A-Fa-f0-9]{128}$/.test(wallet.seed))\r
+                       assert.ok(wallet.seed === undefined)\r
 \r
                        await assert.resolves(wallet.destroy())\r
                })\r
 \r
                await test('BLAKE2b wallet with random entropy', async () => {\r
-                       const wallet = await Blake2bWallet.create(NANO_TEST_VECTORS.PASSWORD)\r
+                       const wallet = await Wallet.create('BLAKE2b', NANO_TEST_VECTORS.PASSWORD)\r
                        await wallet.unlock(NANO_TEST_VECTORS.PASSWORD)\r
 \r
                        assert.ok('id' in wallet)\r
-                       assert.ok(/^BLAKE2b_[A-Fa-f0-9]{32,64}$/.test(wallet.id))\r
+                       assert.ok(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(wallet.id))\r
+                       assert.ok('mnemonic' in wallet)\r
+                       assert.ok(/^(?:[a-z]{3,} ){11,23}[a-z]{3,}$/.test(wallet.mnemonic ?? ''))\r
+                       assert.ok('seed' in wallet)\r
+                       assert.ok(/^[A-Fa-f0-9]{64}$/.test(wallet.seed ?? ''))\r
+\r
+                       assert.ok('id' in wallet)\r
+                       assert.ok(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(wallet.id))\r
                        assert.ok('mnemonic' in wallet)\r
-                       assert.ok(/^(?:[a-z]{3,} ){11,23}[a-z]{3,}$/.test(wallet.mnemonic))\r
+                       assert.ok(wallet.mnemonic === undefined)\r
                        assert.ok('seed' in wallet)\r
-                       assert.ok(/^[A-Fa-f0-9]{64}$/.test(wallet.seed))\r
+                       assert.ok(wallet.seed === undefined)\r
 \r
                        await assert.resolves(wallet.destroy())\r
                })\r
@@ -71,7 +72,7 @@ await Promise.all([
                        const invalidArgs = [null, true, false, 0, 1, 2, { foo: 'bar' }]\r
                        for (const arg of invalidArgs) {\r
                                //@ts-expect-error\r
-                               const wallet = Bip44Wallet.create(NANO_TEST_VECTORS.PASSWORD, arg)\r
+                               const wallet = Wallet.create('BIP-44', NANO_TEST_VECTORS.PASSWORD, arg)\r
                                await assert.resolves(wallet)\r
                                await assert.resolves((await wallet).destroy())\r
                        }\r
@@ -79,16 +80,16 @@ await Promise.all([
 \r
                await test('fail when using new', async () => {\r
                        //@ts-expect-error\r
-                       assert.throws(() => new Bip44Wallet())\r
+                       assert.throws(() => new Wallet())\r
                        //@ts-expect-error\r
-                       assert.throws(() => new Blake2bWallet())\r
+                       assert.throws(() => new Wallet())\r
                })\r
 \r
                await test('fail without a password', async () => {\r
                        //@ts-expect-error\r
-                       await assert.rejects(Bip44Wallet.create())\r
+                       await assert.rejects(Wallet.create())\r
                        //@ts-expect-error\r
-                       await assert.rejects(Blake2bWallet.create())\r
+                       await assert.rejects(Wallet.create())\r
                })\r
        })\r
 ])\r