diff --git a/.travis.yml b/.travis.yml index 7845927b0170ef046b99ff1324849c359361bf9b..07a9cfc861db5185e4e2f5f4583b35c193305132 100644 --- a/.travis.yml +++ b/.travis.yml @@ -58,7 +58,7 @@ before_install: - pip install --upgrade wheel # Set numpy version first, other packages link against it - pip install six nose coverage codecov pytest pytest-cov coveralls codacy-coverage parameterized secp256k1prp cryptography scrypt - - pip install pycryptodomex pyyaml appdirs pylibscrypt tox diff_match_patch + - pip install pycryptodomex pyyaml appdirs pylibscrypt tox diff_match_patch asn1 - pip install ecdsa requests future websocket-client pytz six Click events prettytable click_shell script: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6dd40c71ba68c9ce1d9781ee000ac0b194d61f9c..217ecf99a08e5f1de8be4c9e15be5cb0b6a7fe34 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,5 +1,15 @@ Changelog ========= +0.23.7 +------ +* Fix update_account_jsonmetadata and add posting_json_metadata property to Account +* Add Ledger Nano S support +* beempy -u activates ledger signing +* beempy -u listkeys shows pubkey from ledger +* beempy -u listaccounts searches for accounts that have pubkey derived from attached ledger +* beempy -u keygen creates pubkey lists that can be used for newaccount and changekeys +* new option use_ledger and path for Hive + 0.23.6 ------ * beempy --key key_list.json command can be used to set keys in beempy without using the wallet. diff --git a/README.rst b/README.rst index 66334035cbc1bafcde9e4e87b556dce4db7ae9ec..7442115336183e2eeaed370f40e0589bf3823167 100644 --- a/README.rst +++ b/README.rst @@ -156,6 +156,12 @@ A command line tool is available. The help output shows the available commands: beempy --help +Ledger support +-------------- +For Ledger (Nano S) signing, the following package must be installed: + + pip install ledgerblue + Stand alone version of CLI tool beempy -------------------------------------- With the help of pyinstaller, a stand alone version of beempy was created for Windows, OSX and linux. diff --git a/appveyor.yml b/appveyor.yml index c772261d829f652292cd25d7fefaed5370e305e9..e2736cc7f5ce5499877d0afaaa25e406f0f94fe4 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -52,7 +52,7 @@ install: - cmd: conda install --yes conda-build setuptools pip parameterized cryptography - cmd: conda install --yes pycryptodomex pyyaml pytest pytest-mock coverage mock appdirs pylibscrypt pywin32 - cmd: pip install scrypt -U -- cmd: conda install --yes ecdsa requests future websocket-client pytz six Click events prettytable pyinstaller click-shell +- cmd: conda install --yes ecdsa requests future websocket-client pytz six Click events prettytable pyinstaller click-shell asn1 build_script: diff --git a/beem/account.py b/beem/account.py index e67d44c4f66b4fd0b28920e14b3a574787c6536a..555ba88ef579fbdf7de973534e90ecdc5822a186 100644 --- a/beem/account.py +++ b/beem/account.py @@ -321,6 +321,12 @@ class Account(BlockchainObject): return {} return json.loads(self["json_metadata"]) + @property + def posting_json_metadata(self): + if self["posting_json_metadata"] == '': + return {} + return json.loads(self["posting_json_metadata"]) + def print_info(self, force_refresh=False, return_str=False, use_table=False, **kwargs): """ Prints import information about the account """ @@ -2489,7 +2495,7 @@ class Account(BlockchainObject): metadata = json.dumps(metadata) elif not isinstance(metadata, str): raise ValueError("Profile must be a dict or string!") - op = operations.Account_update( + op = operations.Account_update2( **{ "account": account["name"], "posting_json_metadata": metadata, diff --git a/beem/blockchaininstance.py b/beem/blockchaininstance.py index 3b220762d2f45b8c9c6fcaf20294df50cebd9f7d..f1bff90dd4715802e271a15b8afdba9455095b3f 100644 --- a/beem/blockchaininstance.py +++ b/beem/blockchaininstance.py @@ -170,6 +170,8 @@ class BlockChainInstance(object): :param bool use_sc2: When True, a steemconnect object is created. Can be used for broadcast posting op or creating hot_links (default is False) :param SteemConnect steemconnect: A SteemConnect object can be set manually, set use_sc2 to True + :param bool use_ledger: When True, a ledger Nano S is used for signing + :param str path: bip32 path from which the pubkey is derived, when use_ledger is True """ @@ -187,9 +189,13 @@ class BlockChainInstance(object): self.use_hs = bool(kwargs.get("use_hs", False)) self.blocking = kwargs.get("blocking", False) self.custom_chains = kwargs.get("custom_chains", {}) + self.use_ledger = bool(kwargs.get("use_ledger", False)) + self.path = kwargs.get("path", None) # Store config for access through other Classes self.config = get_default_config_storage() + if self.path is None: + self.path = self.config["default_path"] if not self.offline: self.connect(node=node, diff --git a/beem/cli.py b/beem/cli.py index 073d5bf5b0efba0dc4d06b365ed66f2f88a3a039..3199627455aa07b894f53103d46e089033d0bc96 100644 --- a/beem/cli.py +++ b/beem/cli.py @@ -80,6 +80,7 @@ availableConfigurationKeys = [ "nodes", "password_storage", "client_id", + "default_path" ] @@ -107,6 +108,8 @@ def prompt_flag_callback(ctx, param, value): def unlock_wallet(stm, password=None, allow_wif=True): if stm.unsigned and stm.nobroadcast: return True + if stm.use_ledger: + return True if not stm.wallet.locked(): return True if len(stm.wallet.keys) > 0: @@ -186,6 +189,10 @@ def node_answer_time(node): '--hive', '-h', is_flag=True, default=False, help="Connect to the Hive blockchain") @click.option( '--keys', '-k', help="JSON file that contains account keys, when set, the wallet cannot be used.") +@click.option( + '--use-ledger', '-u', is_flag=True, default=False, help="Uses the ledger device Nano S for signing.") +@click.option( + '--path', help="BIP32 path from which the keys are derived, when not set, default_path is used.") @click.option( '--token', '-t', is_flag=True, default=False, help="Uses a hivesigner/steemconnect token to broadcast (only broadcast operation with posting permission)") @click.option( @@ -194,7 +201,7 @@ def node_answer_time(node): @click.option( '--verbose', '-v', default=3, help='Verbosity') @click.version_option(version=__version__) -def cli(node, offline, no_broadcast, no_wallet, unsigned, create_link, steem, hive, keys, token, expires, verbose): +def cli(node, offline, no_broadcast, no_wallet, unsigned, create_link, steem, hive, keys, use_ledger, path, token, expires, verbose): # Logging log = logging.getLogger(__name__) @@ -246,6 +253,8 @@ def cli(node, offline, no_broadcast, no_wallet, unsigned, create_link, steem, hi use_hs=token, expiration=expires, hivesigner=sc2, + use_ledger=use_ledger, + path=path, debug=debug, num_retries=10, num_retries_call=3, @@ -263,6 +272,8 @@ def cli(node, offline, no_broadcast, no_wallet, unsigned, create_link, steem, hi use_sc2=token, expiration=expires, steemconnect=sc2, + use_ledger=use_ledger, + path=path, debug=debug, num_retries=10, num_retries_call=3, @@ -324,6 +335,8 @@ def set(key, value): stm.config["hs_api_url"] = value elif key == "oauth_base_url": stm.config["oauth_base_url"] = value + elif key == "default_path": + stm.config["default_path"] = value else: print("wrong key") @@ -559,8 +572,10 @@ def config(): t.add_row([key, stm.config[key]]) node = stm.get_default_nodes() blockchain = stm.config["default_chain"] + ledger_path = stm.config["default_path"] nodes = json.dumps(node, indent=4) t.add_row(["default_chain", blockchain]) + t.add_row(["default_path", ledger_path]) t.add_row(["nodes", nodes]) if "password_storage" not in availableConfigurationKeys: t.add_row(["password_storage", stm.config["password_storage"]]) @@ -721,7 +736,7 @@ def delkey(confirm, pub): @cli.command() @click.option('--import-word-list', '-l', help='Imports a BIP39 wordlist and derives a private and public key', is_flag=True, default=False) -@click.option('--strength', '-s', help='Defines word list length for BIP39 (default = 256).', default=256) +@click.option('--strength', help='Defines word list length for BIP39 (default = 256).', default=256) @click.option('--passphrase', '-p', help='Sets a BIP39 passphrase', is_flag=True, default=False) @click.option('--path', '-m', help='Sets a path for BIP39 key creations. When path is set, network, role, account_keys, account and sequence is not used') @click.option('--network', '-n', help='Network index, when using BIP39, 0 for steem and 13 for hive, (default is 13)', default=13) @@ -742,8 +757,6 @@ def keygen(import_word_list, strength, passphrase, path, network, role, account_ stm = shared_blockchain_instance() if not account and import_password or create_password: account = stm.config["default_account"] - else: - account = 0 if import_password: import_password = click.prompt("Enter password", confirmation_prompt=False, hide_input=True) elif create_password: @@ -777,7 +790,41 @@ def keygen(import_word_list, strength, passphrase, path, network, role, account_ if wif > 0: t.add_row(["WIF itersions", wif]) t.add_row(["Entered/created Password", import_password]) + elif stm.use_ledger: + if stm.rpc is not None: + stm.rpc.rpcconnect() + ledgertx = stm.new_tx() + ledgertx.constructTx() + if account is None: + account = 0 + else: + account = int(account) + t = PrettyTable(["Key", "Value"]) + t_pub = PrettyTable(["Key", "Value"]) + t.align = "l" + t_pub.align = "l" + t.add_row(["Account sequence", account]) + t.add_row(["Key sequence", sequence]) + if account_keys and path is None: + for role in ['owner', 'active', 'posting', 'memo']: + path = ledgertx.ledgertx.build_path(role, account, sequence) + pubkey = ledgertx.ledgertx.get_pubkey(path, request_screen_approval=False) + t_pub.add_row(["%s Public Key" % role, format(pubkey, "STM")]) + t.add_row(["%s path" % role, path]) + pub_json[role] = format(pubkey, "STM") + else: + if path is None: + path = ledgertx.ledgertx.build_path(role, account, sequence) + t.add_row(["Key role", role]) + t.add_row(["path", path]) + pubkey = ledgertx.ledgertx.get_pubkey(path, request_screen_approval=False) + t_pub.add_row(["Public Key", format(pubkey, "STM")]) + pub_json[role] = format(pubkey, "STM") else: + if account is None: + account = 0 + else: + account = int(account) if import_word_list: n_words = click.prompt("Enter word list length or complete word list") if len(n_words.split(" ")) > 0: @@ -822,8 +869,8 @@ def keygen(import_word_list, strength, passphrase, path, network, role, account_ t.add_row(["Key sequence", sequence]) if account_keys and path is None: for role in ['owner', 'active', 'posting', 'memo']: - mk.set_path_BIP48(network_index=network, role=role, account_sequence=account, key_sequence=sequence) t.add_row(["%s Private Key" % role, str(mk.get_private())]) + mk.set_path_BIP48(network_index=network, role=role, account_sequence=account, key_sequence=sequence) t_pub.add_row(["%s Public Key" % role, format(mk.get_public(), "STM")]) t.add_row(["%s path" % role, mk.get_path()]) pub_json[role] = format(mk.get_public(), "STM") @@ -897,18 +944,35 @@ def deltoken(confirm, name): @cli.command() -def listkeys(): +@click.option('--path', '-p', help='Set path (when using ledger)') +@click.option('--ledger-approval', '-a', is_flag=True, default=False, help='When set, you can confirm the shown pubkey on your ledger.') +def listkeys(path, ledger_approval): """ Show stored keys + + Can be used to receive and approve the pubkey obtained from the ledger """ stm = shared_blockchain_instance() if stm.rpc is not None: stm.rpc.rpcconnect() - t = PrettyTable(["Available Key"]) - t.align = "l" - for key in stm.wallet.getPublicKeys(): - t.add_row([key]) - print(t) + if stm.use_ledger: + if path is None: + path = stm.config["default_path"] + t = PrettyTable(["Available Key for %s" % path]) + t.align = "l" + ledgertx = stm.new_tx() + ledgertx.constructTx() + pubkey = ledgertx.ledgertx.get_pubkey(path, request_screen_approval=False) + t.add_row([str(pubkey)]) + if ledger_approval: + print(t) + ledgertx.ledgertx.get_pubkey(path, request_screen_approval=True) + else: + t = PrettyTable(["Available Key"]) + t.align = "l" + for key in stm.wallet.getPublicKeys(): + t.add_row([key]) + print(t) @cli.command() def listtoken(): @@ -930,18 +994,50 @@ def listtoken(): @cli.command() -def listaccounts(): - """Show stored accounts""" +@click.option('--role', '-r', help='When set, limits the shown keys for this role') +@click.option('--max-account-index', '-a', help='Set maximum account index to check pubkeys (only when using ledger)', default=5) +@click.option('--max-sequence', '-s', help='Set maximum key sequence to check pubkeys (only when using ledger)', default=2) +def listaccounts(role, max_account_index, max_sequence): + """Show stored accounts + + Can be used with the ledger to obtain all accounts that uses pubkeys derived from this ledger + """ stm = shared_blockchain_instance() if stm.rpc is not None: stm.rpc.rpcconnect() - t = PrettyTable(["Name", "Type", "Available Key"]) - t.align = "l" - for account in stm.wallet.getAccounts(): - t.add_row([ - account["name"] or "n/a", account["type"] or "n/a", - account["pubkey"] - ]) + + if stm.use_ledger: + t = PrettyTable(["Name", "Type", "Available Key", "Path"]) + t.align = "l" + ledgertx = stm.new_tx() + ledgertx.constructTx() + key_found = False + path = None + current_account_index = 0 + current_key_index = 0 + role_list = ["owner", "active", "posting", "memo"] + if role: + role_list = [role] + while not key_found and current_account_index < max_account_index: + for perm in role_list: + path = ledgertx.ledgertx.build_path(perm, current_account_index, current_key_index) + current_pubkey = ledgertx.ledgertx.get_pubkey(path) + account = stm.wallet.getAccountFromPublicKey(str(current_pubkey)) + if account is not None: + t.add_row([str(account), perm, str(current_pubkey), path]) + if current_key_index < max_sequence: + current_key_index += 1 + else: + current_key_index = 0 + current_account_index += 1 + else: + t = PrettyTable(["Name", "Type", "Available Key"]) + t.align = "l" + for account in stm.wallet.getAccounts(): + t.add_row([ + account["name"] or "n/a", account["type"] or "n/a", + account["pubkey"] + ]) print(t) diff --git a/beem/storage.py b/beem/storage.py index 33be4d0050a1fdf9567ddd9185001de43e8c6002..370a690e118bc8e7945cdc8e11f1964406283570 100644 --- a/beem/storage.py +++ b/beem/storage.py @@ -425,7 +425,8 @@ class Configuration(DataDir): "sc2_api_url": "https://api.steemconnect.com/api/", "oauth_base_url": "https://api.steemconnect.com/oauth2/", "hs_api_url": "https://hivesigner.com/api/", - "hs_oauth_base_url": "https://hivesigner.com/oauth2/"} + "hs_oauth_base_url": "https://hivesigner.com/oauth2/", + "default_path": "48'/13'/0'/0'/0'"} def __init__(self): super(Configuration, self).__init__() diff --git a/beem/transactionbuilder.py b/beem/transactionbuilder.py index 91143e22d3e57f3513160c693d1d934cab410726..8a928ed5703a05b37dff3c32a80007ef2b2f5b8c 100644 --- a/beem/transactionbuilder.py +++ b/beem/transactionbuilder.py @@ -13,6 +13,7 @@ from .steemconnect import SteemConnect from beembase.objects import Operation from beemgraphenebase.account import PrivateKey, PublicKey from beembase.signedtransactions import Signed_Transaction +from beembase.ledgertransactions import Ledger_Transaction from beembase import transactions, operations from .exceptions import ( InsufficientAuthorityError, @@ -72,6 +73,8 @@ class TransactionBuilder(dict): self._require_reconstruction = False else: self._require_reconstruction = True + self._use_ledger = self.blockchain.use_ledger + self.path = self.blockchain.path self._use_condenser_api = use_condenser_api self.set_expiration(kwargs.get("expiration", self.blockchain.expiration)) @@ -166,6 +169,28 @@ class TransactionBuilder(dict): raise AssertionError("Could not access permission") required_treshold = account[permission]["weight_threshold"] + if self._use_ledger: + if not self._is_constructed() or self._is_require_reconstruction(): + self.constructTx() + + key_found = False + if self.path is not None: + current_pubkey = self.ledgertx.get_pubkey(self.path) + for authority in account[permission]["key_auths"]: + if str(current_pubkey) == authority[1]: + key_found = True + if permission == "posting" and not key_found: + for authority in account["active"]["key_auths"]: + if str(current_pubkey) == authority[1]: + key_found = True + if not key_found: + for authority in account["owner"]["key_auths"]: + if str(current_pubkey) == authority[1]: + key_found = True + if not key_found: + raise AssertionError("Could not find pubkey from %s in path: %s!" % (account["name"], self.path)) + return + if self.blockchain.wallet.locked(): raise WalletLocked() if self.blockchain.use_sc2 and self.blockchain.steemconnect is not None: @@ -239,6 +264,35 @@ class TransactionBuilder(dict): """Clear all stored wifs""" self.wifs = set() + def setPath(self, path): + self.path = path + + def searchPath(self, account, perm): + if not self.blockchain.use_ledger: + return + if not self._is_constructed() or self._is_require_reconstruction(): + self.constructTx() + key_found = False + path = None + current_account_index = 0 + current_key_index = 0 + while not key_found and current_account_index < 5: + path = self.ledgertx.build_path(perm, current_account_index, current_key_index) + current_pubkey = self.ledgertx.get_pubkey(path) + key_found = False + for authority in account[perm]["key_auths"]: + if str(current_pubkey) == authority[1]: + key_found = True + if not key_found and current_key_index < 5: + current_key_index += 1 + elif not key_found and current_key_index >= 5: + current_key_index = 0 + current_account_index += 1 + if not key_found: + return None + else: + return path + def constructTx(self, ref_block_num=None, ref_block_prefix=None): """ Construct the actual transaction and store it in the class's dict store @@ -262,6 +316,16 @@ class TransactionBuilder(dict): if ref_block_num is None or ref_block_prefix is None: ref_block_num, ref_block_prefix = transactions.getBlockParams( self.blockchain.rpc) + if self._use_ledger: + self.ledgertx = Ledger_Transaction( + ref_block_prefix=ref_block_prefix, + expiration=expiration, + operations=ops, + ref_block_num=ref_block_num, + custom_chains=self.blockchain.custom_chains, + prefix=self.blockchain.prefix + ) + self.tx = Signed_Transaction( ref_block_prefix=ref_block_prefix, expiration=expiration, @@ -300,19 +364,30 @@ class TransactionBuilder(dict): self.blockchain.chain_params["prefix"]) elif "blockchain" in self: operations.default_prefix = self["blockchain"]["prefix"] - - try: - signedtx = Signed_Transaction(**self.json(with_prefix=True)) - signedtx.add_custom_chains(self.blockchain.custom_chains) - except: - raise ValueError("Invalid TransactionBuilder Format") - - if not any(self.wifs): - raise MissingKeyError - - signedtx.sign(self.wifs, chain=self.blockchain.chain_params) - self["signatures"].extend(signedtx.json().get("signatures")) - return signedtx + + if self._use_ledger: + #try: + # ledgertx = Ledger_Transaction(**self.json(with_prefix=True)) + # ledgertx.add_custom_chains(self.blockchain.custom_chains) + #except: + # raise ValueError("Invalid TransactionBuilder Format") + #ledgertx.sign(self.path, chain=self.blockchain.chain_params) + self.ledgertx.sign(self.path, chain=self.blockchain.chain_params) + self["signatures"].extend(self.ledgertx.json().get("signatures")) + return self.ledgertx + else: + try: + signedtx = Signed_Transaction(**self.json(with_prefix=True)) + signedtx.add_custom_chains(self.blockchain.custom_chains) + except: + raise ValueError("Invalid TransactionBuilder Format") + + if not any(self.wifs): + raise MissingKeyError + + signedtx.sign(self.wifs, chain=self.blockchain.chain_params) + self["signatures"].extend(signedtx.json().get("signatures")) + return signedtx def verify_authority(self): """ Verify the authority of the signed transaction diff --git a/beem/version.py b/beem/version.py index 0ffc41134b688754a333b27cfcb9286f8d757dcc..807d3748088d9fa8a2180e86f12af0d6ea504966 100644 --- a/beem/version.py +++ b/beem/version.py @@ -1,2 +1,2 @@ """THIS FILE IS GENERATED FROM beem SETUP.PY.""" -version = '0.23.6' +version = '0.23.7' diff --git a/beemapi/version.py b/beemapi/version.py index 0ffc41134b688754a333b27cfcb9286f8d757dcc..807d3748088d9fa8a2180e86f12af0d6ea504966 100644 --- a/beemapi/version.py +++ b/beemapi/version.py @@ -1,2 +1,2 @@ """THIS FILE IS GENERATED FROM beem SETUP.PY.""" -version = '0.23.6' +version = '0.23.7' diff --git a/beembase/__init__.py b/beembase/__init__.py index ede480dae5958c740a3d12f53c032397950a8bfb..2e16525b58e83504db50d2a3a98aabedad12e8cb 100644 --- a/beembase/__init__.py +++ b/beembase/__init__.py @@ -7,5 +7,6 @@ __all__ = [ 'operationids', 'operations', 'signedtransactions', + 'ledgertransactions', 'transactions', ] diff --git a/beembase/ledgertransactions.py b/beembase/ledgertransactions.py new file mode 100644 index 0000000000000000000000000000000000000000..fe30ffbc0e926137fbcdf261d9ff6e1805fea65c --- /dev/null +++ b/beembase/ledgertransactions.py @@ -0,0 +1,70 @@ +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals +from builtins import int, str +from beemgraphenebase.unsignedtransactions import Unsigned_Transaction as GrapheneUnsigned_Transaction +from .operations import Operation +from beemgraphenebase.chains import known_chains +from beemgraphenebase.py23 import py23_bytes +from beemgraphenebase.account import PublicKey +from beemgraphenebase.types import ( + Array, + Signature, +) +from binascii import hexlify +import logging +log = logging.getLogger(__name__) + + +class Ledger_Transaction(GrapheneUnsigned_Transaction): + """ Create an unsigned transaction and offer method to send it to a ledger device for signing + + :param num refNum: parameter ref_block_num (see :func:`beembase.transactions.getBlockParams`) + :param num refPrefix: parameter ref_block_prefix (see :func:`beembase.transactions.getBlockParams`) + :param str expiration: expiration date + :param array operations: array of operations + :param dict custom_chains: custom chain which should be added to the known chains + """ + def __init__(self, *args, **kwargs): + self.known_chains = known_chains + custom_chain = kwargs.get("custom_chains", {}) + if len(custom_chain) > 0: + for c in custom_chain: + if c not in self.known_chains: + self.known_chains[c] = custom_chain[c] + super(Ledger_Transaction, self).__init__(*args, **kwargs) + + def add_custom_chains(self, custom_chain): + if len(custom_chain) > 0: + for c in custom_chain: + if c not in self.known_chains: + self.known_chains[c] = custom_chain[c] + + def getOperationKlass(self): + return Operation + + def getKnownChains(self): + return self.known_chains + + def sign(self, path="48'/13'/0'/0'/0'", chain=u"STEEM"): + from ledgerblue.comm import getDongle + dongle = getDongle(True) + apdu_list = self.build_apdu(path, chain) + for apdu in apdu_list: + result = dongle.exchange(py23_bytes(apdu)) + sigs = [] + signature = result + sigs.append(Signature(signature)) + self.data["signatures"] = Array(sigs) + return self + + def get_pubkey(self, path="48'/13'/0'/0'/0'", request_screen_approval=False, prefix="STM"): + from ledgerblue.comm import getDongle + dongle = getDongle(True) + apdu = self.build_apdu_pubkey(path, request_screen_approval) + result = dongle.exchange(py23_bytes(apdu)) + offset = 1 + result[0] + address = result[offset + 1: offset + 1 + result[offset]] + # public_key = result[1: 1 + result[0]] + return PublicKey(address.decode(), prefix=prefix) diff --git a/beembase/operations.py b/beembase/operations.py index 8d517b588c1dc7008c7250f0eabc0c4a5a59ffa4..11bea656f669911d728a8b5e0bc6dbd7f82c4e05 100644 --- a/beembase/operations.py +++ b/beembase/operations.py @@ -285,6 +285,7 @@ class Account_update2(GrapheneObject): if len(args) == 1 and len(kwargs) == 0: kwargs = args[0] prefix = kwargs.get("prefix", default_prefix) + extensions = Array([]) if "owner" in kwargs: owner = Optional(Permission(kwargs["owner"], prefix=prefix)) @@ -302,7 +303,7 @@ class Account_update2(GrapheneObject): posting = Optional(None) if "memo_key" in kwargs: - memo_key = Optional(Permission(kwargs["memo_key"], prefix=prefix)) + memo_key = Optional(PublicKey(kwargs["memo_key"], prefix=prefix)) else: memo_key = Optional(None) @@ -327,6 +328,7 @@ class Account_update2(GrapheneObject): ('memo_key', memo_key), ('json_metadata', String(meta)), ('posting_json_metadata', String(posting_meta)), + ('extensions', extensions) ])) diff --git a/beembase/version.py b/beembase/version.py index 0ffc41134b688754a333b27cfcb9286f8d757dcc..807d3748088d9fa8a2180e86f12af0d6ea504966 100644 --- a/beembase/version.py +++ b/beembase/version.py @@ -1,2 +1,2 @@ """THIS FILE IS GENERATED FROM beem SETUP.PY.""" -version = '0.23.6' +version = '0.23.7' diff --git a/beemgraphenebase/__init__.py b/beemgraphenebase/__init__.py index 487c905f10355d430333de3a4cb423d87a636f66..f7a36fbf49782fd8a53176f07fd812c9d1a6644a 100644 --- a/beemgraphenebase/__init__.py +++ b/beemgraphenebase/__init__.py @@ -18,5 +18,6 @@ __all__ = ['account', 'objects', 'operations', 'signedtransactions', + 'unsignedtransactions', 'objecttypes', 'py23'] diff --git a/beemgraphenebase/bip32.py b/beemgraphenebase/bip32.py index 3e1ffe8ba75605cae1ce048a41cb606739d0450c..7249cbd37885fef55b52c79d2692479d0b56f007 100644 --- a/beemgraphenebase/bip32.py +++ b/beemgraphenebase/bip32.py @@ -10,6 +10,7 @@ import hashlib import struct import codecs from beemgraphenebase.base58 import base58CheckDecode, base58CheckEncode +from beemgraphenebase.py23 import py23_bytes from hashlib import sha256 from binascii import hexlify, unhexlify import ecdsa @@ -33,7 +34,11 @@ EX_TEST_PRIVATE = [codecs.decode('04358394', 'hex')] # Version strings for test EX_TEST_PUBLIC = [codecs.decode('043587CF', 'hex')] # Version strings for testnet extended public keys -def parse_path(nstr): +def int_to_hex(x): + return py23_bytes(hex(x)[2:], encoding="utf-8") + + +def parse_path(nstr, as_bytes=False): """""" r = list() for s in nstr.split('/'): @@ -43,7 +48,15 @@ def parse_path(nstr): r.append(int(s[:-1]) + BIP32_HARDEN) else: r.append(int(s)) - return r + if not as_bytes: + return r + path = None + for p in r: + if path is None: + path = int_to_hex(p) + else: + path += int_to_hex(p) + return path class BIP32Key(object): diff --git a/beemgraphenebase/unsignedtransactions.py b/beemgraphenebase/unsignedtransactions.py new file mode 100644 index 0000000000000000000000000000000000000000..f3eb933c446f81fffe07bf978945989197602942 --- /dev/null +++ b/beemgraphenebase/unsignedtransactions.py @@ -0,0 +1,290 @@ +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals +from builtins import bytes, str, int +from beemgraphenebase.py23 import py23_bytes, bytes_types +import ecdsa +import hashlib +from binascii import hexlify, unhexlify +from collections import OrderedDict +import asn1 +import struct +from future.utils import python_2_unicode_compatible +from collections import OrderedDict +import json + +from .py23 import py23_bytes, bytes_types, integer_types, string_types, py23_chr +from .objecttypes import object_type +from .bip32 import parse_path + +from .account import PublicKey +from .types import ( + Array, + Set, + Signature, + PointInTime, + Uint16, + Uint32, + JsonObj, + String, + Varint32, + Optional +) +from .objects import GrapheneObject, isArgsThisClass +from .operations import Operation +from .chains import known_chains +from .ecdsasig import sign_message, verify_message +import logging +log = logging.getLogger(__name__) + +try: + import secp256k1prp as secp256k1 + USE_SECP256K1 = True + log.debug("Loaded secp256k1prp binding.") +except: + try: + import secp256k1 + USE_SECP256K1 = True + log.debug("Loaded secp256k1 binding.") + except Exception: + USE_SECP256K1 = False + log.debug("To speed up transactions signing install \n" + " pip install secp256k1\n" + "or pip install secp256k1prp") + + +@python_2_unicode_compatible +class GrapheneObjectASN1(object): + """ Core abstraction class + + This class is used for any JSON reflected object in Graphene. + + * ``instance.__json__()``: encodes data into json format + * ``bytes(instance)``: encodes data into wire format + * ``str(instances)``: dumps json object as string + + """ + def __init__(self, data=None): + self.data = data + + def __bytes__(self): + if self.data is None: + return py23_bytes() + b = b"" + encoder = asn1.Encoder() + encoder.start() + for name, value in list(self.data.items()): + if name == "operations": + for operation in value: + if isinstance(value, string_types): + b = py23_bytes(operation, 'utf-8') + else: + b = py23_bytes(operation) + encoder.write(b, asn1.Numbers.OctetString) + elif name != "signatures": + if isinstance(value, string_types): + b = py23_bytes(value, 'utf-8') + else: + b = py23_bytes(value) + encoder.write(b, asn1.Numbers.OctetString) + return encoder.output() + + def __json__(self): + if self.data is None: + return {} + d = {} # JSON output is *not* ordered + for name, value in list(self.data.items()): + if isinstance(value, Optional) and value.isempty(): + continue + + if isinstance(value, String): + d.update({name: str(value)}) + else: + try: + d.update({name: JsonObj(value)}) + except Exception: + d.update({name: value.__str__()}) + return d + + def __str__(self): + return json.dumps(self.__json__()) + + def toJson(self): + return self.__json__() + + def json(self): + return self.__json__() + + +class Unsigned_Transaction(GrapheneObjectASN1): + """ Create an unsigned transaction with ASN1 encoder for using it with ledger + + :param num refNum: parameter ref_block_num (see :func:`beembase.transactions.getBlockParams`) + :param num refPrefix: parameter ref_block_prefix (see :func:`beembase.transactions.getBlockParams`) + :param str expiration: expiration date + :param array operations: array of operations + """ + def __init__(self, *args, **kwargs): + if isArgsThisClass(self, args): + self.data = args[0].data + else: + if len(args) == 1 and len(kwargs) == 0: + kwargs = args[0] + prefix = kwargs.pop("prefix", "STM") + if "extensions" not in kwargs: + kwargs["extensions"] = Set([]) + elif not kwargs.get("extensions"): + kwargs["extensions"] = Set([]) + if "signatures" not in kwargs: + kwargs["signatures"] = Array([]) + else: + kwargs["signatures"] = Array([Signature(unhexlify(a)) for a in kwargs["signatures"]]) + operations_count = 0 + if "operations" in kwargs: + operations_count = len(kwargs["operations"]) + #opklass = self.getOperationKlass() + #if all([not isinstance(a, opklass) for a in kwargs["operations"]]): + # kwargs['operations'] = Array([opklass(a, prefix=prefix) for a in kwargs["operations"]]) + #else: + # kwargs['operations'] = (kwargs["operations"]) + + super(Unsigned_Transaction, self).__init__(OrderedDict([ + ('ref_block_num', Uint16(kwargs['ref_block_num'])), + ('ref_block_prefix', Uint32(kwargs['ref_block_prefix'])), + ('expiration', PointInTime(kwargs['expiration'])), + ('operations_count', Varint32(operations_count)), + ('operations', kwargs['operations']), + ('extensions', kwargs['extensions']), + ('signatures', kwargs['signatures']), + ])) + + @property + def id(self): + """ The transaction id of this transaction + """ + # Store signatures temporarily since they are not part of + # transaction id + sigs = self.data["signatures"] + self.data.pop("signatures", None) + + # Generage Hash of the seriliazed version + h = hashlib.sha256(py23_bytes(self)).digest() + + # recover signatures + self.data["signatures"] = sigs + + # Return properly truncated tx hash + return hexlify(h[:20]).decode("ascii") + + def getOperationKlass(self): + return Operation + + def derSigToHexSig(self, s): + """ Format DER to HEX signature + """ + s, junk = ecdsa.der.remove_sequence(unhexlify(s)) + if junk: + log.debug('JUNK: %s', hexlify(junk).decode('ascii')) + if not (junk == b''): + raise AssertionError() + x, s = ecdsa.der.remove_integer(s) + y, s = ecdsa.der.remove_integer(s) + return '%064x%064x' % (x, y) + + def getKnownChains(self): + return known_chains + + def getChainParams(self, chain): + # Which network are we on: + chains = self.getKnownChains() + if isinstance(chain, str) and chain in chains: + chain_params = chains[chain] + elif isinstance(chain, dict): + chain_params = chain + else: + raise Exception("sign() only takes a string or a dict as chain!") + if "chain_id" not in chain_params: + raise Exception("sign() needs a 'chain_id' in chain params!") + return chain_params + + def deriveDigest(self, chain): + chain_params = self.getChainParams(chain) + # Chain ID + self.chainid = chain_params["chain_id"] + + # Do not serialize signatures + sigs = self.data["signatures"] + self.data["signatures"] = [] + + # Get message to sign + # bytes(self) will give the wire formated data according to + # GrapheneObject and the data given in __init__() + encoder = asn1.Encoder() + encoder.start() + encoder.write(unhexlify(self.chainid), asn1.Numbers.OctetString) + for name, value in list(self.data.items()): + if name == "operations": + for operation in value: + if isinstance(value, string_types): + b = py23_bytes(operation, 'utf-8') + else: + b = py23_bytes(operation) + encoder.write(b, asn1.Numbers.OctetString) + elif name != "signatures": + if isinstance(value, string_types): + b = py23_bytes(value, 'utf-8') + else: + b = py23_bytes(value) + encoder.write(b, asn1.Numbers.OctetString) + + self.message = encoder.output() + self.digest = hashlib.sha256(self.message).digest() + + # restore signatures + self.data["signatures"] = sigs + + def build_path(self, role, account_index, key_index): + if role == "owner": + return "48'/13'/0'/%d'/%d'" % (account_index, key_index) + elif role == "active": + return "48'/13'/1'/%d'/%d'" % (account_index, key_index) + elif role == "posting": + return "48'/13'/4'/%d'/%d'" % (account_index, key_index) + elif role == "memo": + return "48'/13'/3'/%d'/%d'" % (account_index, key_index) + + def build_apdu(self, path="48'/13'/0'/0'/0'", chain=None): + self.deriveDigest(chain) + path = unhexlify(parse_path(path, as_bytes=True)) + + message = self.message + path_size = int(len(path) / 4) + message_size = len(message) + + offset = 0 + first = True + result = [] + while offset != message_size: + if message_size - offset > 200: + chunk = message[offset: offset + 200] + else: + chunk = message[offset:] + + if first: + total_size = int(len(path)) + 1 + len(chunk) + apdu = unhexlify("d4040000") + py23_chr(total_size) + py23_chr(path_size) + path + chunk + first = False + else: + total_size = len(chunk) + apdu = unhexlify("d4048000") + py23_chr(total_size) + chunk + result.append(apdu) + offset += len(chunk) + return result + + def build_apdu_pubkey(self, path="48'/13'/0'/0'/0'", request_screen_approval=False): + path = unhexlify(parse_path(path, as_bytes=True)) + if not request_screen_approval: + return unhexlify("d4020001") + py23_chr(int(len(path)) + 1) + py23_chr(int(len(path) / 4)) + path + else: + return unhexlify("d4020101") + py23_chr(int(len(path)) + 1) + py23_chr(int(len(path) / 4)) + path diff --git a/beemgraphenebase/version.py b/beemgraphenebase/version.py index 0ffc41134b688754a333b27cfcb9286f8d757dcc..807d3748088d9fa8a2180e86f12af0d6ea504966 100644 --- a/beemgraphenebase/version.py +++ b/beemgraphenebase/version.py @@ -1,2 +1,2 @@ """THIS FILE IS GENERATED FROM beem SETUP.PY.""" -version = '0.23.6' +version = '0.23.7' diff --git a/docs/beembase.ledgertransactions.rst b/docs/beembase.ledgertransactions.rst new file mode 100644 index 0000000000000000000000000000000000000000..fd05673680ce6913d49e64df226e899960cb1d42 --- /dev/null +++ b/docs/beembase.ledgertransactions.rst @@ -0,0 +1,7 @@ +beembase\.ledgertransactions +============================ + +.. automodule:: beembase.ledgertransactions + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/beemgraphenebase.unsignedtransactions.rst b/docs/beemgraphenebase.unsignedtransactions.rst new file mode 100644 index 0000000000000000000000000000000000000000..b328d5cd349c1786622ec2d9033532a99e143672 --- /dev/null +++ b/docs/beemgraphenebase.unsignedtransactions.rst @@ -0,0 +1,7 @@ +beemgraphenebase\.unsignedtransactions +====================================== + +.. automodule:: beemgraphenebase.unsignedtransactions + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/modules.rst b/docs/modules.rst index 5de6a58f27fa039f6d1695f325e58587da3f4053..cbae7f58292aaeede71bccfc2f9e6961f025d64c 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -62,6 +62,7 @@ beembase Modules beembase.operationids beembase.operations beembase.signedtransactions + beembase.ledgertransactions beembase.transactions @@ -78,4 +79,5 @@ beemgraphenebase Modules beemgraphenebase.objects beemgraphenebase.objecttypes beemgraphenebase.operations - beemgraphenebase.signedtransactions \ No newline at end of file + beemgraphenebase.signedtransactions + beemgraphenebase.unsignedtransactions \ No newline at end of file diff --git a/requirements-test.txt b/requirements-test.txt index a11278a40ea9dbe954ef016e15f1e7ef01e7d32a..819e1de9061bc370ff1d98630aea874ad0469537 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -29,4 +29,5 @@ tox codacy-coverage virtualenv codecov -diff_match_patch \ No newline at end of file +diff_match_patch +asn1 \ No newline at end of file diff --git a/setup.py b/setup.py index a80cdcf2683cafa5823c9bc191e3aa965e254d5f..b8a64dd5bc08d420e5ab29d47ad06bf6fbaa0b65 100755 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ except LookupError: ascii = codecs.lookup('ascii') codecs.register(lambda name, enc=ascii: {True: enc}.get(name == 'mbcs')) -VERSION = '0.23.6' +VERSION = '0.23.7' tests_require = ['mock >= 2.0.0', 'pytest', 'pytest-mock', 'parameterized'] @@ -35,7 +35,8 @@ requires = [ "click_shell", "prettytable", "pyyaml", - "diff_match_patch" + "diff_match_patch", + "asn1" ] diff --git a/tests/beembase/test_ledgertransactions.py b/tests/beembase/test_ledgertransactions.py new file mode 100644 index 0000000000000000000000000000000000000000..d05eb672d98582bc4af609a978784d27dd33f9d5 --- /dev/null +++ b/tests/beembase/test_ledgertransactions.py @@ -0,0 +1,118 @@ +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals +from builtins import bytes +from builtins import chr +from builtins import range +from builtins import super +import random +import unittest +from pprint import pprint +from binascii import hexlify, unhexlify +from collections import OrderedDict + +from beembase import ( + transactions, + memo, + operations, + objects +) +from beembase.objects import Operation +from beembase.ledgertransactions import Ledger_Transaction +from beemgraphenebase.account import PrivateKey +from beemgraphenebase import account +from beembase.operationids import getOperationNameForId +from beemgraphenebase.py23 import py23_bytes, bytes_types +from beem.amount import Amount +from beem.asset import Asset +from beem.steem import Steem + + +TEST_AGAINST_CLI_WALLET = False + +prefix = u"STEEM" +default_prefix = u"STM" +ref_block_num = 34843 +ref_block_prefix = 1663841413 +expiration = "2020-05-10T20:30:57" +path = "48'/13'/0'/0'/0'" + + +class Testcases(unittest.TestCase): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.stm = Steem( + offline=True + ) + + def doit(self, printWire=False, ops=None): + if ops is None: + ops = [Operation(self.op)] + tx = Ledger_Transaction(ref_block_num=ref_block_num, + ref_block_prefix=ref_block_prefix, + expiration=expiration, + operations=ops) + txWire = hexlify(py23_bytes(tx)).decode("ascii") + txApdu = tx.build_apdu(path, chain=prefix) + if printWire: + print() + print(txWire) + print() + if len(self.cm) > 0: + self.assertEqual(self.cm, txWire) + if len(self.apdu) > 0: + self.assertEqual(len(self.apdu), len(txApdu)) + for i in range(len(txApdu)): + self.assertEqual(self.apdu[i], hexlify(txApdu[i])) + + def test_Transfer(self): + self.op = operations.Transfer(**{ + "from": "nettybot", + "to": "netuoso", + "amount": Amount("0.001 STEEM", steem_instance=self.stm), + "memo": "", + "prefix": default_prefix + }) + self.apdu = ([b"d40400007205800000308000000d80000000800000008000000004" + b"200000000000000000000000000000000000000000000000000000" + b"00000000000004021b88040485342c6304048164b85e0401010423" + b"02086e65747479626f74076e6574756f736f010000000000000003" + b"535445454d000000040100"]) + self.cm = (u"04021b88040485342c6304048164b85e040101042302086e65747479626f74076e6574756f736f010000000000000003535445454d000000040100") + self.doit() + + def test_createclaimedaccount(self): + self.op = operations.Create_claimed_account( + **{ + "creator": "netuoso", + "new_account_name": "netuoso2", + "owner": {"weight_threshold":1,"account_auths":[],"key_auths":[["STM7QtTRvd1owAh4uGaC6trxjR9M1cpqfi2WfLQed1GbUGPomt9DP",1]]}, + "active": {"weight_threshold":1,"account_auths":[],"key_auths":[["STM7QtTRvd1owAh4uGaC6trxjR9M1cpqfi2WfLQed1GbUGPomt9DP",1]]}, + "posting": {"weight_threshold":1,"account_auths":[],"key_auths":[["STM7QtTRvd1owAh4uGaC6trxjR9M1cpqfi2WfLQed1GbUGPomt9DP",1]]}, + "memo_key": "STM7QtTRvd1owAh4uGaC6trxjR9M1cpqfi2WfLQed1GbUGPomt9DP", + "json_metadata": "{}" + }) + self.apdu = ([b"d4040000dd05800000308000000d800000008000000080000000042000000000000000000000000000000000000000000000000000000000000000000402bd8c04045fe26f450404f179a8570401010481b217076e6574756f736f086e6574756f736f32010000000001034c6a518a9b9e9cb8099176854a322c87db6c7e82c47bd5fe68c273ba63a647160100010000000001034c6a518a9b9e9cb8099176854a322c87db6c7e82c47bd5fe68c273ba63a647160100010000000001034c6a518a9b9e9cb8099176854a322c87db6c7e82c47bd5fe68c273ba63a647160100034c6a", + b"d404800025518a9b9e9cb8099176854a322c87db6c7e82c47bd5fe68c273ba63a6471600000000040100"]) + + def test_vote(self): + self.op = operations.Vote( + **{ + "voter": "nettybot", + "author": "jrcornel", + "permlink": "hive-sitting-back-at-previous-support-levels-is-this-a-buy", + "weight": 10000 + } + ) + self.cm = b"0402528804049ce2ccea04047660b85e040101045000086e65747479626f74086a72636f726e656c3a686976652d73697474696e672d6261636b2d61742d70726576696f75732d737570706f72742d6c6576656c732d69732d746869732d612d6275791027040100" + self.apdu = ([b"d40400009f05800000308000000d800000008000000080000000042000000000000000000000000000000000000000000000000000000000000000000402528804049ce2ccea04047660b85e040101045000086e65747479626f74086a72636f726e656c3a686976652d73697474696e672d6261636b2d61742d70726576696f75732d737570706f72742d6c6576656c732d69732d746869732d612d6275791027040100"]) + + def test_pubkey(self): + tx = Ledger_Transaction(ref_block_num=ref_block_num, + ref_block_prefix=ref_block_prefix, + expiration=expiration, + operations=[]) + apdu = tx.build_apdu_pubkey() + self.assertEqual((py23_bytes(apdu)), b'\xd4\x02\x00\x01\x15\x05\x80\x00\x000\x80\x00\x00\r\x80\x00\x00\x00\x80\x00\x00\x00\x80\x00\x00\x00') diff --git a/tests/beemgraphene/test_bip32.py b/tests/beemgraphene/test_bip32.py index db6c8363364ac0c53a8166276f4c50cf4af8f04b..ce9dcef8c2668df56c9ad0858016b94dd8dc4a89 100644 --- a/tests/beemgraphene/test_bip32.py +++ b/tests/beemgraphene/test_bip32.py @@ -164,5 +164,12 @@ class Testcases(unittest.TestCase): self.assertEqual(m.ExtendedKey(), "xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L") self.assertEqual(m.ExtendedKey(private=False, encoded=True), "xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y") + def test_parse_path(self): + path = "48'/13'/0'/0'/0'" + + bin_path = parse_path(path, as_bytes=True) + self.assertEqual(b'800000308000000d800000008000000080000000', bin_path) + + if __name__ == '__main__': unittest.main()