diff --git a/packages/cli/src/argparse.ts b/packages/cli/src/argparse.ts index e2d11a5dd..0b92c1fd2 100644 --- a/packages/cli/src/argparse.ts +++ b/packages/cli/src/argparse.ts @@ -1362,9 +1362,14 @@ export const CLI_ARGS = { type: 'string', realtype: '24_words_or_ciphertext', }, + { + name: 'derivation_path', + type: 'string', + realtype: 'custom_derivation_path_string', + }, ], minItems: 1, - maxItems: 1, + maxItems: 2, help: 'Get the payment private key from a 24-word backup phrase used by the Stacks wallet. If you provide an ' + 'encrypted backup phrase, you will be asked for your password to decrypt it. This command ' + @@ -1474,9 +1479,14 @@ export const CLI_ARGS = { type: 'string', realtype: '12_words_or_ciphertext', }, + { + name: 'derivation_path', + type: 'string', + realtype: 'custom_derivation_path_string', + }, ], minItems: 0, - maxItems: 1, + maxItems: 2, help: 'Generate the owner and payment private keys, optionally from a given 12-word ' + 'backup phrase. If no backup phrase is given, a new one will be generated. If you provide ' + diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 1ab8a71fa..7756585c0 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -331,8 +331,9 @@ async function getPaymentKey(network: CLINetworkAdapter, args: string[]): Promis */ async function getStacksWalletKey(network: CLINetworkAdapter, args: string[]): Promise { const mnemonic = await getBackupPhrase(args[0]); + const derivationPath: string | undefined = args[1] || undefined; // keep the return value consistent with getOwnerKeys - const keyObj = await getStacksWalletKeyInfo(network, mnemonic); + const keyObj = await getStacksWalletKeyInfo(network, mnemonic, derivationPath); const keyInfo: StacksKeyInfoType[] = []; keyInfo.push(keyObj); return JSONStringify(keyInfo); @@ -355,7 +356,8 @@ async function makeKeychain(network: CLINetworkAdapter, args: string[]): Promise ); } - const stacksKeyInfo = await getStacksWalletKeyInfo(network, mnemonic); + const derivationPath: string | undefined = args[1] || undefined; + const stacksKeyInfo = await getStacksWalletKeyInfo(network, mnemonic, derivationPath); return JSONStringify({ mnemonic: mnemonic, keyInfo: stacksKeyInfo, @@ -1965,5 +1967,7 @@ export const testables = ? { addressConvert, contractFunctionCall, + makeKeychain, + getStacksWalletKey, } : undefined; diff --git a/packages/cli/src/keys.ts b/packages/cli/src/keys.ts index 9fe3bf3bb..50d02a63d 100644 --- a/packages/cli/src/keys.ts +++ b/packages/cli/src/keys.ts @@ -134,11 +134,12 @@ export async function getPaymentKeyInfo( */ export async function getStacksWalletKeyInfo( network: CLINetworkAdapter, - mnemonic: string + mnemonic: string, + derivationPath = DERIVATION_PATH ): Promise { const seed = await bip39.mnemonicToSeed(mnemonic); const master = bip32.fromSeed(seed); - const child = master.derivePath("m/44'/5757'/0'/0/0"); // taken from stacks-wallet. See https://github.com/blockstack/stacks-wallet + const child = master.derivePath(derivationPath); // taken from stacks-wallet. See https://github.com/blockstack/stacks-wallet const ecPair = bitcoin.ECPair.fromPrivateKey(child.privateKey!); const privkey = blockstack.ecPairToHexString(ecPair); const wif = child.toWIF(); diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 9e55443c1..fea2a307b 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -675,6 +675,9 @@ export async function getBackupPhrase( if (!process.stdin.isTTY && !password) { // password must be given reject(new Error('Password argument required in non-interactive mode')); + } else if (process.env.password) { + // Do not prompt password for unit tests + resolve(process.env.password); } else { // prompt password getpass('Enter password: ', p => { diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index b60b8b831..a17ce37ba 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -8,11 +8,12 @@ import {ClarityAbi} from '@stacks/transactions'; import {readFileSync} from 'fs'; import path from 'path'; import fetchMock from 'jest-fetch-mock'; +import { makekeychainTests, keyInfoTests, MakeKeychainResult, WalletKeyInfoResult } from './derivation-path/keychain'; const TEST_ABI: ClarityAbi = JSON.parse(readFileSync(path.join(__dirname, './abi/test-abi.json')).toString()); jest.mock('inquirer'); -const { addressConvert, contractFunctionCall } = testables as any; +const { addressConvert, contractFunctionCall, makeKeychain, getStacksWalletKey } = testables as any; const mainnetNetwork = new CLINetworkAdapter( getNetwork({} as CLI_CONFIG_TYPE, false), @@ -179,3 +180,39 @@ describe('Contract function call', () => { expect(result.txid).toEqual(txid); }); }); + +describe('Keychain custom derivation path', () => { + test.each(makekeychainTests)('Make keychain using custom derivation path %#', async (derivationPath: string, keyChainResult: MakeKeychainResult) => { + const encrypted = 'vim+XrRNSm+SqSn0MyWNEi/e+UK5kX8WGCLE/sevT6srZG+quzpp911sWP0CcvsExCH1M4DgOfOldMitLdkq1b6rApDwtAcOWdAqiaBk37M='; + const args = [encrypted, derivationPath]; + + // Mock TTY + process.stdin.isTTY = true; + process.env.password = 'supersecret'; + + const keyChain = await makeKeychain(testnetNetwork, args); + const result = JSON.parse(keyChain); + expect(result).toEqual(keyChainResult); + // Unmock TTY + process.stdin.isTTY = false; + process.env.password = undefined; + }); + + test.each(keyInfoTests)('Make keychain using custom derivation path %#', async (derivationPath: string, walletInfoResult: WalletKeyInfoResult ) => { + const encrypted = 'vim+XrRNSm+SqSn0MyWNEi/e+UK5kX8WGCLE/sevT6srZG+quzpp911sWP0CcvsExCH1M4DgOfOldMitLdkq1b6rApDwtAcOWdAqiaBk37M='; + const args = [encrypted, derivationPath]; + + // Mock TTY + process.stdin.isTTY = true; + process.env.password = 'supersecret'; + + const walletKey = await getStacksWalletKey(testnetNetwork, args); + const result = JSON.parse(walletKey); + expect(result).toEqual([ + walletInfoResult + ]); + // Unmock TTY + process.stdin.isTTY = false; + process.env.password = undefined; + }); +}); diff --git a/packages/cli/tests/derivation-path/keychain.ts b/packages/cli/tests/derivation-path/keychain.ts new file mode 100644 index 000000000..1d8d0883c --- /dev/null +++ b/packages/cli/tests/derivation-path/keychain.ts @@ -0,0 +1,80 @@ +export type MakeKeychainResult = { + mnemonic: string, + keyInfo: { + privateKey: string; + address: string; + btcAddress: string; + wif: string; + index: number; + }; +}; + +export type WalletKeyInfoResult = { + privateKey: string; + address: string; + btcAddress: string; + wif: string; + index: number; +}; + +export const makekeychainTests: Array<[string, MakeKeychainResult]> = [ + [ + // Derivation Path + "m/44'/5757'/0'/0/0", + // Expected result + { + mnemonic: 'vivid oxygen neutral wheat find thumb cigar wheel board kiwi portion business', + keyInfo: { + privateKey: 'd1124855494c883c5e1df0201be40a835f08ae5fc3a6520224b2239db94a818001', + address: 'ST1J28031BYDX19TYXSNDG9Q4HDB2TBDAM921Y7MS', + btcAddress: 'mpeSzfUTBba7qzKNcg8ojNm4GAfwmNPX8X', + wif: 'L4E7pXmqdm8C8TakpX7YDDmFopaQw32Ak6V5BpRFNDJmo7wjGVqc', + index: 0 + } + } + ], + [ + // Derivation Path + "m/888'/0'/0", + // Expected result + { + mnemonic: 'vivid oxygen neutral wheat find thumb cigar wheel board kiwi portion business', + keyInfo: { + privateKey: 'd4d30d4fdaa59e166865b836548015c2780063b82e7b2a364c8a2e32df7139ce01', + address: 'ST1WT20920NVRQ892MS535R7XEMV6KD6M6X2HQPK3', + btcAddress: 'mrc4w3oQZ39Yvkimk9DDJQnHFjv1e336mg', + wif: 'L4MQx6c6ZmoiwFYUHnmt39THRGeQnPmfA2AFobwWmssZJabi3qXm', + index: 0 + } + } + ] +]; + +export const keyInfoTests: Array<[string, WalletKeyInfoResult]> = [ + [ + // Derivation Path + "m/44'/5757'/0'/0/0", + // Expected result + { + privateKey: 'd1124855494c883c5e1df0201be40a835f08ae5fc3a6520224b2239db94a818001', + address: 'ST1J28031BYDX19TYXSNDG9Q4HDB2TBDAM921Y7MS', + btcAddress: 'mpeSzfUTBba7qzKNcg8ojNm4GAfwmNPX8X', + wif: 'L4E7pXmqdm8C8TakpX7YDDmFopaQw32Ak6V5BpRFNDJmo7wjGVqc', + index: 0 + } + ], + [ + // Derivation Path + "m/888'/0'/0", + // Expected result + { + privateKey: 'd4d30d4fdaa59e166865b836548015c2780063b82e7b2a364c8a2e32df7139ce01', + address: 'ST1WT20920NVRQ892MS535R7XEMV6KD6M6X2HQPK3', + btcAddress: 'mrc4w3oQZ39Yvkimk9DDJQnHFjv1e336mg', + wif: 'L4MQx6c6ZmoiwFYUHnmt39THRGeQnPmfA2AFobwWmssZJabi3qXm', + index: 0 + } + ] +]; + + diff --git a/packages/cli/tests/derivation-path/wallet.key.info.ts b/packages/cli/tests/derivation-path/wallet.key.info.ts new file mode 100644 index 000000000..b9a85798b --- /dev/null +++ b/packages/cli/tests/derivation-path/wallet.key.info.ts @@ -0,0 +1,32 @@ +import { WalletKeyInfoResult } from './keychain'; + +export const keyInfoTests: Array<[string, WalletKeyInfoResult]> = [ + [ + // Derivation path + "m/44'/5757'/0'/0/0", + // Expected result + { + privateKey: '25899fab1b9b95cc2d1692529f00fb788e85664df3d14db1a660f33c5f96d8ab01', + address: 'SP3RBZ4TZ3EK22SZRKGFZYBCKD7WQ5B8FFS0AYVF7', + btcAddress: '1Nwxfx7VoYAg2mEN35dTRw4H7gte8ajFki', + wif: 'KxUgLbeVeFZEUUQpc3ncYn5KFB3WH5MVRv3SJ2g5yPwkrXs3QRaP', + index: 0 + } + ], + [ + // Derivation path + "m/888'/0'/0", + // Expected result + { + privateKey: '0f0936f59a7d55be6bcd1820f798460ac4b3aa50f26c8fa76beb82a19af5110901', + address: 'SPGJAPK47Z9XY7E7BCEJFAEX9C7WGB0YB74A54MA', + btcAddress: '142G3fnfn1WZPtnYLYiVGt8aU55GZYxeVP', + wif: 'KwiwQgTK2412XSdBfcRWJ4xQFbevUHCwGnRCuvjeHjSqceNwS1wW', + index: 0 + } + ] +]; + +export { WalletKeyInfoResult }; + + diff --git a/packages/cli/tests/keys.test.ts b/packages/cli/tests/keys.test.ts index 9af0afa9b..e2b8f5996 100644 --- a/packages/cli/tests/keys.test.ts +++ b/packages/cli/tests/keys.test.ts @@ -1,6 +1,7 @@ import { getStacksWalletKeyInfo, getOwnerKeyInfo, findIdentityIndex } from '../src/keys'; import { getNetwork, CLINetworkAdapter, CLI_NETWORK_OPTS } from '../src/network'; import { CLI_CONFIG_TYPE } from '../src/argparse'; +import { keyInfoTests, WalletKeyInfoResult } from './derivation-path/wallet.key.info'; import * as fixtures from './fixtures/keys.fixture'; @@ -23,6 +24,15 @@ test('getStacksWalletKeyInfo', async () => { }); }); +describe('getStacksWalletKeyInfo custom derivation path', () => { + test.each(keyInfoTests)('%#', async (derivationPath: string, keyInfoResult: WalletKeyInfoResult) => { + const mnemonic = 'apart spin rich leader siren foil dish sausage fee pipe ethics bundle'; + const info = await getStacksWalletKeyInfo(mainnetNetwork, mnemonic, derivationPath); + + expect(info).toEqual(keyInfoResult); + }); +}); + describe('getOwnerKeyInfo', () => { test.each(fixtures.getOwnerKeyInfo)('%#', async (mnemonic, index, version, result) => { const info = await getOwnerKeyInfo(mainnetNetwork, mnemonic, index, version);