diff --git a/packages/bitcore-client/src/encryption.ts b/packages/bitcore-client/src/encryption.ts index 0276c70832e..cad311fb976 100644 --- a/packages/bitcore-client/src/encryption.ts +++ b/packages/bitcore-client/src/encryption.ts @@ -23,33 +23,83 @@ export function encryptEncryptionKey(encryptionKey, password) { return encData; } -export function decryptEncryptionKey(encEncryptionKey, password) { +export function decryptEncryptionKey(encEncryptionKey, password, toBuffer: true): Buffer; +export function decryptEncryptionKey(encEncryptionKey, password, toBuffer: false): string; +export function decryptEncryptionKey(encEncryptionKey, password, toBuffer?: boolean): Buffer | string { const password_hash = Buffer.from(SHA512(password)); const key = password_hash.subarray(0, 32); const iv = password_hash.subarray(32, 48); const decipher = crypto.createDecipheriv(algo, key, iv); - const decrypted = decipher.update(encEncryptionKey, 'hex', 'hex' as any) + decipher.final('hex'); - return decrypted; + + let payload: Buffer | undefined; + let final: Buffer | undefined; + let output: Buffer | undefined; + try { + payload = decipher.update(encEncryptionKey, 'hex'); + final = decipher.final(); + output = Buffer.concat([payload, final]); + return toBuffer ? output : output.toString('hex'); + } finally { + payload.fill(0); + final.fill(0); + if (!toBuffer) { + // Don't fill output if it's what's returned directly + output.fill(0); + } + } } +/** @deprecated - Use encryptBuffer */ export function encryptPrivateKey(privKey, pubKey, encryptionKey) { - const key = Buffer.from(encryptionKey, 'hex'); - const doubleHash = Buffer.from(SHA256(SHA256(pubKey)), 'hex'); - const iv = doubleHash.subarray(0, 16); - const cipher = crypto.createCipheriv(algo, key, iv); - const encData = cipher.update(privKey, 'utf8', 'hex') + cipher.final('hex'); - return encData; + encryptionKey = Buffer.from(encryptionKey, 'hex'); + privKey = Buffer.from(privKey, 'utf8'); + return encryptBuffer(privKey, pubKey, encryptionKey).toString('hex'); } -function decryptPrivateKey(encPrivateKey: string, pubKey: string, encryptionKey: string) { - const key = Buffer.from(encryptionKey, 'hex'); +function decryptPrivateKey(encPrivateKey: string, pubKey: string, encryptionKey: Buffer | string) { + if (!Buffer.isBuffer(encryptionKey)) { + encryptionKey = Buffer.from(encryptionKey, 'hex'); + } const doubleHash = Buffer.from(SHA256(SHA256(pubKey)), 'hex'); const iv = doubleHash.subarray(0, 16); - const decipher = crypto.createDecipheriv(algo, key, iv); + const decipher = crypto.createDecipheriv(algo, encryptionKey, iv); const decrypted = decipher.update(encPrivateKey, 'hex', 'utf8') + decipher.final('utf8'); return decrypted; } +function encryptBuffer(data: Buffer, pubKey: string, encryptionKey: Buffer): Buffer { + let payload: Buffer | undefined; + try { + const iv = Buffer.from(SHA256(SHA256(pubKey)), 'hex').subarray(0, 16); + const cipher = crypto.createCipheriv(algo, encryptionKey, iv); + payload = cipher.update(data); + return Buffer.concat([payload, cipher.final()]); + } finally { + if (Buffer.isBuffer(payload)) { + payload.fill(0); + } + } +} + +function decryptToBuffer(encHex: string, pubKey: string, encryptionKey: Buffer): Buffer { + let decrypted: Buffer | undefined; + let final: Buffer | undefined; + try { + const iv = Buffer.from(SHA256(SHA256(pubKey)), 'hex').subarray(0, 16); + const decipher = crypto.createDecipheriv(algo, encryptionKey, iv); + decrypted = decipher.update(encHex, 'hex'); + final = decipher.final(); + return Buffer.concat([decrypted, final]); + } finally { + if (Buffer.isBuffer(decrypted)) { + decrypted.fill(0); + } + if (Buffer.isBuffer(final)) { + final.fill(0); + } + } +} + function sha512KDF(passphrase: string, salt: Buffer, derivationOptions: { rounds?: number }): string { const rounds = derivationOptions.rounds || 1; // if salt was sent in as a string, we will have to assume the default encoding type @@ -134,6 +184,8 @@ export const Encryption = { decryptEncryptionKey, encryptPrivateKey, decryptPrivateKey, + encryptBuffer, + decryptToBuffer, generateEncryptionKey, bitcoinCoreDecrypt }; diff --git a/packages/bitcore-client/src/storage.ts b/packages/bitcore-client/src/storage.ts index b45772b8ff0..0d8e6b6f776 100644 --- a/packages/bitcore-client/src/storage.ts +++ b/packages/bitcore-client/src/storage.ts @@ -57,8 +57,11 @@ export class Storage { (this.storageType as Mongo)?.close?.(); } - async loadWallet(params: { name: string }): Promise { - const { name } = params; + async loadWallet(params: { name: string }): Promise + async loadWallet(params: { name: string; raw: true }): Promise + async loadWallet(params: { name: string; raw: false }): Promise + async loadWallet(params: { name: string; raw?: boolean }): Promise { + const { name, raw } = params; let wallet: string | void; for (const db of await this.verifyDbs(this.db)) { try { @@ -72,7 +75,7 @@ export class Storage { if (!wallet) { return; } - return JSON.parse(wallet) as IWallet; + return raw ? wallet : JSON.parse(wallet) as IWallet; } async deleteWallet(params: { name: string }) { @@ -113,6 +116,7 @@ export class Storage { return this.storageType.saveWallet({ wallet }); } + /** @deprecated - Use getStoredKey */ async getKey(params: { address: string; name: string; @@ -132,6 +136,7 @@ export class Storage { } } + /** @deprecated - Use getStoredKeys */ async getKeys(params: { addresses: string[]; name: string; encryptionKey: string }): Promise> { const { addresses, name, encryptionKey } = params; const keys = new Array(); @@ -158,6 +163,7 @@ export class Storage { return keys; } + /** @deprecated - Use addKeysSafe */ async addKeys(params: { name: string; keys: KeyImport[]; encryptionKey: string }) { const { name, keys, encryptionKey } = params; let open = true; @@ -189,4 +195,64 @@ export class Storage { const { name, limit, skip } = params; return this.storageType.getAddresses({ name, limit, skip }); } + + async addKeysSafe(params: { name: string; keys: KeyImport[] }) { + const { name, keys } = params; + let i = 0; + for (const key of keys) { + const { path } = key; + const pubKey = key.pubKey; + // key.privKey is encrypted - cannot be directly used to retrieve pubKey if required + if (!pubKey) { + throw new Error(`pubKey is undefined for ${name}. Keys not added to storage`); + } + let payload = {}; + if (pubKey) { + payload = { key: JSON.stringify(key), pubKey, path }; + } + const toStore = JSON.stringify(payload); + // open on first, close on last + await this.storageType.addKeys({ name, key, toStore, open: i === 0, keepAlive: i < keys.length - 1 }); + ++i; + } + } + + async getStoredKeys(params: { addresses: string[]; name: string }): Promise> { + const { addresses, name } = params; + const keys = new Array(); + let i = 0; + for (const address of addresses) { + try { + const key = await this.getStoredKey({ + name, + address, + open: i === 0, // open on first + keepAlive: i < addresses.length - 1, // close on last + }); + keys.push(key); + } catch (err) { + // don't continue from catch - i must be incremented + console.error(err); + } + ++i; + } + return keys; + } + + private async getStoredKey(params: { + address: string; + name: string; + keepAlive: boolean; + open: boolean; + }): Promise { + const { address, name, keepAlive, open } = params; + const payload = await this.storageType.getKey({ name, address, keepAlive, open }); + const json = JSON.parse(payload) || payload; + const { key } = json; // pubKey available - not needed + if (key) { + return JSON.parse(key); + } else { + return json; + } + } } diff --git a/packages/bitcore-client/src/wallet.ts b/packages/bitcore-client/src/wallet.ts index b1bd9348435..b9b2a19b2f6 100644 --- a/packages/bitcore-client/src/wallet.ts +++ b/packages/bitcore-client/src/wallet.ts @@ -1,4 +1,7 @@ +import { mkdir, writeFile } from 'fs/promises'; import 'source-map-support/register'; +import os from 'os'; +import path from 'path'; import Mnemonic from '@bitpay-labs/bitcore-mnemonic'; import { BitcoreLib, @@ -36,9 +39,11 @@ const chainLibs = { XRP: xrpl, SOL: { SolKit, SolanaProgram } }; +const CURRENT_WALLET_VERSION = 2; export interface IWalletExt extends IWallet { storage?: Storage; + version?: 0 | 2; // Wallet versioning used for backwards compatibility } export class Wallet { @@ -49,7 +54,7 @@ export class Wallet { client: Client; storage: Storage; storageType: string; - unlocked?: { encryptionKey: string; masterKey: string }; + unlocked?: { encryptionKey: Buffer; masterKey: { xprivkey: Buffer; privateKey: Buffer } }; password: string; encryptionKey: string; authPubKey: string; @@ -64,6 +69,7 @@ export class Wallet { lite: boolean; addressType: string; addressZero: string; + version?: number; static XrpAccountFlags = xrpl.AccountSetTfFlags; @@ -120,7 +126,8 @@ export class Wallet { storageType: this.storageType, lite, addressType: this.addressType, - addressZero: this.addressZero + addressZero: this.addressZero, + version: this.version }; } @@ -157,11 +164,7 @@ export class Wallet { const keyType = Constants.ALGO_TO_KEY_TYPE[algo]; hdPrivKey = mnemonic.toHDPrivateKey('', network).derive(Deriver.pathFor(chain, network), keyType); } - const privKeyObj = hdPrivKey.toObject(); - - // Generate authentication keys - const authKey = new PrivateKey(); - const authPubKey = authKey.toPublicKey().toString(); + const privKeyObj = hdPrivKey.toObjectWithBufferPrivateKey(); // Generate public keys // bip44 compatible pubKey @@ -169,8 +172,18 @@ export class Wallet { // Generate and encrypt the encryption key and private key const walletEncryptionKey = Encryption.generateEncryptionKey(); - const encryptionKey = Encryption.encryptEncryptionKey(walletEncryptionKey, password); - const encPrivateKey = Encryption.encryptPrivateKey(JSON.stringify(privKeyObj), pubKey, walletEncryptionKey); + const encryptionKey = Encryption.encryptEncryptionKey(walletEncryptionKey, password); // stored, password-wrapped + + // Encrypt privKeyObj.privateKey & privKeyObj.xprivkey + const xprivBuffer = BitcoreLib.encoding.Base58Check.decode(privKeyObj.xprivkey); + privKeyObj.xprivkey = Encryption.encryptBuffer(xprivBuffer, pubKey, walletEncryptionKey).toString('hex'); + privKeyObj.privateKey = Encryption.encryptBuffer(privKeyObj.privateKey, pubKey, walletEncryptionKey).toString('hex'); + + // Generate authentication keys + const authKey = new PrivateKey(); + const authPubKey = authKey.toPublicKey().toString(); + + const masterKeyWithEncryptedPrivateKeys = JSON.stringify(privKeyObj); storageType = storageType ? storageType : 'Level'; storage = @@ -182,13 +195,11 @@ export class Wallet { storageType }); - let alreadyExists; - try { - alreadyExists = await this.loadWallet({ storage, name, storageType }); - } catch { /* ignore */ } + const alreadyExists = await this.loadWallet({ storage, name, storageType }).catch(() => {/** no op */}); if (alreadyExists) { throw new Error('Wallet already exists'); } + const wallet = new Wallet({ name, chain, @@ -198,7 +209,7 @@ export class Wallet { encryptionKey, authKey, authPubKey, - masterKey: encPrivateKey, + masterKey: masterKeyWithEncryptedPrivateKeys, password, xPubKey: hdPrivKey.xpubkey, pubKey, @@ -207,7 +218,8 @@ export class Wallet { storageType, lite, addressType, - addressZero: null + addressZero: null, + version: CURRENT_WALLET_VERSION, } as IWalletExt); // save wallet to storage and then bitcore-node @@ -218,12 +230,6 @@ export class Wallet { storageType }); - if (!xpriv) { - console.log(mnemonic.toString()); - } else { - console.log(hdPrivKey.toString()); - } - await loadedWallet.register().catch(e => { console.debug(e); console.error('Failed to register wallet with bitcore-node.'); @@ -251,11 +257,12 @@ export class Wallet { let { storage } = params; storage = storage || new Storage({ errorIfExists: false, createIfMissing: false, path, storageType }); const loadedWallet = await storage.loadWallet({ name }); - if (loadedWallet) { - return new Wallet(Object.assign(loadedWallet, { storage })); - } else { + + if (!loadedWallet) { throw new Error('No wallet could be found'); } + + return new Wallet(Object.assign(loadedWallet, { storage })); } /** @@ -280,7 +287,24 @@ export class Wallet { } lock() { - this.unlocked = undefined; + if (!this.unlocked) { + return this; + } + + if (Buffer.isBuffer(this.unlocked.masterKey.xprivkey)) { + this.unlocked.masterKey.xprivkey.fill(0); + } + + if (Buffer.isBuffer(this.unlocked.masterKey.privateKey)) { + this.unlocked.masterKey.privateKey.fill(0); + } + this.unlocked.masterKey = null; + + if (Buffer.isBuffer(this.unlocked.encryptionKey)) { + this.unlocked.encryptionKey.fill(0); + } + this.unlocked.encryptionKey = null; + this.unlocked = null; return this; } @@ -289,12 +313,15 @@ export class Wallet { if (!validPass) { throw new Error('Incorrect Password'); } - const encryptionKey = await Encryption.decryptEncryptionKey(this.encryptionKey, password); + const encryptionKey = Encryption.decryptEncryptionKey(this.encryptionKey, password, true); + if (this.version != CURRENT_WALLET_VERSION) { + await this.migrateWallet(encryptionKey); + } let masterKey; if (!this.lite) { - const encMasterKey = this.masterKey; - const masterKeyStr = await Encryption.decryptPrivateKey(encMasterKey, this.pubKey, encryptionKey); - masterKey = JSON.parse(masterKeyStr); + masterKey = JSON.parse(this.masterKey); + masterKey.xprivkey = Encryption.decryptToBuffer(masterKey.xprivkey, this.pubKey, encryptionKey); + masterKey.privateKey = Encryption.decryptToBuffer(masterKey.privateKey, this.pubKey, encryptionKey); } this.unlocked = { encryptionKey, @@ -303,6 +330,140 @@ export class Wallet { return this; } + async migrateWallet(encryptionKey: Buffer): Promise { + const preMigrationVersion = this.version ?? 1; + /** + * 0: Checks + */ + if (preMigrationVersion == CURRENT_WALLET_VERSION) { + console.warn('Wallet migration unnecessarily called - wallet is current version'); + return this; + } + + if (preMigrationVersion > CURRENT_WALLET_VERSION) { + console.warn(`Wallet version ${preMigrationVersion} greater than expected current wallet version ${CURRENT_WALLET_VERSION}`); + return this; + } + + console.log(`Migrating wallet from version ${preMigrationVersion} to version ${CURRENT_WALLET_VERSION}`); + + /** + * 1: Wallet to .bak + */ + const rawWallet = await this.storage.loadWallet({ name: this.name, raw: true }); + if (!rawWallet) { + throw new Error('Migration failed - wallet not found'); + } + + const backupDir = path.join( + os.homedir(), + '.bitcore', + 'bitcoreWallet', + 'backup' + ); + await mkdir(backupDir, { recursive: true }); + + const walletFilePath = path.join(backupDir, `${this.name}.v${preMigrationVersion}.bak`); + + await writeFile(walletFilePath, rawWallet, 'utf8') + .then(() => console.log(`Pre-migration wallet backup written to ${walletFilePath}`)) + .catch(err => { + console.error('Wallet backup failed, aborting migration', err.message); + throw new Error('Migration failure: failed to write wallet backup file. Aborting.'); + }); + + /** + * Retrieve stored keys for backup and for migration + */ + const addresses = await this.getAddresses(); + const storedKeys = await this.storage.getStoredKeys({ + addresses, + name: this.name, + }); + + // Back up keys (enc) + const backupKeysStr = JSON.stringify(storedKeys); + const keysFilePath = path.join(backupDir, `${this.name}_keys.v${preMigrationVersion}.bak`); + await writeFile(keysFilePath, backupKeysStr, 'utf8') + .then(() => console.log(`Pre-migration keys backup written to ${keysFilePath}`)) + .catch(err => { + console.error('Keys backup failed, aborting migration', err.message); + throw new Error('Migration failure: failed to write keys backup file. Aborting.'); + }); + + /** + * 2. Convert + */ + + /** + * 2a. Convert masterKey and encryptionKey + */ + const masterKeyStr = Encryption.decryptPrivateKey(this.masterKey, this.pubKey, encryptionKey); + const masterKey = JSON.parse(masterKeyStr); + if (!(masterKey.xprivkey && masterKey.privateKey)) { + throw new Error('Migration failure: masterKey is not formatted as expected'); + } + + const xprivBuffer = BitcoreLib.encoding.Base58Check.decode(masterKey.xprivkey); + const enc_xprivkeyBuffer = Encryption.encryptBuffer(xprivBuffer, this.pubKey, encryptionKey); + xprivBuffer.fill(0); + + masterKey.xprivkey = enc_xprivkeyBuffer.toString('hex'); + enc_xprivkeyBuffer.fill(0); + + const privateKeyBuffer = Buffer.from(masterKey.privateKey, 'hex'); + const enc_privateKeyBuffer = Encryption.encryptBuffer(privateKeyBuffer, this.pubKey, encryptionKey); + privateKeyBuffer.fill(0); + + masterKey.privateKey = enc_privateKeyBuffer.toString('hex'); + enc_privateKeyBuffer.fill(0); + + // String with encrypted hex-encoded xprivkey and privateKey strings + this.masterKey = JSON.stringify(masterKey); + + /** + * 2b. Convert signing keys + */ + const newKeys = []; + for (const key of storedKeys) { + const { encKey, pubKey } = key; + const decryptedKey = Encryption.decryptPrivateKey(encKey, pubKey, encryptionKey); + const decryptedKeyJSON = JSON.parse(decryptedKey); + + // Convert private key to buffer format (uniform across all chains) + const privKeyBuffer = Deriver.privateKeyToBuffer(this.chain, decryptedKeyJSON.privKey); + const encryptedPrivateKeyBuffer = Encryption.encryptBuffer(privKeyBuffer, pubKey, encryptionKey); + privKeyBuffer.fill(0); // Zero out the plaintext buffer + + decryptedKeyJSON.privKey = encryptedPrivateKeyBuffer.toString('hex'); + newKeys.push(decryptedKeyJSON); + } + + /** + * 3. Overwrite + */ + // 3a. Overwrite keys + await this.storage.addKeysSafe({ name: this.name, keys: newKeys }) + .catch(err => { + console.error('Migration failure: updated keys not successfully stored', err); + throw new Error('Migration failure: keys not successfully stored. Use backups to restore prior wallet and keys.'); + }); + + // 3b. Overwrite wallet + this.version = CURRENT_WALLET_VERSION; + const storedEncryptedPassword = this.password; // Wallet.toObject() rehashes password - save and replace + const walletObj = this.toObject(false); + walletObj.password = storedEncryptedPassword; + await this.storage.saveWallet({ wallet: walletObj }) + .catch(err => { + console.error('Migration failure: wallet not successfully saved', err); + throw new Error('Migration failure: wallet not successfully saved. Use backups to restore prior wallet and keys'); + }); + + console.log('Migration succeeded'); + return this; + } + async register(params: { baseUrl?: string } = {}) { const { baseUrl } = params; if (baseUrl) { @@ -408,7 +569,6 @@ export class Wallet { // If tokenName was given, find the token by name (e.g. USDC_m) let tokenObj = tokenName && this.tokens.find(tok => tok.name === tokenName); // If not found by name AND token was given, find the token by symbol (e.g. USDC) - // NOTE: we don't want to tokenObj = tokenObj || (token && this.tokens.find(tok => tok.symbol === token && [token, undefined].includes(tok.name))); if (!tokenObj) { throw new Error(`${tokenName || token} not found on wallet ${this.name}`); @@ -596,26 +756,43 @@ export class Wallet { } async importKeys(params: { keys: KeyImport[]; rederiveAddys?: boolean }) { - const { encryptionKey } = this.unlocked; const { rederiveAddys } = params; let { keys } = params; - let keysToSave = keys.filter(key => typeof key.privKey === 'string'); + // Avoid mutating caller-owned references (we'll encrypt privKeys below) + keys = keys.map(k => ({ ...k })); + + const keysToSave: KeyImport[] = []; + for (const key of keys) { + if (typeof key.privKey !== 'string') { + continue; + } + + let privKeyBuffer: Buffer | undefined; + try { + privKeyBuffer = Deriver.privateKeyToBuffer(this.chain, key.privKey); + if (typeof key.pubKey !== 'string') { + key.pubKey = Deriver.getPublicKey(this.chain, this.network, privKeyBuffer); + } + if (!key.pubKey) { + throw new Error(`pubKey is undefined for ${this.name}. Keys not added to storage`); + } - if (rederiveAddys) { - keysToSave = keysToSave.map(key => ({ - ...key, - address: key.pubKey ? Deriver.getAddress(this.chain, this.network, key.pubKey, this.addressType) : key.address - }) as KeyImport); - keys = keys.map(key => ({ - ...key, - address: key.pubKey ? Deriver.getAddress(this.chain, this.network, key.pubKey, this.addressType) : key.address - }) as KeyImport); + if (rederiveAddys) { + key.address = Deriver.getAddress(this.chain, this.network, key.pubKey, this.addressType); + } + + key.privKey = Encryption.encryptBuffer(privKeyBuffer, key.pubKey, this.unlocked.encryptionKey).toString('hex'); + } finally { + if (Buffer.isBuffer(privKeyBuffer)) { + privKeyBuffer.fill(0); + } + } + keysToSave.push(key); } if (keysToSave.length) { - await this.storage.addKeys({ + await this.storage.addKeysSafe({ keys: keysToSave, - encryptionKey, name: this.name }); } @@ -642,24 +819,27 @@ export class Wallet { } let addresses = []; let decryptedKeys; - if (!keys && !signingKeys) { - for (const utxo of utxos) { - addresses.push(utxo.address); - } - addresses = addresses.length > 0 ? addresses : await this.getAddresses(); - decryptedKeys = await this.storage.getKeys({ - addresses, - name: this.name, - encryptionKey: this.unlocked.encryptionKey - }); - } else if (!signingKeys) { - addresses.push(keys[0]); - for (const element of utxos) { - const keyToDecrypt = keys.find(key => key.address === element.address); - addresses.push(keyToDecrypt); + let decryptPrivateKeys = true; + if (!signingKeys) { + if (!keys) { + for (const utxo of utxos) { + addresses.push(utxo.address); + } + addresses = addresses.length > 0 ? addresses : await this.getAddresses(); + decryptedKeys = await this.storage.getStoredKeys({ + addresses, + name: this.name, + }); + } else { + addresses.push(keys[0]); + for (const element of utxos) { + const keyToDecrypt = keys.find(key => key.address === element.address); + addresses.push(keyToDecrypt); + } + const decryptedParams = Encryption.bitcoinCoreDecrypt(addresses, passphrase); + decryptedKeys = [...decryptedParams.jsonlDecrypted]; + decryptPrivateKeys = false; } - const decryptedParams = Encryption.bitcoinCoreDecrypt(addresses, passphrase); - decryptedKeys = [...decryptedParams.jsonlDecrypted]; } if (this.isUtxoChain()) { // If changeAddressIdx == null, then save the change key at the current addressIndex (just in case) @@ -667,12 +847,37 @@ export class Wallet { await this.importKeys({ keys: [changeKey] }); } + // Shallow copy to avoid mutation if signingKeys are passed in + const keysForSigning = [...(signingKeys || decryptedKeys)]; + + if (decryptPrivateKeys) { + for (const key of keysForSigning) { + let privKeyBuf: Buffer | undefined; + try { + privKeyBuf = Encryption.decryptToBuffer(key.privKey, key.pubKey, this.unlocked.encryptionKey); + + // Convert buffer to chain-specific native format (e.g., WIF for BTC, hex for ETH, base58 for SOL) + const nativePrivKey = Deriver.bufferToPrivateKey_TEMP(this.chain, this.network, privKeyBuf); + + key.privKey = nativePrivKey; + } catch (e) { + console.error('Failed to decrypt/convert private key:', e); + continue; + } finally { + // Zero out the buffer immediately after use + if (Buffer.isBuffer(privKeyBuf)) { + privKeyBuf.fill(0); + } + } + } + } + const payload = { chain: this.chain, network: this.network, tx, - keys: signingKeys || decryptedKeys, - key: signingKeys ? signingKeys[0] : decryptedKeys[0], + keys: keysForSigning, + key: keysForSigning[0], utxos }; return Transactions.sign({ ...payload }); @@ -741,7 +946,7 @@ export class Wallet { } async derivePrivateKey(isChange, addressIndex = this.addressIndex) { - const keyToImport = await Deriver.derivePrivateKey( + return Deriver.derivePrivateKey( this.chain, this.network, this.unlocked.masterKey, @@ -749,7 +954,6 @@ export class Wallet { isChange, this.addressType ); - return keyToImport; } async nextAddressPair(withChangeAddress?: boolean) { diff --git a/packages/bitcore-client/test/unit/data/ethMigrationTestWallet.fixture.ts b/packages/bitcore-client/test/unit/data/ethMigrationTestWallet.fixture.ts new file mode 100644 index 00000000000..7732350e26c --- /dev/null +++ b/packages/bitcore-client/test/unit/data/ethMigrationTestWallet.fixture.ts @@ -0,0 +1,68 @@ +export const ethMigrationTestWalletFixture = { + exportedAt: '2026-03-11T19:39:04.994Z', + name: 'ETH_migration_test_wallet', + storageType: 'Level', + password: 'password', + wallet: { + name: 'ETH_migration_test_wallet', + chain: 'ETH', + network: 'regtest', + baseUrl: 'http://localhost:3000/api', + encryptionKey: '7eab496f0253b5395844356faa629b48ff063fb4186c51e8e448ee7db0eeb9b24afd041bad14c7153dd35a2755b5438f', + authKey: { + bn: '1a0202578666668324a496bd06b9bbf1c197ec5a6b2f0c3ccd6470abbc5328a4', + compressed: true, + network: 'livenet' + }, + authPubKey: '03a7107731216187960c8b7436e3cdc5d4d1b7004e21d85907a402838b5702a118', + masterKey: 'e8daacd89b584a43c5e14b9baba7b94c358d4e271fa6eaa3fd140bebc7507a2f80e88a460042a785adf07660b5126fb05016416fa1544c5f20f1cb9a9bb25c4cca198b9162075e902143b5dfebfcb0e88c305fc08ab0f672f5cdfaeb0ee501a4ba5a1d22f586f69ab4625a719b1b9a4ce5f217574417892fc8748b08db5a4b76938e470e768054e3758071e6de291c66d372c30613a02d0c2a63647bbfa4a305f094138d728e78b6f7f05017bc0c0c8bd390f9837e56fa463f6edeb4761fe645c53c482cae062af7fe4a01c5045fee2fbcc93b56e4362d9f0b9aaa4bf4352e3abd430cd967747fc68c2515aa088a6b5f34751a2cfaa34ef02eca74d5d7aaca2d5ed109348d2e85a22ae08a665543649279b405dac3e8879e0e1f5a784e6af1aaa5b1253bca30cbb2197346e9ce13ff358963cce0f11f46c798dc25ebfda9d920e374c785ae8983b30f661ba9b26806def02553d85533225b394847269861c310dc8c19914348219ff2d59b8d9e7ed22ee79c7552353b22eebe7e6a568e04371071932c1f5331009455b0e7944bcfa5d9e685a44f637aa53d9eafc7d3b62eafdee70543732d5d646f60108fd2c0bdef36', + password: '$2b$10$EgUUeV0zld1TOQvjMOmmPuyWQxF9hwpCBe1G4UiaAW1XYNY5/wMDm', + xPubKey: 'tpubDDT9XiD9hNQpHjpCizEvPq2TqDwZYwtKKiKHJuC5AAV2vbfooRVg2Mi7WogY8ygCTve5NgAjDXWEo3JF6sPayayYM2kNL3sQMeb6bDbv1Lk', + pubKey: '0332f26acf0584acb762eb99e638190e6ea1ddbae6bb59f79d8611d2412a50791d', + tokens: [], + storageType: 'Level', + addressType: 'pubkeyhash', + addressZero: '0xAf4a2A1c3cc0cbE9FF17B04dD91692F7686696Af', + client: { + apiUrl: 'http://localhost:3000/api/ETH/regtest', + authKey: { + bn: '1a0202578666668324a496bd06b9bbf1c197ec5a6b2f0c3ccd6470abbc5328a4', + compressed: true, + network: 'livenet' + } + }, + addressIndex: 2, + lite: false + }, + rawWallet: + '{"name":"ETH_migration_test_wallet","chain":"ETH","network":"regtest","baseUrl":"http://localhost:3000/api","encryptionKey":"7eab496f0253b5395844356faa629b48ff063fb4186c51e8e448ee7db0eeb9b24afd041bad14c7153dd35a2755b5438f","authKey":{"bn":"1a0202578666668324a496bd06b9bbf1c197ec5a6b2f0c3ccd6470abbc5328a4","compressed":true,"network":"livenet"},"authPubKey":"03a7107731216187960c8b7436e3cdc5d4d1b7004e21d85907a402838b5702a118","masterKey":"e8daacd89b584a43c5e14b9baba7b94c358d4e271fa6eaa3fd140bebc7507a2f80e88a460042a785adf07660b5126fb05016416fa1544c5f20f1cb9a9bb25c4cca198b9162075e902143b5dfebfcb0e88c305fc08ab0f672f5cdfaeb0ee501a4ba5a1d22f586f69ab4625a719b1b9a4ce5f217574417892fc8748b08db5a4b76938e470e768054e3758071e6de291c66d372c30613a02d0c2a63647bbfa4a305f094138d728e78b6f7f05017bc0c0c8bd390f9837e56fa463f6edeb4761fe645c53c482cae062af7fe4a01c5045fee2fbcc93b56e4362d9f0b9aaa4bf4352e3abd430cd967747fc68c2515aa088a6b5f34751a2cfaa34ef02eca74d5d7aaca2d5ed109348d2e85a22ae08a665543649279b405dac3e8879e0e1f5a784e6af1aaa5b1253bca30cbb2197346e9ce13ff358963cce0f11f46c798dc25ebfda9d920e374c785ae8983b30f661ba9b26806def02553d85533225b394847269861c310dc8c19914348219ff2d59b8d9e7ed22ee79c7552353b22eebe7e6a568e04371071932c1f5331009455b0e7944bcfa5d9e685a44f637aa53d9eafc7d3b62eafdee70543732d5d646f60108fd2c0bdef36","password":"$2b$10$EgUUeV0zld1TOQvjMOmmPuyWQxF9hwpCBe1G4UiaAW1XYNY5/wMDm","xPubKey":"tpubDDT9XiD9hNQpHjpCizEvPq2TqDwZYwtKKiKHJuC5AAV2vbfooRVg2Mi7WogY8ygCTve5NgAjDXWEo3JF6sPayayYM2kNL3sQMeb6bDbv1Lk","pubKey":"0332f26acf0584acb762eb99e638190e6ea1ddbae6bb59f79d8611d2412a50791d","tokens":[],"storageType":"Level","addressType":"pubkeyhash","addressZero":"0xAf4a2A1c3cc0cbE9FF17B04dD91692F7686696Af","client":{"apiUrl":"http://localhost:3000/api/ETH/regtest","authKey":{"bn":"1a0202578666668324a496bd06b9bbf1c197ec5a6b2f0c3ccd6470abbc5328a4","compressed":true,"network":"livenet"}},"addressIndex":2,"lite":false}', + localAddresses: [ + { + address: '0x021e018Ae71D1A9136cf15B3aF4be9E023EE8A70', + pubKey: '02ed66479c577e81ba397fa4498934172a8f6502b836a0d7ba340032a940cfcfd1', + path: 'm/0/1' + }, + { + address: '0xAf4a2A1c3cc0cbE9FF17B04dD91692F7686696Af', + pubKey: '030cd384611d36e243a4cdf9cca229dbc62bef0dfb11abe4a928cf8576b4bc79f7', + path: 'm/0/0' + } + ], + addresses: ['0x021e018Ae71D1A9136cf15B3aF4be9E023EE8A70', '0xAf4a2A1c3cc0cbE9FF17B04dD91692F7686696Af'], + storedKeys: [ + { + encKey: + '788e8e1d3f1b263307f2e28c76e8a842f38e162db17d02e136b333228044e0f9b16b10ce6d19c035e727826419b2707728647feaf92aeb63890941bcec2c7d1e46e655e5ad4c76e0f1dd7935e7a0b1f77a5fa28a00719b635d4744790e1c5d63857d57e83fc66a7073659e302e710b625cedfc9f35b9b78dc8e032e6f70db55f672699d388ce957a59266bfcdb29e4b78267854698c730d295686a513a66773a9395d4edf6e9acd4815e58125479adb4cf271114add96df038de5e754b786c4db0e22bb5411e46f870c7f699d0d9ee10644c0bbe29b003641945390d10b6322a2859c8dfff3312daeedc87fbbf64cdcd', + pubKey: '02ed66479c577e81ba397fa4498934172a8f6502b836a0d7ba340032a940cfcfd1', + path: 'm/0/1' + }, + { + encKey: + 'c416e891b133305bd5294cd079be851bf75f5a4c2a0b04e3e09f0ce91f0b2175a61e9e451f48af3202314886006bc9d378741c82987189abe8f01f187df34bdde9519dac08d8d327b6ccd825b70eda155f316d581bb339e1d8b2311ae8f5666de1716ce8a3d1400550d070b641d684bf04906c2b9c573af64c02baf7ccb83f7997a9295e075d314708c06106a8544bab5a7d6326a00b85bf4b961e7492adfcecd84d41ffe2e95c2b4475a500a2d27ef6368ce8998974cc46e953b93a52f25b629d24ae61c719e1c5cdb6a6848bcf4d0e8770d1df2a47e12a113409e158719aded23ccafc42a8f95b63029b735f76d3b6', + pubKey: '030cd384611d36e243a4cdf9cca229dbc62bef0dfb11abe4a928cf8576b4bc79f7', + path: 'm/0/0' + } + ] +}; + +export default ethMigrationTestWalletFixture; diff --git a/packages/bitcore-client/test/unit/wallet.test.ts b/packages/bitcore-client/test/unit/wallet.test.ts index a9947ce4e26..9f9a4a2dead 100644 --- a/packages/bitcore-client/test/unit/wallet.test.ts +++ b/packages/bitcore-client/test/unit/wallet.test.ts @@ -1,6 +1,10 @@ import * as chai from 'chai'; import * as CWC from '@bitpay-labs/crypto-wallet-core'; -import { AddressTypes, Wallet } from '../../src/wallet'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { AddressTypes, IWalletExt, Wallet } from '../../src/wallet'; +import { Encryption } from '../../src/encryption'; import { Api as bcnApi } from '../../../bitcore-node/build/src/services/api'; import { Storage as bcnStorage } from '../../../bitcore-node/build/src/services/storage'; import crypto from 'crypto'; @@ -12,6 +16,7 @@ import sinon from 'sinon'; import { StorageType } from '../../src/types/storage'; import supertest from 'supertest'; import { utils } from '../../src/utils'; +import ethMigrationTestWalletFixture from './data/ethMigrationTestWallet.fixture'; const should = chai.should(); @@ -31,6 +36,19 @@ describe('Wallet', function() { let walletName; let wallet: Wallet; let api; + const walletsToCleanup: Array<{ name: string; storageType?: StorageType; path?: string }> = []; + const createWalletForTest = async (params: Partial) => { + if (!params.name) { + throw new Error('Tests must provide a wallet name'); + } + const createdWallet = await Wallet.create(params); + walletsToCleanup.push({ + name: params.name, + storageType: params.storageType, + path: params.path + }); + return createdWallet; + }; before(async function() { this.timeout(20000); await bcnStorage.start({ @@ -64,7 +82,12 @@ describe('Wallet', function() { }); }); afterEach(async function() { - await Wallet.deleteWallet({ name: walletName, storageType }); + while (walletsToCleanup.length) { + const walletToCleanup = walletsToCleanup.pop(); + if (walletToCleanup) { + await Wallet.deleteWallet(walletToCleanup); + } + } sandbox.restore(); }); for (const chain of ['BTC', 'BCH', 'LTC', 'DOGE', 'ETH', 'XRP', 'MATIC']) { @@ -73,7 +96,7 @@ describe('Wallet', function() { it(`should create a wallet for chain and addressType: ${chain} ${addressType}`, async function() { walletName = 'BitcoreClientTest' + chain + addressType; - wallet = await Wallet.create({ + wallet = await createWalletForTest({ chain, network: 'mainnet', name: walletName, @@ -82,7 +105,8 @@ describe('Wallet', function() { lite: false, addressType, storageType, - baseUrl + baseUrl, + version: 0 }); expect(wallet.addressType).to.equal(AddressTypes[chain]?.[addressType] || 'pubkeyhash'); @@ -116,14 +140,15 @@ describe('Wallet', function() { walletName = 'BitcoreClientTestBumpFee-UTXO'; beforeEach(async function() { - wallet = await Wallet.create({ + wallet = await createWalletForTest({ name: walletName, chain: 'BTC', network: 'testnet', phrase: 'snap impact summer because must pipe weasel gorilla actor acid web whip', password: 'abc123', storageType, - baseUrl + baseUrl, + // version: 0 }); await wallet.unlock('abc123'); }); @@ -192,14 +217,15 @@ describe('Wallet', function() { walletName = 'BitcoreClientTestBumpFee-EVM'; beforeEach(async function() { - wallet = await Wallet.create({ + wallet = await createWalletForTest({ name: walletName, chain: 'ETH', network: 'testnet', phrase: 'snap impact summer because must pipe weasel gorilla actor acid web whip', password: 'abc123', storageType, - baseUrl + baseUrl, + version: 0 }); await wallet.unlock('abc123'); }); @@ -262,7 +288,8 @@ describe('Wallet', function() { password: 'abc123', storageType, path, - baseUrl + baseUrl, + version: 0 }); await wallet.unlock('abc123'); // 3 address pairs @@ -296,14 +323,15 @@ describe('Wallet', function() { let sleepStub; beforeEach(async function() { - wallet = await Wallet.create({ + wallet = await createWalletForTest({ name: walletName, chain: 'BTC', network: 'testnet', phrase: 'snap impact summer because must pipe weasel gorilla actor acid web whip', password: 'abc123', storageType, - baseUrl + baseUrl, + version: 0 }); await wallet.unlock('abc123'); requestStub = sandbox.stub(wallet.client, '_request').resolves(); @@ -366,18 +394,612 @@ describe('Wallet', function() { sleepStub.callCount.should.equal(1); requestStub.args.flatMap(arg => arg[0].body).should.deep.equal(keys.map(k => ({ address: k.address }))); }); + + it('can derive the public key if not included', async function () { + const keys = []; + for (let i = 0; i < 101; i++) { + const pk = crypto.randomBytes(32).toString('hex'); + keys.push({ + privKey: pk, + address: libMap.BTC.PrivateKey(pk).toAddress().toString() + }); + } + const getPublicKeyStub = sandbox.stub(CWC.Deriver, 'getPublicKey').returns('mockedPubKey'); + sandbox.stub(wallet.storage, 'addKeysSafe').resolves(); + + await wallet.importKeys({ + keys, + rederiveAddys: false + }); + + getPublicKeyStub.callCount.should.be.greaterThan(0); + }); + + it('encrypts key.privKey only', async function () { + const keys = []; + for (let i = 0; i < 1; i++) { + const pk = crypto.randomBytes(32).toString('hex'); + keys.push({ + privKey: pk, + address: libMap.BTC.PrivateKey(pk).toAddress().toString() + }); + } + const addKeysSafeStub = sandbox.stub(wallet.storage, 'addKeysSafe').resolves(); + + await wallet.importKeys({ + keys, + rederiveAddys: false + }); + + addKeysSafeStub.calledOnce.should.equal(true); + const savedKeys = addKeysSafeStub.firstCall.args[0].keys; + + for (const originalKey of keys) { + const matchingSavedKey = savedKeys.find(sk => { + // Match on pubKey only if it was in originalKey + return !(originalKey.pubKey && sk.pubKey === originalKey.pubKey) && sk.address === originalKey.address; + }); + expect(matchingSavedKey).to.exist; + matchingSavedKey.privKey.should.not.equal(originalKey.privKey); + } + }); + + it('can rederive addresses', async function () { + const keys = []; + for (let i = 0; i < 1; i++) { + const pk = crypto.randomBytes(32).toString('hex'); + keys.push({ + privKey: pk, + address: libMap.BTC.PrivateKey(pk).toAddress().toString() + }); + } + const getPublicKeyStub = sandbox.stub(CWC.Deriver, 'getPublicKey').returns('mockedPubKey'); + const getAddressStub = sandbox.stub(CWC.Deriver, 'getAddress').returns('mockedAddress'); + sandbox.stub(wallet.storage, 'addKeysSafe').resolves(); + + await wallet.importKeys({ + keys, + rederiveAddys: true + }); + + getAddressStub.callCount.should.be.greaterThan(0); + }); + }); + + describe('signTx', function() { + let txStub: sinon.SinonStub; + afterEach(async function() { + sandbox.restore(); + }); + + describe('BTC (UTXO) decrypts ciphertext to WIF', function() { + walletName = 'BitcoreClientTestSignTxV2-BTC'; + let wallet: Wallet; + + beforeEach(async function() { + wallet = await createWalletForTest({ + name: walletName, + chain: 'BTC', + network: 'testnet', + phrase: 'snap impact summer because must pipe weasel gorilla actor acid web whip', + password: 'abc123', + storageType, + baseUrl + }); + await wallet.unlock('abc123'); + }); + + it('should decrypt stored ciphertext and hand WIF to Transactions.sign', async function() { + const pk = new CWC.BitcoreLib.PrivateKey(undefined, 'testnet'); + const address = pk.toAddress().toString(); + const privBuf = CWC.Deriver.privateKeyToBuffer('BTC', pk.toString()); + // v2 key encryption uses the key's pubKey as the IV salt (not the wallet pubKey) + const encPriv = Encryption.encryptBuffer(privBuf, pk.publicKey.toString(), wallet.unlocked.encryptionKey).toString('hex'); + privBuf.fill(0); + + sandbox.stub(wallet.storage, 'getStoredKeys').resolves([ + { + address, + privKey: encPriv, + pubKey: pk.publicKey.toString() + } + ]); + sandbox.stub(wallet, 'derivePrivateKey').resolves({ + address: 'change', + privKey: pk.toString(), + pubKey: pk.publicKey.toString(), + path: 'm/1/0' + }); + sandbox.stub(wallet, 'importKeys').resolves(); + + let capturedPayload; + txStub = sandbox.stub(CWC.Transactions, 'sign').callsFake(payload => { + capturedPayload = payload; + return 'signed'; + }); + + const utxos = [{ address, value: 1 }]; + await wallet.signTx({ tx: 'raw', utxos }); + + txStub.calledOnce.should.equal(true); + capturedPayload.keys[0].privKey.should.equal(pk.toWIF()); + capturedPayload.key.privKey.should.equal(pk.toWIF()); + }); + }); + + describe('ETH (account) decrypts ciphertext to hex and skips plaintext', function() { + walletName = 'BitcoreClientTestSignTxV2-ETH'; + let wallet: Wallet; + + beforeEach(async function() { + wallet = await createWalletForTest({ + name: walletName, + chain: 'ETH', + network: 'testnet', + phrase: 'snap impact summer because must pipe weasel gorilla actor acid web whip', + password: 'abc123', + storageType, + baseUrl + }); + await wallet.unlock('abc123'); + }); + + it('should decrypt stored ciphertext and hand hex privKey to Transactions.sign', async function() { + const privHex = crypto.randomBytes(32).toString('hex'); + const privBufForPubKey = CWC.Deriver.privateKeyToBuffer('ETH', privHex); + const pubKey = CWC.Deriver.getPublicKey('ETH', wallet.network, privBufForPubKey); + privBufForPubKey.fill(0); + const privBuf = CWC.Deriver.privateKeyToBuffer('ETH', privHex); + const encPriv = Encryption.encryptBuffer(privBuf, pubKey, wallet.unlocked.encryptionKey).toString('hex'); + privBuf.fill(0); + + let capturedPayload; + txStub = sandbox.stub(CWC.Transactions, 'sign').callsFake(payload => { + capturedPayload = payload; + return 'signed'; + }); + + const signingKeys = [{ address: '0xabc', privKey: encPriv, pubKey }]; + await wallet.signTx({ tx: 'raw', signingKeys }); + + txStub.calledOnce.should.equal(true); + capturedPayload.keys[0].privKey.should.equal(privHex); + }); + }); + + describe('XRP (account) decrypts ciphertext to uppercase hex and skips plaintext', function() { + walletName = 'BitcoreClientTestSignTxV2-XRP'; + let wallet: Wallet; + + beforeEach(async function() { + wallet = await createWalletForTest({ + name: walletName, + chain: 'XRP', + network: 'testnet', + phrase: 'snap impact summer because must pipe weasel gorilla actor acid web whip', + password: 'abc123', + storageType, + baseUrl + }); + await wallet.unlock('abc123'); + }); + + it('should decrypt stored ciphertext and hand uppercase hex privKey to Transactions.sign', async function() { + const privHex = crypto.randomBytes(32).toString('hex').toUpperCase(); + const privBufForPubKey = CWC.Deriver.privateKeyToBuffer('XRP', privHex); + const pubKey = CWC.Deriver.getPublicKey('XRP', wallet.network, privBufForPubKey); + privBufForPubKey.fill(0); + const privBuf = CWC.Deriver.privateKeyToBuffer('XRP', privHex); + const encPriv = Encryption.encryptBuffer(privBuf, pubKey, wallet.unlocked.encryptionKey).toString('hex'); + privBuf.fill(0); + + let capturedPayload; + txStub = sandbox.stub(CWC.Transactions, 'sign').callsFake(payload => { + capturedPayload = payload; + return 'signed'; + }); + + const signingKeys = [{ address: 'rabc', privKey: encPriv, pubKey }]; + await wallet.signTx({ tx: 'raw', signingKeys }); + + txStub.calledOnce.should.equal(true); + capturedPayload.keys[0].privKey.should.equal(privHex); + }); + }); + + describe('SOL (account) decrypts ciphertext to base58 and skips plaintext', function() { + walletName = 'BitcoreClientTestSignTxV2-SOL'; + let wallet: Wallet; + + beforeEach(async function() { + wallet = await createWalletForTest({ + name: walletName, + chain: 'SOL', + network: 'devnet', + phrase: 'snap impact summer because must pipe weasel gorilla actor acid web whip', + password: 'abc123', + storageType, + baseUrl + }); + await wallet.unlock('abc123'); + }); + + it('should decrypt stored ciphertext and hand base58 privKey to Transactions.sign', async function() { + const privBufForPubKey = crypto.randomBytes(32); + const pubKey = CWC.Deriver.getPublicKey('SOL', wallet.network, privBufForPubKey); + const privBuf = Buffer.from(privBufForPubKey); + privBufForPubKey.fill(0); + const encPriv = Encryption.encryptBuffer(privBuf, pubKey, wallet.unlocked.encryptionKey).toString('hex'); + const expectedPrivKey = CWC.Deriver.bufferToPrivateKey_TEMP('SOL', wallet.network, Buffer.from(privBuf)); + privBuf.fill(0); + + let capturedPayload; + txStub = sandbox.stub(CWC.Transactions, 'sign').callsFake(payload => { + capturedPayload = payload; + return Promise.resolve('signed'); + }); + + const signingKeys = [{ address: pubKey, privKey: encPriv, pubKey }]; + await wallet.signTx({ tx: 'raw', signingKeys }); + + txStub.calledOnce.should.equal(true); + capturedPayload.keys[0].privKey.should.equal(expectedPrivKey); + }); + }); + }); + + describe('derivePrivateKey', function () { + let wallet: Wallet; + const createUnlockedWallet = async (params: { chain: string; network: string; xpriv: string }) => { + walletName = `BitcoreClientTestDerivePrivateKey-${params.chain}`; + wallet = await createWalletForTest({ + name: walletName, + chain: params.chain, + network: params.network, + xpriv: params.xpriv, + password: 'abc123', + storageType, + baseUrl + }); + await wallet.unlock('abc123'); + expect(Buffer.isBuffer(wallet.unlocked?.masterKey?.xprivkey)).to.equal(true); + expect(Buffer.isBuffer(wallet.unlocked?.masterKey?.privateKey)).to.equal(true); + return wallet; + }; + + const walletVectors = { + BTC: { + chain: 'BTC', + network: 'mainnet', + xpriv: 'xprv9s21ZrQH143K3aKdQ6kXF1vj7R6LtkoLCiUXfM5bdbGXmhQkC1iXdnFfrxAAtaTunPUCCLwUQ3cpNixGLMbLAH1gzeCr8VZDe4gPgmKLb2X', + expected: { + address: '14FubqQhpG1dhTSgD5nRsiQRJEEcxVojRf', + privKey: '79cab08ffc77750721329a0033c43fd1e5c32e9e2da273c18e5e36abb05cca32', + pubKey: '03d69dd136b999433a9f6c8f38076831ec0d3a3cf7a555bec8bc8c6d76fc266231', + path: 'm/0/0' + } + }, + ETH: { + chain: 'ETH', + network: 'mainnet', + xpriv: 'xprv9ypBjKErGMqCdzd44hfSdy1Vk6PGtU3si8ogZcow7rA23HTxMi9XfT99EKmiNdLMr9BAZ9S8ZKCYfN1eCmzYSmXYHje1jnYQseV1VJDDfdS', + expected: { + address: '0xb497281830dE4F19a3482AbF3D5C35c514e6fB36', + privKey: '62b8311c71f355c5c07f6bffe9b1ae60aa20d90e2e2ec93ec11b6014b2ae6340', + pubKey: '0386d153aad9395924631dbc78fa560107123a759eaa3e105958248c60cd4472ad', + path: 'm/0/0' + } + }, + XRP: { + chain: 'XRP', + network: 'mainnet', + xpriv: 'xprvA58pn8bWSyoRGvEY97ALTHP4Dj6t47Q3PTBUEw78CF91kALMwhs7D2GutQSvpRN6ACR4RX4HbF3KmF7zDf48gR8nwG7DqLp6ezUcMiPHDtV', + expected: { + address: 'r9dmAJBfBe7JL2RRLiFWGJ8kM4CHEeTpgN', + privKey: 'D02C6801D8F328FF2EAD51D01F9580AF36C8D74E2BD463963AC4ADBE51AE5F2C', + pubKey: '03DBEEC5E9E76DA09C5B502A67136BC2D73423E8902A7C35A8CBC0C5A6AC0469E8', + path: 'm/0/0' + } + }, + SOL: { + chain: 'SOL', + network: 'mainnet', + xpriv: 'xprv9s21ZrQH143K3aKdQ6kXF1vj7R6LtkoLCiUXfM5bdbGXmhQkC1iXdnFfrxAAtaTunPUCCLwUQ3cpNixGLMbLAH1gzeCr8VZDe4gPgmKLb2X', + expected: { + address: '7EWwMxKQa5Gru7oTcS1Wi3AaEgTfA6MU3z7MaLUT6hnD', + privKey: 'E4Tp4nTgMCa5dtGwqvkWoMGrJC7FKRNjcpeFFXi4nNb9', + pubKey: '5c9c85b20525ee81d3cc56da1f8307ec169086ae41458c5458519aced7683b66' + } + } + }; + + it('derives the expected BTC key material', async function () { + await createUnlockedWallet(walletVectors.BTC); + const result = await wallet.derivePrivateKey(false, 0); + expect(result).to.deep.equal(walletVectors.BTC.expected); + }); + + it('derives the expected ETH key material', async function () { + await createUnlockedWallet(walletVectors.ETH); + const result = await wallet.derivePrivateKey(false, 0); + expect(result).to.deep.equal(walletVectors.ETH.expected); + }); + + it('derives the expected XRP key material', async function () { + await createUnlockedWallet(walletVectors.XRP); + const result = await wallet.derivePrivateKey(false, 0); + expect(result).to.deep.equal(walletVectors.XRP.expected); + }); + + it('derives the expected SOL key material', async function () { + await createUnlockedWallet(walletVectors.SOL); + const result = await wallet.derivePrivateKey(false, 0); + expect(result).to.deep.equal(walletVectors.SOL.expected); + }); + }); + + describe('unlock', function () { + it('performs wallet migration for previous wallet versions', async () => { + const fixture = ethMigrationTestWalletFixture; + const wallet = new Wallet(fixture.wallet as any); + const tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bitcore-client-migration-unlock-')); + const backupDir = path.join(tempHomeDir, '.bitcore', 'bitcoreWallet', 'backup'); + const loadWalletStub = sandbox.stub(); + const getStoredKeysStub = sandbox.stub(); + const addKeysSafeStub = sandbox.stub(); + const saveWalletStub = sandbox.stub(); + + wallet.storage = { + loadWallet: loadWalletStub, + getStoredKeys: getStoredKeysStub, + addKeysSafe: addKeysSafeStub, + saveWallet: saveWalletStub + } as any; + + loadWalletStub.resolves(fixture.rawWallet); + getStoredKeysStub.resolves(fixture.storedKeys); + addKeysSafeStub.resolves(); + saveWalletStub.resolves(); + + const homedirStub = sandbox.stub(os, 'homedir').returns(tempHomeDir); + sandbox.stub(wallet, 'getAddresses').resolves(fixture.addresses); + + await wallet.unlock(fixture.password); + + // Assert wallet.storage methods are called (from migrateWallet) + expect(addKeysSafeStub.calledOnce).to.equal(true); + expect(saveWalletStub.calledOnce).to.equal(true); + + expect(wallet.version).to.equal(2); + expect(wallet.unlocked).to.exist; + expect(Buffer.isBuffer(wallet.unlocked?.encryptionKey)).to.equal(true); + expect(Buffer.isBuffer(wallet.unlocked?.masterKey?.privateKey)).to.equal(true); + expect(Buffer.isBuffer(wallet.unlocked?.masterKey?.xprivkey)).to.equal(true); + expect(fs.readFileSync(path.join(backupDir, `${fixture.name}.v1.bak`), 'utf8')).to.equal(fixture.rawWallet); + expect(fs.readFileSync(path.join(backupDir, `${fixture.name}_keys.v1.bak`), 'utf8')).to.equal(JSON.stringify(fixture.storedKeys)); + homedirStub.restore(); + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + }); + }); + + describe('migrateWallet', function () { + let wallet: Wallet; + let decryptedEncryptionKey: Buffer | undefined; + let loadWalletStub: sinon.SinonStub; + let getStoredKeysStub: sinon.SinonStub; + let addKeysSafeStub: sinon.SinonStub; + let saveWalletStub: sinon.SinonStub; + let homedirStub: sinon.SinonStub; + let tempHomeDir: string; + let backupDir: string; + + + beforeEach(async function () { + wallet = new Wallet(ethMigrationTestWalletFixture.wallet as any); + tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bitcore-client-migration-')); + backupDir = path.join(tempHomeDir, '.bitcore', 'bitcoreWallet', 'backup'); + wallet.storage = { + loadWallet: async () => undefined, + getStoredKeys: async () => [], + addKeysSafe: async () => undefined, + saveWallet: async () => undefined + } as any; + + loadWalletStub = sandbox.stub(wallet.storage, 'loadWallet').resolves(ethMigrationTestWalletFixture.rawWallet); + getStoredKeysStub = sandbox.stub(wallet.storage, 'getStoredKeys').resolves(ethMigrationTestWalletFixture.storedKeys); + addKeysSafeStub = sandbox.stub(wallet.storage, 'addKeysSafe').resolves(); + saveWalletStub = sandbox.stub(wallet.storage, 'saveWallet').resolves(); + homedirStub = sandbox.stub(os, 'homedir').returns(tempHomeDir); + sandbox.stub(wallet, 'getAddresses').resolves(ethMigrationTestWalletFixture.addresses); + + decryptedEncryptionKey = Encryption.decryptEncryptionKey( + ethMigrationTestWalletFixture.wallet.encryptionKey, + ethMigrationTestWalletFixture.password, + true + ) as Buffer; + }); + + afterEach(function () { + homedirStub?.restore(); + if (tempHomeDir) { + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + } + }); + + it('should back up existing raw wallet & keys (separately) before migration', async () => { + await wallet.migrateWallet(decryptedEncryptionKey); + + expect(loadWalletStub.calledOnceWithExactly({ name: ethMigrationTestWalletFixture.name, raw: true })).to.be.true; + expect(getStoredKeysStub.calledOnceWithExactly({ + addresses: ethMigrationTestWalletFixture.addresses, + name: ethMigrationTestWalletFixture.name + })).to.be.true; + expect(fs.readFileSync(path.join(backupDir, `${ethMigrationTestWalletFixture.name}.v1.bak`), 'utf8')).to.equal( + ethMigrationTestWalletFixture.rawWallet + ); + expect(fs.readFileSync(path.join(backupDir, `${ethMigrationTestWalletFixture.name}_keys.v1.bak`), 'utf8')).to.equal( + JSON.stringify(ethMigrationTestWalletFixture.storedKeys) + ); + }); + + it('should overwrite existing keys', async () => { + await wallet.migrateWallet(decryptedEncryptionKey); + + expect(addKeysSafeStub.calledOnce).to.equal(true); + const { name, keys } = addKeysSafeStub.firstCall.args[0]; + expect(name).to.equal(ethMigrationTestWalletFixture.name); + expect(keys).to.have.length(ethMigrationTestWalletFixture.storedKeys.length); + + const expectedKeys = ethMigrationTestWalletFixture.storedKeys.map(storedKey => { + // Added keys encrypt privKey, convert to hex, and store that on key.privKey + const decryptedKey = JSON.parse( + Encryption.decryptPrivateKey(storedKey.encKey, storedKey.pubKey, decryptedEncryptionKey) + ); + const privKeyBuffer = CWC.Deriver.privateKeyToBuffer(wallet.chain, decryptedKey.privKey); + const encryptedPrivKey = Encryption.encryptBuffer(privKeyBuffer, storedKey.pubKey, decryptedEncryptionKey).toString('hex'); + privKeyBuffer.fill(0); + return { + ...decryptedKey, + privKey: encryptedPrivKey + }; + }); + + // Stored keys exhibit new encryption process + expect(keys).to.deep.equal(expectedKeys); + }); + + it('should overwrite existing wallet', async () => { + await wallet.migrateWallet(decryptedEncryptionKey); + + expect(saveWalletStub.calledOnce).to.equal(true); + const savedWallet = saveWalletStub.firstCall.args[0].wallet; + + expect(savedWallet.version).to.equal(2); + expect(savedWallet.password).to.equal(ethMigrationTestWalletFixture.wallet.password); + expect(savedWallet.masterKey).to.not.equal(ethMigrationTestWalletFixture.wallet.masterKey); + + const migratedMasterKey = JSON.parse(savedWallet.masterKey); + const originalMasterKey = JSON.parse( + Encryption.decryptPrivateKey( + ethMigrationTestWalletFixture.wallet.masterKey, + ethMigrationTestWalletFixture.wallet.pubKey, + decryptedEncryptionKey + ) + ); + + const migratedXpriv = Encryption.decryptToBuffer( + migratedMasterKey.xprivkey, + ethMigrationTestWalletFixture.wallet.pubKey, + decryptedEncryptionKey + ); + const migratedPrivateKey = Encryption.decryptToBuffer( + migratedMasterKey.privateKey, + ethMigrationTestWalletFixture.wallet.pubKey, + decryptedEncryptionKey + ); + + expect(wallet.version).to.equal(2); + + // Decrypted master keys same as prior master keys + expect(migratedXpriv.toString('hex')).to.equal( + CWC.BitcoreLib.encoding.Base58Check.decode(originalMasterKey.xprivkey).toString('hex') + ); + expect(migratedPrivateKey.toString('hex')).to.equal(originalMasterKey.privateKey); + }); + + it('should throw if the raw wallet cannot be loaded', async () => { + loadWalletStub.resolves(undefined); + + try { + await wallet.migrateWallet(decryptedEncryptionKey); + expect.fail('Expected migrateWallet to throw'); + } catch (err) { + expect(err.message).to.equal('Migration failed - wallet not found'); + } + + expect(getStoredKeysStub.called).to.equal(false); + expect(addKeysSafeStub.called).to.equal(false); + expect(saveWalletStub.called).to.equal(false); + }); + + it('should throw if the decrypted masterKey is malformed', async () => { + wallet.masterKey = Encryption.encryptPrivateKey( + JSON.stringify({ invalid: true }), + wallet.pubKey, + decryptedEncryptionKey.toString('hex') + ); + + try { + await wallet.migrateWallet(decryptedEncryptionKey); + expect.fail('Expected migrateWallet to throw'); + } catch (err) { + expect(err.message).to.equal('Migration failure: masterKey is not formatted as expected'); + } + + expect(addKeysSafeStub.called).to.equal(false); + expect(saveWalletStub.called).to.equal(false); + }); + + it('should throw if addKeysSafe fails and should not save the wallet', async () => { + addKeysSafeStub.rejects(new Error('write failed')); + + try { + await wallet.migrateWallet(decryptedEncryptionKey); + expect.fail('Expected migrateWallet to throw'); + } catch (err) { + expect(err.message).to.equal( + 'Migration failure: keys not successfully stored. Use backups to restore prior wallet and keys.' + ); + } + + expect(addKeysSafeStub.calledOnce).to.equal(true); + expect(saveWalletStub.called).to.equal(false); + }); + + it('should no-op when the wallet is already on the current version', async () => { + wallet.version = 2; + const warnStub = sandbox.stub(console, 'warn'); + + const migratedWallet = await wallet.migrateWallet(decryptedEncryptionKey); + + expect(migratedWallet).to.equal(wallet); + expect(warnStub.calledOnceWithExactly('Wallet migration unnecessarily called - wallet is current version')).to.equal(true); + expect(loadWalletStub.called).to.equal(false); + expect(getStoredKeysStub.called).to.equal(false); + expect(addKeysSafeStub.called).to.equal(false); + expect(saveWalletStub.called).to.equal(false); + expect(fs.existsSync(path.join(backupDir, `${ethMigrationTestWalletFixture.name}.v2.bak`))).to.equal(false); + }); + + it('should no-op when the wallet version is newer than the current version', async () => { + wallet.version = 3; + const warnStub = sandbox.stub(console, 'warn'); + + const migratedWallet = await wallet.migrateWallet(decryptedEncryptionKey); + + expect(migratedWallet).to.equal(wallet); + expect( + warnStub.calledOnceWithExactly('Wallet version 3 greater than expected current wallet version 2') + ).to.equal(true); + expect(loadWalletStub.called).to.equal(false); + expect(getStoredKeysStub.called).to.equal(false); + expect(addKeysSafeStub.called).to.equal(false); + expect(saveWalletStub.called).to.equal(false); + expect(fs.existsSync(path.join(backupDir, `${ethMigrationTestWalletFixture.name}.v3.bak`))).to.equal(false); + }); }); describe('getBalance', function() { walletName = 'BitcoreClientTestGetBalance'; beforeEach(async function() { - wallet = await Wallet.create({ + wallet = await createWalletForTest({ name: walletName, chain: 'MATIC', network: 'testnet', phrase: 'snap impact summer because must pipe weasel gorilla actor acid web whip', password: 'abc123', storageType: 'Level', + version: 0, }); await wallet.unlock('abc123'); }); @@ -420,13 +1042,14 @@ describe('Wallet', function() { describe('getTokenObj', function() { walletName = 'BitcoreClientTestGetTokenObj'; beforeEach(async function() { - wallet = await Wallet.create({ + wallet = await createWalletForTest({ name: walletName, chain: 'MATIC', network: 'testnet', phrase: 'snap impact summer because must pipe weasel gorilla actor acid web whip', password: 'abc123', storageType: 'Level', + version: 0, }); await wallet.unlock('abc123'); }); @@ -498,7 +1121,7 @@ describe('Wallet', function() { }; beforeEach(async function() { - wallet = await Wallet.create({ + wallet = await createWalletForTest({ chain: 'ETH', network: 'mainnet', name: walletName, @@ -506,7 +1129,8 @@ describe('Wallet', function() { password: 'abc123', lite: false, storageType, - baseUrl + baseUrl, + version: 0 }); wallet.tokens = [ diff --git a/packages/bitcore-lib/lib/hdprivatekey.js b/packages/bitcore-lib/lib/hdprivatekey.js index e9bb35d7752..19f1f53b244 100644 --- a/packages/bitcore-lib/lib/hdprivatekey.js +++ b/packages/bitcore-lib/lib/hdprivatekey.js @@ -4,21 +4,20 @@ var assert = require('assert'); var buffer = require('buffer'); var _ = require('lodash'); -var $ = require('./util/preconditions'); - var BN = require('./crypto/bn'); +var Hash = require('./crypto/hash'); +var Point = require('./crypto/point'); +var Random = require('./crypto/random'); var Base58 = require('./encoding/base58'); var Base58Check = require('./encoding/base58check'); -var Hash = require('./crypto/hash'); +var errors = require('./errors'); var Network = require('./networks'); -var Point = require('./crypto/point'); var PrivateKey = require('./privatekey'); -var Random = require('./crypto/random'); -var errors = require('./errors'); var hdErrors = errors.HDPrivateKey; var BufferUtil = require('./util/buffer'); var JSUtil = require('./util/js'); +var $ = require('./util/preconditions'); var MINIMUM_ENTROPY_BITS = 128; var BITS_TO_BYTES = 1 / 8; @@ -73,7 +72,7 @@ function HDPrivateKey(arg) { */ HDPrivateKey.isValidPath = function(arg, hardened) { if (_.isString(arg)) { - var indexes = HDPrivateKey._getDerivationIndexes(arg); + const indexes = HDPrivateKey._getDerivationIndexes(arg); return indexes !== null && _.every(indexes, HDPrivateKey.isValidPath); } @@ -96,7 +95,7 @@ HDPrivateKey.isValidPath = function(arg, hardened) { * @return {Array} */ HDPrivateKey._getDerivationIndexes = function(path) { - var steps = path.split('/'); + const steps = path.split('/'); // Special cases: if (_.includes(HDPrivateKey.RootElementAlias, path)) { @@ -107,15 +106,15 @@ HDPrivateKey._getDerivationIndexes = function(path) { return null; } - var indexes = steps.slice(1).map(function(step) { - var isHardened = step.slice(-1) === '\''; + const indexes = steps.slice(1).map(function(step) { + const isHardened = step.slice(-1) === '\''; if (isHardened) { step = step.slice(0, -1); } if (!step || step[0] === '-') { return NaN; } - var index = +step; // cast to number + let index = +step; // cast to number if (isHardened) { index += HDPrivateKey.Hardened; } @@ -233,28 +232,28 @@ HDPrivateKey.prototype._deriveWithNumber = function(index, hardened, nonComplian index += HDPrivateKey.Hardened; } - var indexBuffer = BufferUtil.integerAsBuffer(index); - var data; + const indexBuffer = BufferUtil.integerAsBuffer(index); + let data; if (hardened && nonCompliant) { // The private key serialization in this case will not be exactly 32 bytes and can be // any value less, and the value is not zero-padded. - var nonZeroPadded = this.privateKey.bn.toBuffer(); + const nonZeroPadded = this.privateKey.bn.toBuffer(); data = BufferUtil.concat([Buffer.from([0]), nonZeroPadded, indexBuffer]); } else if (hardened) { // This will use a 32 byte zero padded serialization of the private key - var privateKeyBuffer = this.privateKey.bn.toBuffer({size: 32}); + const privateKeyBuffer = this.privateKey.bn.toBuffer({ size: 32 }); assert(privateKeyBuffer.length === 32, 'length of private key buffer is expected to be 32 bytes'); data = BufferUtil.concat([Buffer.from([0]), privateKeyBuffer, indexBuffer]); } else { data = BufferUtil.concat([this.publicKey.toBuffer(), indexBuffer]); } - var hash = Hash.sha512hmac(data, this._buffers.chainCode); - var leftPart = BN.fromBuffer(hash.slice(0, 32), { + const hash = Hash.sha512hmac(data, this._buffers.chainCode); + const leftPart = BN.fromBuffer(hash.slice(0, 32), { size: 32 }); - var chainCode = hash.slice(32, 64); + const chainCode = hash.slice(32, 64); - var privateKey = leftPart.add(this.privateKey.toBigNumber()).umod(Point.getN()).toBuffer({ + const privateKey = leftPart.add(this.privateKey.toBigNumber()).umod(Point.getN()).toBuffer({ size: 32 }); @@ -263,7 +262,7 @@ HDPrivateKey.prototype._deriveWithNumber = function(index, hardened, nonComplian return this._deriveWithNumber(index + 1, null, nonCompliant); } - var derived = new HDPrivateKey({ + const derived = new HDPrivateKey({ network: this.network, depth: this.depth + 1, parentFingerPrint: this.fingerPrint, @@ -280,8 +279,8 @@ HDPrivateKey.prototype._deriveFromString = function(path, nonCompliant) { throw new hdErrors.InvalidPath(path); } - var indexes = HDPrivateKey._getDerivationIndexes(path); - var derived = indexes.reduce(function(prev, index) { + const indexes = HDPrivateKey._getDerivationIndexes(path); + const derived = indexes.reduce(function(prev, index) { return prev._deriveWithNumber(index, null, nonCompliant); }, this); @@ -327,7 +326,7 @@ HDPrivateKey.getSerializedError = function(data, network) { return new hdErrors.InvalidLength(data); } if (!_.isUndefined(network)) { - var error = HDPrivateKey._validateNetwork(data, network); + const error = HDPrivateKey._validateNetwork(data, network); if (error) { return error; } @@ -336,11 +335,11 @@ HDPrivateKey.getSerializedError = function(data, network) { }; HDPrivateKey._validateNetwork = function(data, networkArg) { - var network = Network.get(networkArg); + const network = Network.get(networkArg); if (!network) { return new errors.InvalidNetworkArgument(networkArg); } - var version = data.slice(0, 4); + const version = data.slice(0, 4); if (BufferUtil.integerFromBuffer(version) !== network.xprivkey) { return new errors.InvalidNetwork(version); } @@ -364,21 +363,21 @@ HDPrivateKey.prototype._buildFromJSON = function(arg) { HDPrivateKey.prototype._buildFromObject = function(arg) { /* jshint maxcomplexity: 12 */ // TODO: Type validation - var buffers = { + const buffers = { version: arg.network ? BufferUtil.integerAsBuffer(Network.get(arg.network).xprivkey) : arg.version, depth: _.isNumber(arg.depth) ? BufferUtil.integerAsSingleByteBuffer(arg.depth) : arg.depth, parentFingerPrint: _.isNumber(arg.parentFingerPrint) ? BufferUtil.integerAsBuffer(arg.parentFingerPrint) : arg.parentFingerPrint, childIndex: _.isNumber(arg.childIndex) ? BufferUtil.integerAsBuffer(arg.childIndex) : arg.childIndex, - chainCode: _.isString(arg.chainCode) ? Buffer.from(arg.chainCode,'hex') : arg.chainCode, - privateKey: (_.isString(arg.privateKey) && JSUtil.isHexa(arg.privateKey)) ? Buffer.from(arg.privateKey,'hex') : arg.privateKey, + chainCode: _.isString(arg.chainCode) ? Buffer.from(arg.chainCode, 'hex') : arg.chainCode, + privateKey: (_.isString(arg.privateKey) && JSUtil.isHexa(arg.privateKey)) ? Buffer.from(arg.privateKey, 'hex') : arg.privateKey, checksum: arg.checksum ? (arg.checksum.length ? arg.checksum : BufferUtil.integerAsBuffer(arg.checksum)) : undefined }; return this._buildFromBuffers(buffers); }; HDPrivateKey.prototype._buildFromSerialized = function(arg) { - var decoded = Base58Check.decode(arg); - var buffers = { + const decoded = Base58Check.decode(arg); + const buffers = { version: decoded.slice(HDPrivateKey.VersionStart, HDPrivateKey.VersionEnd), depth: decoded.slice(HDPrivateKey.DepthStart, HDPrivateKey.DepthEnd), parentFingerPrint: decoded.slice(HDPrivateKey.ParentFingerPrintStart, @@ -431,7 +430,7 @@ HDPrivateKey.fromSeed = function(hexa, network) { HDPrivateKey.prototype._calcHDPublicKey = function() { if (!this._hdPublicKey) { - var HDPublicKey = require('./hdpublickey'); + const HDPublicKey = require('./hdpublickey'); this._hdPublicKey = new HDPublicKey(this); } }; @@ -462,11 +461,11 @@ HDPrivateKey.prototype._buildFromBuffers = function(arg) { _buffers: arg }); - var sequence = [ + const sequence = [ arg.version, arg.depth, arg.parentFingerPrint, arg.childIndex, arg.chainCode, BufferUtil.emptyBuffer(1), arg.privateKey ]; - var concat = buffer.Buffer.concat(sequence); + const concat = buffer.Buffer.concat(sequence); if (!arg.checksum || !arg.checksum.length) { arg.checksum = Base58Check.checksum(concat); } else { @@ -475,15 +474,15 @@ HDPrivateKey.prototype._buildFromBuffers = function(arg) { } } - var network = Network.get(BufferUtil.integerFromBuffer(arg.version)); - var xprivkey; + const network = Network.get(BufferUtil.integerFromBuffer(arg.version)); + let xprivkey; xprivkey = Base58Check.encode(buffer.Buffer.concat(sequence)); arg.xprivkey = Buffer.from(xprivkey); - var privateKey = new PrivateKey(BN.fromBuffer(arg.privateKey), network); - var publicKey = privateKey.toPublicKey(); - var size = HDPrivateKey.ParentFingerPrintSize; - var fingerPrint = Hash.sha256ripemd160(publicKey.toBuffer()).slice(0, size); + const privateKey = new PrivateKey(BN.fromBuffer(arg.privateKey), network); + const publicKey = privateKey.toPublicKey(); + const size = HDPrivateKey.ParentFingerPrintSize; + const fingerPrint = Hash.sha256ripemd160(publicKey.toBuffer()).slice(0, size); JSUtil.defineImmutable(this, { xprivkey: xprivkey, @@ -516,8 +515,8 @@ HDPrivateKey.prototype._buildFromBuffers = function(arg) { }; HDPrivateKey._validateBufferArguments = function(arg) { - var checkBuffer = function(name, size) { - var buff = arg[name]; + const checkBuffer = function(name, size) { + const buff = arg[name]; assert(BufferUtil.isBuffer(buff), name + ' argument is not a buffer'); assert( buff.length === size, @@ -586,6 +585,20 @@ HDPrivateKey.prototype.toObject = HDPrivateKey.prototype.toJSON = function toObj }; }; +HDPrivateKey.prototype.toObjectWithBufferPrivateKey = function toObjectWithBufferPrivateKey() { + return { + network: Network.get(BufferUtil.integerFromBuffer(this._buffers.version), 'xprivkey').name, + depth: BufferUtil.integerFromSingleByteBuffer(this._buffers.depth), + fingerPrint: BufferUtil.integerFromBuffer(this.fingerPrint), + parentFingerPrint: BufferUtil.integerFromBuffer(this._buffers.parentFingerPrint), + childIndex: BufferUtil.integerFromBuffer(this._buffers.childIndex), + chainCode: BufferUtil.bufferToHex(this._buffers.chainCode), + privateKey: this.privateKey.toBuffer(), + checksum: BufferUtil.integerFromBuffer(this._buffers.checksum), + xprivkey: this.xprivkey + }; +}; + /** * Build a HDPrivateKey from a buffer * diff --git a/packages/crypto-wallet-core/src/derivation/btc/index.ts b/packages/crypto-wallet-core/src/derivation/btc/index.ts index fd64f0d37e5..417f7c3089e 100644 --- a/packages/crypto-wallet-core/src/derivation/btc/index.ts +++ b/packages/crypto-wallet-core/src/derivation/btc/index.ts @@ -33,6 +33,35 @@ export abstract class AbstractBitcoreLibDeriver implements IDeriver { pubKey = new this.bitcoreLib.PublicKey(pubKey); return new this.bitcoreLib.Address(pubKey, network, addressType).toString(); } + + getPublicKey(network: string, privKey: Buffer): string { + if (!Buffer.isBuffer(privKey)) { + throw new Error('Expected privKey to be a Buffer'); + } + // Force compressed pubkey (buffer does not encode compression flag) + const bn = this.bitcoreLib.crypto.BN.fromBuffer(privKey); + const key = new this.bitcoreLib.PrivateKey({ bn, network, compressed: true }); + return key.publicKey.toString(); + } + + /** + * @returns {Buffer} raw secpk1 private key buffer (32 bytes, big-endian) + * @throws {Error} If privKey is not a Buffer (planned forwards compatibility) or string. Propagates all other errors + */ + privateKeyToBuffer(privKey: Buffer | string): Buffer { + if (Buffer.isBuffer(privKey)) return privKey; // forward compatibility + if (typeof privKey !== 'string') throw new Error(`Expected key to be a string, got ${typeof privKey}`); + + const key = new this.bitcoreLib.PrivateKey(privKey); + return key.toBuffer(); + } + + bufferToPrivateKey_TEMP(buf: Buffer, network: string): string { + // force compressed WIF without mutating instances + const bn = this.bitcoreLib.crypto.BN.fromBuffer(buf); + const key = new this.bitcoreLib.PrivateKey({ bn, network, compressed: true }); + return key.toWIF(); + } } export class BtcDeriver extends AbstractBitcoreLibDeriver { bitcoreLib = BitcoreLib; diff --git a/packages/crypto-wallet-core/src/derivation/eth/index.ts b/packages/crypto-wallet-core/src/derivation/eth/index.ts index c3b84b77190..3b24c53a441 100644 --- a/packages/crypto-wallet-core/src/derivation/eth/index.ts +++ b/packages/crypto-wallet-core/src/derivation/eth/index.ts @@ -55,4 +55,32 @@ export class EthDeriver implements IDeriver { pubKey = new BitcoreLib.PublicKey(pubKey, network); // network not needed here since ETH doesn't differentiate addresses by network. return this.addressFromPublicKeyBuffer(pubKey.toBuffer()); } + + getPublicKey(network: string, privKey: Buffer): string { + if (!Buffer.isBuffer(privKey)) { + throw new Error('Expected privKey to be a Buffer'); + } + const bn = BitcoreLib.crypto.BN.fromBuffer(privKey); + const key = new BitcoreLib.PrivateKey({ bn, network, compressed: true }); + return key.publicKey.toString('hex'); + } + + /** + * @param {Buffer | string} privKey - expects hex-encoded string, as returned from EthDeriver.derivePrivateKey + * @returns {Buffer} + * @throws {Error} If privKey is not a Buffer (planned forwards compatibility) or string. Propagates all other errors + */ + privateKeyToBuffer(privKey: Buffer | string): Buffer { + if (Buffer.isBuffer(privKey)) return privKey; + if (typeof privKey !== 'string') throw new Error(`Expected string, got ${typeof privKey}`); + if (privKey.startsWith('0x')) { + privKey = privKey.slice(2); + }; + // Expects to match return from derivePrivateKey's privKey. + return Buffer.from(privKey, 'hex'); + } + + bufferToPrivateKey_TEMP(buf: Buffer, _network: string): string { + return buf.toString('hex'); + } } diff --git a/packages/crypto-wallet-core/src/derivation/index.ts b/packages/crypto-wallet-core/src/derivation/index.ts index 2e61381f761..6994763a6cf 100644 --- a/packages/crypto-wallet-core/src/derivation/index.ts +++ b/packages/crypto-wallet-core/src/derivation/index.ts @@ -105,6 +105,13 @@ export class DeriverProxy { return this.get(chain).getAddress(network, pubKey, addressType); } + /** + * Caller responsible for cleaning up privKey buffer + */ + getPublicKey(chain, network, privKey: Buffer) { + return this.get(chain).getPublicKey(network, privKey); + } + pathFor(chain, network, account = 0) { const normalizedChain = chain.toUpperCase(); const accountStr = `${account}'`; @@ -119,6 +126,14 @@ export class DeriverProxy { return Paths.BTC.default + accountStr; } } + + privateKeyToBuffer(chain, privateKey: Buffer | string): Buffer { + return this.get(chain).privateKeyToBuffer(privateKey); + } + + bufferToPrivateKey_TEMP(chain: string, network: string, buf: Buffer): string { + return this.get(chain).bufferToPrivateKey_TEMP(buf, network); + } } export default new DeriverProxy(); diff --git a/packages/crypto-wallet-core/src/derivation/sol/index.ts b/packages/crypto-wallet-core/src/derivation/sol/index.ts index 8ce21e18dcb..da5c6566dbf 100644 --- a/packages/crypto-wallet-core/src/derivation/sol/index.ts +++ b/packages/crypto-wallet-core/src/derivation/sol/index.ts @@ -16,6 +16,14 @@ export class SolDeriver implements IDeriver { return this.addressFromPublicKeyBuffer(Buffer.from(pubKey, 'hex')); } + getPublicKey(_network: string, privKey: Buffer): string { + if (!Buffer.isBuffer(privKey)) { + throw new Error('Expected privKey to be a Buffer'); + } + const pubKey = ed25519.getPublicKey(privKey, false); + return Buffer.from(pubKey).toString('hex'); + } + addressFromPublicKeyBuffer(pubKey: Buffer): string { if (pubKey.length > 32) { pubKey = pubKey.subarray(pubKey.length - 32); @@ -54,4 +62,20 @@ export class SolDeriver implements IDeriver { pubKey: Buffer.from(pubKey).toString('hex') } as Key; }; + + /** + * @param {Buffer | string} privKey - expects base 58 encoded string + * @returns {Buffer} + * @throws {Error} If privKey is not a Buffer (planned forwards compatibility) or string. Propagates all other errors + */ + privateKeyToBuffer(privKey: Buffer | string): Buffer { + if (Buffer.isBuffer(privKey)) return privKey; + if (typeof privKey !== 'string') throw new Error(`Expected string, got ${typeof privKey}`); + // Expects to match return from derivePrivateKey's privKey. + return encoding.Base58.decode(privKey); + } + + bufferToPrivateKey_TEMP(buf: Buffer, _network: string): string { + return encoding.Base58.encode(buf); + } } \ No newline at end of file diff --git a/packages/crypto-wallet-core/src/derivation/xrp/index.ts b/packages/crypto-wallet-core/src/derivation/xrp/index.ts index 15cb5d1b832..a5050cb27bb 100644 --- a/packages/crypto-wallet-core/src/derivation/xrp/index.ts +++ b/packages/crypto-wallet-core/src/derivation/xrp/index.ts @@ -35,4 +35,29 @@ export class XrpDeriver implements IDeriver { const address = deriveAddress(pubKey); return address; } + + getPublicKey(network: string, privKey: Buffer): string { + if (!Buffer.isBuffer(privKey)) { + throw new Error('Expected privKey to be a Buffer'); + } + const bn = BitcoreLib.crypto.BN.fromBuffer(privKey); + const key = new BitcoreLib.PrivateKey({ bn, network, compressed: true }); + return key.publicKey.toString('hex').toUpperCase(); + } + + /** + * @param {Buffer | string} privKey - expects hex-encoded string, as returned from XrpDeriver.derivePrivateKey privKey + * @returns {Buffer} + * @throws {Error} If privKey is not a Buffer (planned forwards compatibility) or string. Propagates all other errors + */ + privateKeyToBuffer(privKey: Buffer | string): Buffer { + if (Buffer.isBuffer(privKey)) return privKey; + if (typeof privKey !== 'string') throw new Error(`Expected string, got ${typeof privKey}`); + // Expects to match return from derivePrivateKey's privKey. + return Buffer.from(privKey, 'hex'); + } + + bufferToPrivateKey_TEMP(buf: Buffer, _network: string): string { + return buf.toString('hex').toUpperCase(); + } } diff --git a/packages/crypto-wallet-core/src/types/derivation.ts b/packages/crypto-wallet-core/src/types/derivation.ts index 1ada0ffccd7..babb0a87890 100644 --- a/packages/crypto-wallet-core/src/types/derivation.ts +++ b/packages/crypto-wallet-core/src/types/derivation.ts @@ -14,4 +14,21 @@ export interface IDeriver { derivePrivateKeyWithPath(network: string, xprivKey: string, path: string, addressType: string): Key; getAddress(network: string, pubKey, addressType: string): string; + + /** + * Derive the public key for a given chain-native private key representation. + * Used when importing plaintext private keys that may not include `pubKey`. + * Caller should clean up buffer after use + */ + getPublicKey(network: string, privKey: Buffer): string; + + /** + * Used to normalize output of Key.privKey + */ + privateKeyToBuffer(privKey: any): Buffer; + + /** + * Temporary - converts decrypted private key buffer to lib-specific private key format + */ + bufferToPrivateKey_TEMP(buf: Buffer, network: string): string; } \ No newline at end of file diff --git a/packages/crypto-wallet-core/test/deriver.test.ts b/packages/crypto-wallet-core/test/deriver.test.ts new file mode 100644 index 00000000000..9fea11f2d2d --- /dev/null +++ b/packages/crypto-wallet-core/test/deriver.test.ts @@ -0,0 +1,126 @@ +import { expect } from 'chai'; +import crypto from 'crypto'; +import { Deriver } from '../src'; + +describe('IDeriver', function () { + describe('getPublicKey (Buffer-first)', () => { + it('BTC: should derive the compressed secp256k1 public key', () => { + // Well-known secp256k1 test vector: private key 1 maps to generator point G. + const privHex = '0000000000000000000000000000000000000000000000000000000000000001'; + const expectedPubKey = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + const privBuf = Deriver.privateKeyToBuffer('BTC', privHex); + try { + const pubKey = Deriver.getPublicKey('BTC', 'testnet', privBuf); + + expect(pubKey).to.equal(expectedPubKey); + expect(pubKey).to.match(/^(02|03)/); + expect(pubKey).to.have.length(66); + } finally { + privBuf.fill(0); + } + }); + + it('ETH: should derive the compressed pubKey used by wallet imports', () => { + // Vetted fixture from `test/address.test.ts`. + const privHex = '62b8311c71f355c5c07f6bffe9b1ae60aa20d90e2e2ec93ec11b6014b2ae6340'; + const expectedPubKey = '0386d153aad9395924631dbc78fa560107123a759eaa3e105958248c60cd4472ad'; + const expectedAddress = '0xb497281830dE4F19a3482AbF3D5C35c514e6fB36'; + const privBuf = Deriver.privateKeyToBuffer('ETH', privHex); + try { + const pubKey = Deriver.getPublicKey('ETH', 'mainnet', privBuf); + + expect(pubKey).to.equal(expectedPubKey); + expect(pubKey).to.match(/^(02|03)/); + expect(pubKey).to.have.length(66); + expect(Deriver.getAddress('ETH', 'mainnet', pubKey)).to.equal(expectedAddress); + } finally { + privBuf.fill(0); + } + }); + + it('XRP: should derive the compressed pubKey used by wallet imports', () => { + // Vetted fixture from `test/address.test.ts`. + const privHex = 'D02C6801D8F328FF2EAD51D01F9580AF36C8D74E2BD463963AC4ADBE51AE5F2C'; + const expectedPubKey = '03DBEEC5E9E76DA09C5B502A67136BC2D73423E8902A7C35A8CBC0C5A6AC0469E8'; + const expectedAddress = 'r9dmAJBfBe7JL2RRLiFWGJ8kM4CHEeTpgN'; + const privBuf = Deriver.privateKeyToBuffer('XRP', privHex); + try { + const pubKey = Deriver.getPublicKey('XRP', 'mainnet', privBuf); + + expect(pubKey).to.equal(expectedPubKey); + expect(pubKey).to.match(/^(02|03)/); + expect(pubKey).to.have.length(66); + expect(Deriver.getAddress('XRP', 'mainnet', pubKey)).to.equal(expectedAddress); + } finally { + privBuf.fill(0); + } + }); + + it('SOL: should derive the public key used by wallet imports', () => { + // Vetted fixture from `test/address.test.ts`. + const privKey = 'E4Tp4nTgMCa5dtGwqvkWoMGrJC7FKRNjcpeFFXi4nNb9'; + const expectedPubKey = '5c9c85b20525ee81d3cc56da1f8307ec169086ae41458c5458519aced7683b66'; + const expectedAddress = '7EWwMxKQa5Gru7oTcS1Wi3AaEgTfA6MU3z7MaLUT6hnD'; + const privBuf = Deriver.privateKeyToBuffer('SOL', privKey); + try { + const pubKey = Deriver.getPublicKey('SOL', 'mainnet', privBuf); + + expect(pubKey).to.equal(expectedPubKey); + expect(pubKey).to.have.length(64); + expect(Deriver.getAddress('SOL', 'mainnet', pubKey)).to.equal(expectedAddress); + } finally { + privBuf.fill(0); + } + }); + }); + + describe('privateKeyToBuffer', function () { + it('ETH: should accept with/without 0x', () => { + const privHex = crypto.randomBytes(32).toString('hex'); + const privBufNo0x = Deriver.privateKeyToBuffer('ETH', privHex); + const privBuf0x = Deriver.privateKeyToBuffer('ETH', `0x${privHex}`); + try { + expect(privBuf0x.equals(privBufNo0x)).to.be.true; + } finally { + privBufNo0x.fill(0); + privBuf0x.fill(0); + } + }); + it('ETH: hex-stringified buffer output equals input', () => { + const privHex = crypto.randomBytes(32).toString('hex'); + const privBuf = Deriver.privateKeyToBuffer('ETH', privHex); + try { + expect(privBuf.toString('hex')).to.equal(privHex); + } finally { + privBuf.fill(0); + } + }); + + it('XRP: should accept uppercase/lowercase hex', () => { + // Vetted fixture from `test/address.test.ts`. + const privHexUpper = 'D02C6801D8F328FF2EAD51D01F9580AF36C8D74E2BD463963AC4ADBE51AE5F2C'; + const privHexLower = privHexUpper.toLowerCase(); + const privBufUpper = Deriver.privateKeyToBuffer('XRP', privHexUpper); + const privBufLower = Deriver.privateKeyToBuffer('XRP', privHexLower); + try { + expect(privBufUpper.equals(privBufLower)).to.be.true; + } finally { + privBufUpper.fill(0); + privBufLower.fill(0); + } + }); + + it('XRP: hex-stringified buffer output equals fixture bytes', () => { + // Vetted fixture from `test/address.test.ts`. + const privHexUpper = 'D02C6801D8F328FF2EAD51D01F9580AF36C8D74E2BD463963AC4ADBE51AE5F2C'; + const privBuf = Deriver.privateKeyToBuffer('XRP', privHexUpper); + try { + expect(privBuf.toString('hex')).to.equal(privHexUpper.toLowerCase()); + } finally { + privBuf.fill(0); + } + }); + }); +}); + +