Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions hathor/cli/side_dag.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@

from __future__ import annotations

import argparse
import os
import signal
import sys
Expand Down Expand Up @@ -136,14 +135,14 @@ def main(capture_stdout: bool) -> None:

def _process_logging_output(argv: list[str]) -> tuple[LoggingOutput, LoggingOutput]:
"""Extract logging output before argv parsing."""
from hathor.cli.util import LoggingOutput
from hathor.cli.util import LoggingOutput, create_parser

class LogOutputConfig(str, Enum):
HATHOR = 'hathor'
SIDE_DAG = 'side-dag'
BOTH = 'both'

parser = argparse.ArgumentParser(add_help=False)
parser = create_parser(add_help=False)
log_args = parser.add_mutually_exclusive_group()
log_args.add_argument('--json-logs', nargs='?', const='both', type=LogOutputConfig)
log_args.add_argument('--disable-logs', nargs='?', const='both', type=LogOutputConfig)
Expand Down
9 changes: 4 additions & 5 deletions hathor/cli/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import argparse
import json
import sys
import traceback
Expand All @@ -28,8 +27,8 @@
from typing_extensions import assert_never


def create_parser(*, prefix: str | None = None) -> ArgumentParser:
return configargparse.ArgumentParser(auto_env_var_prefix=prefix or 'hathor_')
def create_parser(*, prefix: str | None = None, add_help: bool = True) -> ArgumentParser:
return configargparse.ArgumentParser(auto_env_var_prefix=prefix or 'hathor_', add_help=add_help)


# docs at http://www.structlog.org/en/stable/api.html#structlog.dev.ConsoleRenderer
Expand Down Expand Up @@ -138,7 +137,7 @@ class LoggingOptions(NamedTuple):

def process_logging_output(argv: list[str]) -> LoggingOutput:
"""Extract logging output before argv parsing."""
parser = argparse.ArgumentParser(add_help=False)
parser = create_parser(add_help=False)

log_args = parser.add_mutually_exclusive_group()
log_args.add_argument('--json-logs', action='store_true')
Expand All @@ -159,7 +158,7 @@ def process_logging_output(argv: list[str]) -> LoggingOutput:

def process_logging_options(argv: list[str]) -> LoggingOptions:
"""Extract logging-specific options that are processed before argv parsing."""
parser = argparse.ArgumentParser(add_help=False)
parser = create_parser(add_help=False)
parser.add_argument('--debug', action='store_true')

args, remaining_argv = parser.parse_known_args(argv)
Expand Down
4 changes: 4 additions & 0 deletions hathor/conf/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,10 @@ def GENESIS_TX2_TIMESTAMP(self) -> int:
# The consensus algorithm protocol settings.
CONSENSUS_ALGORITHM: ConsensusSettings = PowSettings()

# The name and symbol of the native token. This is only used in APIs to serve clients.
NATIVE_TOKEN_NAME: str = 'Hathor'
NATIVE_TOKEN_SYMBOL: str = 'HTR'

@classmethod
def from_yaml(cls, *, filepath: str) -> 'HathorSettings':
"""Takes a filepath to a yaml file and returns a validated HathorSettings instance."""
Expand Down
6 changes: 6 additions & 0 deletions hathor/consensus/poa/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
BLOCK_WEIGHT_IN_TURN,
BLOCK_WEIGHT_OUT_OF_TURN,
SIGNER_ID_LEN,
InvalidSignature,
ValidSignature,
calculate_weight,
get_hashed_poa_data,
is_in_turn,
verify_poa_signature,
)
from .poa_block_producer import PoaBlockProducer
from .poa_signer import PoaSigner, PoaSignerFile
Expand All @@ -19,4 +22,7 @@
'PoaBlockProducer',
'PoaSigner',
'PoaSignerFile',
'verify_poa_signature',
'InvalidSignature',
'ValidSignature'
]
41 changes: 41 additions & 0 deletions hathor/consensus/poa/poa.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,15 @@
from __future__ import annotations

import hashlib
from dataclasses import dataclass
from typing import TYPE_CHECKING

from cryptography.exceptions import InvalidSignature as CryptographyInvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec

from hathor.consensus.consensus_settings import PoaSettings
from hathor.crypto.util import get_public_key_from_bytes_compressed
from hathor.transaction import Block

if TYPE_CHECKING:
Expand Down Expand Up @@ -46,3 +52,38 @@ def calculate_weight(settings: PoaSettings, block: PoaBlock, signer_index: int)
"""Return the weight for the given block and signer."""
is_in_turn_flag = is_in_turn(settings=settings, height=block.get_height(), signer_index=signer_index)
return BLOCK_WEIGHT_IN_TURN if is_in_turn_flag else BLOCK_WEIGHT_OUT_OF_TURN


@dataclass(frozen=True, slots=True)
class InvalidSignature:
pass


@dataclass(frozen=True, slots=True)
class ValidSignature:
signer_index: int
public_key: bytes


def verify_poa_signature(settings: PoaSettings, block: PoaBlock) -> InvalidSignature | ValidSignature:
"""Return whether the provided public key was used to sign the block Proof-of-Authority."""
from hathor.consensus.poa import PoaSigner
sorted_signers = sorted(settings.signers)
hashed_poa_data = get_hashed_poa_data(block)

for signer_index, public_key_bytes in enumerate(sorted_signers):
signer_id = PoaSigner.get_poa_signer_id(public_key_bytes)
if block.signer_id != signer_id:
# this is not our signer
continue

public_key = get_public_key_from_bytes_compressed(public_key_bytes)
try:
public_key.verify(block.signature, hashed_poa_data, ec.ECDSA(hashes.SHA256()))
except CryptographyInvalidSignature:
# the signer_id is correct, but not the signature
continue
# the signer and signature are valid!
return ValidSignature(signer_index, public_key_bytes)

return InvalidSignature()
20 changes: 20 additions & 0 deletions hathor/transaction/poa/poa_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from typing import Any

from typing_extensions import override

from hathor.conf.settings import HathorSettings
from hathor.consensus import poa
from hathor.consensus.consensus_settings import PoaSettings
from hathor.transaction import Block, TxOutput, TxVersion
from hathor.transaction.storage import TransactionStorage
from hathor.transaction.util import VerboseCallback, int_to_bytes, unpack, unpack_len
Expand Down Expand Up @@ -56,6 +61,7 @@ def __init__(
self.signer_id = signer_id
self.signature = signature

@override
def get_graph_fields_from_struct(self, buf: bytes, *, verbose: VerboseCallback = None) -> bytes:
buf = super().get_graph_fields_from_struct(buf, verbose=verbose)

Expand All @@ -76,8 +82,22 @@ def get_graph_fields_from_struct(self, buf: bytes, *, verbose: VerboseCallback =

return buf

@override
def get_graph_struct(self) -> bytes:
assert len(self.signer_id) == poa.SIGNER_ID_LEN
struct_bytes_without_poa = super().get_graph_struct()
signature_len = int_to_bytes(len(self.signature), 1)
return struct_bytes_without_poa + self.signer_id + signature_len + self.signature

@override
def to_json(self, decode_script: bool = False, include_metadata: bool = False) -> dict[str, Any]:
poa_settings = self._settings.CONSENSUS_ALGORITHM
assert isinstance(poa_settings, PoaSettings)
json = super().to_json(decode_script=decode_script, include_metadata=include_metadata)
signature_validation = poa.verify_poa_signature(poa_settings, self)

if isinstance(signature_validation, poa.ValidSignature):
json['signer'] = signature_validation.public_key.hex()

json['signer_id'] = self.signer_id.hex()
return json
40 changes: 9 additions & 31 deletions hathor/verification/poa_block_verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,9 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec

from hathor.conf.settings import HathorSettings
from hathor.consensus import poa
from hathor.consensus.consensus_settings import PoaSettings
from hathor.consensus.poa.poa_signer import PoaSigner
from hathor.crypto.util import get_public_key_from_bytes_compressed
from hathor.transaction.exceptions import PoaValidationError
from hathor.transaction.poa import PoaBlock

Expand All @@ -36,38 +30,22 @@ def verify_poa(self, block: PoaBlock) -> None:
poa_settings = self._settings.CONSENSUS_ALGORITHM
assert isinstance(poa_settings, PoaSettings)

parent_block = block.get_block_parent()
if block.timestamp < parent_block.timestamp + self._settings.AVG_TIME_BETWEEN_BLOCKS:
raise PoaValidationError(
f'blocks must have at least {self._settings.AVG_TIME_BETWEEN_BLOCKS} seconds between them'
)

# validate block rewards
if block.outputs:
raise PoaValidationError('blocks must not have rewards in a PoA network')

# validate that the signature is valid
sorted_signers = sorted(poa_settings.signers)
signer_index: int | None = None

for i, public_key_bytes in enumerate(sorted_signers):
if self._verify_poa_signature(block, public_key_bytes):
signer_index = i
break

if signer_index is None:
signature_validation = poa.verify_poa_signature(poa_settings, block)
if not isinstance(signature_validation, poa.ValidSignature):
raise PoaValidationError('invalid PoA signature')

# validate block weight is in turn
expected_weight = poa.calculate_weight(poa_settings, block, signer_index)
expected_weight = poa.calculate_weight(poa_settings, block, signature_validation.signer_index)
if block.weight != expected_weight:
raise PoaValidationError(f'block weight is {block.weight}, expected {expected_weight}')

@staticmethod
def _verify_poa_signature(block: PoaBlock, public_key_bytes: bytes) -> bool:
"""Return whether the provided public key was used to sign the block Proof-of-Authority."""
signer_id = PoaSigner.get_poa_signer_id(public_key_bytes)
if block.signer_id != signer_id:
return False

public_key = get_public_key_from_bytes_compressed(public_key_bytes)
hashed_poa_data = poa.get_hashed_poa_data(block)
try:
public_key.verify(block.signature, hashed_poa_data, ec.ECDSA(hashes.SHA256()))
except InvalidSignature:
return False
return True
8 changes: 8 additions & 0 deletions hathor/version_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ def render_GET(self, request):
'reward_spend_min_blocks': self._settings.REWARD_SPEND_MIN_BLOCKS,
'max_number_inputs': self._settings.MAX_NUM_INPUTS,
'max_number_outputs': self._settings.MAX_NUM_OUTPUTS,
'decimal_places': self._settings.DECIMAL_PLACES,
'genesis_block_hash': self._settings.GENESIS_BLOCK_HASH.hex(),
'genesis_tx1_hash': self._settings.GENESIS_TX1_HASH.hex(),
'genesis_tx2_hash': self._settings.GENESIS_TX2_HASH.hex(),
'native_token': dict(
name=self._settings.NATIVE_TOKEN_NAME,
symbol=self._settings.NATIVE_TOKEN_SYMBOL,
),
}
return json_dumpb(data)

Expand Down
17 changes: 15 additions & 2 deletions tests/poa/test_poa.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from hathor.consensus.consensus_settings import PoaSettings
from hathor.consensus.poa.poa_signer import PoaSigner, PoaSignerFile
from hathor.crypto.util import get_address_b58_from_public_key, get_private_key_bytes, get_public_key_bytes_compressed
from hathor.transaction import TxOutput
from hathor.transaction import Block, TxOutput
from hathor.transaction.exceptions import PoaValidationError
from hathor.transaction.poa import PoaBlock
from hathor.verification.poa_block_verifier import PoaBlockVerifier
Expand Down Expand Up @@ -94,9 +94,14 @@ def get_signer() -> tuple[PoaSigner, bytes]:
poa_signer, public_key_bytes = get_signer()
settings = Mock(spec_set=HathorSettings)
settings.CONSENSUS_ALGORITHM = PoaSettings.construct(signers=())
settings.AVG_TIME_BETWEEN_BLOCKS = 30
block_verifier = PoaBlockVerifier(settings=settings)
storage = Mock()
storage.get_transaction = Mock(return_value=Block(timestamp=123))

block = PoaBlock(
timestamp=123,
storage=storage,
timestamp=153,
signal_bits=0b1010,
weight=poa.BLOCK_WEIGHT_IN_TURN,
parents=[b'parent1', b'parent2'],
Expand All @@ -111,6 +116,13 @@ def get_signer() -> tuple[PoaSigner, bytes]:
assert str(e.value) == 'blocks must not have rewards in a PoA network'
block.outputs = []

# Test timestamp
block.timestamp = 152
with pytest.raises(PoaValidationError) as e:
block_verifier.verify_poa(block)
assert str(e.value) == 'blocks must have at least 30 seconds between them'
block.timestamp = 153

# Test no signers
settings.CONSENSUS_ALGORITHM = PoaSettings.construct(signers=())
with pytest.raises(PoaValidationError) as e:
Expand All @@ -131,6 +143,7 @@ def get_signer() -> tuple[PoaSigner, bytes]:

# Test incorrect private key
PoaSigner(ec.generate_private_key(ec.SECP256K1())).sign_block(block)
block.signer_id = poa_signer._signer_id # we set the correct signer id to test only the signature
with pytest.raises(PoaValidationError) as e:
block_verifier.verify_poa(block)
assert str(e.value) == 'invalid PoA signature'
Expand Down