From 075a2ef4b1988f6f1e447b9e193a7e350967510f Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Wed, 16 Jul 2025 07:58:45 -0700 Subject: [PATCH] Lock accounts using the wallet seed by default. Refactor account unlock method as export method. Always overwrite records in Safe db. Update tests. --- src/lib/account.ts | 68 ++++++++++++++++------------------- src/lib/block.ts | 4 +-- src/lib/wallets/wallet.ts | 22 ++++++------ src/lib/workers/safe.ts | 21 +++++------ test/test.derive-accounts.mjs | 18 ++++++---- 5 files changed, 64 insertions(+), 69 deletions(-) diff --git a/src/lib/account.ts b/src/lib/account.ts index 7a167af..b892016 100644 --- a/src/lib/account.ts +++ b/src/lib/account.ts @@ -120,14 +120,19 @@ export class Account { } /** - * Instantiates an Account object from its private key. The corresponding - * public key will automatically be derived and saved. + * Instantiates an Account object from its private key which is then encrypted + * and stored in IndexedDB. The corresponding public key will automatically be + * derived and saved. * * @param {(string|Uint8Array)} privateKey - Private key of the account * @param {number} [index] - Account number used when deriving the key * @returns {Account} A new Account object */ - static async fromPrivateKey (privateKey: string | Uint8Array, index?: number): Promise { + static async fromPrivateKey (password: string | Uint8Array, privateKey: string | Uint8Array, index?: number): Promise { + if (typeof password === 'string') password = utf8.toBytes(password) + if (password == null || !(password instanceof Uint8Array)) { + throw new Error('Invalid password when importing Account') + } this.#validateKey(privateKey) if (typeof privateKey === 'string') privateKey = hex.toBytes(privateKey) let publicKey: string @@ -142,42 +147,25 @@ export class Account { } catch (err) { throw new Error(`Failed to derive public key from private key`, { cause: err }) } - try { - const self = await this.fromPublicKey(publicKey, index) - self.#privateKey = privateKey - return self - } catch (err) { - throw new Error(`Failed to lock new Account`, { cause: err }) - } - } - /** - * Locks the account with a password that will be needed to unlock it later. - * - * @param {(string|Uint8Array)} password Used to lock the account - * @returns True if successfully locked - */ - async lock (password: string | Uint8Array): Promise { - if (this.isLocked) { - throw new Error(`Account ${this.address} is already locked`) - } - if (typeof password === 'string') password = utf8.toBytes(password) - if (password == null || !(password instanceof Uint8Array)) { - throw new Error(`Failed to lock Account ${this.address}`) - } + const self = await this.fromPublicKey(publicKey, index) try { const headers = { method: 'set', - name: this.publicKey + name: publicKey } const data = { password: password.buffer, - id: new Uint8Array(this.#publicKey).buffer, - privateKey: this.#privateKey.buffer + id: hex.toBytes(publicKey).buffer, + privateKey: privateKey.buffer } - return await SafeWorker.add(headers, data) + const isLocked = await SafeWorker.add(headers, data) + if (!isLocked) { + throw null + } + return self } catch (err) { - throw new Error(`Failed to lock Account ${this.address}`, { cause: err }) + throw new Error(`Failed to lock Account ${self.address}`, { cause: err }) } finally { bytes.erase(password) } @@ -223,16 +211,14 @@ export class Account { * @param {(ChangeBlock|ReceiveBlock|SendBlock)} block - The block data to be hashed and signed * @returns {Promise} Hexadecimal-formatted 64-byte signature */ - async sign (block: ChangeBlock | ReceiveBlock | SendBlock): Promise { - if (this.isLocked) { - throw new Error(`Account ${this.address} must be unlocked prior to signing`) - } + async sign (password: string | Uint8Array, block: ChangeBlock | ReceiveBlock | SendBlock): Promise { + const privateKey = await this.exportPrivateKey(password) try { const headers = { method: 'detached' } const data = { - privateKey: new Uint8Array(this.#privateKey).buffer, + privateKey: privateKey.buffer, msg: hex.toBytes(block.hash).buffer } const result = await NanoNaClWorker.add(headers, data) @@ -240,6 +226,8 @@ export class Account { return result } catch (err) { throw new Error(`Failed to sign block`, { cause: err }) + } finally { + bytes.erase(privateKey) } } @@ -249,7 +237,9 @@ export class Account { * @param {(string|Uint8Array)} password Used previously to lock the account * @returns True if successfully unlocked */ - async unlock (password: string | Uint8Array): Promise { + async exportPrivateKey (password: string | Uint8Array): Promise> + async exportPrivateKey (password: string | Uint8Array, format: 'hex'): Promise + async exportPrivateKey (password: string | Uint8Array, format?: 'hex'): Promise> { if (typeof password === 'string') password = utf8.toBytes(password) if (password == null || !(password instanceof Uint8Array)) { throw new Error('Password must be string or bytes') @@ -271,12 +261,14 @@ export class Account { if (id !== this.publicKey) { throw null } - this.#privateKey = new Uint8Array(privateKey as ArrayBuffer) + const sk = new Uint8Array(privateKey as ArrayBuffer) + return format === 'hex' + ? bytes.toHex(sk) + : sk } catch (err) { throw new Error(`Failed to export private key for Account ${this.address}`, { cause: err }) } finally { bytes.erase(password) - return this.isUnlocked } } diff --git a/src/lib/block.ts b/src/lib/block.ts index 025eb80..5e35952 100644 --- a/src/lib/block.ts +++ b/src/lib/block.ts @@ -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 }) } diff --git a/src/lib/wallets/wallet.ts b/src/lib/wallets/wallet.ts index a7530d7..12eedf6 100644 --- a/src/lib/wallets/wallet.ts +++ b/src/lib/wallets/wallet.ts @@ -106,7 +106,7 @@ export abstract class Wallet { const { privateKey, publicKey, index } = keypair if (index == null) throw new RangeError('Account keys derived but index missing') if (privateKey != null) { - output[index] = await Account.fromPrivateKey(privateKey, index) + output[index] = await Account.fromPrivateKey(this.seed, privateKey, index) } else if (publicKey != null) { output[index] = await Account.fromPublicKey(publicKey, index) } else { @@ -152,11 +152,11 @@ export abstract class Wallet { throw new Error('Failed to lock wallet') } try { - const promises = [] - for (const account of this.#accounts) { - promises.push(account.lock(this.seed)) - } - await Promise.all(promises) + // const promises = [] + // for (const account of this.#accounts) { + // promises.push(account.lock(this.seed)) + // } + // await Promise.all(promises) const headers = { method: 'set', name: this.id @@ -248,11 +248,11 @@ export abstract class Wallet { this.#s = new Uint8Array(seed as ArrayBuffer) seed = null } - const promises = [] - for (const account of this.#accounts) { - promises.push(account.unlock(this.seed)) - } - await Promise.all(promises) + // const promises = [] + // for (const account of this.#accounts) { + // promises.push(account.exportPrivateKey(this.seed)) + // } + // await Promise.all(promises) } catch (err) { throw new Error('Failed to unlock wallet') } finally { diff --git a/src/lib/workers/safe.ts b/src/lib/workers/safe.ts index ac194f0..f67733b 100644 --- a/src/lib/workers/safe.ts +++ b/src/lib/workers/safe.ts @@ -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('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 { - const result = await this.#transact('readwrite', db => db.add(record, name)) + static async #put (record: SafeRecord, name: string): Promise { + const result = await this.#transact('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) } }) diff --git a/test/test.derive-accounts.mjs b/test/test.derive-accounts.mjs index a3ef8ca..3830780 100644 --- a/test/test.derive-accounts.mjs +++ b/test/test.derive-accounts.mjs @@ -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) await wallet.unlock(NANO_TEST_VECTORS.PASSWORD) const account = await wallet.account() + const privateKey = await account.exportPrivateKey(wallet.seed, 'hex') - assert.equals(account.privateKey, NANO_TEST_VECTORS.PRIVATE_0) + assert.equals(privateKey, NANO_TEST_VECTORS.PRIVATE_0) assert.equals(account.publicKey, NANO_TEST_VECTORS.PUBLIC_0) assert.equals(account.address, NANO_TEST_VECTORS.ADDRESS_0) assert.equals(account.index, 0) @@ -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) await wallet.unlock(NANO_TEST_VECTORS.PASSWORD) const accounts = await wallet.accounts(1, 2) + const privateKey1 = await accounts[1].exportPrivateKey(wallet.seed, 'hex') + const privateKey2 = await accounts[2].exportPrivateKey(wallet.seed, 'hex') assert.equals(accounts.length, 2) - assert.equals(accounts[1].privateKey, NANO_TEST_VECTORS.PRIVATE_1) + assert.equals(privateKey1, NANO_TEST_VECTORS.PRIVATE_1) assert.equals(accounts[1].publicKey, NANO_TEST_VECTORS.PUBLIC_1) assert.equals(accounts[1].address, NANO_TEST_VECTORS.ADDRESS_1) - assert.equals(accounts[2].privateKey, NANO_TEST_VECTORS.PRIVATE_2) + assert.equals(privateKey2, NANO_TEST_VECTORS.PRIVATE_2) assert.equals(accounts[2].publicKey, NANO_TEST_VECTORS.PUBLIC_2) assert.equals(accounts[2].address, NANO_TEST_VECTORS.ADDRESS_2) @@ -51,9 +54,10 @@ await suite('BIP-44 account derivation', async () => { assert.exists(a) assert.exists(a.address) assert.exists(a.publicKey) - assert.exists(a.privateKey) assert.exists(a.index) assert.equals(a.index, i) + const privateKey = await a.exportPrivateKey(wallet.seed, 'hex') + assert.exists(privateKey) } await wallet.destroy() @@ -71,8 +75,9 @@ await suite('BLAKE2b account derivation', async () => { assert.exists(a) assert.exists(a.address) assert.exists(a.publicKey) - assert.exists(a.privateKey) assert.exists(a.index) + const privateKey = await a.exportPrivateKey(wallet.seed, 'hex') + assert.exists(privateKey) } const highAccounts = await wallet.accounts(0x70000000, 0x700000ff) @@ -82,8 +87,9 @@ await suite('BLAKE2b account derivation', async () => { assert.exists(a) assert.exists(a.address) assert.exists(a.publicKey) - assert.exists(a.privateKey) assert.exists(a.index) + const privateKey = await a.exportPrivateKey(wallet.seed, 'hex') + assert.exists(privateKey) } await wallet.destroy() -- 2.47.3