diff --git a/apps/cli/__init__.py b/apps/cli/__init__.py index f6d347c..58be430 100644 --- a/apps/cli/__init__.py +++ b/apps/cli/__init__.py @@ -13,6 +13,8 @@ APPOINTMENTS_FOLDER_NAME = "appointments" SUPPORTED_HASH_FUNCTIONS = ["SHA256"] SUPPORTED_CIPHERS = ["AES-GCM-128"] +CLI_PUBLIC_KEY = "cli_pk.pem" +CLI_PRIVATE_KEY = "cli_sk.pem" PISA_PUBLIC_KEY = "pisa_pk.pem" # Configure logging diff --git a/apps/cli/pisa_cli.py b/apps/cli/pisa_cli.py index 2d1da6c..12213dd 100644 --- a/apps/cli/pisa_cli.py +++ b/apps/cli/pisa_cli.py @@ -6,21 +6,28 @@ import requests import time from sys import argv from hashlib import sha256 -from binascii import unhexlify +from binascii import hexlify, unhexlify from getopt import getopt, GetoptError from requests import ConnectTimeout, ConnectionError from uuid import uuid4 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.serialization import load_pem_public_key +from cryptography.hazmat.primitives.serialization import load_pem_public_key, load_pem_private_key from cryptography.hazmat.primitives.asymmetric import ec from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm from apps.cli.blob import Blob from apps.cli.help import help_add_appointment, help_get_appointment -from apps.cli import DEFAULT_PISA_API_SERVER, DEFAULT_PISA_API_PORT, PISA_PUBLIC_KEY, APPOINTMENTS_FOLDER_NAME -from apps.cli import logger +from apps.cli import ( + DEFAULT_PISA_API_SERVER, + DEFAULT_PISA_API_PORT, + CLI_PUBLIC_KEY, + CLI_PRIVATE_KEY, + PISA_PUBLIC_KEY, + APPOINTMENTS_FOLDER_NAME, + logger, +) HTTP_OK = 200 @@ -48,21 +55,42 @@ def generate_dummy_appointment(): print("\nData stored in dummy_appointment_data.json") -# Loads and returns Pisa's public key from disk. -# Will raise NotFoundError or IOError if the attempts to open and read the public key file fail. -# Will raise ValueError if it the public key file was present but it failed to be deserialized. -def load_pisa_public_key(): +def sign_appointment(sk, appointment): + data = json.dumps(appointment, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hexlify(sk.sign(data, ec.ECDSA(hashes.SHA256()))).decode("utf-8") + + +# Loads and returns Pisa keys from disk +def load_key_file_data(file_name): try: - with open(PISA_PUBLIC_KEY, "r") as key_file: - pubkey_pem = key_file.read().encode("utf-8") - pisa_public_key = load_pem_public_key(pubkey_pem, backend=default_backend()) - return pisa_public_key + with open(file_name, "r") as key_file: + key_pem = key_file.read().encode("utf-8") + return key_pem + + except FileNotFoundError: + raise FileNotFoundError("File not found.") + + +# Deserialize public key from pem data. +def load_public_key(pk_pem): + try: + pisa_pk = load_pem_public_key(pk_pem, backend=default_backend()) + return pisa_pk except UnsupportedAlgorithm: raise ValueError("Could not deserialize the public key (unsupported algorithm).") -# Verifies that the appointment signature is a valid signature with public key `pk`, +# Deserialize private key from pem data. +def load_private_key(sk_pem): + try: + cli_sk = load_pem_private_key(sk_pem, None, backend=default_backend()) + return cli_sk + + except UnsupportedAlgorithm: + raise ValueError("Could not deserialize the private key (unsupported algorithm).") + + # returning True or False accordingly. def is_appointment_signature_valid(appointment, signature, pk): try: @@ -143,7 +171,38 @@ def add_appointment(args): appointment_data.get("end_time"), appointment_data.get("dispute_delta"), ) - appointment_json = json.dumps(appointment, sort_keys=True, separators=(",", ":")) + + try: + sk_pem = load_key_file_data(CLI_PRIVATE_KEY) + cli_sk = load_private_key(sk_pem) + + except ValueError: + logger.error("Failed to deserialize the public key. It might be in an unsupported format.") + return False + + except FileNotFoundError: + logger.error("Client's private key file not found. Please check your settings.") + return False + + except IOError as e: + logger.error("I/O error({}): {}".format(e.errno, e.strerror)) + return False + + signature = sign_appointment(cli_sk, appointment) + try: + cli_pk_pem = load_key_file_data(CLI_PUBLIC_KEY) + + except FileNotFoundError: + logger.error("Client's private key file not found. Please check your settings.") + return False + + except IOError as e: + logger.error("I/O error({}): {}".format(e.errno, e.strerror)) + return False + + data = {"appointment": appointment, "signature": signature, "public_key": cli_pk_pem.decode("utf-8")} + + appointment_json = json.dumps(data, sort_keys=True, separators=(",", ":")) logger.info("Sending appointment to PISA") @@ -181,7 +240,8 @@ def add_appointment(args): signature = response_json["signature"] # verify that the returned signature is valid try: - pk = load_pisa_public_key() + pk_pem = load_key_file_data(PISA_PUBLIC_KEY) + pk = load_public_key(pk_pem) is_sig_valid = is_appointment_signature_valid(appointment, signature, pk) except ValueError: diff --git a/pisa/api.py b/pisa/api.py index 562386f..29a33e4 100644 --- a/pisa/api.py +++ b/pisa/api.py @@ -32,7 +32,9 @@ def add_appointment(): # Check content type once if properly defined request_data = json.loads(request.get_json()) inspector = Inspector() - appointment = inspector.inspect(request_data) + appointment = inspector.inspect( + request_data.get("appointment"), request_data.get("signature"), request_data.get("public_key") + ) error = None response = None diff --git a/pisa/errors.py b/pisa/errors.py index ad2baf4..275e097 100644 --- a/pisa/errors.py +++ b/pisa/errors.py @@ -8,6 +8,7 @@ APPOINTMENT_FIELD_TOO_BIG = -6 APPOINTMENT_WRONG_FIELD = -7 APPOINTMENT_CIPHER_NOT_SUPPORTED = -8 APPOINTMENT_HASH_FUNCTION_NOT_SUPPORTED = -9 +APPOINTMENT_INVALID_SIGNATURE = -10 # Custom RPC errors RPC_TX_REORGED_AFTER_BROADCAST = -98 diff --git a/pisa/inspector.py b/pisa/inspector.py index 68ac698..4d20503 100644 --- a/pisa/inspector.py +++ b/pisa/inspector.py @@ -1,4 +1,12 @@ +import json import re +from binascii import unhexlify + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.serialization import load_pem_public_key +from cryptography.exceptions import InvalidSignature from pisa import errors import pisa.conf as conf @@ -15,14 +23,14 @@ logger = Logger("Inspector") class Inspector: - def inspect(self, data): - locator = data.get("locator") - start_time = data.get("start_time") - end_time = data.get("end_time") - dispute_delta = data.get("dispute_delta") - encrypted_blob = data.get("encrypted_blob") - cipher = data.get("cipher") - hash_function = data.get("hash_function") + def inspect(self, appt, signature, public_key): + locator = appt.get("locator") + start_time = appt.get("start_time") + end_time = appt.get("end_time") + dispute_delta = appt.get("dispute_delta") + encrypted_blob = appt.get("encrypted_blob") + cipher = appt.get("cipher") + hash_function = appt.get("hash_function") block_height = BlockProcessor.get_block_count() @@ -41,6 +49,8 @@ class Inspector: rcode, message = self.check_cipher(cipher) if rcode == 0: rcode, message = self.check_hash_function(hash_function) + if rcode == 0: + rcode, message = self.check_appointment_signature(appt, signature, public_key) if rcode == 0: r = Appointment(locator, start_time, end_time, dispute_delta, encrypted_blob, cipher, hash_function) @@ -245,3 +255,24 @@ class Inspector: logger.error(message) return rcode, message + + @staticmethod + # Verifies that the appointment signature is a valid signature with public key + def check_appointment_signature(appointment, signature, pk_pem): + message = None + rcode = 0 + + if signature is None: + rcode = errors.APPOINTMENT_EMPTY_FIELD + message = "empty signature received" + + try: + sig_bytes = unhexlify(signature.encode("utf-8")) + client_pk = load_pem_public_key(pk_pem.encode("utf-8"), backend=default_backend()) + data = json.dumps(appointment, sort_keys=True, separators=(",", ":")).encode("utf-8") + client_pk.verify(sig_bytes, data, ec.ECDSA(hashes.SHA256())) + + except InvalidSignature: + rcode = errors.APPOINTMENT_INVALID_SIGNATURE + + return rcode, message diff --git a/test/apps/cli/test_pisa_cli.py b/test/apps/cli/test_pisa_cli.py index caf2e91..8390b25 100644 --- a/test/apps/cli/test_pisa_cli.py +++ b/test/apps/cli/test_pisa_cli.py @@ -50,7 +50,7 @@ def test_is_appointment_signature_valid(): assert not pisa_cli.is_appointment_signature_valid(dummy_appointment, other_signature, pisa_pk) -def get_dummy_pisa_pk(): +def get_dummy_pisa_pk(pem_data): return pisa_pk @@ -60,7 +60,7 @@ def test_add_appointment(monkeypatch): # and the return value is True # make sure the test uses the right dummy key instead of loading it from disk - monkeypatch.setattr(pisa_cli, "load_pisa_public_key", get_dummy_pisa_pk) + monkeypatch.setattr(pisa_cli, "load_public_key", get_dummy_pisa_pk) response = {"locator": dummy_appointment["locator"], "signature": sign_appointment(pisa_sk, dummy_appointment)} @@ -81,7 +81,7 @@ def test_add_appointment_with_invalid_signature(monkeypatch): # make sure that the right endpoint is requested, but the return value is False # make sure the test uses the right dummy key instead of loading it from disk - monkeypatch.setattr(pisa_cli, "load_pisa_public_key", get_dummy_pisa_pk) + monkeypatch.setattr(pisa_cli, "load_public_key", get_dummy_pisa_pk) response = { "locator": dummy_appointment["locator"], diff --git a/test/unit/conftest.py b/test/unit/conftest.py index 4f27d60..2027673 100644 --- a/test/unit/conftest.py +++ b/test/unit/conftest.py @@ -1,3 +1,4 @@ +import json import pytest import random import requests @@ -5,7 +6,12 @@ from time import sleep from shutil import rmtree from threading import Thread from hashlib import sha256 -from binascii import unhexlify +from binascii import hexlify, unhexlify + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization from pisa.conf import DB_PATH from apps.cli.blob import Blob @@ -48,6 +54,18 @@ def prng_seed(): random.seed(0) +@pytest.fixture(scope="module") +def generate_keypair(): + client_sk = ec.generate_private_key(ec.SECP256K1, default_backend()) + client_pk = ( + client_sk.public_key() + .public_bytes(encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo) + .decode("utf-8") + ) + + return client_sk, client_pk + + @pytest.fixture(scope="module") def db_manager(): manager = DBManager("test_db") @@ -73,6 +91,11 @@ def generate_blocks(n): generate_block() +def sign_appointment(sk, appointment): + data = json.dumps(appointment, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hexlify(sk.sign(data, ec.ECDSA(hashes.SHA256()))).decode("utf-8") + + def generate_dummy_appointment_data(start_time_offset=5, end_time_offset=30): current_height = bitcoin_cli().getblockcount() @@ -91,6 +114,14 @@ def generate_dummy_appointment_data(start_time_offset=5, end_time_offset=30): cipher = "AES-GCM-128" hash_function = "SHA256" + # dummy keys for this test + client_sk = ec.generate_private_key(ec.SECP256K1, default_backend()) + client_pk = ( + client_sk.public_key() + .public_bytes(encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo) + .decode("utf-8") + ) + locator = sha256(unhexlify(dispute_txid)).hexdigest() blob = Blob(dummy_appointment_data.get("tx"), cipher, hash_function) @@ -107,7 +138,11 @@ def generate_dummy_appointment_data(start_time_offset=5, end_time_offset=30): "triggered": False, } - return appointment_data, dispute_tx + signature = sign_appointment(client_sk, appointment_data) + + data = {"appointment": appointment_data, "signature": signature, "public_key": client_pk} + + return data, dispute_tx def generate_dummy_appointment(start_time_offset=5, end_time_offset=30): @@ -115,7 +150,7 @@ def generate_dummy_appointment(start_time_offset=5, end_time_offset=30): start_time_offset=start_time_offset, end_time_offset=end_time_offset ) - return Appointment.from_dict(appointment_data), dispute_tx + return Appointment.from_dict(appointment_data["appointment"]), dispute_tx def generate_dummy_job(): diff --git a/test/unit/test_api.py b/test/unit/test_api.py index 14c0c3f..32e4724 100644 --- a/test/unit/test_api.py +++ b/test/unit/test_api.py @@ -17,40 +17,40 @@ locator_dispute_tx_map = {} @pytest.fixture -def new_appointment(): - appointment, dispute_tx = generate_dummy_appointment_data() - locator_dispute_tx_map[appointment["locator"]] = dispute_tx +def new_appt_data(): + appt_data, dispute_tx = generate_dummy_appointment_data() + locator_dispute_tx_map[appt_data["appointment"]["locator"]] = dispute_tx - return appointment + return appt_data -def add_appointment(appointment): - r = requests.post(url=PISA_API, json=json.dumps(appointment), timeout=5) +def add_appointment(new_appt_data): + r = requests.post(url=PISA_API, json=json.dumps(new_appt_data), timeout=5) if r.status_code == 200: - appointments.append(appointment) + appointments.append(new_appt_data["appointment"]) return r -def test_add_appointment(run_api, run_bitcoind, new_appointment): +def test_add_appointment(run_api, run_bitcoind, new_appt_data): # Properly formatted appointment - r = add_appointment(new_appointment) + r = add_appointment(new_appt_data) assert r.status_code == 200 # Incorrect appointment - new_appointment["dispute_delta"] = 0 - r = add_appointment(new_appointment) + new_appt_data["appointment"]["dispute_delta"] = 0 + r = add_appointment(new_appt_data) assert r.status_code == 400 -def test_request_appointment(new_appointment): +def test_request_appointment(new_appt_data): # First we need to add an appointment - r = add_appointment(new_appointment) + r = add_appointment(new_appt_data) assert r.status_code == 200 # Next we can request it - r = requests.get(url=PISA_API + "/get_appointment?locator=" + new_appointment["locator"]) + r = requests.get(url=PISA_API + "/get_appointment?locator=" + new_appt_data["appointment"]["locator"]) assert r.status_code == 200 # Each locator may point to multiple appointments, check them all @@ -60,7 +60,7 @@ def test_request_appointment(new_appointment): appointment_status = [appointment.pop("status") for appointment in received_appointments] # Check that the appointment is within the received appoints - assert new_appointment in received_appointments + assert new_appt_data["appointment"] in received_appointments # Check that all the appointments are being watched assert all([status == "being_watched" for status in appointment_status]) @@ -76,28 +76,28 @@ def test_request_random_appointment(): assert all([status == "not_found" for status in appointment_status]) -def test_add_appointment_multiple_times(new_appointment, n=MULTIPLE_APPOINTMENTS): +def test_add_appointment_multiple_times(new_appt_data, n=MULTIPLE_APPOINTMENTS): # Multiple appointments with the same locator should be valid # DISCUSS: #34-store-identical-appointments for _ in range(n): - r = add_appointment(new_appointment) + r = add_appointment(new_appt_data) assert r.status_code == 200 -def test_request_multiple_appointments_same_locator(new_appointment, n=MULTIPLE_APPOINTMENTS): +def test_request_multiple_appointments_same_locator(new_appt_data, n=MULTIPLE_APPOINTMENTS): for _ in range(n): - r = add_appointment(new_appointment) + r = add_appointment(new_appt_data) assert r.status_code == 200 - test_request_appointment(new_appointment) + test_request_appointment(new_appt_data) -def test_add_too_many_appointment(new_appointment): +def test_add_too_many_appointment(new_appt_data): for _ in range(MAX_APPOINTMENTS - len(appointments)): - r = add_appointment(new_appointment) + r = add_appointment(new_appt_data) assert r.status_code == 200 - r = add_appointment(new_appointment) + r = add_appointment(new_appt_data) assert r.status_code == 503 diff --git a/test/unit/test_inspector.py b/test/unit/test_inspector.py index 30b272d..297642c 100644 --- a/test/unit/test_inspector.py +++ b/test/unit/test_inspector.py @@ -1,11 +1,18 @@ -from binascii import unhexlify +import json +from binascii import hexlify, unhexlify +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec + +from apps.cli.pisa_cli import build_appointment from pisa import c_logger from pisa.errors import * from pisa.inspector import Inspector from pisa.appointment import Appointment from pisa.block_processor import BlockProcessor from test.unit.conftest import get_random_value_hex + from pisa.conf import MIN_DISPUTE_DELTA, SUPPORTED_CIPHERS, SUPPORTED_HASH_FUNCTIONS c_logger.disabled = True @@ -18,6 +25,11 @@ WRONG_TYPES = [[], "", get_random_value_hex(32), 3.2, 2.0, (), object, {}, " " * WRONG_TYPES_NO_STR = [[], unhexlify(get_random_value_hex(32)), 3.2, 2.0, (), object, {}, object()] +def sign_appointment(sk, appointment): + data = json.dumps(appointment, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hexlify(sk.sign(data, ec.ECDSA(hashes.SHA256()))).decode("utf-8") + + def test_check_locator(): # Right appointment type, size and format locator = get_random_value_hex(32) @@ -189,13 +201,42 @@ def test_check_hash_function(): assert Inspector.check_hash_function(hash_function)[0] == APPOINTMENT_EMPTY_FIELD -def test_inspect(run_bitcoind): +def test_check_appointment_signature(generate_keypair): + client_sk, client_pk = generate_keypair + + dummy_appointment_request = { + "tx": get_random_value_hex(192), + "tx_id": get_random_value_hex(32), + "start_time": 1500, + "end_time": 50000, + "dispute_delta": 200, + } + dummy_appointment = build_appointment(**dummy_appointment_request) + + # Verify that an appointment signed by the client is valid + signature = sign_appointment(client_sk, dummy_appointment) + assert Inspector.check_appointment_signature(dummy_appointment, signature, client_pk) + + fake_sk = ec.generate_private_key(ec.SECP256K1, default_backend()) + + # Create a bad signature to make sure inspector rejects it + bad_signature = sign_appointment(fake_sk, dummy_appointment) + assert ( + Inspector.check_appointment_signature(dummy_appointment, bad_signature, client_pk)[0] + == APPOINTMENT_INVALID_SIGNATURE + ) + + +def test_inspect(run_bitcoind, generate_keypair): # At this point every single check function has been already tested, let's test inspect with an invalid and a valid # appointments. + client_sk, client_pk = generate_keypair + # Invalid appointment, every field is empty appointment_data = dict() - appointment = inspector.inspect(appointment_data) + signature = sign_appointment(client_sk, appointment_data) + appointment = inspector.inspect(appointment_data, signature, client_pk) assert type(appointment) == tuple and appointment[0] != 0 # Valid appointment @@ -217,7 +258,9 @@ def test_inspect(run_bitcoind): "hash_function": hash_function, } - appointment = inspector.inspect(appointment_data) + signature = sign_appointment(client_sk, appointment_data) + + appointment = inspector.inspect(appointment_data, signature, client_pk) assert ( type(appointment) == Appointment