From 7011e244f37bda2179cbf70c6f49aeacc5a9cc18 Mon Sep 17 00:00:00 2001 From: Gabriel Levcovitz Date: Mon, 9 Dec 2024 20:21:56 -0300 Subject: [PATCH] feat(sighash): verify required SighashAll --- hathor/transaction/exceptions.py | 4 +++ hathor/verification/transaction_verifier.py | 8 +++-- tests/tx/scripts/test_sighash_bitmask.py | 20 ++++++++--- tests/tx/scripts/test_sighash_range.py | 37 ++++++++++++++++----- tests/tx/test_tx.py | 32 ++++++++++++++++++ 5 files changed, 85 insertions(+), 16 deletions(-) diff --git a/hathor/transaction/exceptions.py b/hathor/transaction/exceptions.py index 1f9a2c1f2..cfa9c9a9f 100644 --- a/hathor/transaction/exceptions.py +++ b/hathor/transaction/exceptions.py @@ -86,6 +86,10 @@ class TooManySighashSubsets(TxValidationError): """More sighash subsets than the configured maximum.""" +class MissingSighashAll(TxValidationError): + """At least one input is required to be signed using SighashAll.""" + + class InvalidOutputValue(TxValidationError): """Value of output is invalid""" diff --git a/hathor/verification/transaction_verifier.py b/hathor/verification/transaction_verifier.py index fffb37a30..93326ee85 100644 --- a/hathor/verification/transaction_verifier.py +++ b/hathor/verification/transaction_verifier.py @@ -27,6 +27,7 @@ InvalidInputData, InvalidInputDataSize, InvalidToken, + MissingSighashAll, NoInputError, OutputNotSelected, RewardLocked, @@ -39,7 +40,7 @@ ) from hathor.transaction.scripts import script_eval from hathor.transaction.scripts.script_context import ScriptContext -from hathor.transaction.scripts.sighash import get_unique_sighash_subsets +from hathor.transaction.scripts.sighash import SighashAll, get_unique_sighash_subsets from hathor.transaction.transaction import TokenInfo from hathor.transaction.util import get_deposit_amount, get_withdraw_amount from hathor.types import TokenUid, VertexId @@ -144,7 +145,6 @@ def verify_scripts(self, tx: Transaction, *, spent_txs: dict[VertexId, BaseTrans script contexts. """ all_contexts: list[ScriptContext] = [] - for input_index, input_tx in enumerate(tx.inputs): try: script_context = script_eval(tx, input_tx, spent_txs[input_tx.tx_id], input_index=input_index) @@ -155,7 +155,6 @@ def verify_scripts(self, tx: Transaction, *, spent_txs: dict[VertexId, BaseTrans all_sighashes = [context.get_sighash() for context in all_contexts] sighash_subsets = get_unique_sighash_subsets(all_sighashes) - all_max_sighash_subsets = [context.get_max_sighash_subsets() for context in all_contexts] valid_max_sighash_subsets: list[int] = [ max_subsets for max_subsets in all_max_sighash_subsets if max_subsets is not None @@ -175,6 +174,9 @@ def verify_scripts(self, tx: Transaction, *, spent_txs: dict[VertexId, BaseTrans if index not in all_selected_outputs: raise OutputNotSelected(f'Output at index {index} is not signed by any input.') + if not any(isinstance(sighash, SighashAll) for sighash in all_sighashes): + raise MissingSighashAll('At least one input is required to be signed using SighashAll.') + def verify_reward_locked(self, tx: Transaction) -> None: """Will raise `RewardLocked` if any reward is spent before the best block height is enough, considering both the block rewards spent by this tx itself, and the inherited `min_height`.""" diff --git a/tests/tx/scripts/test_sighash_bitmask.py b/tests/tx/scripts/test_sighash_bitmask.py index 24aea0aaa..16b3855d3 100644 --- a/tests/tx/scripts/test_sighash_bitmask.py +++ b/tests/tx/scripts/test_sighash_bitmask.py @@ -21,7 +21,7 @@ from hathor.exception import InvalidNewTransaction from hathor.manager import HathorManager from hathor.transaction import Transaction, TxInput, TxOutput -from hathor.transaction.exceptions import InputOutputMismatch, InvalidInputData, InvalidScriptError +from hathor.transaction.exceptions import InputOutputMismatch, InvalidInputData, InvalidScriptError, MissingSighashAll from hathor.transaction.scripts.p2pkh import P2PKH from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashBitmask from hathor.util import not_none @@ -93,9 +93,14 @@ def test_sighash_bitmask(self) -> None: sighash=sighash_bitmask, ) - # 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) + # At this point, the tx is partial. The inputs are valid, but they're mismatched with outputs, and they're + # missing a SighashAll + self.manager1.verification_service.verifiers.tx.verify_inputs(atomic_swap_tx, skip_script=True) + with pytest.raises(InputOutputMismatch): + self.manager1.verification_service.verifiers.tx.verify_sum(atomic_swap_tx) + + with pytest.raises(MissingSighashAll): self.manager1.verification_service.verify(atomic_swap_tx) # Alice sends the tx bytes to Bob, represented here by cloning the tx @@ -166,9 +171,14 @@ def test_sighash_bitmask_with_limit(self) -> None: inputs_outputs_limit=InputsOutputsLimit(max_inputs=2, max_outputs=3) ) - # 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) + # At this point, the tx is partial. The inputs are valid, but they're mismatched with outputs, and they're + # missing a SighashAll + self.manager1.verification_service.verifiers.tx.verify_inputs(atomic_swap_tx, skip_script=True) + with pytest.raises(InputOutputMismatch): + self.manager1.verification_service.verifiers.tx.verify_sum(atomic_swap_tx) + + with pytest.raises(MissingSighashAll): self.manager1.verification_service.verify(atomic_swap_tx) # Alice sends the tx bytes to Bob, represented here by cloning the tx diff --git a/tests/tx/scripts/test_sighash_range.py b/tests/tx/scripts/test_sighash_range.py index 866fd1b0b..3712e1396 100644 --- a/tests/tx/scripts/test_sighash_range.py +++ b/tests/tx/scripts/test_sighash_range.py @@ -25,6 +25,7 @@ InputOutputMismatch, InvalidInputData, InvalidScriptError, + MissingSighashAll, TooManySighashSubsets, ) from hathor.transaction.scripts import MultiSig @@ -100,9 +101,14 @@ def test_sighash_range(self) -> None: 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) + # At this point, the tx is partial. The inputs are valid, but they're mismatched with outputs, and they're + # missing a SighashAll + self.manager1.verification_service.verifiers.tx.verify_inputs(atomic_swap_tx, skip_script=True) + with pytest.raises(InputOutputMismatch): + self.manager1.verification_service.verifiers.tx.verify_sum(atomic_swap_tx) + + with pytest.raises(MissingSighashAll): self.manager1.verification_service.verify(atomic_swap_tx) # Alice sends the tx bytes to Bob, represented here by cloning the tx @@ -195,9 +201,14 @@ def test_sighash_range_with_multisig(self) -> None: 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) + # At this point, the tx is partial. The inputs are valid, but they're mismatched with outputs, and they're + # missing a SighashAll + self.manager1.verification_service.verifiers.tx.verify_inputs(atomic_swap_tx, skip_script=True) + with pytest.raises(InputOutputMismatch): + self.manager1.verification_service.verifiers.tx.verify_sum(atomic_swap_tx) + + with pytest.raises(MissingSighashAll): self.manager1.verification_service.verify(atomic_swap_tx) # Alice sends the tx bytes to Bob, represented here by cloning the tx @@ -268,9 +279,14 @@ def test_sighash_range_with_limit(self) -> None: inputs_outputs_limit=InputsOutputsLimit(max_inputs=2, max_outputs=3) ) - # 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) + # At this point, the tx is partial. The inputs are valid, but they're mismatched with outputs, and they're + # missing a SighashAll + self.manager1.verification_service.verifiers.tx.verify_inputs(atomic_swap_tx, skip_script=True) + with pytest.raises(InputOutputMismatch): + self.manager1.verification_service.verifiers.tx.verify_sum(atomic_swap_tx) + + with pytest.raises(MissingSighashAll): self.manager1.verification_service.verify(atomic_swap_tx) # Alice sends the tx bytes to Bob, represented here by cloning the tx @@ -450,9 +466,14 @@ def test_sighash_range_with_max_subsets(self) -> None: max_sighash_subsets=1, ) - # 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) + # At this point, the tx is partial. The inputs are valid, but they're mismatched with outputs, and they're + # missing a SighashAll + self.manager1.verification_service.verifiers.tx.verify_inputs(atomic_swap_tx, skip_script=True) + with pytest.raises(InputOutputMismatch): + self.manager1.verification_service.verifiers.tx.verify_sum(atomic_swap_tx) + + with pytest.raises(MissingSighashAll): self.manager1.verification_service.verify(atomic_swap_tx) # Alice sends the tx bytes to Bob, represented here by cloning the tx diff --git a/tests/tx/test_tx.py b/tests/tx/test_tx.py index 3bc18bbd3..854a86c2a 100644 --- a/tests/tx/test_tx.py +++ b/tests/tx/test_tx.py @@ -23,6 +23,7 @@ InvalidInputDataSize, InvalidOutputScriptSize, InvalidOutputValue, + MissingSighashAll, NoInputError, OutputNotSelected, ParentDoesNotExist, @@ -216,6 +217,37 @@ def test_too_many_sighash_subsets(self) -> None: self.assertEqual(str(e.value), "There are more custom sighash subsets than the configured maximum (1 > 0).") + @patch('hathor.transaction.scripts.opcode.is_opcode_valid', lambda _: True) + def test_missing_sighash_all(self) -> None: + parents = [tx.hash for tx in self.genesis_txs] + genesis_block = self.genesis_blocks[0] + + value = genesis_block.outputs[0].value + address = get_address_from_public_key(self.genesis_public_key) + script = P2PKH.create_output_script(address) + output = TxOutput(value, script) + + tx_input = TxInput(genesis_block.hash, 0, b'') + tx = Transaction( + weight=1, + inputs=[tx_input], + outputs=[output], + parents=parents, + storage=self.tx_storage, + timestamp=self.last_block.timestamp + 1 + ) + + sighash = SighashBitmask(inputs=0b1, outputs=0b1) + data_to_sign = tx.get_custom_sighash_data(sighash) + public_bytes, signature = self.wallet.get_input_aux_data(data_to_sign, self.genesis_private_key) + tx_input.data = P2PKH.create_input_data(public_bytes, signature, sighash=sighash) + + self.manager.cpu_mining_service.resolve(tx) + with pytest.raises(MissingSighashAll) as e: + self.manager.verification_service.verify(tx) + + self.assertEqual(str(e.value), 'At least one input is required to be signed using SighashAll.') + def _gen_tx_spending_genesis_block(self): parents = [tx.hash for tx in self.genesis_txs] genesis_block = self.genesis_blocks[0]