]> git.codecow.com Git - libnemo.git/commitdiff
Lock accounts using the wallet seed by default. Refactor account unlock method as...
authorChris Duncan <chris@zoso.dev>
Wed, 16 Jul 2025 14:58:45 +0000 (07:58 -0700)
committerChris Duncan <chris@zoso.dev>
Wed, 16 Jul 2025 14:58:45 +0000 (07:58 -0700)
src/lib/account.ts
src/lib/block.ts
src/lib/wallets/wallet.ts
src/lib/workers/safe.ts
test/test.derive-accounts.mjs

index 7a167afcac1650c97e2ed676c3e3227109743aa9..b8920160abaef3e912574a8b85742bb6119bb168 100644 (file)
@@ -120,14 +120,19 @@ export class Account {
        }\r
 \r
        /**\r
-       * Instantiates an Account object from its private key. The corresponding\r
-       * public key will automatically be derived and saved.\r
+       * Instantiates an Account object from its private key which is then encrypted\r
+       * and stored in IndexedDB. The corresponding public key will automatically be\r
+       * derived and saved.\r
        *\r
        * @param {(string|Uint8Array)} privateKey - Private key of the account\r
        * @param {number} [index] - Account number used when deriving the key\r
        * @returns {Account} A new Account object\r
        */\r
-       static async fromPrivateKey (privateKey: string | Uint8Array<ArrayBuffer>, index?: number): Promise<Account> {\r
+       static async fromPrivateKey (password: string | Uint8Array<ArrayBuffer>, privateKey: string | Uint8Array<ArrayBuffer>, index?: number): Promise<Account> {\r
+               if (typeof password === 'string') password = utf8.toBytes(password)\r
+               if (password == null || !(password instanceof Uint8Array)) {\r
+                       throw new Error('Invalid password when importing Account')\r
+               }\r
                this.#validateKey(privateKey)\r
                if (typeof privateKey === 'string') privateKey = hex.toBytes(privateKey)\r
                let publicKey: string\r
@@ -142,42 +147,25 @@ export class Account {
                } catch (err) {\r
                        throw new Error(`Failed to derive public key from private key`, { cause: err })\r
                }\r
-               try {\r
-                       const self = await this.fromPublicKey(publicKey, index)\r
-                       self.#privateKey = privateKey\r
-                       return self\r
-               } catch (err) {\r
-                       throw new Error(`Failed to lock new Account`, { cause: err })\r
-               }\r
-       }\r
 \r
-       /**\r
-       * Locks the account with a password that will be needed to unlock it later.\r
-       *\r
-       * @param {(string|Uint8Array)} password Used to lock the account\r
-       * @returns True if successfully locked\r
-       */\r
-       async lock (password: string | Uint8Array<ArrayBuffer>): Promise<boolean> {\r
-               if (this.isLocked) {\r
-                       throw new Error(`Account ${this.address} is already locked`)\r
-               }\r
-               if (typeof password === 'string') password = utf8.toBytes(password)\r
-               if (password == null || !(password instanceof Uint8Array)) {\r
-                       throw new Error(`Failed to lock Account ${this.address}`)\r
-               }\r
+               const self = await this.fromPublicKey(publicKey, index)\r
                try {\r
                        const headers = {\r
                                method: 'set',\r
-                               name: this.publicKey\r
+                               name: publicKey\r
                        }\r
                        const data = {\r
                                password: password.buffer,\r
-                               id: new Uint8Array(this.#publicKey).buffer,\r
-                               privateKey: this.#privateKey.buffer\r
+                               id: hex.toBytes(publicKey).buffer,\r
+                               privateKey: privateKey.buffer\r
                        }\r
-                       return await SafeWorker.add(headers, data)\r
+                       const isLocked = await SafeWorker.add(headers, data)\r
+                       if (!isLocked) {\r
+                               throw null\r
+                       }\r
+                       return self\r
                } catch (err) {\r
-                       throw new Error(`Failed to lock Account ${this.address}`, { cause: err })\r
+                       throw new Error(`Failed to lock Account ${self.address}`, { cause: err })\r
                } finally {\r
                        bytes.erase(password)\r
                }\r
@@ -223,16 +211,14 @@ export class Account {
        * @param {(ChangeBlock|ReceiveBlock|SendBlock)} block - The block data to be hashed and signed\r
        * @returns {Promise<string>} Hexadecimal-formatted 64-byte signature\r
        */\r
-       async sign (block: ChangeBlock | ReceiveBlock | SendBlock): Promise<string> {\r
-               if (this.isLocked) {\r
-                       throw new Error(`Account ${this.address} must be unlocked prior to signing`)\r
-               }\r
+       async sign (password: string | Uint8Array<ArrayBuffer>, block: ChangeBlock | ReceiveBlock | SendBlock): Promise<string> {\r
+               const privateKey = await this.exportPrivateKey(password)\r
                try {\r
                        const headers = {\r
                                method: 'detached'\r
                        }\r
                        const data = {\r
-                               privateKey: new Uint8Array(this.#privateKey).buffer,\r
+                               privateKey: privateKey.buffer,\r
                                msg: hex.toBytes(block.hash).buffer\r
                        }\r
                        const result = await NanoNaClWorker.add(headers, data)\r
@@ -240,6 +226,8 @@ export class Account {
                        return result\r
                } catch (err) {\r
                        throw new Error(`Failed to sign block`, { cause: err })\r
+               } finally {\r
+                       bytes.erase(privateKey)\r
                }\r
        }\r
 \r
@@ -249,7 +237,9 @@ export class Account {
        * @param {(string|Uint8Array)} password Used previously to lock the account\r
        * @returns True if successfully unlocked\r
        */\r
-       async unlock (password: string | Uint8Array<ArrayBuffer>): Promise<boolean> {\r
+       async exportPrivateKey (password: string | Uint8Array<ArrayBuffer>): Promise<Uint8Array<ArrayBuffer>>\r
+       async exportPrivateKey (password: string | Uint8Array<ArrayBuffer>, format: 'hex'): Promise<string>\r
+       async exportPrivateKey (password: string | Uint8Array<ArrayBuffer>, format?: 'hex'): Promise<string | Uint8Array<ArrayBuffer>> {\r
                if (typeof password === 'string') password = utf8.toBytes(password)\r
                if (password == null || !(password instanceof Uint8Array)) {\r
                        throw new Error('Password must be string or bytes')\r
@@ -271,12 +261,14 @@ export class Account {
                        if (id !== this.publicKey) {\r
                                throw null\r
                        }\r
-                       this.#privateKey = new Uint8Array(privateKey as ArrayBuffer)\r
+                       const sk = new Uint8Array(privateKey as ArrayBuffer)\r
+                       return format === 'hex'\r
+                               ? bytes.toHex(sk)\r
+                               : sk\r
                } catch (err) {\r
                        throw new Error(`Failed to export private key for Account ${this.address}`, { cause: err })\r
                } finally {\r
                        bytes.erase(password)\r
-                       return this.isUnlocked\r
                }\r
        }\r
 \r
index 025eb80b22d358ee631cf47e9116cec92ea09ba1..5e35952892c48cbb3ab8eb392935775207b13106 100644 (file)
@@ -141,9 +141,9 @@ abstract class Block {
                } else {
                        try {
                                const account = (typeof input === 'string')
-                                       ? await Account.fromPrivateKey(input)
+                                       ? await Account.fromPrivateKey('', input)
                                        : this.account
-                               this.signature = await account.sign(this)
+                               this.signature = await account.sign('', this)
                        } catch (err) {
                                throw new Error(`Failed to sign block`, { cause: err })
                        }
index a7530d72999a81f721005bc4e4d99ab1a76f0d9b..12eedf6515bd6660a467779f1c4dc016e0fc4732 100644 (file)
@@ -106,7 +106,7 @@ export abstract class Wallet {
                                const { privateKey, publicKey, index } = keypair\r
                                if (index == null) throw new RangeError('Account keys derived but index missing')\r
                                if (privateKey != null) {\r
-                                       output[index] = await Account.fromPrivateKey(privateKey, index)\r
+                                       output[index] = await Account.fromPrivateKey(this.seed, privateKey, index)\r
                                } else if (publicKey != null) {\r
                                        output[index] = await Account.fromPublicKey(publicKey, index)\r
                                } else {\r
@@ -152,11 +152,11 @@ export abstract class Wallet {
                        throw new Error('Failed to lock wallet')\r
                }\r
                try {\r
-                       const promises = []\r
-                       for (const account of this.#accounts) {\r
-                               promises.push(account.lock(this.seed))\r
-                       }\r
-                       await Promise.all(promises)\r
+                       // const promises = []\r
+                       // for (const account of this.#accounts) {\r
+                       //      promises.push(account.lock(this.seed))\r
+                       // }\r
+                       // await Promise.all(promises)\r
                        const headers = {\r
                                method: 'set',\r
                                name: this.id\r
@@ -248,11 +248,11 @@ export abstract class Wallet {
                                this.#s = new Uint8Array(seed as ArrayBuffer)\r
                                seed = null\r
                        }\r
-                       const promises = []\r
-                       for (const account of this.#accounts) {\r
-                               promises.push(account.unlock(this.seed))\r
-                       }\r
-                       await Promise.all(promises)\r
+                       // const promises = []\r
+                       // for (const account of this.#accounts) {\r
+                       //      promises.push(account.exportPrivateKey(this.seed))\r
+                       // }\r
+                       // await Promise.all(promises)\r
                } catch (err) {\r
                        throw new Error('Failed to unlock wallet')\r
                } finally {\r
index ac194f0b12ad09f76f1376663271e0efd4d27762..f67733b7fcec7dab7d13ec88f74eefc3d5d8b14c 100644 (file)
@@ -70,12 +70,9 @@ export class Safe extends WorkerInterface {
                delete data.password
 
                try {
-                       if (await this.#exists(name)) {
-                               throw null
-                       }
                        const derivationKey = await globalThis.crypto.subtle.importKey('raw', password, 'PBKDF2', false, ['deriveBits', 'deriveKey'])
                        if (this.#isInvalid(name, derivationKey, data)) {
-                               throw null
+                               throw new Error('Failed to import key')
                        }
 
                        const base32: { [key: string]: string } = {}
@@ -105,7 +102,7 @@ export class Safe extends WorkerInterface {
                                salt: salt.hex,
                                encrypted
                        }
-                       return await this.#add(record, name)
+                       return await this.#put(record, name)
                } catch (err) {
                        throw new Error(this.ERR_MSG)
                } finally {
@@ -123,12 +120,12 @@ export class Safe extends WorkerInterface {
                try {
                        const derivationKey = await globalThis.crypto.subtle.importKey('raw', password, 'PBKDF2', false, ['deriveBits', 'deriveKey'])
                        if (this.#isInvalid(name, derivationKey)) {
-                               throw null
+                               throw new Error('Failed to import key')
                        }
 
                        const record: SafeRecord = await this.#get(name)
                        if (record == null) {
-                               throw null
+                               throw new Error('Failed to find record')
                        }
                        const { encrypted } = record
 
@@ -197,7 +194,7 @@ export class Safe extends WorkerInterface {
                try {
                        return await this.#transact<SafeRecord>('readonly', db => db.get(name))
                } catch {
-                       throw new Error(this.ERR_MSG)
+                       throw new Error('Failed to get record')
                }
        }
 
@@ -214,13 +211,13 @@ export class Safe extends WorkerInterface {
                                resolve((event.target as IDBOpenDBRequest).result)
                        }
                        request.onerror = (event) => {
-                               reject(new Error('Failed to open IndexedDB', { cause: event }))
+                               reject(new Error('Database error', { cause: event }))
                        }
                })
        }
 
-       static async #add (record: SafeRecord, name: string): Promise<boolean> {
-               const result = await this.#transact<typeof name>('readwrite', db => db.add(record, name))
+       static async #put (record: SafeRecord, name: string): Promise<boolean> {
+               const result = await this.#transact<typeof name>('readwrite', db => db.put(record, name))
                return await this.#exists(result)
        }
 
@@ -232,7 +229,7 @@ export class Safe extends WorkerInterface {
                                resolve((event.target as IDBRequest).result)
                        }
                        request.onerror = (event) => {
-                               console.error('IndexedDB transaction error:', (event.target as IDBRequest).error)
+                               console.error('Database error')
                                reject((event.target as IDBRequest).error)
                        }
                })
index a3ef8ca39b222ad612733291c3eb81b59b488dd4..38307803d4335c5c41305f6290aaf218853e2bb0 100644 (file)
@@ -12,8 +12,9 @@ await suite('BIP-44 account derivation', async () => {
                const wallet = await Bip44Wallet.fromSeed(NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED)\r
                await wallet.unlock(NANO_TEST_VECTORS.PASSWORD)\r
                const account = await wallet.account()\r
+               const privateKey = await account.exportPrivateKey(wallet.seed, 'hex')\r
 \r
-               assert.equals(account.privateKey, NANO_TEST_VECTORS.PRIVATE_0)\r
+               assert.equals(privateKey, NANO_TEST_VECTORS.PRIVATE_0)\r
                assert.equals(account.publicKey, NANO_TEST_VECTORS.PUBLIC_0)\r
                assert.equals(account.address, NANO_TEST_VECTORS.ADDRESS_0)\r
                assert.equals(account.index, 0)\r
@@ -28,12 +29,14 @@ await suite('BIP-44 account derivation', async () => {
                const wallet = await Bip44Wallet.fromSeed(NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED)\r
                await wallet.unlock(NANO_TEST_VECTORS.PASSWORD)\r
                const accounts = await wallet.accounts(1, 2)\r
+               const privateKey1 = await accounts[1].exportPrivateKey(wallet.seed, 'hex')\r
+               const privateKey2 = await accounts[2].exportPrivateKey(wallet.seed, 'hex')\r
 \r
                assert.equals(accounts.length, 2)\r
-               assert.equals(accounts[1].privateKey, NANO_TEST_VECTORS.PRIVATE_1)\r
+               assert.equals(privateKey1, NANO_TEST_VECTORS.PRIVATE_1)\r
                assert.equals(accounts[1].publicKey, NANO_TEST_VECTORS.PUBLIC_1)\r
                assert.equals(accounts[1].address, NANO_TEST_VECTORS.ADDRESS_1)\r
-               assert.equals(accounts[2].privateKey, NANO_TEST_VECTORS.PRIVATE_2)\r
+               assert.equals(privateKey2, NANO_TEST_VECTORS.PRIVATE_2)\r
                assert.equals(accounts[2].publicKey, NANO_TEST_VECTORS.PUBLIC_2)\r
                assert.equals(accounts[2].address, NANO_TEST_VECTORS.ADDRESS_2)\r
 \r
@@ -51,9 +54,10 @@ await suite('BIP-44 account derivation', async () => {
                        assert.exists(a)\r
                        assert.exists(a.address)\r
                        assert.exists(a.publicKey)\r
-                       assert.exists(a.privateKey)\r
                        assert.exists(a.index)\r
                        assert.equals(a.index, i)\r
+                       const privateKey = await a.exportPrivateKey(wallet.seed, 'hex')\r
+                       assert.exists(privateKey)\r
                }\r
 \r
                await wallet.destroy()\r
@@ -71,8 +75,9 @@ await suite('BLAKE2b account derivation', async () => {
                        assert.exists(a)\r
                        assert.exists(a.address)\r
                        assert.exists(a.publicKey)\r
-                       assert.exists(a.privateKey)\r
                        assert.exists(a.index)\r
+                       const privateKey = await a.exportPrivateKey(wallet.seed, 'hex')\r
+                       assert.exists(privateKey)\r
                }\r
 \r
                const highAccounts = await wallet.accounts(0x70000000, 0x700000ff)\r
@@ -82,8 +87,9 @@ await suite('BLAKE2b account derivation', async () => {
                        assert.exists(a)\r
                        assert.exists(a.address)\r
                        assert.exists(a.publicKey)\r
-                       assert.exists(a.privateKey)\r
                        assert.exists(a.index)\r
+                       const privateKey = await a.exportPrivateKey(wallet.seed, 'hex')\r
+                       assert.exists(privateKey)\r
                }\r
 \r
                await wallet.destroy()\r