From 54ecb24cb5ece7185526d1f6371a1925faa0c078 Mon Sep 17 00:00:00 2001 From: overcat <4catcode@gmail.com> Date: Mon, 23 Sep 2019 23:31:11 +0800 Subject: [PATCH] Add support for SEP10 validation --- stellar_base/builder.py | 102 +++++++++- stellar_base/exceptions.py | 7 +- stellar_base/transaction_envelope.py | 2 +- stellar_base/utils.py | 9 +- tests/test_builder.py | 285 ++++++++++++++++++++++++++- 5 files changed, 390 insertions(+), 15 deletions(-) diff --git a/stellar_base/builder.py b/stellar_base/builder.py index 24346090..51d2281f 100644 --- a/stellar_base/builder.py +++ b/stellar_base/builder.py @@ -7,12 +7,14 @@ from . import memo from . import operation from .asset import Asset -from .exceptions import NoStellarSecretOrAddressError, StellarAddressInvalidError, SequenceError +from .exceptions import NoStellarSecretOrAddressError, StellarAddressInvalidError, SequenceError, \ + InvalidSep10ChallengeError, BadSignatureError from .federation import federation, FederationError from .horizon import HORIZON_LIVE, HORIZON_TEST from .horizon import Horizon from .keypair import Keypair from .network import NETWORKS, Network +from .operation import ManageData from .transaction import Transaction from .transaction_envelope import TransactionEnvelope as Te @@ -72,12 +74,10 @@ def __init__(self, else: self.horizon = Horizon(HORIZON_TEST) - if sequence: + if sequence is not None: self.sequence = int(sequence) elif self.address: self.sequence = self.get_sequence() - else: - self.sequence = None # Do we need this? Doesn't the address always exist? if fee is None: self.fee = self.horizon.base_fee() @@ -947,9 +947,9 @@ def challenge_tx(cls, server_secret, client_account_id, archor_name, network='TE """Returns a valid `SEP0010 `_ challenge transaction which you can use for Stellar Web Authentication. - :param server_secret: secret key for server's signing account. - :param client_account_id: The stellar account that the wallet wishes to authenticate with the server. - :param archor_name: Anchor's name to be used in the manage_data key. + :param str server_secret: secret key for server's signing account. + :param str client_account_id: The stellar account that the wallet wishes to authenticate with the server. + :param str archor_name: Anchor's name to be used in the manage_data key. :param str network: The network to connect to for verifying and retrieving additional attributes from. 'PUBLIC' is an alias for 'Public Global Stellar Network ; September 2015', 'TESTNET' is an alias for 'Test SDF Network ; September 2015'. Defaults to TESTNET. @@ -958,9 +958,95 @@ def challenge_tx(cls, server_secret, client_account_id, archor_name, network='TE :rtype: :class:`Builder` """ now = int(time.time()) - transaction = cls(secret=server_secret, network=network, sequence=-1) + transaction = cls(secret=server_secret, network=network, sequence=-1, fee=100) transaction.add_time_bounds({'minTime': now, 'maxTime': now + timeout}) nonce = os.urandom(64) transaction.append_manage_data_op(data_name='{} auth'.format(archor_name), data_value=nonce, source=client_account_id) return transaction + + @staticmethod + def verify_challenge_tx(challenge_tx, server_account_id, network='TESTNET'): + """Verifies if a transaction is a valid + `SEP0010 `_ + challenge transaction. + + This function performs the following checks: + 1. verify that transaction sequenceNumber is equal to zero; + 2. verify that transaction source account is equal to the server's signing key; + 3. verify that transaction has time bounds set, and that current time is between the minimum and maximum bounds; + 4. verify that transaction contains a single Manage Data operation and it's source account is not null; + 5. verify that transaction envelope has a correct signature by server's signing key; + 6. verify that transaction envelope has a correct signature by the operation's source account; + + :param str challenge_tx: SEP0010 transaction challenge transaction in base64. + :param str server_account_id: public key for server's account. + :param str network: The network to connect to for verifying and retrieving + additional attributes from. 'PUBLIC' is an alias for 'Public Global Stellar Network ; September 2015', + 'TESTNET' is an alias for 'Test SDF Network ; September 2015'. Defaults to TESTNET. + :raises: :exc:`SEP10VerificationError ` + """ + try: + transaction_envelope = Te.from_xdr(challenge_tx) + except Exception: + raise InvalidSep10ChallengeError("Importing XDR failed, please check if XDR is correct.") + + if network is None: + network = NETWORKS['TESTNET'] + if network.upper() in NETWORKS: + network = NETWORKS[network.upper()] + network = Network(network) + + transaction_envelope.network_id = network.network_id() + transaction = transaction_envelope.tx + if transaction.sequence != 0: + raise InvalidSep10ChallengeError("The transaction sequence number should be zero.") + + if transaction.source.decode() != server_account_id: + raise InvalidSep10ChallengeError("Transaction source account is not equal to server's account.") + + if not transaction.time_bounds: + raise InvalidSep10ChallengeError("Transaction requires timebounds.") + + max_time = transaction.time_bounds[0].maxTime + min_time = transaction.time_bounds[0].minTime + + if max_time == 0: + raise InvalidSep10ChallengeError("Transaction requires non-infinite timebounds.") + + current_time = time.time() + if current_time < min_time or current_time > max_time: + raise InvalidSep10ChallengeError("Transaction is not within range of the specified timebounds.") + + if len(transaction.operations) != 1: + raise InvalidSep10ChallengeError("Transaction requires a single ManageData operation.") + + manage_data_op = transaction.operations[0] + if manage_data_op.type_code != ManageData.type_code: + raise InvalidSep10ChallengeError("Operation type should be ManageData.") + + if not manage_data_op.source: + raise InvalidSep10ChallengeError("Operation should have a source account.") + + if len(manage_data_op.data_value) != 64: + raise InvalidSep10ChallengeError("Operation value should be a 64 bytes base64 random string.") + + if not transaction_envelope.signatures: + raise InvalidSep10ChallengeError("Transaction has no signatures.") + + if not _verify_te_signed_by(transaction_envelope, server_account_id): + raise InvalidSep10ChallengeError("transaction not signed by server: {}.".format(server_account_id)) + + if not _verify_te_signed_by(transaction_envelope, manage_data_op.source): + raise InvalidSep10ChallengeError("transaction not signed by client: {}.".format(manage_data_op.source)) + + +def _verify_te_signed_by(transaction_envelope, account_id): + kp = Keypair.from_address(account_id) + for decorated_signature in transaction_envelope.signatures: + try: + kp.verify(transaction_envelope.hash_meta(), decorated_signature.signature) + return True + except BadSignatureError: + pass + return False diff --git a/stellar_base/exceptions.py b/stellar_base/exceptions.py index 169f876c..11565511 100644 --- a/stellar_base/exceptions.py +++ b/stellar_base/exceptions.py @@ -40,6 +40,7 @@ class HorizonError(StellarError): Stellar Horizon. """ + def __init__(self, msg, status_code): super(HorizonError, self).__init__(msg) self.message = msg @@ -78,4 +79,8 @@ class FederationError(Exception): """A :exc:`FederationError` that represents an issue stemming from Stellar Federation. - """ \ No newline at end of file + """ + + +class InvalidSep10ChallengeError(StellarError): + pass diff --git a/stellar_base/transaction_envelope.py b/stellar_base/transaction_envelope.py index 0165a75c..d705ee83 100644 --- a/stellar_base/transaction_envelope.py +++ b/stellar_base/transaction_envelope.py @@ -50,7 +50,7 @@ def sign(self, keypair): envelope. :type keypair: :class:`Keypair ` :raises: :exc:`SignatureExistError - ` + ` """ assert isinstance(keypair, Keypair) diff --git a/stellar_base/utils.py b/stellar_base/utils.py index 439882b7..34e8cae7 100644 --- a/stellar_base/utils.py +++ b/stellar_base/utils.py @@ -1,15 +1,16 @@ # coding: utf-8 from __future__ import print_function -import sys + import base64 import binascii -from decimal import Decimal, ROUND_FLOOR -from fractions import Fraction import hashlib import hmac import io import os import struct +import sys +from decimal import Decimal, ROUND_FLOOR +from fractions import Fraction from mnemonic import Mnemonic from pbkdf2 import PBKDF2 @@ -22,7 +23,7 @@ from .stellarxdr import Xdr from .exceptions import DecodeError, ConfigurationError, MnemonicError, StellarAddressInvalidError, \ - StellarSecretInvalidError, NotValidParamError, NoApproximationError + StellarSecretInvalidError, NotValidParamError, NoApproximationError, BadSignatureError # Compatibility for Python 3.x that don't have unicode type if sys.version_info.major == 3: diff --git a/tests/test_builder.py b/tests/test_builder.py index 22d5a06f..09ab8ddd 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -1,4 +1,5 @@ # encoding: utf-8 +import os import time import mock @@ -6,7 +7,7 @@ from stellar_base import memo from stellar_base.builder import Builder -from stellar_base.exceptions import NoStellarSecretOrAddressError, FederationError +from stellar_base.exceptions import InvalidSep10ChallengeError, NoStellarSecretOrAddressError, FederationError from stellar_base.horizon import horizon_testnet, horizon_livenet, Horizon from stellar_base.keypair import Keypair from stellar_base.operation import ManageData @@ -226,3 +227,285 @@ def test_challenge_tx(self): assert tx.time_bounds['maxTime'] - tx.time_bounds['minTime'] == timeout assert tx.keypair == server_kp assert tx.sequence == -1 + + def test_verify_challenge_tx(self): + server_kp = Keypair.random() + client_kp = Keypair.random() + timeout = 600 + network = 'Public Global Stellar Network ; September 2015' + archor_name = "SDF" + + challenge = Builder.challenge_tx(server_secret=server_kp.seed().decode(), + client_account_id=client_kp.address().decode(), + archor_name=archor_name, + network=network, + timeout=timeout) + challenge.sign() + challenge_tx_server_signed = challenge.gen_xdr() + + transaction = Builder(client_kp.seed().decode(), network='Public Global Stellar Network ; September 2015', + sequence=0, fee=100) + transaction.import_from_xdr(challenge_tx_server_signed) + transaction.sign() + challenge_tx = transaction.gen_xdr() + Builder.verify_challenge_tx(challenge_tx, server_kp.address().decode(), + 'Public Global Stellar Network ; September 2015') + + def test_verify_challenge_tx_sequence_not_zero(self): + server_kp = Keypair.random() + client_kp = Keypair.random() + + challenge = Builder(server_kp.seed().decode(), sequence=10086, fee=100) + challenge.sign() + challenge_tx_server_signed = challenge.gen_xdr() + + transaction = Builder(client_kp.seed().decode(), network='Public Global Stellar Network ; September 2015', + sequence=0, fee=100) + transaction.import_from_xdr(challenge_tx_server_signed) + transaction.sign() + challenge_tx = transaction.gen_xdr() + with pytest.raises(InvalidSep10ChallengeError, match="The transaction sequence number should be zero."): + Builder.verify_challenge_tx(challenge_tx, server_kp.address().decode(), + 'Public Global Stellar Network ; September 2015') + + def test_verify_challenge_tx_source_is_different_to_server_account_id(self): + server_kp = Keypair.random() + client_kp = Keypair.random() + network = 'TESTNET' + + challenge = Builder(server_kp.seed().decode(), network=network, sequence=-1, fee=100) + challenge.sign() + challenge_tx_server_signed = challenge.gen_xdr() + + transaction = Builder(client_kp.seed().decode(), network='TESTNET', + sequence=0, fee=100) + transaction.import_from_xdr(challenge_tx_server_signed) + transaction.sign() + challenge_tx = transaction.gen_xdr() + with pytest.raises(InvalidSep10ChallengeError, + match="Transaction source account is not equal to server's account."): + Builder.verify_challenge_tx(challenge_tx, Keypair.random().address().decode(), + 'TESTNET') + + def test_verify_challenge_tx_donot_contain_any_operation(self): + server_kp = Keypair.random() + client_kp = Keypair.random() + timeout = 600 + network = 'TESTNET' + + challenge = Builder(server_kp.seed().decode(), network=network, sequence=-1, fee=100) + now = int(time.time()) + challenge.add_time_bounds({'minTime': now, 'maxTime': now + timeout}) + challenge.sign() + challenge_tx_server_signed = challenge.gen_xdr() + + transaction = Builder(client_kp.seed().decode(), network='TESTNET', + sequence=0, fee=100) + + transaction.import_from_xdr(challenge_tx_server_signed) + transaction.sign() + challenge_tx = transaction.gen_xdr() + with pytest.raises(InvalidSep10ChallengeError, match="Transaction requires a single ManageData operation."): + Builder.verify_challenge_tx(challenge_tx, server_kp.address().decode(), + 'TESTNET') + + def test_verify_challenge_tx_donot_contain_managedata_operation(self): + server_kp = Keypair.random() + client_kp = Keypair.random() + timeout = 600 + network = 'TESTNET' + + challenge = Builder(server_kp.seed().decode(), network=network, sequence=-1, fee=100) + now = int(time.time()) + challenge.add_time_bounds({'minTime': now, 'maxTime': now + timeout}) + challenge.append_bump_sequence_op(12, source=client_kp.address().decode()) + challenge.sign() + challenge_tx_server_signed = challenge.gen_xdr() + + transaction = Builder(client_kp.seed().decode(), network='TESTNET', + sequence=0, fee=100) + + transaction.import_from_xdr(challenge_tx_server_signed) + transaction.sign() + challenge_tx = transaction.gen_xdr() + with pytest.raises(InvalidSep10ChallengeError, match="Operation type should be ManageData."): + Builder.verify_challenge_tx(challenge_tx, server_kp.address().decode(), + 'TESTNET') + + def test_verify_challenge_tx_operation_does_not_contain_the_source_account(self): + server_kp = Keypair.random() + client_kp = Keypair.random() + timeout = 600 + network = 'TESTNET' + archor_name = 'sdf' + + challenge = Builder(server_kp.seed().decode(), network=network, sequence=-1, fee=100) + now = int(time.time()) + challenge.add_time_bounds({'minTime': now, 'maxTime': now + timeout}) + nonce = os.urandom(64) + challenge.append_manage_data_op(data_name='{} auth'.format(archor_name), data_value=nonce) + challenge.sign() + challenge_tx_server_signed = challenge.gen_xdr() + + transaction = Builder(client_kp.seed().decode(), network='TESTNET', + sequence=0, fee=100) + + transaction.import_from_xdr(challenge_tx_server_signed) + transaction.sign() + challenge_tx = transaction.gen_xdr() + with pytest.raises(InvalidSep10ChallengeError, match="Operation should have a source account."): + Builder.verify_challenge_tx(challenge_tx, server_kp.address().decode(), + 'TESTNET') + + def test_verify_challenge_tx_operation_value_is_not_a_64_bytes_base64_string(self): + server_kp = Keypair.random() + client_kp = Keypair.random() + timeout = 600 + network = 'TESTNET' + archor_name = 'sdf' + + challenge = Builder(server_kp.seed().decode(), network=network, sequence=-1, fee=100) + now = int(time.time()) + challenge.add_time_bounds({'minTime': now, 'maxTime': now + timeout}) + nonce = os.urandom(32) + challenge.append_manage_data_op(data_name='{} auth'.format(archor_name), data_value=nonce, + source=client_kp.address().decode()) + challenge.sign() + challenge_tx_server_signed = challenge.gen_xdr() + + transaction = Builder(client_kp.seed().decode(), network='TESTNET', + sequence=0, fee=100) + + transaction.import_from_xdr(challenge_tx_server_signed) + transaction.sign() + challenge_tx = transaction.gen_xdr() + with pytest.raises(InvalidSep10ChallengeError, + match="Operation value should be a 64 bytes base64 random string."): + Builder.verify_challenge_tx(challenge_tx, server_kp.address().decode(), + 'TESTNET') + + def test_verify_challenge_tx_transaction_is_not_signed_by_the_server(self): + server_kp = Keypair.random() + client_kp = Keypair.random() + timeout = 600 + network = 'TESTNET' + archor_name = 'sdf' + + challenge = Builder(server_kp.seed().decode(), network=network, sequence=-1, fee=100) + now = int(time.time()) + challenge.add_time_bounds({'minTime': now, 'maxTime': now + timeout}) + nonce = os.urandom(64) + challenge.append_manage_data_op(data_name='{} auth'.format(archor_name), data_value=nonce, + source=client_kp.address().decode()) + challenge_tx_server_signed = challenge.gen_xdr() + + transaction = Builder(client_kp.seed().decode(), network='TESTNET', + sequence=0, fee=100) + + transaction.import_from_xdr(challenge_tx_server_signed) + transaction.sign() + challenge_tx = transaction.gen_xdr() + with pytest.raises(InvalidSep10ChallengeError, + match="transaction not signed by server: {}".format(server_kp.address().decode())): + Builder.verify_challenge_tx(challenge_tx, server_kp.address().decode(), + 'TESTNET') + + def test_verify_challenge_tx_transaction_is_not_signed_by_the_client(self): + server_kp = Keypair.random() + client_kp = Keypair.random() + timeout = 600 + network = 'TESTNET' + archor_name = 'sdf' + + challenge = Builder(server_kp.seed().decode(), network=network, sequence=-1, fee=100) + now = int(time.time()) + challenge.add_time_bounds({'minTime': now, 'maxTime': now + timeout}) + nonce = os.urandom(64) + challenge.append_manage_data_op(data_name='{} auth'.format(archor_name), data_value=nonce, + source=client_kp.address().decode()) + challenge.sign() + challenge_tx_server_signed = challenge.gen_xdr() + + transaction = Builder(client_kp.seed().decode(), network='TESTNET', + sequence=0, fee=100) + transaction.import_from_xdr(challenge_tx_server_signed) + challenge_tx = transaction.gen_xdr() + with pytest.raises(InvalidSep10ChallengeError, + match="transaction not signed by client: {}".format(client_kp.address().decode())): + Builder.verify_challenge_tx(challenge_tx, server_kp.address().decode(), + 'TESTNET') + + def test_verify_challenge_tx_dont_contains_timebound(self): + server_kp = Keypair.random() + client_kp = Keypair.random() + network = 'TESTNET' + archor_name = 'sdf' + + challenge = Builder(server_kp.seed().decode(), network=network, sequence=-1, fee=100) + nonce = os.urandom(64) + challenge.append_manage_data_op(data_name='{} auth'.format(archor_name), data_value=nonce, + source=client_kp.address().decode()) + challenge.sign() + challenge_tx_server_signed = challenge.gen_xdr() + + transaction = Builder(client_kp.seed().decode(), network='TESTNET', + sequence=0, fee=100) + transaction.import_from_xdr(challenge_tx_server_signed) + transaction.sign() + challenge_tx = transaction.gen_xdr() + with pytest.raises(InvalidSep10ChallengeError, + match="Transaction requires timebounds."): + Builder.verify_challenge_tx(challenge_tx, server_kp.address().decode(), + 'TESTNET') + + def test_verify_challenge_tx_contains_infinite_timebounds(self): + server_kp = Keypair.random() + client_kp = Keypair.random() + timeout = 600 + network = 'TESTNET' + archor_name = 'sdf' + + challenge = Builder(server_kp.seed().decode(), network=network, sequence=-1, fee=100) + now = int(time.time()) + challenge.add_time_bounds({'minTime': now, 'maxTime': 0}) + nonce = os.urandom(64) + challenge.append_manage_data_op(data_name='{} auth'.format(archor_name), data_value=nonce, + source=client_kp.address().decode()) + challenge.sign() + challenge_tx_server_signed = challenge.gen_xdr() + + transaction = Builder(client_kp.seed().decode(), network='TESTNET', + sequence=0, fee=100) + transaction.import_from_xdr(challenge_tx_server_signed) + transaction.sign() + challenge_tx = transaction.gen_xdr() + with pytest.raises(InvalidSep10ChallengeError, + match="Transaction requires non-infinite timebounds."): + Builder.verify_challenge_tx(challenge_tx, server_kp.address().decode(), + 'TESTNET') + + def test_verify_challenge_tx_not_within_range_of_the_specified_timebounds(self): + server_kp = Keypair.random() + client_kp = Keypair.random() + timeout = 600 + network = 'TESTNET' + archor_name = 'sdf' + + challenge = Builder(server_kp.seed().decode(), network=network, sequence=-1, fee=100) + now = int(time.time()) + challenge.add_time_bounds({'minTime': now - 100, 'maxTime': now - 20}) + nonce = os.urandom(64) + challenge.append_manage_data_op(data_name='{} auth'.format(archor_name), data_value=nonce, + source=client_kp.address().decode()) + challenge.sign() + challenge_tx_server_signed = challenge.gen_xdr() + + transaction = Builder(client_kp.seed().decode(), network='TESTNET', + sequence=0, fee=100) + transaction.import_from_xdr(challenge_tx_server_signed) + transaction.sign() + challenge_tx = transaction.gen_xdr() + with pytest.raises(InvalidSep10ChallengeError, + match="Transaction is not within range of the specified timebounds."): + Builder.verify_challenge_tx(challenge_tx, server_kp.address().decode(), + 'TESTNET')