diff options
author | Wladimir J. van der Laan <laanwj@protonmail.com> | 2021-02-23 17:56:24 +0100 |
---|---|---|
committer | Wladimir J. van der Laan <laanwj@protonmail.com> | 2021-02-23 17:56:43 +0100 |
commit | a9335e4f129cadd87fda0fe49baf74670ded491a (patch) | |
tree | c16d6d229e204758a32362f8c10a88791d2c03c4 /test | |
parent | 78effb37f35ff09733e79497bd6b06d355272d79 (diff) | |
parent | f75e0c1edde39a91cc353b0102638e232def9476 (diff) | |
download | bitcoin-a9335e4f129cadd87fda0fe49baf74670ded491a.tar.xz |
Merge #16546: External signer support - Wallet Box edition
f75e0c1edde39a91cc353b0102638e232def9476 doc: add external-signer.md (Sjors Provoost)
d4b0107d68a91ed4d1a5c78c8ca76251329d3f3c rpc: send: support external signer (Sjors Provoost)
245b4457cf9265190a05529a0a97e1cb258cca8a rpc: signerdisplayaddress (Sjors Provoost)
7ebc7c0215979c53b92a436acc8b5b607b8d735a wallet: ExternalSigner: add GetDescriptors method (Sjors Provoost)
fc5da520f5c72287f59823b8a6d748dda49c574a wallet: add GetExternalSigner() (Sjors Provoost)
259f52cc33817a00b91ec9c7d078c07b88db7ab4 test: external_signer wallet flag is immutable (Sjors Provoost)
2655197e1c2dea9536c32afe1482ced4a1f481e9 rpc: add external_signer option to createwallet (Sjors Provoost)
2700f09c4130af6167ce71f46960e92ca800e205 rpc: signer: add enumeratesigners to list external signers (Sjors Provoost)
07b7c940a7da138d55a484ef83fee19ebf58a867 rpc: add external signer RPC files (Sjors Provoost)
8ce7767071779a0170364e6426bd393ed71bf281 wallet: add ExternalSignerScriptPubKeyMan (Sjors Provoost)
157ea7c614950d61bfe405310e2aaabcee31f7a3 wallet: add external_signer flag (Sjors Provoost)
f3e6ce78fba2b31173fe7b606aa9edb5b615bff3 test: add external signer test (Sjors Provoost)
8cf543f96dcd6fdfac1367b9e2b1d7d51be8bb76 wallet: add -signer argument for external signer command (Sjors Provoost)
f7eb7ecc6750ab267a979d9268ce5b5d151c26de test: framework: add skip_if_no_external_signer (Sjors Provoost)
87a97941f667483bbf2ab00929e03a2199cb8a62 configure: add --enable-external-signer (Sjors Provoost)
Pull request description:
Big picture overview in [this gist](https://gist.github.com/Sjors/29d06728c685e6182828c1ce9b74483d).
This PR lets `bitcoind` call an arbitrary command `-signer=<cmd>`, e.g. a hardware wallet driver, where it can fetch public keys, ask to display an address, and sign a transaction (using PSBT under the hood).
It's design to work with https://github.com/bitcoin-core/HWI, which supports multiple hardware wallets. Any command with the same arguments and return values will work. It simplifies the manual procedure described [here](https://github.com/bitcoin-core/HWI/blob/master/docs/bitcoin-core-usage.md).
Usage is documented in [doc/external-signer.md](
https://github.com/Sjors/bitcoin/blob/2019/08/hww-box2/doc/external-signer.md), which also describes what protocol a different signer binary should conform to.
Use `--enable-external-signer` to opt in, requires Boost::Process:
```
Options used to compile and link:
with wallet = yes
with gui / qt = no
external signer = yes
```
It adds the following RPC methods:
* `enumeratesigners`: asks <cmd> for a list of signers (e.g. devices) and their master key fingerprint
* `signerdisplayaddress <address>`: asks <cmd> to display an address
It enhances the following RPC methods:
* `createwallet`: takes an additional `external_signer` argument and fetches keys from device
* `send`: automatically sends transaction to device and waits
Usage TL&DR:
* clone HWI repo somewhere and launch `bitcoind -signer=../HWI/hwi.py`
* check if you can see your hardware device: `bitcoin-cli enumeratesigners`
* create wallet and auto import keys `bitcoin-cli createwallet "hww" true true "" true true true`
* display address on device: `bitcoin-cli signerdisplayaddress ...`
* to spend, use `send` RPC and approve transaction on device
Prerequisites:
- [x] #21127 load wallet flags before everything else
- [x] #21182 remove mostly pointless BOOST_PROCESS macro
Potentially useful followups:
- GUI support: bitcoin-core/gui#4
- bumpfee support
- (automatically) verify (a subset of) keys on the device after import, through message signing
ACKs for top commit:
laanwj:
re-ACK f75e0c1edde39a91cc353b0102638e232def9476
Tree-SHA512: 7db8afd54762295c1424c3f01d8c587ec256a72f34bd5256e04b21832dabd5dc212be8ab975ae3b67de75259fd569a561491945750492f417111dc7b6641e77f
Diffstat (limited to 'test')
-rw-r--r-- | test/config.ini.in | 1 | ||||
-rwxr-xr-x | test/functional/mocks/signer.py | 102 | ||||
-rwxr-xr-x | test/functional/rpc_help.py | 5 | ||||
-rwxr-xr-x | test/functional/test_framework/test_framework.py | 9 | ||||
-rwxr-xr-x | test/functional/test_framework/test_node.py | 4 | ||||
-rwxr-xr-x | test/functional/test_runner.py | 1 | ||||
-rwxr-xr-x | test/functional/wallet_signer.py | 217 |
7 files changed, 336 insertions, 3 deletions
diff --git a/test/config.ini.in b/test/config.ini.in index 77c9a720c3..e3872181cd 100644 --- a/test/config.ini.in +++ b/test/config.ini.in @@ -23,3 +23,4 @@ RPCAUTH=@abs_top_srcdir@/share/rpcauth/rpcauth.py @BUILD_BITCOIND_TRUE@ENABLE_BITCOIND=true @ENABLE_FUZZ_TRUE@ENABLE_FUZZ=true @ENABLE_ZMQ_TRUE@ENABLE_ZMQ=true +@ENABLE_EXTERNAL_SIGNER_TRUE@ENABLE_EXTERNAL_SIGNER=true diff --git a/test/functional/mocks/signer.py b/test/functional/mocks/signer.py new file mode 100755 index 0000000000..676d0a0a4d --- /dev/null +++ b/test/functional/mocks/signer.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# Copyright (c) 2018 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import os +import sys +import argparse +import json + +def perform_pre_checks(): + mock_result_path = os.path.join(os.getcwd(), "mock_result") + if(os.path.isfile(mock_result_path)): + with open(mock_result_path, "r", encoding="utf8") as f: + mock_result = f.read() + if mock_result[0]: + sys.stdout.write(mock_result[2:]) + sys.exit(int(mock_result[0])) + +def enumerate(args): + sys.stdout.write(json.dumps([{"fingerprint": "00000001", "type": "trezor", "model": "trezor_t"}, {"fingerprint": "00000002"}])) + +def getdescriptors(args): + xpub = "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B" + + sys.stdout.write(json.dumps({ + "receive": [ + "pkh([00000001/44'/1'/" + args.account + "']" + xpub + "/0/*)#vt6w3l3j", + "sh(wpkh([00000001/49'/1'/" + args.account + "']" + xpub + "/0/*))#r0grqw5x", + "wpkh([00000001/84'/1'/" + args.account + "']" + xpub + "/0/*)#x30uthjs" + ], + "internal": [ + "pkh([00000001/44'/1'/" + args.account + "']" + xpub + "/1/*)#all0v2p2", + "sh(wpkh([00000001/49'/1'/" + args.account + "']" + xpub + "/1/*))#kwx4c3pe", + "wpkh([00000001/84'/1'/" + args.account + "']" + xpub + "/1/*)#h92akzzg" + ] + })) + + +def displayaddress(args): + # Several descriptor formats are acceptable, so allowing for potential + # changes to InferDescriptor: + if args.fingerprint != "00000001": + return sys.stdout.write(json.dumps({"error": "Unexpected fingerprint", "fingerprint": args.fingerprint})) + + expected_desc = [ + "wpkh([00000001/84'/1'/0'/0/0]02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7)#0yneg42r" + ] + if args.desc not in expected_desc: + return sys.stdout.write(json.dumps({"error": "Unexpected descriptor", "desc": args.desc})) + + return sys.stdout.write(json.dumps({"address": "bcrt1qm90ugl4d48jv8n6e5t9ln6t9zlpm5th68x4f8g"})) + +def signtx(args): + if args.fingerprint != "00000001": + return sys.stdout.write(json.dumps({"error": "Unexpected fingerprint", "fingerprint": args.fingerprint})) + + with open(os.path.join(os.getcwd(), "mock_psbt"), "r", encoding="utf8") as f: + mock_psbt = f.read() + + if args.fingerprint == "00000001" : + sys.stdout.write(json.dumps({ + "psbt": mock_psbt, + "complete": True + })) + else: + sys.stdout.write(json.dumps({"psbt": args.psbt})) + +parser = argparse.ArgumentParser(prog='./signer.py', description='External signer mock') +parser.add_argument('--fingerprint') +parser.add_argument('--chain', default='main') +parser.add_argument('--stdin', action='store_true') + +subparsers = parser.add_subparsers(description='Commands', dest='command') +subparsers.required = True + +parser_enumerate = subparsers.add_parser('enumerate', help='list available signers') +parser_enumerate.set_defaults(func=enumerate) + +parser_getdescriptors = subparsers.add_parser('getdescriptors') +parser_getdescriptors.set_defaults(func=getdescriptors) +parser_getdescriptors.add_argument('--account', metavar='account') + +parser_displayaddress = subparsers.add_parser('displayaddress', help='display address on signer') +parser_displayaddress.add_argument('--desc', metavar='desc') +parser_displayaddress.set_defaults(func=displayaddress) + +parser_signtx = subparsers.add_parser('signtx') +parser_signtx.add_argument('psbt', metavar='psbt') + +parser_signtx.set_defaults(func=signtx) + +if not sys.stdin.isatty(): + buffer = sys.stdin.read() + if buffer and buffer.rstrip() != "": + sys.argv.extend(buffer.rstrip().split(" ")) + +args = parser.parse_args() + +perform_pre_checks() + +args.func(args) diff --git a/test/functional/rpc_help.py b/test/functional/rpc_help.py index 1eefd109f8..de21f43747 100755 --- a/test/functional/rpc_help.py +++ b/test/functional/rpc_help.py @@ -105,10 +105,13 @@ class HelpRpcTest(BitcoinTestFramework): if self.is_wallet_compiled(): components.append('Wallet') + if self.is_external_signer_compiled(): + components.append('Signer') + if self.is_zmq_compiled(): components.append('Zmq') - assert_equal(titles, components) + assert_equal(titles, sorted(components)) def dump_help(self): dump_dir = os.path.join(self.options.tmpdir, 'rpc_help_dump') diff --git a/test/functional/test_framework/test_framework.py b/test/functional/test_framework/test_framework.py index 70a9798449..f7eaaa548f 100755 --- a/test/functional/test_framework/test_framework.py +++ b/test/functional/test_framework/test_framework.py @@ -827,10 +827,19 @@ class BitcoinTestFramework(metaclass=BitcoinTestMetaClass): self.options.previous_releases_path)) return self.options.prev_releases + def skip_if_no_external_signer(self): + """Skip the running test if external signer support has not been compiled.""" + if not self.is_external_signer_compiled(): + raise SkipTest("external signer support has not been compiled.") + def is_cli_compiled(self): """Checks whether bitcoin-cli was compiled.""" return self.config["components"].getboolean("ENABLE_CLI") + def is_external_signer_compiled(self): + """Checks whether external signer support was compiled.""" + return self.config["components"].getboolean("ENABLE_EXTERNAL_SIGNER") + def is_wallet_compiled(self): """Checks whether the wallet module was compiled.""" return self.config["components"].getboolean("ENABLE_WALLET") diff --git a/test/functional/test_framework/test_node.py b/test/functional/test_framework/test_node.py index b820c36e6e..ce9c1bc024 100755 --- a/test/functional/test_framework/test_node.py +++ b/test/functional/test_framework/test_node.py @@ -678,10 +678,10 @@ class RPCOverloadWrapper(): def __getattr__(self, name): return getattr(self.rpc, name) - def createwallet(self, wallet_name, disable_private_keys=None, blank=None, passphrase='', avoid_reuse=None, descriptors=None, load_on_startup=None): + def createwallet(self, wallet_name, disable_private_keys=None, blank=None, passphrase='', avoid_reuse=None, descriptors=None, load_on_startup=None, external_signer=None): if descriptors is None: descriptors = self.descriptors - return self.__getattr__('createwallet')(wallet_name, disable_private_keys, blank, passphrase, avoid_reuse, descriptors, load_on_startup) + return self.__getattr__('createwallet')(wallet_name, disable_private_keys, blank, passphrase, avoid_reuse, descriptors, load_on_startup, external_signer) def importprivkey(self, privkey, label=None, rescan=None): wallet_info = self.getwalletinfo() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index d742ef4eee..79ad2cf161 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -111,6 +111,7 @@ BASE_SCRIPTS = [ 'wallet_listtransactions.py --legacy-wallet', 'wallet_listtransactions.py --descriptors', 'feature_taproot.py', + 'wallet_signer.py --descriptors', # vv Tests less than 60s vv 'p2p_sendheaders.py', 'wallet_importmulti.py --legacy-wallet', diff --git a/test/functional/wallet_signer.py b/test/functional/wallet_signer.py new file mode 100755 index 0000000000..9dd080dca9 --- /dev/null +++ b/test/functional/wallet_signer.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +# Copyright (c) 2017-2018 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test external signer. + +Verify that a bitcoind node can use an external signer command +""" +import os +import platform + +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import ( + assert_equal, + assert_raises_rpc_error, +) + + +class SignerTest(BitcoinTestFramework): + def mock_signer_path(self): + path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'mocks', 'signer.py') + if platform.system() == "Windows": + return "py " + path + else: + return path + + def set_test_params(self): + self.num_nodes = 4 + + self.extra_args = [ + [], + [f"-signer={self.mock_signer_path()}", '-keypool=10'], + [f"-signer={self.mock_signer_path()}", '-keypool=10'], + ["-signer=fake.py"], + ] + + def skip_test_if_missing_module(self): + self.skip_if_no_wallet() + self.skip_if_no_external_signer() + + def set_mock_result(self, node, res): + with open(os.path.join(node.cwd, "mock_result"), "w", encoding="utf8") as f: + f.write(res) + + def clear_mock_result(self, node): + os.remove(os.path.join(node.cwd, "mock_result")) + + def run_test(self): + self.log.debug(f"-signer={self.mock_signer_path()}") + + assert_raises_rpc_error(-4, 'Error: restart bitcoind with -signer=<cmd>', + self.nodes[0].enumeratesigners + ) + + # Handle script missing: + assert_raises_rpc_error(-1, 'execve failed: No such file or directory', + self.nodes[3].enumeratesigners + ) + + # Handle error thrown by script + self.set_mock_result(self.nodes[1], "2") + assert_raises_rpc_error(-1, 'RunCommandParseJSON error', + self.nodes[1].enumeratesigners + ) + self.clear_mock_result(self.nodes[1]) + + self.set_mock_result(self.nodes[1], '0 [{"type": "trezor", "model": "trezor_t", "error": "fingerprint not found"}]') + assert_raises_rpc_error(-4, 'fingerprint not found', + self.nodes[1].enumeratesigners + ) + self.clear_mock_result(self.nodes[1]) + + # Create new wallets for an external signer. + # disable_private_keys and descriptors must be true: + assert_raises_rpc_error(-4, "Private keys must be disabled when using an external signer", self.nodes[1].createwallet, wallet_name='not_hww', disable_private_keys=False, descriptors=True, external_signer=True) + if self.is_bdb_compiled(): + assert_raises_rpc_error(-4, "Descriptor support must be enabled when using an external signer", self.nodes[1].createwallet, wallet_name='not_hww', disable_private_keys=True, descriptors=False, external_signer=True) + else: + assert_raises_rpc_error(-4, "Compiled without bdb support (required for legacy wallets)", self.nodes[1].createwallet, wallet_name='not_hww', disable_private_keys=True, descriptors=False, external_signer=True) + + self.nodes[1].createwallet(wallet_name='hww', disable_private_keys=True, descriptors=True, external_signer=True) + hww = self.nodes[1].get_wallet_rpc('hww') + + result = hww.enumeratesigners() + assert_equal(len(result['signers']), 2) + assert_equal(result['signers'][0]["fingerprint"], "00000001") + assert_equal(result['signers'][0]["name"], "trezor_t") + + # Flag can't be set afterwards (could be added later for non-blank descriptor based watch-only wallets) + self.nodes[1].createwallet(wallet_name='not_hww', disable_private_keys=True, descriptors=True, external_signer=False) + not_hww = self.nodes[1].get_wallet_rpc('not_hww') + assert_raises_rpc_error(-8, "Wallet flag is immutable: external_signer", not_hww.setwalletflag, "external_signer", True) + + # assert_raises_rpc_error(-4, "Multiple signers found, please specify which to use", wallet_name='not_hww', disable_private_keys=True, descriptors=True, external_signer=True) + + # TODO: Handle error thrown by script + # self.set_mock_result(self.nodes[1], "2") + # assert_raises_rpc_error(-1, 'Unable to parse JSON', + # self.nodes[1].createwallet, wallet_name='not_hww2', disable_private_keys=True, descriptors=True, external_signer=False + # ) + # self.clear_mock_result(self.nodes[1]) + + assert_equal(hww.getwalletinfo()["keypoolsize"], 30) + + address1 = hww.getnewaddress(address_type="bech32") + assert_equal(address1, "bcrt1qm90ugl4d48jv8n6e5t9ln6t9zlpm5th68x4f8g") + address_info = hww.getaddressinfo(address1) + assert_equal(address_info['solvable'], True) + assert_equal(address_info['ismine'], True) + assert_equal(address_info['hdkeypath'], "m/84'/1'/0'/0/0") + + address2 = hww.getnewaddress(address_type="p2sh-segwit") + assert_equal(address2, "2N2gQKzjUe47gM8p1JZxaAkTcoHPXV6YyVp") + address_info = hww.getaddressinfo(address2) + assert_equal(address_info['solvable'], True) + assert_equal(address_info['ismine'], True) + assert_equal(address_info['hdkeypath'], "m/49'/1'/0'/0/0") + + address3 = hww.getnewaddress(address_type="legacy") + assert_equal(address3, "n1LKejAadN6hg2FrBXoU1KrwX4uK16mco9") + address_info = hww.getaddressinfo(address3) + assert_equal(address_info['solvable'], True) + assert_equal(address_info['ismine'], True) + assert_equal(address_info['hdkeypath'], "m/44'/1'/0'/0/0") + + self.log.info('Test signerdisplayaddress') + result = hww.signerdisplayaddress(address1) + assert_equal(result, {"address": address1}) + + # Handle error thrown by script + self.set_mock_result(self.nodes[1], "2") + assert_raises_rpc_error(-1, 'RunCommandParseJSON error', + hww.signerdisplayaddress, address1 + ) + self.clear_mock_result(self.nodes[1]) + + self.log.info('Prepare mock PSBT') + self.nodes[0].sendtoaddress(address1, 1) + self.nodes[0].generate(1) + self.sync_all() + + # Load private key into wallet to generate a signed PSBT for the mock + self.nodes[1].createwallet(wallet_name="mock", disable_private_keys=False, blank=True, descriptors=True) + mock_wallet = self.nodes[1].get_wallet_rpc("mock") + assert mock_wallet.getwalletinfo()['private_keys_enabled'] + + result = mock_wallet.importdescriptors([{ + "desc": "wpkh([00000001/84'/1'/0']tprv8ZgxMBicQKsPd7Uf69XL1XwhmjHopUGep8GuEiJDZmbQz6o58LninorQAfcKZWARbtRtfnLcJ5MQ2AtHcQJCCRUcMRvmDUjyEmNUWwx8UbK/0/*)#rweraev0", + "timestamp": 0, + "range": [0,1], + "internal": False, + "active": True + }, + { + "desc": "wpkh([00000001/84'/1'/0']tprv8ZgxMBicQKsPd7Uf69XL1XwhmjHopUGep8GuEiJDZmbQz6o58LninorQAfcKZWARbtRtfnLcJ5MQ2AtHcQJCCRUcMRvmDUjyEmNUWwx8UbK/1/*)#j6uzqvuh", + "timestamp": 0, + "range": [0, 0], + "internal": True, + "active": True + }]) + assert_equal(result[0], {'success': True}) + assert_equal(result[1], {'success': True}) + assert_equal(mock_wallet.getwalletinfo()["txcount"], 1) + dest = self.nodes[0].getnewaddress(address_type='bech32') + mock_psbt = mock_wallet.walletcreatefundedpsbt([], {dest:0.5}, 0, {}, True)['psbt'] + mock_psbt_signed = mock_wallet.walletprocesspsbt(psbt=mock_psbt, sign=True, sighashtype="ALL", bip32derivs=True) + mock_psbt_final = mock_wallet.finalizepsbt(mock_psbt_signed["psbt"]) + mock_tx = mock_psbt_final["hex"] + assert(mock_wallet.testmempoolaccept([mock_tx])[0]["allowed"]) + + # # Create a new wallet and populate with specific public keys, in order + # # to work with the mock signed PSBT. + # self.nodes[1].createwallet(wallet_name="hww4", disable_private_keys=True, descriptors=True, external_signer=True) + # hww4 = self.nodes[1].get_wallet_rpc("hww4") + # + # descriptors = [{ + # "desc": "wpkh([00000001/84'/1'/0']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/*)#x30uthjs", + # "timestamp": "now", + # "range": [0, 1], + # "internal": False, + # "watchonly": True, + # "active": True + # }, + # { + # "desc": "wpkh([00000001/84'/1'/0']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/1/*)#h92akzzg", + # "timestamp": "now", + # "range": [0, 0], + # "internal": True, + # "watchonly": True, + # "active": True + # }] + + # result = hww4.importdescriptors(descriptors) + # assert_equal(result[0], {'success': True}) + # assert_equal(result[1], {'success': True}) + assert_equal(hww.getwalletinfo()["txcount"], 1) + + assert(hww.testmempoolaccept([mock_tx])[0]["allowed"]) + + with open(os.path.join(self.nodes[1].cwd, "mock_psbt"), "w", encoding="utf8") as f: + f.write(mock_psbt_signed["psbt"]) + + self.log.info('Test send using hww1') + + res = hww.send(outputs={dest:0.5},options={"add_to_wallet": False}) + assert(res["complete"]) + assert_equal(res["hex"], mock_tx) + + # # Handle error thrown by script + # self.set_mock_result(self.nodes[4], "2") + # assert_raises_rpc_error(-1, 'Unable to parse JSON', + # hww4.signerprocesspsbt, psbt_orig, "00000001" + # ) + # self.clear_mock_result(self.nodes[4]) + +if __name__ == '__main__': + SignerTest().main() |