Skip to content

Commit 5df23a6

Browse files
committed
feat(sighash): implement sighash bitmask
1 parent 9d042eb commit 5df23a6

File tree

12 files changed

+401
-103
lines changed

12 files changed

+401
-103
lines changed

hathor/transaction/exceptions.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ class ConflictingInputs(TxValidationError):
7474
"""Inputs in the tx are spending the same output"""
7575

7676

77+
class OutputNotSelected(TxValidationError):
78+
"""At least one output is not selected for signing by some input."""
79+
80+
7781
class TooManyOutputs(TxValidationError):
7882
"""More than 256 outputs"""
7983

@@ -194,3 +198,25 @@ class VerifyFailed(ScriptError):
194198

195199
class TimeLocked(ScriptError):
196200
"""Transaction is invalid because it is time locked"""
201+
202+
203+
class InputNotSelectedError(ScriptError):
204+
"""Raised when an input does not select itself for signing in its script."""
205+
206+
207+
class MaxInputsExceededError(ScriptError):
208+
"""The transaction has more inputs than the maximum configured in the script."""
209+
210+
211+
class MaxOutputsExceededError(ScriptError):
212+
"""The transaction has more outputs than the maximum configured in the script."""
213+
214+
215+
class InputsOutputsLimitModelInvalid(ScriptError):
216+
"""
217+
Raised when the inputs outputs limit model could not be constructed from the arguments provided in the script.
218+
"""
219+
220+
221+
class CustomSighashModelInvalid(ScriptError):
222+
"""Raised when the sighash model could not be constructed from the arguments provided in the script."""

hathor/transaction/scripts/execute.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,21 @@
1313
# limitations under the License.
1414

1515
import struct
16-
from typing import NamedTuple, Optional, Union
16+
from typing import TYPE_CHECKING, NamedTuple, Optional, Union
1717

18+
from hathor.conf.get_settings import get_settings
1819
from hathor.transaction import BaseTransaction, Transaction, TxInput
1920
from hathor.transaction.exceptions import DataIndexError, FinalStackInvalid, InvalidScriptError, OutOfData
2021

22+
if TYPE_CHECKING:
23+
from hathor.transaction.scripts.script_context import ScriptContext
24+
2125

2226
class ScriptExtras(NamedTuple):
2327
tx: Transaction
2428
txin: TxInput
2529
spent_tx: BaseTransaction
30+
input_index: int
2631

2732

2833
# XXX: Because the Stack is a heterogeneous list of bytes and int, and some OPs only work for when the stack has some
@@ -39,7 +44,7 @@ class OpcodePosition(NamedTuple):
3944
position: int
4045

4146

42-
def execute_eval(data: bytes, log: list[str], extras: ScriptExtras) -> None:
47+
def execute_eval(data: bytes, log: list[str], extras: ScriptExtras) -> 'ScriptContext':
4348
""" Execute eval from data executing opcode methods
4449
4550
:param data: data to be evaluated that contains data and opcodes
@@ -56,8 +61,9 @@ def execute_eval(data: bytes, log: list[str], extras: ScriptExtras) -> None:
5661
"""
5762
from hathor.transaction.scripts.opcode import Opcode, execute_op_code
5863
from hathor.transaction.scripts.script_context import ScriptContext
64+
settings = get_settings()
5965
stack: Stack = []
60-
context = ScriptContext(stack=stack, logs=log, extras=extras)
66+
context = ScriptContext(settings=settings, stack=stack, logs=log, extras=extras)
6167
data_len = len(data)
6268
pos = 0
6369
while pos < data_len:
@@ -70,6 +76,8 @@ def execute_eval(data: bytes, log: list[str], extras: ScriptExtras) -> None:
7076

7177
evaluate_final_stack(stack, log)
7278

79+
return context
80+
7381

7482
def evaluate_final_stack(stack: Stack, log: list[str]) -> None:
7583
""" Checks the final state of the stack.
@@ -88,7 +96,7 @@ def evaluate_final_stack(stack: Stack, log: list[str]) -> None:
8896
raise FinalStackInvalid('\n'.join(log))
8997

9098

91-
def script_eval(tx: Transaction, txin: TxInput, spent_tx: BaseTransaction) -> None:
99+
def script_eval(tx: Transaction, txin: TxInput, spent_tx: BaseTransaction, *, input_index: int) -> 'ScriptContext':
92100
"""Evaluates the output script and input data according to
93101
a very limited subset of Bitcoin's scripting language.
94102
@@ -106,7 +114,7 @@ def script_eval(tx: Transaction, txin: TxInput, spent_tx: BaseTransaction) -> No
106114
input_data = txin.data
107115
output_script = spent_tx.outputs[txin.index].script
108116
log: list[str] = []
109-
extras = ScriptExtras(tx=tx, txin=txin, spent_tx=spent_tx)
117+
extras = ScriptExtras(tx=tx, txin=txin, spent_tx=spent_tx, input_index=input_index)
110118

111119
from hathor.transaction.scripts import MultiSig
112120
if MultiSig.re_match.search(output_script):
@@ -121,11 +129,11 @@ def script_eval(tx: Transaction, txin: TxInput, spent_tx: BaseTransaction) -> No
121129
# Second, we need to validate that the signatures on the input_data solves the redeem_script
122130
# we pop and append the redeem_script to the input_data and execute it
123131
multisig_data = MultiSig.get_multisig_data(extras.txin.data)
124-
execute_eval(multisig_data, log, extras)
132+
return execute_eval(multisig_data, log, extras)
125133
else:
126134
# merge input_data and output_script
127135
full_data = input_data + output_script
128-
execute_eval(full_data, log, extras)
136+
return execute_eval(full_data, log, extras)
129137

130138

131139
def decode_opn(opcode: int) -> int:

hathor/transaction/scripts/opcode.py

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,14 @@
2828
is_pubkey_compressed,
2929
)
3030
from hathor.transaction.exceptions import (
31+
CustomSighashModelInvalid,
3132
EqualVerifyFailed,
33+
InputNotSelectedError,
34+
InputsOutputsLimitModelInvalid,
3235
InvalidScriptError,
3336
InvalidStackData,
37+
MaxInputsExceededError,
38+
MaxOutputsExceededError,
3439
MissingStackItems,
3540
OracleChecksigFailed,
3641
ScriptError,
@@ -39,6 +44,8 @@
3944
)
4045
from hathor.transaction.scripts.execute import Stack, binary_to_int, decode_opn, get_data_value, get_script_op
4146
from hathor.transaction.scripts.script_context import ScriptContext
47+
from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashBitmask
48+
from hathor.transaction.util import bytes_to_int
4249

4350

4451
class Opcode(IntEnum):
@@ -72,6 +79,9 @@ class Opcode(IntEnum):
7279
OP_DATA_GREATERTHAN = 0xC1
7380
OP_FIND_P2PKH = 0xD0
7481
OP_DATA_MATCH_VALUE = 0xD1
82+
OP_SIGHASH_BITMASK = 0xE0
83+
OP_SIGHASH_RANGE = 0xE1
84+
OP_MAX_INPUTS_OUTPUTS = 0xE2
7585

7686
@classmethod
7787
def is_pushdata(cls, opcode: int) -> bool:
@@ -249,7 +259,8 @@ def op_checksig(context: ScriptContext) -> None:
249259
# pubkey is not compressed public key
250260
raise ScriptError('OP_CHECKSIG: pubkey is not a public key') from e
251261
try:
252-
public_key.verify(signature, context.extras.tx.get_sighash_all_data(), ec.ECDSA(hashes.SHA256()))
262+
sighash_data = context.get_tx_sighash_data(context.extras.tx)
263+
public_key.verify(signature, sighash_data, ec.ECDSA(hashes.SHA256()))
253264
# valid, push true to stack
254265
context.stack.append(1)
255266
except InvalidSignature:
@@ -583,7 +594,7 @@ def op_checkmultisig(context: ScriptContext) -> None:
583594
while pubkey_index < len(pubkeys):
584595
pubkey = pubkeys[pubkey_index]
585596
new_stack = [signature, pubkey]
586-
op_checksig(ScriptContext(stack=new_stack, logs=context.logs, extras=context.extras))
597+
op_checksig(ScriptContext(stack=new_stack, logs=context.logs, extras=context.extras, settings=settings))
587598
result = new_stack.pop()
588599
pubkey_index += 1
589600
if result == 1:
@@ -617,6 +628,59 @@ def op_integer(opcode: int, stack: Stack) -> None:
617628
raise ScriptError(e) from e
618629

619630

631+
def op_sighash_bitmask(context: ScriptContext) -> None:
632+
"""Pop two items from the stack, constructing a sighash bitmask and setting it in the script context."""
633+
if len(context.stack) < 2:
634+
raise MissingStackItems(f'OP_SIGHASH_BITMASK: expected 2 elements on stack, has {len(context.stack)}')
635+
636+
outputs = context.stack.pop()
637+
inputs = context.stack.pop()
638+
assert isinstance(inputs, bytes)
639+
assert isinstance(outputs, bytes)
640+
641+
try:
642+
sighash = SighashBitmask(
643+
inputs=bytes_to_int(inputs),
644+
outputs=bytes_to_int(outputs)
645+
)
646+
except Exception as e:
647+
raise CustomSighashModelInvalid('Could not construct sighash bitmask.') from e
648+
649+
if context.extras.input_index not in sighash.get_input_indexes():
650+
raise InputNotSelectedError(
651+
f'Input at index {context.extras.input_index} must select itself when using a custom sighash.'
652+
)
653+
654+
context.set_sighash(sighash)
655+
656+
657+
def op_max_inputs_outputs(context: ScriptContext) -> None:
658+
"""Pop two items from the stack, constructing an inputs and outputs limit and setting it in the script context."""
659+
if len(context.stack) < 2:
660+
raise MissingStackItems(f'OP_MAX_INPUTS_OUTPUTS: expected 2 elements on stack, has {len(context.stack)}')
661+
662+
max_outputs = context.stack.pop()
663+
max_inputs = context.stack.pop()
664+
assert isinstance(max_inputs, bytes)
665+
assert isinstance(max_outputs, bytes)
666+
667+
try:
668+
limit = InputsOutputsLimit(
669+
max_inputs=bytes_to_int(max_inputs),
670+
max_outputs=bytes_to_int(max_outputs)
671+
)
672+
except Exception as e:
673+
raise InputsOutputsLimitModelInvalid("Could not construct inputs and outputs limits.") from e
674+
675+
tx_inputs_len = len(context.extras.tx.inputs)
676+
if tx_inputs_len > limit.max_inputs:
677+
raise MaxInputsExceededError(f'Maximum number of inputs exceeded ({tx_inputs_len} > {limit.max_inputs}).')
678+
679+
tx_outputs_len = len(context.extras.tx.outputs)
680+
if tx_outputs_len > limit.max_outputs:
681+
raise MaxOutputsExceededError(f'Maximum number of outputs exceeded ({tx_outputs_len} > {limit.max_outputs}).')
682+
683+
620684
def execute_op_code(opcode: Opcode, context: ScriptContext) -> None:
621685
"""
622686
Execute a function opcode.
@@ -625,6 +689,8 @@ def execute_op_code(opcode: Opcode, context: ScriptContext) -> None:
625689
opcode: the opcode to be executed.
626690
context: the script context to be manipulated.
627691
"""
692+
if not is_opcode_valid(opcode):
693+
raise ScriptError(f'Opcode "{opcode.name}" is invalid.')
628694
context.logs.append(f'Executing function opcode {opcode.name} ({hex(opcode.value)})')
629695
match opcode:
630696
case Opcode.OP_DUP: op_dup(context)
@@ -639,4 +705,26 @@ def execute_op_code(opcode: Opcode, context: ScriptContext) -> None:
639705
case Opcode.OP_DATA_MATCH_VALUE: op_data_match_value(context)
640706
case Opcode.OP_CHECKDATASIG: op_checkdatasig(context)
641707
case Opcode.OP_FIND_P2PKH: op_find_p2pkh(context)
708+
case Opcode.OP_SIGHASH_BITMASK: op_sighash_bitmask(context)
709+
case Opcode.OP_MAX_INPUTS_OUTPUTS: op_max_inputs_outputs(context)
642710
case _: raise ScriptError(f'unknown opcode: {opcode}')
711+
712+
713+
def is_opcode_valid(opcode: Opcode) -> bool:
714+
"""Return whether an opcode is valid, that is, it's currently enabled."""
715+
valid_opcodes = [
716+
Opcode.OP_DUP,
717+
Opcode.OP_EQUAL,
718+
Opcode.OP_EQUALVERIFY,
719+
Opcode.OP_CHECKSIG,
720+
Opcode.OP_HASH160,
721+
Opcode.OP_GREATERTHAN_TIMESTAMP,
722+
Opcode.OP_CHECKMULTISIG,
723+
Opcode.OP_DATA_STREQUAL,
724+
Opcode.OP_DATA_GREATERTHAN,
725+
Opcode.OP_DATA_MATCH_VALUE,
726+
Opcode.OP_CHECKDATASIG,
727+
Opcode.OP_FIND_P2PKH,
728+
]
729+
730+
return opcode in valid_opcodes

hathor/transaction/scripts/p2pkh.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from hathor.transaction.scripts.construct import get_pushdata, re_compile
2121
from hathor.transaction.scripts.hathor_script import HathorScript
2222
from hathor.transaction.scripts.opcode import Opcode
23+
from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashBitmask
2324

2425

2526
class P2PKH(BaseScript):
@@ -91,16 +92,35 @@ def create_output_script(cls, address: bytes, timelock: Optional[Any] = None) ->
9192
return s.data
9293

9394
@classmethod
94-
def create_input_data(cls, public_key_bytes: bytes, signature: bytes) -> bytes:
95+
def create_input_data(
96+
cls,
97+
public_key_bytes: bytes,
98+
signature: bytes,
99+
*,
100+
sighash: SighashBitmask | None = None,
101+
inputs_outputs_limit: InputsOutputsLimit | None = None
102+
) -> bytes:
95103
"""
96104
:param private_key: key corresponding to the address we want to spend tokens from
97105
:type private_key: :py:class:`cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey`
98106
99107
:rtype: bytes
100108
"""
101109
s = HathorScript()
110+
111+
if sighash:
112+
s.pushData(sighash.inputs)
113+
s.pushData(sighash.outputs)
114+
s.addOpcode(Opcode.OP_SIGHASH_BITMASK)
115+
116+
if inputs_outputs_limit:
117+
s.pushData(inputs_outputs_limit.max_inputs)
118+
s.pushData(inputs_outputs_limit.max_outputs)
119+
s.addOpcode(Opcode.OP_MAX_INPUTS_OUTPUTS)
120+
102121
s.pushData(signature)
103122
s.pushData(public_key_bytes)
123+
104124
return s.data
105125

106126
@classmethod

hathor/transaction/scripts/script_context.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,58 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import hashlib
16+
17+
from typing_extensions import assert_never
18+
19+
from hathor.conf.settings import HathorSettings
20+
from hathor.transaction import Transaction
21+
from hathor.transaction.exceptions import ScriptError
1522
from hathor.transaction.scripts.execute import ScriptExtras, Stack
23+
from hathor.transaction.scripts.sighash import SighashAll, SighashBitmask, SighashType
1624

1725

1826
class ScriptContext:
1927
"""A context to be manipulated during script execution. A separate instance must be used for each script."""
20-
__slots__ = ('stack', 'logs', 'extras')
28+
__slots__ = ('stack', 'logs', 'extras', '_settings', '_sighash')
2129

22-
def __init__(self, *, stack: Stack, logs: list[str], extras: ScriptExtras) -> None:
30+
def __init__(self, *, stack: Stack, logs: list[str], extras: ScriptExtras, settings: HathorSettings) -> None:
2331
self.stack = stack
2432
self.logs = logs
2533
self.extras = extras
34+
self._settings = settings
35+
self._sighash: SighashType = SighashAll()
36+
37+
def set_sighash(self, sighash: SighashType) -> None:
38+
"""
39+
Set a Sighash type in this context.
40+
It can only be set once, that is, a script cannot use more than one sighash type.
41+
"""
42+
if type(self._sighash) is not SighashAll:
43+
raise ScriptError('Cannot modify sighash after it is already set.')
44+
45+
self._sighash = sighash
46+
47+
def get_tx_sighash_data(self, tx: Transaction) -> bytes:
48+
"""
49+
Return the sighash data for a tx, depending on the sighash type set in this context.
50+
Must be used when verifying signatures during script execution.
51+
"""
52+
match self._sighash:
53+
case SighashAll():
54+
return tx.get_sighash_all_data()
55+
case SighashBitmask():
56+
data = tx.get_custom_sighash_data(self._sighash)
57+
return hashlib.sha256(data).digest()
58+
case _:
59+
assert_never(self._sighash)
60+
61+
def get_selected_outputs(self) -> set[int]:
62+
"""Get a set with all output indexes selected (that is, signed) in this context."""
63+
match self._sighash:
64+
case SighashAll():
65+
return set(range(self._settings.MAX_NUM_OUTPUTS))
66+
case SighashBitmask():
67+
return set(self._sighash.get_output_indexes())
68+
case _:
69+
assert_never(self._sighash)

0 commit comments

Comments
 (0)