diff --git a/hathor/transaction/scripts/hathor_script.py b/hathor/transaction/scripts/hathor_script.py index 0a1214c1b..3d6e26d0c 100644 --- a/hathor/transaction/scripts/hathor_script.py +++ b/hathor/transaction/scripts/hathor_script.py @@ -15,7 +15,16 @@ import struct from typing import Union +from typing_extensions import assert_never + from hathor.transaction.scripts.opcode import Opcode +from hathor.transaction.scripts.sighash import ( + InputsOutputsLimit, + SighashAll, + SighashBitmask, + SighashRange, + SighashType, +) class HathorScript: @@ -49,3 +58,27 @@ def pushData(self, data: Union[int, bytes]) -> None: self.data += (bytes([len(data)]) + data) else: self.data += (bytes([Opcode.OP_PUSHDATA1]) + bytes([len(data)]) + data) + + def push_sighash(self, sighash: SighashType) -> None: + """Push a custom sighash to the script.""" + match sighash: + case SighashAll(): + pass + case SighashBitmask(): + self.pushData(sighash.inputs) + self.pushData(sighash.outputs) + self.addOpcode(Opcode.OP_SIGHASH_BITMASK) + case SighashRange(): + self.pushData(sighash.input_start) + self.pushData(sighash.input_end) + self.pushData(sighash.output_start) + self.pushData(sighash.output_end) + self.addOpcode(Opcode.OP_SIGHASH_RANGE) + case _: + assert_never(sighash) + + def push_inputs_outputs_limit(self, limit: InputsOutputsLimit) -> None: + """Push a custom inputs and outputs limit to the script.""" + self.pushData(limit.max_inputs) + self.pushData(limit.max_outputs) + self.addOpcode(Opcode.OP_MAX_INPUTS_OUTPUTS) diff --git a/hathor/transaction/scripts/multi_sig.py b/hathor/transaction/scripts/multi_sig.py index 7fe4f10ed..1f35d711a 100644 --- a/hathor/transaction/scripts/multi_sig.py +++ b/hathor/transaction/scripts/multi_sig.py @@ -21,6 +21,7 @@ from hathor.transaction.scripts.execute import Stack, get_script_op from hathor.transaction.scripts.hathor_script import HathorScript from hathor.transaction.scripts.opcode import Opcode, op_pushdata, op_pushdata1 +from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashAll, SighashType class MultiSig(BaseScript): @@ -111,7 +112,14 @@ def create_output_script(cls, address: bytes, timelock: Optional[Any] = None) -> return s.data @classmethod - def create_input_data(cls, redeem_script: bytes, signatures: list[bytes]) -> bytes: + def create_input_data( + cls, + redeem_script: bytes, + signatures: list[bytes], + *, + sighash: SighashType = SighashAll(), + inputs_outputs_limit: InputsOutputsLimit | None = None + ) -> bytes: """ :param redeem_script: script to redeem the tokens: ... :type redeem_script: bytes @@ -122,6 +130,9 @@ def create_input_data(cls, redeem_script: bytes, signatures: list[bytes]) -> byt :rtype: bytes """ s = HathorScript() + s.push_sighash(sighash) + if inputs_outputs_limit: + s.push_inputs_outputs_limit(inputs_outputs_limit) for signature in signatures: s.pushData(signature) s.pushData(redeem_script) diff --git a/hathor/transaction/scripts/opcode.py b/hathor/transaction/scripts/opcode.py index e787a07c4..edc4d84d1 100644 --- a/hathor/transaction/scripts/opcode.py +++ b/hathor/transaction/scripts/opcode.py @@ -591,21 +591,25 @@ def op_checkmultisig(context: ScriptContext) -> None: # For each signature we check if it's valid with one of the public keys # Signatures must be in order (same as the public keys in the multi sig wallet) pubkey_index = 0 + old_stack = context.stack for signature in signatures: while pubkey_index < len(pubkeys): pubkey = pubkeys[pubkey_index] new_stack = [signature, pubkey] - op_checksig(ScriptContext(stack=new_stack, logs=context.logs, extras=context.extras, settings=settings)) + context.stack = new_stack + op_checksig(context) result = new_stack.pop() pubkey_index += 1 if result == 1: break else: # finished all pubkeys and did not verify all signatures + context.stack = old_stack context.stack.append(0) return # If all signatures are valids we push 1 + context.stack = old_stack context.stack.append(1) diff --git a/hathor/transaction/scripts/p2pkh.py b/hathor/transaction/scripts/p2pkh.py index 8e1d9446c..29ec3b906 100644 --- a/hathor/transaction/scripts/p2pkh.py +++ b/hathor/transaction/scripts/p2pkh.py @@ -15,20 +15,12 @@ import struct from typing import Any, Optional -from typing_extensions import assert_never - from hathor.crypto.util import decode_address, get_address_b58_from_public_key_hash from hathor.transaction.scripts.base_script import BaseScript from hathor.transaction.scripts.construct import get_pushdata, re_compile from hathor.transaction.scripts.hathor_script import HathorScript from hathor.transaction.scripts.opcode import Opcode -from hathor.transaction.scripts.sighash import ( - InputsOutputsLimit, - SighashAll, - SighashBitmask, - SighashRange, - SighashType, -) +from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashAll, SighashType class P2PKH(BaseScript): @@ -115,28 +107,9 @@ def create_input_data( :rtype: bytes """ s = HathorScript() - - match sighash: - case SighashAll(): - pass - case SighashBitmask(): - s.pushData(sighash.inputs) - s.pushData(sighash.outputs) - s.addOpcode(Opcode.OP_SIGHASH_BITMASK) - case SighashRange(): - s.pushData(sighash.input_start) - s.pushData(sighash.input_end) - s.pushData(sighash.output_start) - s.pushData(sighash.output_end) - s.addOpcode(Opcode.OP_SIGHASH_RANGE) - case _: - assert_never(sighash) - + s.push_sighash(sighash) if inputs_outputs_limit: - s.pushData(inputs_outputs_limit.max_inputs) - s.pushData(inputs_outputs_limit.max_outputs) - s.addOpcode(Opcode.OP_MAX_INPUTS_OUTPUTS) - + s.push_inputs_outputs_limit(inputs_outputs_limit) s.pushData(signature) s.pushData(public_key_bytes) diff --git a/hathor/wallet/util.py b/hathor/wallet/util.py index b8b1aa9b4..f7d11f3a1 100644 --- a/hathor/wallet/util.py +++ b/hathor/wallet/util.py @@ -88,7 +88,7 @@ def generate_multisig_address(redeem_script: bytes, version_byte: Optional[bytes def generate_signature(tx: Transaction, private_key_bytes: bytes, password: Optional[bytes] = None) -> bytes: - """ Create a signature for the tx + """ Create a signature for the tx using the default sighash_all :param tx: transaction with the data to be signed :type tx: :py:class:`hathor.transaction.transaction.Transaction` @@ -102,8 +102,16 @@ def generate_signature(tx: Transaction, private_key_bytes: bytes, password: Opti :return: signature of the tx :rtype: bytes """ + return generate_signature_for_data(tx.get_sighash_all(), private_key_bytes, password) + + +def generate_signature_for_data( + data_to_sign: bytes, + private_key_bytes: bytes, + password: Optional[bytes] = None +) -> bytes: + """Create a signature for some custom data.""" private_key = get_private_key_from_bytes(private_key_bytes, password=password) - data_to_sign = tx.get_sighash_all() hashed_data = hashlib.sha256(data_to_sign).digest() signature = private_key.sign(hashed_data, ec.ECDSA(hashes.SHA256())) return signature diff --git a/tests/tx/scripts/test_sighash_bitmask.py b/tests/tx/scripts/test_sighash_bitmask.py index 4038d7df8..67bd10596 100644 --- a/tests/tx/scripts/test_sighash_bitmask.py +++ b/tests/tx/scripts/test_sighash_bitmask.py @@ -69,7 +69,7 @@ def test_sighash_bitmask(self) -> None: alice_output_script = P2PKH.create_output_script(self.address1) htr_output = TxOutput(int(genesis_utxo.value / 2), alice_output_script) - # Alice creates an atomic swap tx that's missing only Bob's input, with half genesis HTR + # Alice creates an atomic swap tx that's missing Bob's input, with half genesis HTR, and his output atomic_swap_tx = Transaction( weight=1, inputs=[tokens_input], @@ -145,7 +145,7 @@ def test_sighash_bitmask_with_limit(self) -> None: alice_output_script = P2PKH.create_output_script(self.address1) htr_output = TxOutput(int(genesis_utxo.value / 2), alice_output_script) - # Alice creates an atomic swap tx that's missing only Bob's input, with half genesis HTR + # Alice creates an atomic swap tx that's missing Bob's input, with half genesis HTR, and his output atomic_swap_tx = Transaction( weight=1, inputs=[tokens_input], @@ -279,7 +279,7 @@ def test_sighash_bitmask_nonexistent_input(self) -> None: alice_output_script = P2PKH.create_output_script(self.address1) htr_output = TxOutput(int(genesis_utxo.value / 2), alice_output_script) - # Alice creates an atomic swap tx that's missing only Bob's input, with half genesis HTR + # Alice creates an atomic swap tx that's missing Bob's input, with half genesis HTR, and his output atomic_swap_tx = Transaction( weight=1, inputs=[tokens_input], diff --git a/tests/tx/scripts/test_sighash_range.py b/tests/tx/scripts/test_sighash_range.py index 58657e1c3..e71243df7 100644 --- a/tests/tx/scripts/test_sighash_range.py +++ b/tests/tx/scripts/test_sighash_range.py @@ -22,10 +22,12 @@ from hathor.manager import HathorManager from hathor.transaction import Transaction, TxInput, TxOutput from hathor.transaction.exceptions import InputOutputMismatch, InvalidInputData, InvalidScriptError +from hathor.transaction.scripts import MultiSig from hathor.transaction.scripts.p2pkh import P2PKH from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashRange from hathor.transaction.static_metadata import TransactionStaticMetadata from hathor.util import not_none +from hathor.wallet.util import generate_multisig_address, generate_multisig_redeem_script, generate_signature_for_data from tests import unittest from tests.utils import add_blocks_unlock_reward, create_tokens, get_genesis_key @@ -69,7 +71,7 @@ def test_sighash_range(self) -> None: alice_output_script = P2PKH.create_output_script(self.address1) htr_output = TxOutput(int(genesis_utxo.value / 2), alice_output_script) - # Alice creates an atomic swap tx that's missing only Bob's input, with half genesis HTR + # Alice creates an atomic swap tx that's missing Bob's input, with half genesis HTR, and his output atomic_swap_tx = Transaction( weight=1, inputs=[tokens_input], @@ -129,6 +131,118 @@ def test_sighash_range(self) -> None: self.manager1.verification_service.verify(atomic_swap_tx_clone) self.manager1.propagate_tx(atomic_swap_tx_clone, fails_silently=False) + @patch('hathor.transaction.scripts.opcode.is_opcode_valid', lambda _: True) + def test_sighash_range_with_multisig(self) -> None: + # Create a new test token + token_creation_tx = create_tokens(self.manager1, self.address1_b58) + token_uid = token_creation_tx.tokens[0] + token_creation_utxo = token_creation_tx.outputs[0] + genesis_utxo = self.genesis_block.outputs[0] + parents = self.manager1.get_new_tx_parents() + + public_keys = [ + bytes.fromhex('0250bf5890c9c6e9b4ab7f70375d31b827d45d0b7b4e3ba1918bcbe71b412c11d7'), + bytes.fromhex('02d83dd1e9e0ac7976704eedab43fe0b79309166a47d70ec3ce8bbb08b8414db46'), + bytes.fromhex('02358c539fa7474bf12f774749d0e1b5a9bc6e50920464818ebdb0043b143ae2ba'), + ] + + private_keys = [ + '3081de304906092a864886f70d01050d303c301b06092a864886f70d01050c300e04089abeae5e8a8f75d302020800301d0609608' + '64801650304012a0410abbde27221fd302280c13fca7887c85e048190c41403f39b1e9bbc5b6b7c3be4729c054fae9506dc0f8361' + 'adcff0ea393f0bb3ca9f992fc2eea83d532691bc9a570ed7fb9e939e6d1787881af40b19fb467f06595229e29b5a6268d831f0287' + '530c7935d154deac61dd4ced988166f9c98054912935b607e2fb332e11c95b30ea4686eb0bda7dd57ed1eeb25b07cea9669dde521' + '0528a00653159626a5baa61cdee7f4', + '3081de304906092a864886f70d01050d303c301b06092a864886f70d01050c300e040817ca6c6c47ade0de02020800301d0609608' + '64801650304012a041003746599b1d7dde5b875e4d8e2c4c157048190a25ccabb17e603260f8a1407bdca24904b6ae0aa9ae225d8' + '7552e5a9aa62d98b35b2c6c78f33cb051f3a3932387b4cea6f49e94f14ee856d0b630d77c1299ad7207b0be727d338cf92a3fffe2' + '32aff59764240aff84e079a5f6fb3355048ac15703290a005a9a033fdcb7fcf582a5ddf6fd7b7c1193bd7912cd275a88a8a6823b6' + 'c3ed291b4a3f4724875a3ae058054c', + '3081de304906092a864886f70d01050d303c301b06092a864886f70d01050c300e0408089f48fbf59fa92902020800301d0609608' + '64801650304012a041072f553e860b77654fd5fb80e5891e7c90481900fde272b88f9a70e7220b2d5adeda1ed29667527caedc238' + '5be7f9e0d63defdde20557e90726e102f879eaf2233cceca8d4af239d5b2a159467255446f001c99b69e570bb176b95248fc21cb7' + '52d463b494c2195411639989086336a530d1f4eae91493faf89368f439991baa947ebeca00be7f5099ed69606dc78a4cc384d4154' + '2350a9054c5fa1295305dfc37e5989', # noqa: E501 + ] + + # Change the created token utxo to a MultiSig requiring 2 signatures + redeem_script = generate_multisig_redeem_script(signatures_required=2, public_key_bytes=public_keys) + multisig_address_b58 = generate_multisig_address(redeem_script) + multisig_address = decode_address(multisig_address_b58) + multisig_script = MultiSig.create_output_script(multisig_address) + + token_creation_utxo.script = multisig_script + + # Alice creates an input spending all created test tokens + tokens_input = TxInput(not_none(token_creation_tx.hash), 0, b'') + + # Alice creates an output sending half genesis HTR to herself + alice_output_script = P2PKH.create_output_script(self.address1) + htr_output = TxOutput(int(genesis_utxo.value / 2), alice_output_script) + + # Alice creates an atomic swap tx that's missing Bob's input, with half genesis HTR, and his output + atomic_swap_tx = Transaction( + weight=1, + inputs=[tokens_input], + outputs=[htr_output], + parents=parents, + tokens=[token_uid], + storage=self.manager1.tx_storage, + timestamp=token_creation_tx.timestamp + 1 + ) + self.manager1.cpu_mining_service.resolve(atomic_swap_tx) + + # Alice signs her input using sighash range, instead of sighash_all. + sighash_range = SighashRange(input_start=0, input_end=1, output_start=0, output_end=1) + data_to_sign1 = atomic_swap_tx.get_custom_sighash_data(sighash_range) + + signatures = [] + for private_key_hex in private_keys[:2]: + signature = generate_signature_for_data(data_to_sign1, bytes.fromhex(private_key_hex), password=b'1234') + signatures.append(signature) + + tokens_input.data = MultiSig.create_input_data( + redeem_script=redeem_script, + signatures=signatures, + sighash=sighash_range, + ) + + # At this point, the tx is partial. The inputs are valid, but they're mismatched with outputs + self.manager1.verification_service.verifiers.tx.verify_inputs(atomic_swap_tx) + with pytest.raises(InputOutputMismatch): + self.manager1.verification_service.verify(atomic_swap_tx) + + # Alice sends the tx bytes to Bob, represented here by cloning the tx + atomic_swap_tx_clone = cast(Transaction, atomic_swap_tx.clone()) + self.manager1.cpu_mining_service.resolve(atomic_swap_tx_clone) + + # Bob creates an input spending all genesis HTR and adds it to the atomic swap tx + htr_input = TxInput(not_none(self.genesis_block.hash), 0, b'') + atomic_swap_tx_clone.inputs.append(htr_input) + + # Bob adds an output to receive all test tokens + bob_output_script = P2PKH.create_output_script(self.address2) + tokens_output = TxOutput(token_creation_utxo.value, bob_output_script, 1) + atomic_swap_tx_clone.outputs.append(tokens_output) + + # Bob adds a change output for his HTR + htr_output = TxOutput(int(genesis_utxo.value / 2), bob_output_script) + atomic_swap_tx_clone.outputs.append(htr_output) + + # Bob signs his input using sighash_all to complete the tx + data_to_sign2 = atomic_swap_tx_clone.get_sighash_all() + assert self.manager2.wallet + public_bytes2, signature2 = self.manager2.wallet.get_input_aux_data(data_to_sign2, self.genesis_private_key) + htr_input.data = P2PKH.create_input_data(public_bytes2, signature2) + + static_metadata = TransactionStaticMetadata.create_from_storage( + atomic_swap_tx_clone, self._settings, self.manager1.tx_storage + ) + atomic_swap_tx_clone.set_static_metadata(static_metadata) + + # The atomic swap tx is now completed and valid, and can be propagated + self.manager1.verification_service.verify(atomic_swap_tx_clone) + self.manager1.propagate_tx(atomic_swap_tx_clone, fails_silently=False) + @patch('hathor.transaction.scripts.opcode.is_opcode_valid', lambda _: True) def test_sighash_range_with_limit(self) -> None: # Create a new test token @@ -145,7 +259,7 @@ def test_sighash_range_with_limit(self) -> None: alice_output_script = P2PKH.create_output_script(self.address1) htr_output = TxOutput(int(genesis_utxo.value / 2), alice_output_script) - # Alice creates an atomic swap tx that's missing only Bob's input, with half genesis HTR + # Alice creates an atomic swap tx that's missing Bob's input, with half genesis HTR, and his output atomic_swap_tx = Transaction( weight=1, inputs=[tokens_input], @@ -279,7 +393,7 @@ def test_sighash_range_nonexistent_input(self) -> None: alice_output_script = P2PKH.create_output_script(self.address1) htr_output = TxOutput(int(genesis_utxo.value / 2), alice_output_script) - # Alice creates an atomic swap tx that's missing only Bob's input, with half genesis HTR + # Alice creates an atomic swap tx that's missing Bob's input, with half genesis HTR, and his output atomic_swap_tx = Transaction( weight=1, inputs=[tokens_input],